Merge branch 'feat/add-auth' into beta
This commit is contained in:
@@ -1,271 +0,0 @@
|
||||
package debrid
|
||||
|
||||
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/types"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type AllDebrid struct {
|
||||
BaseDebrid
|
||||
}
|
||||
|
||||
func (r *AllDebrid) GetMountPath() string {
|
||||
return r.MountPath
|
||||
}
|
||||
|
||||
func (r *AllDebrid) GetName() string {
|
||||
return r.Name
|
||||
}
|
||||
|
||||
func (r *AllDebrid) GetLogger() zerolog.Logger {
|
||||
return r.logger
|
||||
}
|
||||
|
||||
func (r *AllDebrid) IsAvailable(infohashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
hashes, result := 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
|
||||
// AllDebrid does not support checking cached infohashes
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *AllDebrid) SubmitMagnet(torrent *Torrent) (*Torrent, error) {
|
||||
url := fmt.Sprintf("%s/magnet/upload", r.Host)
|
||||
query := gourl.Values{}
|
||||
query.Add("magnets[]", torrent.Magnet.Link)
|
||||
url += "?" + query.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data types.AllDebridUploadMagnetResponse
|
||||
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)
|
||||
r.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 []types.AllDebridMagnetFile, parentPath string, index *int) []TorrentFile {
|
||||
result := make([]TorrentFile, 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 := TorrentFile{
|
||||
Id: strconv.Itoa(*index),
|
||||
Name: fileName,
|
||||
Size: f.Size,
|
||||
Path: currentPath,
|
||||
}
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *AllDebrid) GetTorrent(id string) (*Torrent, error) {
|
||||
torrent := &Torrent{}
|
||||
url := fmt.Sprintf("%s/magnet/status?id=%s", r.Host, id)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
var res types.AllDebridTorrentInfoResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
r.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
|
||||
return torrent, err
|
||||
}
|
||||
data := res.Data.Magnets
|
||||
status := getAlldebridStatus(data.StatusCode)
|
||||
name := data.Filename
|
||||
torrent.Id = id
|
||||
torrent.Name = name
|
||||
torrent.Status = status
|
||||
torrent.Filename = name
|
||||
torrent.OriginalFilename = name
|
||||
torrent.Folder = name
|
||||
if status == "downloaded" {
|
||||
torrent.Bytes = data.Size
|
||||
|
||||
torrent.Progress = float64((data.Downloaded / data.Size) * 100)
|
||||
torrent.Speed = data.DownloadSpeed
|
||||
torrent.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 = ""
|
||||
}
|
||||
torrent.OriginalFilename = parentFolder
|
||||
torrent.Files = files
|
||||
}
|
||||
torrent.Debrid = r
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *AllDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error) {
|
||||
for {
|
||||
tb, err := r.GetTorrent(torrent.Id)
|
||||
|
||||
torrent = tb
|
||||
|
||||
if err != nil || tb == nil {
|
||||
return tb, err
|
||||
}
|
||||
status := torrent.Status
|
||||
if status == "downloaded" {
|
||||
r.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||
if !isSymlink {
|
||||
err = r.GetDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if status == "downloading" {
|
||||
if !r.DownloadUncached {
|
||||
go torrent.Delete()
|
||||
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 (r *AllDebrid) DeleteTorrent(torrent *Torrent) {
|
||||
url := fmt.Sprintf("%s/magnet/delete?id=%s", r.Host, torrent.Id)
|
||||
req, _ := http.NewRequest(http.MethodGet, 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 *AllDebrid) GetDownloadLinks(torrent *Torrent) error {
|
||||
downloadLinks := make([]TorrentDownloadLinks, 0)
|
||||
for _, file := range torrent.Files {
|
||||
url := fmt.Sprintf("%s/link/unlock", r.Host)
|
||||
query := gourl.Values{}
|
||||
query.Add("link", file.Link)
|
||||
url += "?" + query.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data types.AllDebridDownloadLink
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
link := data.Data.Link
|
||||
|
||||
dl := TorrentDownloadLinks{
|
||||
Link: file.Link,
|
||||
Filename: data.Data.Filename,
|
||||
DownloadLink: link,
|
||||
}
|
||||
downloadLinks = append(downloadLinks, dl)
|
||||
}
|
||||
torrent.DownloadLinks = downloadLinks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *AllDebrid) GetCheckCached() bool {
|
||||
return r.CheckCached
|
||||
}
|
||||
|
||||
func NewAllDebrid(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{
|
||||
BaseDebrid: BaseDebrid{
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -29,10 +29,6 @@ type AllDebrid struct {
|
||||
CheckCached bool
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetMountPath() string {
|
||||
return ad.MountPath
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetName() string {
|
||||
return ad.Name
|
||||
}
|
||||
@@ -77,7 +73,6 @@ func (ad *AllDebrid) SubmitMagnet(torrent *torrent.Torrent) (*torrent.Torrent, e
|
||||
}
|
||||
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
|
||||
@@ -170,12 +165,6 @@ func (ad *AllDebrid) GetTorrent(id string) (*torrent.Torrent, error) {
|
||||
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
|
||||
|
||||
+20
-125
@@ -3,45 +3,22 @@ package debrid
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/common"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"path/filepath"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/alldebrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/debrid_link"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/realdebrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torbox"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
)
|
||||
|
||||
type BaseDebrid struct {
|
||||
Name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *request.RLHTTPClient
|
||||
cache *common.Cache
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
CheckCached bool
|
||||
}
|
||||
|
||||
type Service interface {
|
||||
SubmitMagnet(torrent *Torrent) (*Torrent, error)
|
||||
CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error)
|
||||
GetDownloadLinks(torrent *Torrent) error
|
||||
DeleteTorrent(torrent *Torrent)
|
||||
IsAvailable(infohashes []string) map[string]bool
|
||||
GetMountPath() string
|
||||
GetCheckCached() bool
|
||||
GetTorrent(id string) (*Torrent, error)
|
||||
GetName() string
|
||||
GetLogger() zerolog.Logger
|
||||
}
|
||||
|
||||
func NewDebrid() *DebridService {
|
||||
func New() *engine.Engine {
|
||||
cfg := config.GetConfig()
|
||||
maxCachedSize := cmp.Or(cfg.MaxCacheSize, 1000)
|
||||
debrids := make([]Service, 0)
|
||||
debrids := make([]engine.Service, 0)
|
||||
// Divide the cache size by the number of debrids
|
||||
maxCacheSize := maxCachedSize / len(cfg.Debrids)
|
||||
|
||||
@@ -51,108 +28,27 @@ func NewDebrid() *DebridService {
|
||||
logger.Info().Msg("Debrid Service started")
|
||||
debrids = append(debrids, d)
|
||||
}
|
||||
d := &DebridService{debrids: debrids, lastUsed: 0}
|
||||
d := &engine.Engine{Debrids: debrids, LastUsed: 0}
|
||||
return d
|
||||
}
|
||||
|
||||
func createDebrid(dc config.Debrid, cache *common.Cache) Service {
|
||||
func createDebrid(dc config.Debrid, cache *common.Cache) engine.Service {
|
||||
switch dc.Name {
|
||||
case "realdebrid":
|
||||
return NewRealDebrid(dc, cache)
|
||||
return realdebrid.New(dc, cache)
|
||||
case "torbox":
|
||||
return NewTorbox(dc, cache)
|
||||
return torbox.New(dc, cache)
|
||||
case "debridlink":
|
||||
return NewDebridLink(dc, cache)
|
||||
return debrid_link.New(dc, cache)
|
||||
case "alldebrid":
|
||||
return NewAllDebrid(dc, cache)
|
||||
return alldebrid.New(dc, cache)
|
||||
default:
|
||||
return NewRealDebrid(dc, cache)
|
||||
return realdebrid.New(dc, cache)
|
||||
}
|
||||
}
|
||||
|
||||
func GetTorrentInfo(filePath string) (*Torrent, error) {
|
||||
// Open and read the .torrent file
|
||||
if filepath.Ext(filePath) == ".torrent" {
|
||||
return getTorrentInfo(filePath)
|
||||
} else {
|
||||
return torrentFromMagnetFile(filePath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func torrentFromMagnetFile(filePath string) (*Torrent, error) {
|
||||
magnetLink := utils.OpenMagnetFile(filePath)
|
||||
magnet, err := utils.GetMagnetInfo(magnetLink)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
torrent := &Torrent{
|
||||
InfoHash: magnet.InfoHash,
|
||||
Name: magnet.Name,
|
||||
Size: magnet.Size,
|
||||
Magnet: magnet,
|
||||
Filename: filePath,
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func getTorrentInfo(filePath string) (*Torrent, error) {
|
||||
mi, err := metainfo.LoadFromFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash := mi.HashInfoBytes()
|
||||
infoHash := hash.HexString()
|
||||
info, err := mi.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infoLength := info.Length
|
||||
magnet := &utils.Magnet{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: infoLength,
|
||||
Link: mi.Magnet(&hash, &info).String(),
|
||||
}
|
||||
torrent := &Torrent{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: infoLength,
|
||||
Magnet: magnet,
|
||||
Filename: filePath,
|
||||
}
|
||||
return torrent, 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
|
||||
}
|
||||
|
||||
func ProcessTorrent(d *DebridService, magnet *utils.Magnet, a *arr.Arr, isSymlink bool) (*Torrent, error) {
|
||||
debridTorrent := &Torrent{
|
||||
func ProcessTorrent(d *engine.Engine, magnet *utils.Magnet, a *arr.Arr, isSymlink bool) (*torrent.Torrent, error) {
|
||||
debridTorrent := &torrent.Torrent{
|
||||
InfoHash: magnet.InfoHash,
|
||||
Magnet: magnet,
|
||||
Name: magnet.Name,
|
||||
@@ -162,7 +58,7 @@ func ProcessTorrent(d *DebridService, magnet *utils.Magnet, a *arr.Arr, isSymlin
|
||||
|
||||
errs := make([]error, 0)
|
||||
|
||||
for index, db := range d.debrids {
|
||||
for index, db := range d.Debrids {
|
||||
logger := db.GetLogger()
|
||||
logger.Info().Msgf("Processing debrid: %s", db.GetName())
|
||||
|
||||
@@ -179,15 +75,14 @@ func ProcessTorrent(d *DebridService, magnet *utils.Magnet, a *arr.Arr, isSymlin
|
||||
|
||||
dbt, err := db.SubmitMagnet(debridTorrent)
|
||||
if dbt != nil {
|
||||
dbt.Debrid = db
|
||||
dbt.Arr = a
|
||||
}
|
||||
if err != nil || dbt == nil || dbt.Id == "" {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
logger.Info().Msgf("Torrent: %s submitted to %s", dbt.Name, db.GetName())
|
||||
d.lastUsed = index
|
||||
logger.Info().Msgf("Torrent: %s(id=%s) submitted to %s", dbt.Name, dbt.Id, db.GetName())
|
||||
d.LastUsed = index
|
||||
return db.CheckStatus(dbt, isSymlink)
|
||||
}
|
||||
err := fmt.Errorf("failed to process torrent")
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
package debrid
|
||||
|
||||
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/types"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DebridLink struct {
|
||||
BaseDebrid
|
||||
}
|
||||
|
||||
func (r *DebridLink) GetMountPath() string {
|
||||
return r.MountPath
|
||||
}
|
||||
|
||||
func (r *DebridLink) GetName() string {
|
||||
return r.Name
|
||||
}
|
||||
|
||||
func (r *DebridLink) GetLogger() zerolog.Logger {
|
||||
return r.logger
|
||||
}
|
||||
|
||||
func (r *DebridLink) IsAvailable(infohashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
hashes, result := 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 += 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", 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 types.DebridLinkAvailableResponse
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
r.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
|
||||
}
|
||||
}
|
||||
}
|
||||
r.cache.AddMultiple(result) // Add the results to the cache
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *DebridLink) GetTorrent(id string) (*Torrent, error) {
|
||||
torrent := &Torrent{}
|
||||
url := fmt.Sprintf("%s/seedbox/list?ids=%s", r.Host, id)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
var res types.DebridLinkTorrentInfo
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
if res.Success == false {
|
||||
return torrent, fmt.Errorf("error getting torrent")
|
||||
}
|
||||
if res.Value == nil {
|
||||
return torrent, fmt.Errorf("torrent not found")
|
||||
}
|
||||
dt := *res.Value
|
||||
|
||||
if len(dt) == 0 {
|
||||
return torrent, fmt.Errorf("torrent not found")
|
||||
}
|
||||
data := dt[0]
|
||||
status := "downloading"
|
||||
if data.Status == 100 {
|
||||
status = "downloaded"
|
||||
}
|
||||
name := common.RemoveInvalidChars(data.Name)
|
||||
torrent.Id = data.ID
|
||||
torrent.Name = name
|
||||
torrent.Bytes = data.TotalSize
|
||||
torrent.Folder = name
|
||||
torrent.Progress = data.DownloadPercent
|
||||
torrent.Status = status
|
||||
torrent.Speed = data.DownloadSpeed
|
||||
torrent.Seeders = data.PeersConnected
|
||||
torrent.Filename = name
|
||||
torrent.OriginalFilename = name
|
||||
files := make([]TorrentFile, len(data.Files))
|
||||
cfg := config.GetConfig()
|
||||
for i, f := range data.Files {
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
files[i] = TorrentFile{
|
||||
Id: f.ID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
Path: f.Name,
|
||||
}
|
||||
}
|
||||
torrent.Files = files
|
||||
torrent.Debrid = r
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *DebridLink) SubmitMagnet(torrent *Torrent) (*Torrent, error) {
|
||||
url := fmt.Sprintf("%s/seedbox/add", r.Host)
|
||||
payload := map[string]string{"url": torrent.Magnet.Link}
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonPayload))
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res types.DebridLinkSubmitTorrentInfo
|
||||
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", torrent.Name, data.ID)
|
||||
name := common.RemoveInvalidChars(data.Name)
|
||||
torrent.Id = data.ID
|
||||
torrent.Name = name
|
||||
torrent.Bytes = data.TotalSize
|
||||
torrent.Folder = name
|
||||
torrent.Progress = data.DownloadPercent
|
||||
torrent.Status = status
|
||||
torrent.Speed = data.DownloadSpeed
|
||||
torrent.Seeders = data.PeersConnected
|
||||
torrent.Filename = name
|
||||
torrent.OriginalFilename = name
|
||||
files := make([]TorrentFile, len(data.Files))
|
||||
for i, f := range data.Files {
|
||||
files[i] = TorrentFile{
|
||||
Id: f.ID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
Path: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
}
|
||||
}
|
||||
torrent.Files = files
|
||||
torrent.Debrid = r
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *DebridLink) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error) {
|
||||
for {
|
||||
t, err := r.GetTorrent(torrent.Id)
|
||||
torrent = t
|
||||
if err != nil || torrent == nil {
|
||||
return torrent, err
|
||||
}
|
||||
status := torrent.Status
|
||||
if status == "downloaded" {
|
||||
r.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||
if !isSymlink {
|
||||
err = r.GetDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if status == "downloading" {
|
||||
if !r.DownloadUncached {
|
||||
go torrent.Delete()
|
||||
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 (r *DebridLink) DeleteTorrent(torrent *Torrent) {
|
||||
url := fmt.Sprintf("%s/seedbox/%s/remove", 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 *DebridLink) GetDownloadLinks(torrent *Torrent) error {
|
||||
downloadLinks := make([]TorrentDownloadLinks, 0)
|
||||
for _, f := range torrent.Files {
|
||||
dl := TorrentDownloadLinks{
|
||||
Link: f.Link,
|
||||
Filename: f.Name,
|
||||
}
|
||||
downloadLinks = append(downloadLinks, dl)
|
||||
}
|
||||
torrent.DownloadLinks = downloadLinks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *DebridLink) GetCheckCached() bool {
|
||||
return r.CheckCached
|
||||
}
|
||||
|
||||
func NewDebridLink(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{
|
||||
BaseDebrid: BaseDebrid{
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -29,10 +28,6 @@ type DebridLink struct {
|
||||
CheckCached bool
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetMountPath() string {
|
||||
return dl.MountPath
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetName() string {
|
||||
return dl.Name
|
||||
}
|
||||
@@ -176,7 +171,6 @@ func (dl *DebridLink) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error)
|
||||
}
|
||||
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
|
||||
|
||||
@@ -12,7 +12,6 @@ type Service interface {
|
||||
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)
|
||||
|
||||
@@ -1,303 +0,0 @@
|
||||
package debrid
|
||||
|
||||
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/types"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RealDebrid struct {
|
||||
BaseDebrid
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func GetTorrentFiles(data types.RealDebridTorrentInfo) []TorrentFile {
|
||||
files := make([]TorrentFile, 0)
|
||||
cfg := config.GetConfig()
|
||||
for _, f := range data.Files {
|
||||
name := filepath.Base(f.Path)
|
||||
if utils.RegexMatch(utils.SAMPLEMATCH, name) {
|
||||
// Skip sample files
|
||||
continue
|
||||
}
|
||||
if !cfg.IsAllowedFile(name) {
|
||||
continue
|
||||
}
|
||||
if !cfg.IsSizeAllowed(f.Bytes) {
|
||||
continue
|
||||
}
|
||||
fileId := f.ID
|
||||
file := TorrentFile{
|
||||
Name: name,
|
||||
Path: name,
|
||||
Size: f.Bytes,
|
||||
Id: strconv.Itoa(fileId),
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
hashes, result := 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 types.RealDebridAvailabilityResponse
|
||||
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(torrent *Torrent) (*Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/addMagnet", r.Host)
|
||||
payload := gourl.Values{
|
||||
"magnet": {torrent.Magnet.Link},
|
||||
}
|
||||
var data types.RealDebridAddMagnetSchema
|
||||
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", torrent.Name, data.Id)
|
||||
torrent.Id = data.Id
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetTorrent(id string) (*Torrent, error) {
|
||||
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 torrent, err
|
||||
}
|
||||
var data types.RealDebridTorrentInfo
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
name := common.RemoveInvalidChars(data.OriginalFilename)
|
||||
torrent.Id = id
|
||||
torrent.Name = name
|
||||
torrent.Bytes = data.Bytes
|
||||
torrent.Folder = name
|
||||
torrent.Progress = data.Progress
|
||||
torrent.Status = data.Status
|
||||
torrent.Speed = data.Speed
|
||||
torrent.Seeders = data.Seeders
|
||||
torrent.Filename = data.Filename
|
||||
torrent.OriginalFilename = data.OriginalFilename
|
||||
torrent.Links = data.Links
|
||||
torrent.Debrid = r
|
||||
files := GetTorrentFiles(data)
|
||||
torrent.Files = files
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrent.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 torrent, err
|
||||
}
|
||||
var data types.RealDebridTorrentInfo
|
||||
err = json.Unmarshal(resp, &data)
|
||||
status := data.Status
|
||||
name := common.RemoveInvalidChars(data.OriginalFilename)
|
||||
torrent.Name = name // Important because some magnet changes the name
|
||||
torrent.Folder = name
|
||||
torrent.Filename = data.Filename
|
||||
torrent.OriginalFilename = data.OriginalFilename
|
||||
torrent.Bytes = data.Bytes
|
||||
torrent.Progress = data.Progress
|
||||
torrent.Speed = data.Speed
|
||||
torrent.Seeders = data.Seeders
|
||||
torrent.Links = data.Links
|
||||
torrent.Status = status
|
||||
torrent.Debrid = r
|
||||
downloadingStatus := []string{"downloading", "magnet_conversion", "queued", "compressing", "uploading"}
|
||||
if status == "waiting_files_selection" {
|
||||
files := GetTorrentFiles(data)
|
||||
torrent.Files = files
|
||||
if len(files) == 0 {
|
||||
return torrent, 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, torrent.Id), payload)
|
||||
_, err = r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
} else if status == "downloaded" {
|
||||
files := GetTorrentFiles(data)
|
||||
torrent.Files = files
|
||||
r.logger.Info().Msgf("Torrent: %s downloaded to RD", torrent.Name)
|
||||
if !isSymlink {
|
||||
err = r.GetDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if slices.Contains(downloadingStatus, status) {
|
||||
if !r.DownloadUncached {
|
||||
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: %s", torrent.Name, status)
|
||||
}
|
||||
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) DeleteTorrent(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(torrent *Torrent) error {
|
||||
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
|
||||
downloadLinks := make([]TorrentDownloadLinks, 0)
|
||||
for _, link := range torrent.Links {
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
payload := gourl.Values{
|
||||
"link": {link},
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data types.RealDebridUnrestrictResponse
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
download := TorrentDownloadLinks{
|
||||
Link: data.Link,
|
||||
Filename: data.Filename,
|
||||
DownloadLink: data.Download,
|
||||
}
|
||||
downloadLinks = append(downloadLinks, download)
|
||||
}
|
||||
torrent.DownloadLinks = downloadLinks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetCheckCached() bool {
|
||||
return r.CheckCached
|
||||
}
|
||||
|
||||
func NewRealDebrid(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{
|
||||
BaseDebrid: BaseDebrid{
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -31,10 +31,6 @@ type RealDebrid struct {
|
||||
CheckCached bool
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetMountPath() string {
|
||||
return r.MountPath
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetName() string {
|
||||
return r.Name
|
||||
}
|
||||
@@ -156,9 +152,9 @@ func (r *RealDebrid) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error)
|
||||
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
|
||||
|
||||
t.Debrid = r.Name
|
||||
t.MountPath = r.MountPath
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -218,6 +214,8 @@ func (r *RealDebrid) CheckStatus(t *torrent.Torrent, isSymlink bool) (*torrent.T
|
||||
t.Seeders = data.Seeders
|
||||
t.Links = data.Links
|
||||
t.Status = status
|
||||
t.Debrid = r.Name
|
||||
t.MountPath = r.MountPath
|
||||
downloadingStatus := []string{"downloading", "magnet_conversion", "queued", "compressing", "uploading"}
|
||||
if status == "waiting_files_selection" {
|
||||
files := GetTorrentFiles(data, true) // Validate files to be selected
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package debrid
|
||||
|
||||
type DebridService struct {
|
||||
debrids []Service
|
||||
lastUsed int
|
||||
}
|
||||
|
||||
func (d *DebridService) Get() Service {
|
||||
if d.lastUsed == 0 {
|
||||
return d.debrids[0]
|
||||
}
|
||||
return d.debrids[d.lastUsed]
|
||||
}
|
||||
|
||||
func (d *DebridService) GetByName(name string) Service {
|
||||
for _, deb := range d.debrids {
|
||||
if deb.GetName() == name {
|
||||
return deb
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,313 +0,0 @@
|
||||
package debrid
|
||||
|
||||
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/types"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Torbox struct {
|
||||
BaseDebrid
|
||||
}
|
||||
|
||||
func (r *Torbox) GetMountPath() string {
|
||||
return r.MountPath
|
||||
}
|
||||
|
||||
func (r *Torbox) GetName() string {
|
||||
return r.Name
|
||||
}
|
||||
|
||||
func (r *Torbox) GetLogger() zerolog.Logger {
|
||||
return r.logger
|
||||
}
|
||||
|
||||
func (r *Torbox) IsAvailable(infohashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
hashes, result := 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 += 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", 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 res types.TorBoxAvailableResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
r.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
|
||||
}
|
||||
}
|
||||
}
|
||||
r.cache.AddMultiple(result) // Add the results to the cache
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *Torbox) SubmitMagnet(torrent *Torrent) (*Torrent, error) {
|
||||
url := fmt.Sprintf("%s/api/torrents/createtorrent", r.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 := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data types.TorBoxAddMagnetResponse
|
||||
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 (r *Torbox) GetTorrent(id string) (*Torrent, error) {
|
||||
torrent := &Torrent{}
|
||||
url := fmt.Sprintf("%s/api/torrents/mylist/?id=%s", r.Host, id)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
var res types.TorboxInfoResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
data := res.Data
|
||||
name := data.Name
|
||||
torrent.Id = id
|
||||
torrent.Name = name
|
||||
torrent.Bytes = data.Size
|
||||
torrent.Folder = name
|
||||
torrent.Progress = data.Progress * 100
|
||||
torrent.Status = getTorboxStatus(data.DownloadState, data.DownloadFinished)
|
||||
torrent.Speed = data.DownloadSpeed
|
||||
torrent.Seeders = data.Seeds
|
||||
torrent.Filename = name
|
||||
torrent.OriginalFilename = name
|
||||
files := make([]TorrentFile, 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 := TorrentFile{
|
||||
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)
|
||||
}
|
||||
|
||||
torrent.OriginalFilename = strings.Split(cleanPath, "/")[0]
|
||||
torrent.Files = files
|
||||
torrent.Debrid = r
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *Torbox) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error) {
|
||||
for {
|
||||
tb, err := r.GetTorrent(torrent.Id)
|
||||
|
||||
torrent = tb
|
||||
|
||||
if err != nil || tb == nil {
|
||||
return tb, err
|
||||
}
|
||||
status := torrent.Status
|
||||
if status == "downloaded" {
|
||||
r.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||
if !isSymlink {
|
||||
err = r.GetDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if status == "downloading" {
|
||||
if !r.DownloadUncached {
|
||||
go torrent.Delete()
|
||||
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 (r *Torbox) DeleteTorrent(torrent *Torrent) {
|
||||
url := fmt.Sprintf("%s/api/torrents/controltorrent/%s", r.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 := 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 *Torbox) GetDownloadLinks(torrent *Torrent) error {
|
||||
downloadLinks := make([]TorrentDownloadLinks, 0)
|
||||
for _, file := range torrent.Files {
|
||||
url := fmt.Sprintf("%s/api/torrents/requestdl/", r.Host)
|
||||
query := gourl.Values{}
|
||||
query.Add("torrent_id", torrent.Id)
|
||||
query.Add("token", r.APIKey)
|
||||
query.Add("file_id", file.Id)
|
||||
url += "?" + query.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data types.TorBoxDownloadLinksResponse
|
||||
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 := TorrentDownloadLinks{
|
||||
Link: link,
|
||||
Filename: torrent.Files[idx].Name,
|
||||
DownloadLink: link,
|
||||
}
|
||||
downloadLinks = append(downloadLinks, dl)
|
||||
}
|
||||
torrent.DownloadLinks = downloadLinks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Torbox) GetCheckCached() bool {
|
||||
return r.CheckCached
|
||||
}
|
||||
|
||||
func NewTorbox(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{
|
||||
BaseDebrid: BaseDebrid{
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
@@ -35,10 +34,6 @@ type Torbox struct {
|
||||
CheckCached bool
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetMountPath() string {
|
||||
return tb.MountPath
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetName() string {
|
||||
return tb.Name
|
||||
}
|
||||
@@ -130,8 +125,9 @@ func (tb *Torbox) SubmitMagnet(torrent *torrent.Torrent) (*torrent.Torrent, erro
|
||||
}
|
||||
dt := *data.Data
|
||||
torrentId := strconv.Itoa(dt.Id)
|
||||
log.Printf("Torrent: %s added with id: %s", torrent.Name, torrentId)
|
||||
torrent.Id = torrentId
|
||||
torrent.MountPath = tb.MountPath
|
||||
torrent.Debrid = tb.Name
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
@@ -1,122 +0,0 @@
|
||||
package debrid
|
||||
|
||||
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 []TorrentFile `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 []TorrentDownloadLinks `json:"download_links"`
|
||||
|
||||
Debrid Service `json:"-"`
|
||||
Arr *arr.Arr `json:"arr"`
|
||||
Mu sync.Mutex `json:"-"`
|
||||
SizeDownloaded int64 `json:"-"` // This is used for local download
|
||||
}
|
||||
|
||||
type TorrentDownloadLinks 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")
|
||||
}
|
||||
|
||||
func (t *Torrent) Delete() {
|
||||
if t.Debrid == nil {
|
||||
return
|
||||
}
|
||||
t.Debrid.DeleteTorrent(t)
|
||||
|
||||
}
|
||||
|
||||
type TorrentFile struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Path string `json:"path"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
func getEventId(eventType string) int {
|
||||
switch eventType {
|
||||
case "grabbed":
|
||||
return 1
|
||||
case "seriesFolderDownloaded":
|
||||
return 2
|
||||
case "DownloadFolderImported":
|
||||
return 3
|
||||
case "DownloadFailed":
|
||||
return 4
|
||||
case "DownloadIgnored":
|
||||
return 7
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Torrent) Cleanup(remove bool) {
|
||||
if remove {
|
||||
err := os.Remove(t.Filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package types
|
||||
|
||||
type errorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type AllDebridMagnetFile struct {
|
||||
Name string `json:"n"`
|
||||
Size int64 `json:"s"`
|
||||
Link string `json:"l"`
|
||||
Elements []AllDebridMagnetFile `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 []AllDebridMagnetFile `json:"files"`
|
||||
}
|
||||
|
||||
type AllDebridTorrentInfoResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Magnets magnetInfo `json:"magnets"`
|
||||
} `json:"data"`
|
||||
Error *errorResponse `json:"error"`
|
||||
}
|
||||
|
||||
type AllDebridUploadMagnetResponse 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 AllDebridDownloadLink 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"`
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package types
|
||||
|
||||
type DebridLinkAPIResponse[T any] struct {
|
||||
Success bool `json:"success"`
|
||||
Value *T `json:"value"` // Use pointer to allow nil
|
||||
}
|
||||
|
||||
type DebridLinkAvailableResponse DebridLinkAPIResponse[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 DebridLinkTorrentInfo DebridLinkAPIResponse[[]debridLinkTorrentInfo]
|
||||
|
||||
type DebridLinkSubmitTorrentInfo DebridLinkAPIResponse[debridLinkTorrentInfo]
|
||||
@@ -1,107 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type RealDebridAvailabilityResponse map[string]Hoster
|
||||
|
||||
func (r *RealDebridAvailabilityResponse) 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 RealDebridAddMagnetSchema struct {
|
||||
Id string `json:"id"`
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
type RealDebridTorrentInfo 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 RealDebridUnrestrictResponse 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"`
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package types
|
||||
|
||||
import "time"
|
||||
|
||||
type TorboxAPIResponse[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 TorBoxAvailableResponse TorboxAPIResponse[map[string]struct {
|
||||
Name string `json:"name"`
|
||||
Size int `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
}]
|
||||
|
||||
type TorBoxAddMagnetResponse TorboxAPIResponse[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 TorboxInfoResponse TorboxAPIResponse[torboxInfo]
|
||||
|
||||
type TorBoxDownloadLinksResponse TorboxAPIResponse[string]
|
||||
@@ -0,0 +1,2 @@
|
||||
package downloader
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package downloaders
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"github.com/valyala/fasthttp"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func GetFastHTTPClient() *fasthttp.Client {
|
||||
return &fasthttp.Client{
|
||||
TLSConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
StreamResponseBody: true,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalFastHTTP(client *fasthttp.Client, url, filename string) error {
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.SetRequestURI(url)
|
||||
req.Header.SetMethod(fasthttp.MethodGet)
|
||||
|
||||
if err := client.Do(req, resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check the response status code
|
||||
if resp.StatusCode() != fasthttp.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode())
|
||||
}
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
fmt.Println("Error closing file:", err)
|
||||
return
|
||||
}
|
||||
}(file)
|
||||
bodyStream := resp.BodyStream()
|
||||
if bodyStream == nil {
|
||||
// Write to memory and then to file
|
||||
_, err := file.Write(resp.Body())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := io.Copy(file, bodyStream); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package downloaders
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"github.com/cavaliergopher/grab/v3"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetGrabClient() *grab.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
return &grab.Client{
|
||||
UserAgent: "qBitTorrent",
|
||||
HTTPClient: &http.Client{
|
||||
Transport: tr,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NormalGrab(client *grab.Client, url, filename string, progressCallback func(int64, int64)) error {
|
||||
req, err := grab.NewRequest(filename, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp := client.Do(req)
|
||||
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
|
||||
var lastReported int64
|
||||
Loop:
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
current := resp.BytesComplete()
|
||||
speed := int64(resp.BytesPerSecond())
|
||||
if current != lastReported {
|
||||
if progressCallback != nil {
|
||||
progressCallback(current-lastReported, speed)
|
||||
}
|
||||
lastReported = current
|
||||
}
|
||||
case <-resp.Done:
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
|
||||
// Report final bytes
|
||||
if progressCallback != nil {
|
||||
progressCallback(resp.BytesComplete()-lastReported, 0)
|
||||
}
|
||||
|
||||
return resp.Err()
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package downloaders
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func GetHTTPClient() *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
return &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
func NormalHTTP(client *http.Client, url, filename string) error {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Send the HTTP GET request
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
fmt.Println("Error downloading file:", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("server returned non-200 status: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// Write the response body to file
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+6
-6
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||
"github.com/valyala/fastjson"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -76,11 +76,10 @@ type Proxy struct {
|
||||
username string
|
||||
password string
|
||||
cachedOnly bool
|
||||
debrid debrid.Service
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func NewProxy(deb *debrid.DebridService) *Proxy {
|
||||
func NewProxy() *Proxy {
|
||||
cfg := config.GetConfig().Proxy
|
||||
port := cmp.Or(os.Getenv("PORT"), cfg.Port, "8181")
|
||||
return &Proxy{
|
||||
@@ -89,8 +88,7 @@ func NewProxy(deb *debrid.DebridService) *Proxy {
|
||||
username: cfg.Username,
|
||||
password: cfg.Password,
|
||||
cachedOnly: *cfg.CachedOnly,
|
||||
debrid: deb.Get(),
|
||||
logger: logger.NewLogger("Proxy", cfg.LogLevel, os.Stdout),
|
||||
logger: logger.NewLogger("proxy", cfg.LogLevel, os.Stdout),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,6 +226,8 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
return resp
|
||||
}
|
||||
|
||||
svc := service.GetService()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
p.logger.Info().Msgf("Error reading response body: %v", err)
|
||||
@@ -262,7 +262,7 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
}
|
||||
availableHashesMap := p.debrid.IsAvailable(hashes)
|
||||
availableHashesMap := svc.Debrid.Get().IsAvailable(hashes)
|
||||
newItems := make([]Item, 0, len(rss.Channel.Items))
|
||||
|
||||
if len(hashes) > 0 {
|
||||
|
||||
+66
-19
@@ -1,27 +1,64 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"github.com/cavaliergopher/grab/v3"
|
||||
"github.com/sirrobot01/debrid-blackhole/common"
|
||||
debrid "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/downloaders"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Download(client *grab.Client, url, filename string, progressCallback func(int64, int64)) error {
|
||||
req, err := grab.NewRequest(filename, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp := client.Do(req)
|
||||
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
|
||||
var lastReported int64
|
||||
Loop:
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
current := resp.BytesComplete()
|
||||
speed := int64(resp.BytesPerSecond())
|
||||
if current != lastReported {
|
||||
if progressCallback != nil {
|
||||
progressCallback(current-lastReported, speed)
|
||||
}
|
||||
lastReported = current
|
||||
}
|
||||
case <-resp.Done:
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
|
||||
// Report final bytes
|
||||
if progressCallback != nil {
|
||||
progressCallback(resp.BytesComplete()-lastReported, 0)
|
||||
}
|
||||
|
||||
return resp.Err()
|
||||
}
|
||||
|
||||
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)
|
||||
torrentPath := common.RemoveInvalidChars(filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, common.RemoveExtension(debridTorrent.OriginalFilename)))
|
||||
err := os.MkdirAll(torrentPath, os.ModePerm)
|
||||
if err != nil {
|
||||
// add previous error to the error and return
|
||||
return "", fmt.Errorf("failed to create directory: %s: %v", parent, err)
|
||||
return "", fmt.Errorf("failed to create directory: %s: %v", torrentPath, err)
|
||||
}
|
||||
q.downloadFiles(torrent, parent)
|
||||
q.downloadFiles(torrent, torrentPath)
|
||||
return torrentPath, nil
|
||||
}
|
||||
|
||||
@@ -37,7 +74,6 @@ func (q *QBit) downloadFiles(torrent *Torrent, parent string) {
|
||||
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()
|
||||
@@ -54,6 +90,17 @@ func (q *QBit) downloadFiles(torrent *Torrent, parent string) {
|
||||
}
|
||||
q.UpdateTorrentMin(torrent, debridTorrent)
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
client := &grab.Client{
|
||||
UserAgent: "qBitTorrent",
|
||||
HTTPClient: &http.Client{
|
||||
Transport: tr,
|
||||
},
|
||||
}
|
||||
for _, link := range debridTorrent.DownloadLinks {
|
||||
if link.DownloadLink == "" {
|
||||
q.logger.Info().Msgf("No download link found for %s", link.Filename)
|
||||
@@ -66,7 +113,7 @@ func (q *QBit) downloadFiles(torrent *Torrent, parent string) {
|
||||
defer func() { <-semaphore }()
|
||||
filename := link.Filename
|
||||
|
||||
err := downloaders.NormalGrab(
|
||||
err := Download(
|
||||
client,
|
||||
link.DownloadLink,
|
||||
filepath.Join(parent, filename),
|
||||
@@ -92,25 +139,26 @@ func (q *QBit) ProcessSymlink(torrent *Torrent) (string, error) {
|
||||
if len(files) == 0 {
|
||||
return "", fmt.Errorf("no video files found")
|
||||
}
|
||||
q.logger.Info().Msgf("Checking %d files...", len(files))
|
||||
q.logger.Info().Msgf("Checking symlinks for %d files...", len(files))
|
||||
rCloneBase := debridTorrent.MountPath
|
||||
torrentPath, err := q.getTorrentPath(rCloneBase, debridTorrent) // /MyTVShow/
|
||||
// This returns filename.ext for alldebrid instead of the parent folder filename/
|
||||
torrentFolder := torrentPath
|
||||
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
|
||||
// Check if the torrent path is a file
|
||||
torrentRclonePath := filepath.Join(rCloneBase, torrentPath) // leave it as is
|
||||
if debridTorrent.Debrid == "alldebrid" && len(files) == 1 {
|
||||
// Alldebrid hotfix for single file torrents
|
||||
torrentFolder = common.RemoveExtension(torrentFolder)
|
||||
torrentRclonePath = rCloneBase // /mnt/rclone/magnets/ // Remove the filename since it's in the root folder
|
||||
}
|
||||
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, newTorrentPath) // /mnt/symlinks/{category}/MyTVShow/
|
||||
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentFolder) // /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)
|
||||
@@ -125,12 +173,11 @@ func (q *QBit) ProcessSymlink(torrent *Torrent) (string, error) {
|
||||
q.logger.Info().Msgf("File is ready: %s", f.Path)
|
||||
q.createSymLink(torrentSymlinkPath, torrentRclonePath, f)
|
||||
}
|
||||
return torrentPath, nil
|
||||
return torrentSymlinkPath, 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)
|
||||
|
||||
@@ -74,6 +74,7 @@ func (i *ImportRequest) Process(q *QBit) (err error) {
|
||||
torrent := CreateTorrentFromMagnet(magnet, i.Arr.Name, "manual")
|
||||
debridTorrent, err := debrid.ProcessTorrent(svc.Debrid, magnet, i.Arr, i.IsSymlink)
|
||||
if err != nil || debridTorrent == nil {
|
||||
fmt.Println("Error deleting torrent: ", err)
|
||||
if debridTorrent != nil {
|
||||
dbClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
|
||||
go dbClient.DeleteTorrent(debridTorrent)
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/server"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/repair"
|
||||
)
|
||||
|
||||
func Start(ctx context.Context, deb *debrid.DebridService, arrs *arr.Storage, _repair *repair.Repair) error {
|
||||
srv := server.NewServer(deb, arrs, _repair)
|
||||
if err := srv.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to start qbit server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+4
-2
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type QBit struct {
|
||||
@@ -22,7 +23,8 @@ type QBit struct {
|
||||
}
|
||||
|
||||
func New() *QBit {
|
||||
cfg := config.GetConfig().QBitTorrent
|
||||
_cfg := config.GetConfig()
|
||||
cfg := _cfg.QBitTorrent
|
||||
port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8282")
|
||||
refreshInterval := cmp.Or(cfg.RefreshInterval, 10)
|
||||
return &QBit{
|
||||
@@ -31,7 +33,7 @@ func New() *QBit {
|
||||
Port: port,
|
||||
DownloadFolder: cfg.DownloadFolder,
|
||||
Categories: cfg.Categories,
|
||||
Storage: NewTorrentStorage(cmp.Or(os.Getenv("TORRENT_FILE"), "/data/qbit_torrents.json")),
|
||||
Storage: NewTorrentStorage(filepath.Join(_cfg.Path, "torrents.json")),
|
||||
logger: logger.NewLogger("qbit", cfg.LogLevel, os.Stdout),
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
|
||||
@@ -2,15 +2,11 @@ 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)
|
||||
|
||||
|
||||
@@ -1,88 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
|
||||
)
|
||||
|
||||
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 *shared.QBit) (err error) {
|
||||
// Use this for now.
|
||||
// This sends the torrent to the arr
|
||||
magnet, err := utils.GetMagnetFromUrl(i.URI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing magnet link: %w", err)
|
||||
}
|
||||
torrent := q.CreateTorrentFromMagnet(magnet, i.Arr.Name, "manual")
|
||||
debridTorrent, err := debrid.ProcessTorrent(q.Debrid, magnet, i.Arr, i.IsSymlink)
|
||||
if err != nil || debridTorrent == nil {
|
||||
if debridTorrent != nil {
|
||||
go debridTorrent.Delete()
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,382 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type QbitHandler struct {
|
||||
qbit *shared.QBit
|
||||
logger zerolog.Logger
|
||||
debug bool
|
||||
}
|
||||
|
||||
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 *QbitHandler) 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 *QbitHandler) 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)
|
||||
}
|
||||
q.qbit.Arrs.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 *QbitHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("v4.3.2"))
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("2.7"))
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handlePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
preferences := shared.NewAppPreferences()
|
||||
|
||||
preferences.WebUiUsername = q.qbit.Username
|
||||
preferences.SavePath = q.qbit.DownloadFolder
|
||||
preferences.TempPath = filepath.Join(q.qbit.DownloadFolder, "temp")
|
||||
|
||||
request.JSONResponse(w, preferences, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleBuildInfo(w http.ResponseWriter, r *http.Request) {
|
||||
res := shared.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 *QbitHandler) shutdown(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) 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.qbit.Storage.GetAll(category, filter, hashes)
|
||||
request.JSONResponse(w, torrents, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) 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.qbit.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.qbit.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 *QbitHandler) 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.qbit.Storage.Delete(hash)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleTorrentsPause(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.qbit.Storage.Get(hash)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.qbit.PauseTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleTorrentsResume(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.qbit.Storage.Get(hash)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.qbit.ResumeTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleTorrentRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.qbit.Storage.Get(hash)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.qbit.RefreshTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||
var categories = map[string]shared.TorrentCategory{}
|
||||
for _, cat := range q.qbit.Categories {
|
||||
path := filepath.Join(q.qbit.DownloadFolder, cat)
|
||||
categories[cat] = shared.TorrentCategory{
|
||||
Name: cat,
|
||||
SavePath: path,
|
||||
}
|
||||
}
|
||||
request.JSONResponse(w, categories, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) 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.qbit.Categories = append(q.qbit.Categories, name)
|
||||
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
torrent := q.qbit.Storage.Get(hash)
|
||||
properties := q.qbit.GetTorrentProperties(torrent)
|
||||
request.JSONResponse(w, properties, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleTorrentFiles(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
torrent := q.qbit.Storage.Get(hash)
|
||||
if torrent == nil {
|
||||
return
|
||||
}
|
||||
files := q.qbit.GetTorrentFiles(torrent)
|
||||
request.JSONResponse(w, files, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleSetCategory(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
category := ctx.Value("category").(string)
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
torrents := q.qbit.Storage.GetAll("", "", hashes)
|
||||
for _, torrent := range torrents {
|
||||
torrent.Category = category
|
||||
q.qbit.Storage.AddOrUpdate(torrent)
|
||||
}
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) 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.qbit.Storage.GetAll("", "", hashes)
|
||||
for _, t := range torrents {
|
||||
q.qbit.SetTorrentTags(t, tags)
|
||||
}
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) 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.qbit.Storage.GetAll("", "", hashes)
|
||||
for _, torrent := range torrents {
|
||||
q.qbit.RemoveTorrentTags(torrent, tags)
|
||||
|
||||
}
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) handleGetTags(w http.ResponseWriter, r *http.Request) {
|
||||
request.JSONResponse(w, q.qbit.Tags, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QbitHandler) 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.qbit.AddTags(tags)
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (q *QbitHandler) Routes(r chi.Router) http.Handler {
|
||||
r.Route("/api/v2", func(r chi.Router) {
|
||||
//if q.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.shutdown)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
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"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/repair"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
qbit *shared.QBit
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func NewServer(deb *debrid.DebridService, arrs *arr.Storage, _repair *repair.Repair) *Server {
|
||||
cfg := config.GetConfig()
|
||||
l := logger.NewLogger("QBit", cfg.QBitTorrent.LogLevel, os.Stdout)
|
||||
q := shared.NewQBit(deb, l, arrs, _repair)
|
||||
return &Server{
|
||||
qbit: q,
|
||||
logger: l,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
logLevel := s.logger.GetLevel().String()
|
||||
debug := logLevel == "debug"
|
||||
q := QbitHandler{qbit: s.qbit, logger: s.logger, debug: debug}
|
||||
ui := UIHandler{
|
||||
qbit: s.qbit,
|
||||
logger: logger.NewLogger("UI", s.logger.GetLevel().String(), os.Stdout),
|
||||
debug: debug,
|
||||
}
|
||||
|
||||
// Register routes
|
||||
r.Get("/logs", s.GetLogs)
|
||||
q.Routes(r)
|
||||
ui.Routes(r)
|
||||
|
||||
go s.qbit.StartWorker(context.Background())
|
||||
|
||||
s.logger.Info().Msgf("Starting QBit server on :%s", s.qbit.Port)
|
||||
port := fmt.Sprintf(":%s", s.qbit.Port)
|
||||
srv := &http.Server{
|
||||
Addr: port,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
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) 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
|
||||
}
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
{{ 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 }}
|
||||
@@ -1,142 +0,0 @@
|
||||
{{ 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 }}
|
||||
@@ -1,245 +0,0 @@
|
||||
{{ 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 }}
|
||||
@@ -1,196 +0,0 @@
|
||||
{{ 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 }}
|
||||
@@ -1,131 +0,0 @@
|
||||
{{ 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 }}
|
||||
@@ -1,94 +0,0 @@
|
||||
{{ 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 }}
|
||||
@@ -1,32 +0,0 @@
|
||||
{{ 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 }}
|
||||
@@ -1,50 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (u *UIHandler) 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 (u *UIHandler) 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
|
||||
}
|
||||
@@ -1,392 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
|
||||
"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 templates/*
|
||||
var content embed.FS
|
||||
|
||||
type UIHandler struct {
|
||||
qbit *shared.QBit
|
||||
logger zerolog.Logger
|
||||
debug bool
|
||||
}
|
||||
|
||||
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,
|
||||
"templates/layout.html",
|
||||
"templates/index.html",
|
||||
"templates/download.html",
|
||||
"templates/repair.html",
|
||||
"templates/config.html",
|
||||
"templates/login.html",
|
||||
"templates/setup.html",
|
||||
))
|
||||
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7,
|
||||
HttpOnly: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (u *UIHandler) 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 u.verifyAuth(credentials.Username, credentials.Password) {
|
||||
session, _ := store.Get(r, "auth-session")
|
||||
session.Values["authenticated"] = true
|
||||
session.Values["username"] = credentials.Username
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (u *UIHandler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := store.Get(r, "auth-session")
|
||||
session.Values["authenticated"] = false
|
||||
session.Options.MaxAge = -1
|
||||
session.Save(r, w)
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UIHandler) 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
|
||||
session.Save(r, w)
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (u *UIHandler) 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 (u *UIHandler) 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 (u *UIHandler) 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 (u *UIHandler) 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 (u *UIHandler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
|
||||
request.JSONResponse(w, u.qbit.Arrs.GetAll(), http.StatusOK)
|
||||
}
|
||||
|
||||
func (u *UIHandler) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
results := make([]*ImportRequest, 0)
|
||||
errs := make([]string, 0)
|
||||
|
||||
arrName := r.FormValue("arr")
|
||||
notSymlink := r.FormValue("notSymlink") == "true"
|
||||
|
||||
_arr := u.qbit.Arrs.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 := NewImportRequest(url, _arr, !notSymlink)
|
||||
err := importReq.Process(u.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 := NewImportRequest(magnet.Link, _arr, !notSymlink)
|
||||
err = importReq.Process(u.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 []*ImportRequest `json:"results"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}{
|
||||
Results: results,
|
||||
Errors: errs,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
func (u *UIHandler) handleCheckCached(w http.ResponseWriter, r *http.Request) {
|
||||
_hashes := r.URL.Query().Get("hash")
|
||||
if _hashes == "" {
|
||||
http.Error(w, "No hashes provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hashes := strings.Split(_hashes, ",")
|
||||
if len(hashes) == 0 {
|
||||
http.Error(w, "No hashes provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
db := r.URL.Query().Get("debrid")
|
||||
var deb debrid.Service
|
||||
if db == "" {
|
||||
// use the first debrid
|
||||
deb = u.qbit.Debrid.Get()
|
||||
} else {
|
||||
deb = u.qbit.Debrid.GetByName(db)
|
||||
}
|
||||
if deb == nil {
|
||||
http.Error(w, "Invalid debrid", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
res := deb.IsAvailable(hashes)
|
||||
result := make(map[string]bool)
|
||||
for _, h := range hashes {
|
||||
_, exists := res[h]
|
||||
result[h] = exists
|
||||
}
|
||||
request.JSONResponse(w, result, http.StatusOK)
|
||||
}
|
||||
|
||||
func (u *UIHandler) 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
|
||||
}
|
||||
|
||||
_arr := u.qbit.Arrs.Get(req.ArrName)
|
||||
if _arr == nil {
|
||||
http.Error(w, "No Arrs found to repair", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Async {
|
||||
go func() {
|
||||
if err := u.qbit.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil {
|
||||
u.logger.Error().Err(err).Msg("Failed to repair media")
|
||||
}
|
||||
}()
|
||||
request.JSONResponse(w, "Repair process started", http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if err := u.qbit.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 (u *UIHandler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
|
||||
v := version.GetInfo()
|
||||
request.JSONResponse(w, v, http.StatusOK)
|
||||
}
|
||||
|
||||
func (u *UIHandler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
|
||||
request.JSONResponse(w, u.qbit.Storage.GetAll("", "", nil), http.StatusOK)
|
||||
}
|
||||
|
||||
func (u *UIHandler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
|
||||
hash := chi.URLParam(r, "hash")
|
||||
if hash == "" {
|
||||
http.Error(w, "No hash provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
u.qbit.Storage.Delete(hash)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (u *UIHandler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := config.GetConfig()
|
||||
arrCfgs := make([]config.Arr, 0)
|
||||
for _, a := range u.qbit.Arrs.GetAll() {
|
||||
arrCfgs = append(arrCfgs, config.Arr{Host: a.Host, Name: a.Name, Token: a.Token})
|
||||
}
|
||||
cfg.Arrs = arrCfgs
|
||||
request.JSONResponse(w, cfg, http.StatusOK)
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (u *UIHandler) Routes(r chi.Router) http.Handler {
|
||||
r.Get("/login", u.LoginHandler)
|
||||
r.Post("/login", u.LoginHandler)
|
||||
r.Get("/setup", u.SetupHandler)
|
||||
r.Post("/setup", u.SetupHandler)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(u.authMiddleware)
|
||||
r.Get("/", u.IndexHandler)
|
||||
r.Get("/download", u.DownloadHandler)
|
||||
r.Get("/repair", u.RepairHandler)
|
||||
r.Get("/config", u.ConfigHandler)
|
||||
r.Route("/internal", func(r chi.Router) {
|
||||
r.Get("/arrs", u.handleGetArrs)
|
||||
r.Post("/add", u.handleAddContent)
|
||||
r.Get("/cached", u.handleCheckCached)
|
||||
r.Post("/repair", u.handleRepairMedia)
|
||||
r.Get("/torrents", u.handleGetTorrents)
|
||||
r.Delete("/torrents/{hash}", u.handleDeleteTorrent)
|
||||
r.Get("/config", u.handleGetConfig)
|
||||
r.Get("/version", u.handleGetVersion)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/debrid-blackhole/common"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"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.TorrentDownloadLinks) {
|
||||
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.TorrentFile, len(files))
|
||||
if len(files) == 0 {
|
||||
return "", fmt.Errorf("no video files found")
|
||||
}
|
||||
q.logger.Info().Msgf("Checking %d files...", len(files))
|
||||
rCloneBase := debridTorrent.Debrid.GetMountPath()
|
||||
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.TorrentFile) {
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/repair"
|
||||
"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"`
|
||||
Debrid *debrid.DebridService
|
||||
Repair *repair.Repair
|
||||
Storage *TorrentStorage
|
||||
debug bool
|
||||
logger zerolog.Logger
|
||||
Arrs *arr.Storage
|
||||
Tags []string
|
||||
RefreshInterval int
|
||||
}
|
||||
|
||||
func NewQBit(deb *debrid.DebridService, logger zerolog.Logger, arrs *arr.Storage, _repair *repair.Repair) *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,
|
||||
Debrid: deb,
|
||||
Storage: NewTorrentStorage(cmp.Or(os.Getenv("TORRENT_FILE"), "/data/torrents.json")),
|
||||
Repair: _repair,
|
||||
logger: logger,
|
||||
Arrs: arrs,
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package shared
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -1,321 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"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 {
|
||||
torrent := q.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 := debrid.ProcessTorrent(q.Debrid, magnet, a, isSymlink)
|
||||
if err != nil || debridTorrent == nil {
|
||||
if debridTorrent != nil {
|
||||
go debridTorrent.Delete()
|
||||
}
|
||||
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) 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
|
||||
}
|
||||
|
||||
func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr *arr.Arr, isSymlink bool) {
|
||||
for debridTorrent.Status != "downloaded" {
|
||||
progress := debridTorrent.Progress
|
||||
q.logger.Debug().Msgf("%s -> (%s) Download Progress: %.2f%%", debridTorrent.Debrid.GetName(), debridTorrent.Name, progress)
|
||||
time.Sleep(10 * time.Second)
|
||||
dbT, err := debridTorrent.Debrid.CheckStatus(debridTorrent, isSymlink)
|
||||
if err != nil {
|
||||
q.logger.Error().Msgf("Error checking status: %v", err)
|
||||
go debridTorrent.Delete()
|
||||
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 debridTorrent.Delete()
|
||||
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.GetName()
|
||||
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 := 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
|
||||
}
|
||||
@@ -1,430 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"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 *debrid.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
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func checkFileLoop(wg *sync.WaitGroup, dir string, file debrid.TorrentFile, ready chan<- debrid.TorrentFile) {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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() {
|
||||
for _, arr := range q.Arrs.GetAll() {
|
||||
err := arr.Refresh()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-14
@@ -90,14 +90,14 @@ func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr
|
||||
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||
}
|
||||
var (
|
||||
torrentPath string
|
||||
err error
|
||||
torrentSymlinkPath string
|
||||
err error
|
||||
)
|
||||
debridTorrent.Arr = arr
|
||||
if isSymlink {
|
||||
torrentPath, err = q.ProcessSymlink(torrent)
|
||||
torrentSymlinkPath, err = q.ProcessSymlink(torrent) // /mnt/symlinks/{category}/MyTVShow/
|
||||
} else {
|
||||
torrentPath, err = q.ProcessManualFile(torrent)
|
||||
torrentSymlinkPath, err = q.ProcessManualFile(torrent)
|
||||
}
|
||||
if err != nil {
|
||||
q.MarkAsFailed(torrent)
|
||||
@@ -105,7 +105,7 @@ func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr
|
||||
q.logger.Info().Msgf("Error: %v", err)
|
||||
return
|
||||
}
|
||||
torrent.TorrentPath = filepath.Base(torrentPath)
|
||||
torrent.TorrentPath = torrentSymlinkPath
|
||||
q.UpdateTorrent(torrent, debridTorrent)
|
||||
_ = arr.Refresh()
|
||||
}
|
||||
@@ -161,7 +161,6 @@ func (q *QBit) UpdateTorrentMin(t *Torrent, debridTorrent *debrid.Torrent) *Torr
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -172,15 +171,8 @@ func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent
|
||||
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
|
||||
t.ContentPath = t.TorrentPath + string(os.PathSeparator)
|
||||
|
||||
if t.IsReady() {
|
||||
t.State = "pausedUP"
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -24,8 +24,8 @@ import (
|
||||
|
||||
type Repair struct {
|
||||
Jobs []Job `json:"jobs"`
|
||||
deb debrid.Service
|
||||
arrs *arr.Storage
|
||||
deb engine.Service
|
||||
duration time.Duration
|
||||
runOnStart bool
|
||||
ZurgURL string
|
||||
@@ -33,16 +33,16 @@ type Repair struct {
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func NewRepair(deb debrid.Service, arrs *arr.Storage) *Repair {
|
||||
func New(deb *engine.Engine, arrs *arr.Storage) *Repair {
|
||||
cfg := config.GetConfig()
|
||||
duration, err := parseSchedule(cfg.Repair.Interval)
|
||||
if err != nil {
|
||||
duration = time.Hour * 24
|
||||
}
|
||||
r := &Repair{
|
||||
deb: deb,
|
||||
logger: logger.NewLogger("Repair", cfg.LogLevel, os.Stdout),
|
||||
arrs: arrs,
|
||||
deb: deb.Get(),
|
||||
logger: logger.NewLogger("repair", cfg.LogLevel, os.Stdout),
|
||||
duration: duration,
|
||||
runOnStart: cfg.Repair.RunOnStart,
|
||||
ZurgURL: cfg.Repair.ZurgURL,
|
||||
|
||||
@@ -3,17 +3,15 @@ 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
|
||||
Repair *repair.Repair
|
||||
Arr *arr.Storage
|
||||
Debrid *engine.Engine
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -26,10 +24,9 @@ func New() *Service {
|
||||
arrs := arr.NewStorage()
|
||||
deb := debrid.New()
|
||||
instance = &Service{
|
||||
Repair: repair.New(deb, arrs),
|
||||
Arr: arrs,
|
||||
Debrid: deb,
|
||||
DebridCache: cache.NewManager(deb),
|
||||
Repair: repair.New(deb, arrs),
|
||||
Arr: arrs,
|
||||
Debrid: deb,
|
||||
}
|
||||
})
|
||||
return instance
|
||||
|
||||
@@ -2,17 +2,12 @@ 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)
|
||||
|
||||
@@ -100,6 +100,11 @@ func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
|
||||
return
|
||||
}
|
||||
|
||||
if !cfg.UseAuth {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip auth check for setup page
|
||||
if r.URL.Path == "/setup" {
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
Reference in New Issue
Block a user