10 Commits

Author SHA1 Message Date
Mukhtar Akere
4b8f1ccfb6 Changelog 0.2.6 2024-10-08 15:43:38 +01:00
Mukhtar Akere
f118c5b794 Changelog 0.2.5 2024-10-01 11:17:31 +01:00
Mukhtar Akere
f6c6144601 Changelog 0.2.4 2024-09-22 16:33:26 +01:00
Mukhtar Akere
ff74e279d9 Wrap up file downloading feature 2024-09-22 16:28:31 +01:00
Mukhtar Akere
ba147ac56c Adds Support for Downloader 2024-09-20 21:09:26 +01:00
Mukhtar Akere
01981114cb Changelog 0.2.3 2024-09-17 00:29:02 +01:00
Mukhtar Akere
2ec0354881 Hotfix: Download Uncached 2024-09-15 22:28:07 +01:00
Mukhtar Akere
329e4c60f5 Changelog 0.2.2 2024-09-15 03:33:28 +01:00
Mukhtar Akere
d5e07dc961 Random Fixes:
- Fix uncached error
- Fix naming and race conditions
2024-09-14 01:44:18 +01:00
Mukhtar Akere
f622cbfe63 Random Fixes:
- Fix uncached error
- Fix naming and race conditions
2024-09-14 01:42:52 +01:00
30 changed files with 1085 additions and 421 deletions

View File

@@ -43,4 +43,37 @@
- Implement 0.2.0-beta changes - Implement 0.2.0-beta changes
- Removed Blackhole - Removed Blackhole
- Added QbitTorrent API - Added QbitTorrent API
- Cleaned up the code - Cleaned up the code
#### 0.2.1
- Fix Uncached torrents not being downloaded/downloaded
- Minor bug fixed
- Fix Race condition in the cache and file system
#### 0.2.2
- Fix name mismatch in the cache
- Fix directory mapping with mounts
- Add Support for refreshing the *arrs
#### 0.2.3
- Delete uncached items from RD
- Fail if the torrent is not cached(optional)
- Fix cache not being updated
#### 0.2.4
- Add file download support(Sequential Download)
- Fix http handler error
- Fix *arrs map failing concurrently
- Fix cache not being updated
#### 0.2.5
- Fix ContentPath not being set prior
- Rewrote Readme
- Cleaned up the code
#### 0.2.6
- Delete torrent for empty matched files
- Update Readme

View File

@@ -18,6 +18,7 @@ ADD . .
RUN CGO_ENABLED=0 GOOS=$(echo $TARGETPLATFORM | cut -d '/' -f1) GOARCH=$(echo $TARGETPLATFORM | cut -d '/' -f2) go build -o /blackhole RUN CGO_ENABLED=0 GOOS=$(echo $TARGETPLATFORM | cut -d '/' -f1) GOARCH=$(echo $TARGETPLATFORM | cut -d '/' -f2) go build -o /blackhole
FROM scratch FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /blackhole /blackhole COPY --from=builder /blackhole /blackhole
EXPOSE 8181 EXPOSE 8181

View File

@@ -3,7 +3,7 @@
This is a Golang implementation go Torrent QbitTorrent with a **Real Debrid Proxy Support**. This is a Golang implementation go Torrent QbitTorrent with a **Real Debrid Proxy Support**.
#### Uses #### Uses
- Mock Qbittorent API that supports the Arrs(Sonarr, Radarr, etc) - Mock Qbittorent API that supports the Arrs(Sonarr, Radarr, Lidarr etc)
- Proxy support for the Arrs - Proxy support for the Arrs
The proxy is useful in filtering out un-cached Real Debrid torrents The proxy is useful in filtering out un-cached Real Debrid torrents
@@ -37,6 +37,8 @@ services:
- QBIT_PORT=8282 # qBittorrent Port. This is optional. You can set this in the config file - QBIT_PORT=8282 # qBittorrent Port. This is optional. You can set this in the config file
- PORT=8181 # Proxy Port. This is optional. You can set this in the config file - PORT=8181 # Proxy Port. This is optional. You can set this in the config file
restart: unless-stopped restart: unless-stopped
depends_on:
- rclone # If you are using rclone with docker
``` ```
@@ -68,15 +70,19 @@ Download the binary from the releases page and run it with the config file.
"max_cache_size": 1000, "max_cache_size": 1000,
"qbittorrent": { "qbittorrent": {
"port": "8282", "port": "8282",
"username": "admin",
"password": "admin",
"download_folder": "/media/symlinks/", "download_folder": "/media/symlinks/",
"categories": ["sonarr", "radarr"] "categories": ["sonarr", "radarr"],
"refresh_interval": 5
} }
} }
``` ```
#### Config Notes #### Config Notes
##### Max Cache Size
- The `max_cache_size` key is used to set the maximum number of infohashes that can be stored in the availability cache. This is used to prevent round trip to the debrid provider when using the proxy/Qbittorrent
- The default value is `1000`
- The cache is stored in memory and is not persisted on restart
##### Debrid Config ##### Debrid Config
- This config key is important as it's used for both Blackhole and Proxy - This config key is important as it's used for both Blackhole and Proxy
@@ -90,9 +96,9 @@ Download the binary from the releases page and run it with the config file.
##### Qbittorrent Config ##### Qbittorrent Config
- The `port` key is the port the qBittorrent will listen on - The `port` key is the port the qBittorrent will listen on
- The `username` and `password` keys are used for basic authentication
- The `download_folder` is the folder where the torrents will be downloaded. e.g `/media/symlinks/` - The `download_folder` is the folder where the torrents will be downloaded. e.g `/media/symlinks/`
- The `categories` key is used to filter out torrents based on the category. e.g `sonarr`, `radarr` - The `categories` key is used to filter out torrents based on the category. e.g `sonarr`, `radarr`
- The `refresh_interval` key is used to set the interval in minutes to refresh the Arrs Monitored Downloads(it's in seconds). The default value is `5` seconds
### Proxy ### Proxy
@@ -120,10 +126,11 @@ Setting Up Qbittorrent in Arr
- Settings -> Download Client -> Add Client -> qBittorrent - Settings -> Download Client -> Add Client -> qBittorrent
- Host: `localhost` # or the IP of the server - Host: `localhost` # or the IP of the server
- Port: `8282` # or the port set in the config file/ docker-compose env - Port: `8282` # or the port set in the config file/ docker-compose env
- Username: `admin` # or the username set in the config file - Username: `http://sonarr:8989` # Your arr host with http/https
- Password: `admin` # or the password set in the config file - Password: `sonarr_token` # Your arr token
- Category: e.g `sonarr`, `radarr` - Category: e.g `sonarr`, `radarr`
- Use SSL -> `No` - Use SSL -> `No`
- Sequential Download -> `No`|`Yes` (If you want to download the torrents locally instead of symlink)
- Test - Test
- Save - Save

View File

@@ -15,25 +15,30 @@ type DebridConfig struct {
RateLimit string `json:"rate_limit"` // 200/minute or 10/second RateLimit string `json:"rate_limit"` // 200/minute or 10/second
} }
type ProxyConfig struct {
Port string `json:"port"`
Enabled bool `json:"enabled"`
Debug bool `json:"debug"`
Username string `json:"username"`
Password string `json:"password"`
CachedOnly *bool `json:"cached_only"`
}
type QBitTorrentConfig struct {
Username string `json:"username"`
Password string `json:"password"`
Port string `json:"port"`
Debug bool `json:"debug"`
DownloadFolder string `json:"download_folder"`
Categories []string `json:"categories"`
RefreshInterval int `json:"refresh_interval"`
}
type Config struct { type Config struct {
Debrid DebridConfig `json:"debrid"` Debrid DebridConfig `json:"debrid"`
Proxy struct { Proxy ProxyConfig `json:"proxy"`
Port string `json:"port"` MaxCacheSize int `json:"max_cache_size"`
Enabled bool `json:"enabled"` QBitTorrent QBitTorrentConfig `json:"qbittorrent"`
Debug bool `json:"debug"`
Username string `json:"username"`
Password string `json:"password"`
CachedOnly *bool `json:"cached_only"`
}
MaxCacheSize int `json:"max_cache_size"`
QBitTorrent struct {
Username string `json:"username"`
Password string `json:"password"`
Port string `json:"port"`
Debug bool `json:"debug"`
DownloadFolder string `json:"download_folder"`
Categories []string `json:"categories"`
} `json:"qbittorrent"`
} }
func LoadConfig(path string) (*Config, error) { func LoadConfig(path string) (*Config, error) {

View File

@@ -1,11 +1,14 @@
package common package common
import ( import (
"path/filepath"
"regexp" "regexp"
"strings"
) )
var ( var (
VIDEOMATCH = "(?i)(\\.)(YUV|WMV|WEBM|VOB|VIV|SVI|ROQ|RMVB|RM|OGV|OGG|NSV|MXF|MTS|M2TS|TS|MPG|MPEG|M2V|MP2|MPE|MPV|MP4|M4P|M4V|MOV|QT|MNG|MKV|FLV|DRC|AVI|ASF|AMV|MKA|F4V|3GP|3G2|DIVX|X264|X265)$" VIDEOMATCH = "(?i)(\\.)(YUV|WMV|WEBM|VOB|VIV|SVI|ROQ|RMVB|RM|OGV|OGG|NSV|MXF|MPG|MPEG|M2V|MP2|MPE|MPV|MP4|M4P|M4V|MOV|QT|MNG|MKV|FLV|DRC|AVI|ASF|AMV|MKA|F4V|3GP|3G2|DIVX|X264|X265)$"
MUSICMATCH = "(?i)(\\.)(?:MP3|WAV|FLAC|AAC|OGG|WMA|AIFF|ALAC|M4A|APE|AC3|DTS|M4P|MID|MIDI|MKA|MP2|MPA|RA|VOC|WV|AMR)$"
SUBMATCH = "(?i)(\\.)(SRT|SUB|SBV|ASS|VTT|TTML|DFXP|STL|SCC|CAP|SMI|TTXT|TDS|USF|JSS|SSA|PSB|RT|LRC|SSB)$" SUBMATCH = "(?i)(\\.)(SRT|SUB|SBV|ASS|VTT|TTML|DFXP|STL|SCC|CAP|SMI|TTXT|TDS|USF|JSS|SSA|PSB|RT|LRC|SSB)$"
SAMPLEMATCH = `(?i)(^|[\\/]|[._-])(sample|trailer|thumb)s?([._-]|$)` SAMPLEMATCH = `(?i)(^|[\\/]|[._-])(sample|trailer|thumb)s?([._-]|$)`
) )
@@ -15,9 +18,26 @@ func RegexMatch(regex string, value string) bool {
return re.MatchString(value) return re.MatchString(value)
} }
func RemoveInvalidChars(value string) string {
return strings.Map(func(r rune) rune {
if r == filepath.Separator || r == ':' {
return r
}
if filepath.IsAbs(string(r)) {
return r
}
if strings.ContainsRune(filepath.VolumeName("C:"+string(r)), r) {
return r
}
if r < 32 || strings.ContainsRune(`<>:"/\|?*`, r) {
return -1
}
return r
}, value)
}
func RemoveExtension(value string) string { func RemoveExtension(value string) string {
pattern := "(?i)(\\.)(YUV|WMV|WEBM|VOB|VIV|SVI|ROQ|RMVB|RM|OGV|OGG|NSV|MXF|MTS|M2TS|TS|MPG|MPEG|M2V|MP2|MPE|MPV|MP4|M4P|M4V|MOV|QT|MNG|MKV|FLV|DRC|AVI|ASF|AMV|MKA|F4V|3GP|3G2|DIVX|X264|X265)$" re := regexp.MustCompile(VIDEOMATCH + "|" + SUBMATCH + "|" + SAMPLEMATCH + "|" + MUSICMATCH)
re := regexp.MustCompile(pattern)
// Find the last index of the matched extension // Find the last index of the matched extension
loc := re.FindStringIndex(value) loc := re.FindStringIndex(value)

View File

@@ -2,6 +2,7 @@ package common
import ( import (
"bufio" "bufio"
"context"
"encoding/base32" "encoding/base32"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
@@ -12,9 +13,11 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strings" "strings"
"time"
) )
type Magnet struct { type Magnet struct {
@@ -122,7 +125,6 @@ func OpenMagnetHttpURL(magnetLink string) (*Magnet, error) {
Link: mi.Magnet(&hash, &info).String(), Link: mi.Magnet(&hash, &info).String(),
} }
return magnet, nil return magnet, nil
} }
func GetMagnetInfo(magnetLink string) (*Magnet, error) { func GetMagnetInfo(magnetLink string) (*Magnet, error) {
@@ -211,3 +213,63 @@ func NewLogger(prefix string, output *os.File) *log.Logger {
f := fmt.Sprintf("[%s] ", prefix) f := fmt.Sprintf("[%s] ", prefix)
return log.New(output, f, log.LstdFlags) return log.New(output, f, log.LstdFlags)
} }
func GetInfohashFromURL(url string) (string, error) {
// Download the torrent file
var magnetLink string
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := &http.Client{
Timeout: 30 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
if len(via) >= 3 {
return fmt.Errorf("stopped after 3 redirects")
}
if strings.HasPrefix(req.URL.String(), "magnet:") {
// Stop the redirect chain
magnetLink = req.URL.String()
return http.ErrUseLastResponse
}
return nil
},
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if magnetLink != "" {
return ExtractInfoHash(magnetLink), nil
}
mi, err := metainfo.Load(resp.Body)
if err != nil {
return "", err
}
hash := mi.HashInfoBytes()
infoHash := hash.HexString()
return infoHash, nil
}
func JoinURL(base string, paths ...string) (string, error) {
// Parse the base URL
u, err := url.Parse(base)
if err != nil {
return "", err
}
// Join the path components
u.Path = path.Join(u.Path, path.Join(paths...))
// Return the resulting URL as a string
return u.String(), nil
}
func FileReady(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err) // Returns true if the file exists
}

9
go.mod
View File

@@ -4,10 +4,12 @@ go 1.22
require ( require (
github.com/anacrolix/torrent v1.55.0 github.com/anacrolix/torrent v1.55.0
github.com/cavaliergopher/grab/v3 v3.0.1
github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2
github.com/fsnotify/fsnotify v1.7.0
github.com/go-chi/chi/v5 v5.1.0 github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/valyala/fasthttp v1.55.0
github.com/valyala/fastjson v1.6.4 github.com/valyala/fastjson v1.6.4
golang.org/x/time v0.6.0 golang.org/x/time v0.6.0
) )
@@ -15,11 +17,12 @@ require (
require ( require (
github.com/anacrolix/missinggo v1.3.0 // indirect github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/v2 v2.7.3 // indirect github.com/anacrolix/missinggo/v2 v2.7.3 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/google/go-cmp v0.6.0 // indirect github.com/google/go-cmp v0.6.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect github.com/huandu/xstrings v1.3.2 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/net v0.27.0 // indirect golang.org/x/net v0.27.0 // indirect
golang.org/x/sys v0.22.0 // indirect
golang.org/x/text v0.16.0 // indirect golang.org/x/text v0.16.0 // indirect
) )

14
go.sum
View File

@@ -35,6 +35,8 @@ github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pm
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8= github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
github.com/anacrolix/torrent v1.55.0 h1:s9yh/YGdPmbN9dTa+0Inh2dLdrLQRvEAj1jdFW/Hdd8= github.com/anacrolix/torrent v1.55.0 h1:s9yh/YGdPmbN9dTa+0Inh2dLdrLQRvEAj1jdFW/Hdd8=
github.com/anacrolix/torrent v1.55.0/go.mod h1:sBdZHBSZNj4de0m+EbYg7vvs/G/STubxu/GzzNbojsE= github.com/anacrolix/torrent v1.55.0/go.mod h1:sBdZHBSZNj4de0m+EbYg7vvs/G/STubxu/GzzNbojsE=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI= github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -44,6 +46,8 @@ github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2w
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo= github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8= github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og= github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4=
github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -62,8 +66,6 @@ github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE= github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
@@ -123,6 +125,8 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -189,6 +193,10 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE= github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ= github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
@@ -233,8 +241,6 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=

View File

@@ -10,9 +10,9 @@ import (
type Service interface { type Service interface {
SubmitMagnet(torrent *Torrent) (*Torrent, error) SubmitMagnet(torrent *Torrent) (*Torrent, error)
CheckStatus(torrent *Torrent) (*Torrent, error) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error)
DownloadLink(torrent *Torrent) error GetDownloadLinks(torrent *Torrent) error
Process(arr *Arr, magnet string) (*Torrent, error) DeleteTorrent(torrent *Torrent)
IsAvailable(infohashes []string) map[string]bool IsAvailable(infohashes []string) map[string]bool
GetMountPath() string GetMountPath() string
GetDownloadUncached() bool GetDownloadUncached() bool
@@ -120,10 +120,7 @@ func GetLocalCache(infohashes []string, cache *common.Cache) ([]string, map[stri
return hashes, result return hashes, result
} }
func ProcessQBitTorrent(d Service, magnet *common.Magnet, category string) (*Torrent, error) { func ProcessQBitTorrent(d Service, magnet *common.Magnet, arr *Arr, isSymlink bool) (*Torrent, error) {
arr := &Arr{
CompletedFolder: category,
}
debridTorrent := &Torrent{ debridTorrent := &Torrent{
InfoHash: magnet.InfoHash, InfoHash: magnet.InfoHash,
Magnet: magnet, Magnet: magnet,
@@ -132,18 +129,20 @@ func ProcessQBitTorrent(d Service, magnet *common.Magnet, category string) (*Tor
Size: magnet.Size, Size: magnet.Size,
} }
logger := d.GetLogger() logger := d.GetLogger()
logger.Printf("Torrent Name: %s", debridTorrent.Name) logger.Printf("Torrent Hash: %s", debridTorrent.InfoHash)
if !d.GetDownloadUncached() { if !d.GetDownloadUncached() {
hash, exists := d.IsAvailable([]string{debridTorrent.InfoHash})[debridTorrent.InfoHash] hash, exists := d.IsAvailable([]string{debridTorrent.InfoHash})[debridTorrent.InfoHash]
if !exists || !hash { if !exists || !hash {
return debridTorrent, fmt.Errorf("torrent is not cached") return debridTorrent, fmt.Errorf("torrent: %s is not cached", debridTorrent.Name)
} else {
logger.Printf("Torrent: %s is cached(or downloading)", debridTorrent.Name)
} }
logger.Printf("Torrent: %s is cached", debridTorrent.Name)
} }
debridTorrent, err := d.SubmitMagnet(debridTorrent) debridTorrent, err := d.SubmitMagnet(debridTorrent)
if err != nil || debridTorrent.Id == "" { if err != nil || debridTorrent.Id == "" {
logger.Printf("Error submitting magnet: %s", err)
return nil, err return nil, err
} }
return d.CheckStatus(debridTorrent) return d.CheckStatus(debridTorrent, isSymlink)
} }

View File

@@ -40,13 +40,15 @@ func GetTorrentFiles(data structs.RealDebridTorrentInfo) []TorrentFile {
files := make([]TorrentFile, 0) files := make([]TorrentFile, 0)
for _, f := range data.Files { for _, f := range data.Files {
name := filepath.Base(f.Path) name := filepath.Base(f.Path)
if (!common.RegexMatch(common.VIDEOMATCH, name) && !common.RegexMatch(common.SUBMATCH, name)) || common.RegexMatch(common.SAMPLEMATCH, name) { if (!common.RegexMatch(common.VIDEOMATCH, name) &&
!common.RegexMatch(common.SUBMATCH, name) &&
!common.RegexMatch(common.MUSICMATCH, name)) || common.RegexMatch(common.SAMPLEMATCH, name) {
continue continue
} }
fileId := f.ID fileId := f.ID
file := &TorrentFile{ file := &TorrentFile{
Name: name, Name: name,
Path: filepath.Join(common.RemoveExtension(data.OriginalFilename), name), Path: name,
Size: int64(f.Bytes), Size: int64(f.Bytes),
Id: strconv.Itoa(fileId), Id: strconv.Itoa(fileId),
} }
@@ -55,28 +57,6 @@ func GetTorrentFiles(data structs.RealDebridTorrentInfo) []TorrentFile {
return files return files
} }
func (r *RealDebrid) Process(arr *Arr, magnet string) (*Torrent, error) {
torrent, err := GetTorrentInfo(magnet)
torrent.Arr = arr
if err != nil {
return torrent, err
}
log.Printf("Torrent Name: %s", torrent.Name)
if !r.DownloadUncached {
hash, exists := r.IsAvailable([]string{torrent.InfoHash})[torrent.InfoHash]
if !exists || !hash {
return torrent, fmt.Errorf("torrent is not cached")
}
log.Printf("Torrent: %s is cached", torrent.Name)
}
torrent, err = r.SubmitMagnet(torrent)
if err != nil || torrent.Id == "" {
return nil, err
}
return r.CheckStatus(torrent)
}
func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool { func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool {
// Check if the infohashes are available in the local cache // Check if the infohashes are available in the local cache
hashes, result := GetLocalCache(infohashes, r.cache) hashes, result := GetLocalCache(infohashes, r.cache)
@@ -160,19 +140,24 @@ func (r *RealDebrid) GetTorrent(id string) (*Torrent, error) {
if err != nil { if err != nil {
return torrent, err return torrent, err
} }
name := common.RemoveExtension(data.OriginalFilename) name := common.RemoveInvalidChars(data.OriginalFilename)
torrent.Id = id torrent.Id = id
torrent.Name = name torrent.Name = name
torrent.Bytes = data.Bytes torrent.Bytes = data.Bytes
torrent.Folder = name torrent.Folder = name
torrent.Progress = data.Progress torrent.Progress = data.Progress
torrent.Status = data.Status torrent.Status = data.Status
torrent.Speed = data.Speed
torrent.Seeders = data.Seeders
torrent.Filename = data.Filename
torrent.OriginalFilename = data.OriginalFilename
torrent.Links = data.Links
files := GetTorrentFiles(data) files := GetTorrentFiles(data)
torrent.Files = files torrent.Files = files
return torrent, nil return torrent, nil
} }
func (r *RealDebrid) CheckStatus(torrent *Torrent) (*Torrent, error) { func (r *RealDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error) {
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrent.Id) url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrent.Id)
for { for {
resp, err := r.client.MakeRequest(http.MethodGet, url, nil) resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
@@ -183,17 +168,24 @@ func (r *RealDebrid) CheckStatus(torrent *Torrent) (*Torrent, error) {
var data structs.RealDebridTorrentInfo var data structs.RealDebridTorrentInfo
err = json.Unmarshal(resp, &data) err = json.Unmarshal(resp, &data)
status := data.Status status := data.Status
torrent.Folder = common.RemoveExtension(data.OriginalFilename) 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.Bytes = data.Bytes
torrent.Progress = data.Progress torrent.Progress = data.Progress
torrent.Speed = data.Speed torrent.Speed = data.Speed
torrent.Seeders = data.Seeders torrent.Seeders = data.Seeders
torrent.Links = data.Links
torrent.Status = status
if status == "error" || status == "dead" || status == "magnet_error" { if status == "error" || status == "dead" || status == "magnet_error" {
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name) return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
} else if status == "waiting_files_selection" { } else if status == "waiting_files_selection" {
files := GetTorrentFiles(data) files := GetTorrentFiles(data)
torrent.Files = files torrent.Files = files
if len(files) == 0 { if len(files) == 0 {
r.DeleteTorrent(torrent)
return torrent, fmt.Errorf("no video files found") return torrent, fmt.Errorf("no video files found")
} }
filesId := make([]string, 0) filesId := make([]string, 0)
@@ -209,11 +201,24 @@ func (r *RealDebrid) CheckStatus(torrent *Torrent) (*Torrent, error) {
return torrent, err return torrent, err
} }
} else if status == "downloaded" { } else if status == "downloaded" {
log.Printf("Torrent: %s downloaded\n", torrent.Name) files := GetTorrentFiles(data)
err = r.DownloadLink(torrent) torrent.Files = files
if err != nil { log.Printf("Torrent: %s downloaded to RD\n", torrent.Name)
return torrent, err if !isSymlink {
err = r.GetDownloadLinks(torrent)
if err != nil {
return torrent, err
}
} }
break
} else if status == "downloading" {
if !r.DownloadUncached {
go r.DeleteTorrent(torrent)
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
}
// Break out of the loop if the torrent is downloading.
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
break break
} }
@@ -221,7 +226,42 @@ func (r *RealDebrid) CheckStatus(torrent *Torrent) (*Torrent, error) {
return torrent, nil return torrent, nil
} }
func (r *RealDebrid) DownloadLink(torrent *Torrent) error { func (r *RealDebrid) DeleteTorrent(torrent *Torrent) {
url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrent.Id)
_, err := r.client.MakeRequest(http.MethodDelete, url, nil)
if err == nil {
r.logger.Printf("Torrent: %s deleted\n", torrent.Name)
} else {
r.logger.Printf("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},
}
resp, err := r.client.MakeRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
if err != nil {
return err
}
var data structs.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 return nil
} }

View File

@@ -70,17 +70,17 @@ type RealDebridAddMagnetSchema struct {
} }
type RealDebridTorrentInfo struct { type RealDebridTorrentInfo struct {
ID string `json:"id"` ID string `json:"id"`
Filename string `json:"filename"` Filename string `json:"filename"`
OriginalFilename string `json:"original_filename"` OriginalFilename string `json:"original_filename"`
Hash string `json:"hash"` Hash string `json:"hash"`
Bytes int64 `json:"bytes"` Bytes int64 `json:"bytes"`
OriginalBytes int `json:"original_bytes"` OriginalBytes int `json:"original_bytes"`
Host string `json:"host"` Host string `json:"host"`
Split int `json:"split"` Split int `json:"split"`
Progress int `json:"progress"` Progress float64 `json:"progress"`
Status string `json:"status"` Status string `json:"status"`
Added string `json:"added"` Added string `json:"added"`
Files []struct { Files []struct {
ID int `json:"id"` ID int `json:"id"`
Path string `json:"path"` Path string `json:"path"`
@@ -89,8 +89,19 @@ type RealDebridTorrentInfo struct {
} `json:"files"` } `json:"files"`
Links []string `json:"links"` Links []string `json:"links"`
Ended string `json:"ended,omitempty"` Ended string `json:"ended,omitempty"`
Speed int `json:"speed,omitempty"` Speed int64 `json:"speed,omitempty"`
Seeders int `json:"seeders,omitempty"` Seeders int `json:"seeders,omitempty"`
} }
// 5e6e2e77fd3921a7903a41336c844cc409bf8788/14527C07BDFDDFC642963238BB6E7507B9742947/66A1CD1A5C7F4014877A51AC2620E857E3BB4D16 type RealDebridUnrestrictResponse struct {
Id string `json:"id"`
Filename string `json:"filename"`
MimeType string `json:"mimeType"`
Filesize int64 `json:"filesize"`
Link string `json:"link"`
Host string `json:"host"`
Chunks int64 `json:"chunks"`
Crc int64 `json:"crc"`
Download string `json:"download"`
Streamable int `json:"streamable"`
}

View File

@@ -1,24 +1,15 @@
package debrid package debrid
import ( import (
"encoding/json"
"goBlack/common" "goBlack/common"
"log"
"net/http"
gourl "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings"
) )
type Arr struct { type Arr struct {
WatchFolder string `json:"watch_folder"` Name string `json:"name"`
CompletedFolder string `json:"completed_folder"` Token string `json:"token"`
Debrid common.DebridConfig `json:"debrid"` Host string `json:"host"`
Token string `json:"token"`
URL string `json:"url"`
Client *common.RLHTTPClient
} }
type ArrHistorySchema struct { type ArrHistorySchema struct {
@@ -34,26 +25,49 @@ type ArrHistorySchema struct {
} }
type Torrent struct { type Torrent struct {
Id string `json:"id"` Id string `json:"id"`
InfoHash string `json:"info_hash"` InfoHash string `json:"info_hash"`
Name string `json:"name"` Name string `json:"name"`
Folder string `json:"folder"` Folder string `json:"folder"`
Filename string `json:"filename"` Filename string `json:"filename"`
Size int64 `json:"size"` OriginalFilename string `json:"original_filename"`
Bytes int64 `json:"bytes"` // Size of only the files that are downloaded Size int64 `json:"size"`
Magnet *common.Magnet `json:"magnet"` Bytes int64 `json:"bytes"` // Size of only the files that are downloaded
Files []TorrentFile `json:"files"` Magnet *common.Magnet `json:"magnet"`
Status string `json:"status"` Files []TorrentFile `json:"files"`
Progress int `json:"progress"` Status string `json:"status"`
Speed int `json:"speed"` Added string `json:"added"`
Seeders int `json:"seeders"` Progress float64 `json:"progress"`
Speed int64 `json:"speed"`
Seeders int `json:"seeders"`
Links []string `json:"links"`
DownloadLinks []TorrentDownloadLinks `json:"download_links"`
Debrid *Debrid Debrid *Debrid
Arr *Arr Arr *Arr
} }
type TorrentDownloadLinks struct {
Filename string `json:"filename"`
Link string `json:"link"`
DownloadLink string `json:"download_link"`
}
func (t *Torrent) GetSymlinkFolder(parent string) string { func (t *Torrent) GetSymlinkFolder(parent string) string {
return filepath.Join(parent, t.Arr.CompletedFolder, t.Folder) return filepath.Join(parent, t.Arr.Name, t.Folder)
}
func (t *Torrent) GetMountFolder(rClonePath string) string {
pathWithNoExt := common.RemoveExtension(t.OriginalFilename)
if common.FileReady(filepath.Join(rClonePath, t.OriginalFilename)) {
return t.OriginalFilename
} else if common.FileReady(filepath.Join(rClonePath, t.Filename)) {
return t.Filename
} else if common.FileReady(filepath.Join(rClonePath, pathWithNoExt)) {
return pathWithNoExt
} else {
return ""
}
} }
type TorrentFile struct { type TorrentFile struct {
@@ -63,17 +77,6 @@ type TorrentFile struct {
Path string `json:"path"` Path string `json:"path"`
} }
func (arr *Arr) GetHeaders() map[string]string {
return map[string]string{
"X-Api-Key": arr.Token,
}
}
func (arr *Arr) GetURL() string {
url, _ := gourl.JoinPath(arr.URL, "api/v3/")
return url
}
func getEventId(eventType string) int { func getEventId(eventType string) int {
switch eventType { switch eventType {
case "grabbed": case "grabbed":
@@ -91,31 +94,6 @@ func getEventId(eventType string) int {
} }
} }
func (arr *Arr) GetHistory(downloadId, eventType string) *ArrHistorySchema {
eventId := getEventId(eventType)
query := gourl.Values{}
if downloadId != "" {
query.Add("downloadId", downloadId)
}
if eventId != 0 {
query.Add("eventId", strconv.Itoa(eventId))
}
query.Add("pageSize", "100")
url := arr.GetURL() + "history/" + "?" + query.Encode()
resp, err := arr.Client.MakeRequest(http.MethodGet, url, nil)
if err != nil {
return nil
}
var data *ArrHistorySchema
err = json.Unmarshal(resp, &data)
if err != nil {
return nil
}
return data
}
func (t *Torrent) Cleanup(remove bool) { func (t *Torrent) Cleanup(remove bool) {
if remove { if remove {
err := os.Remove(t.Filename) err := os.Remove(t.Filename)
@@ -124,29 +102,3 @@ func (t *Torrent) Cleanup(remove bool) {
} }
} }
} }
func (t *Torrent) MarkAsFailed() error {
downloadId := strings.ToUpper(t.Magnet.InfoHash)
history := t.Arr.GetHistory(downloadId, "grabbed")
if history == nil {
return nil
}
torrentId := 0
for _, record := range history.Records {
if strings.EqualFold(record.DownloadID, downloadId) {
torrentId = record.ID
break
}
}
if torrentId != 0 {
url, err := gourl.JoinPath(t.Arr.GetURL(), "history/failed/", strconv.Itoa(torrentId))
if err != nil {
return err
}
_, err = t.Arr.Client.MakeRequest(http.MethodPost, url, nil)
if err == nil {
log.Printf("Marked torrent: %s as failed", t.Name)
}
}
return nil
}

View File

@@ -211,12 +211,12 @@ func (item Item) getHash() string {
} }
infohash = hash infohash = hash
if infohash == "" { if infohash == "" {
//Get torrent file from http link if strings.Contains(magnetLink, "http") {
//Takes too long, not worth it h, _ := common.GetInfohashFromURL(magnetLink)
//magnet, err := common.OpenMagnetHttpURL(magnetLink) if h != "" {
//if err == nil && magnet != nil && magnet.InfoHash != "" { infohash = h
// log.Printf("Magnet: %s", magnet.InfoHash) }
//} }
} }
return infohash return infohash
@@ -249,7 +249,6 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
if len(rss.Channel.Items) > 0 { if len(rss.Channel.Items) > 0 {
indexer = rss.Channel.Items[0].ProwlarrIndexer.Text indexer = rss.Channel.Items[0].ProwlarrIndexer.Text
} else { } else {
p.logger.Println("No items found in RSS feed")
resp.Body = io.NopCloser(bytes.NewReader(body)) resp.Body = io.NopCloser(bytes.NewReader(body))
return resp return resp
} }
@@ -319,9 +318,7 @@ func (p *Proxy) Start() {
}) })
} }
proxy.OnRequest( proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile("^.443$"))).HandleConnect(goproxy.AlwaysMitm)
goproxy.ReqHostMatches(regexp.MustCompile("^.443$")),
UrlMatches(regexp.MustCompile("^.*/api\\?t=(search|tvsearch|movie)(&.*)?$"))).HandleConnect(goproxy.AlwaysMitm)
proxy.OnResponse( proxy.OnResponse(
UrlMatches(regexp.MustCompile("^.*/api\\?t=(search|tvsearch|movie)(&.*)?$")), UrlMatches(regexp.MustCompile("^.*/api\\?t=(search|tvsearch|movie)(&.*)?$")),
goproxy.StatusCodeIs(http.StatusOK, http.StatusAccepted)).DoFunc( goproxy.StatusCodeIs(http.StatusOK, http.StatusAccepted)).DoFunc(

103
pkg/qbit/arr.go Normal file
View File

@@ -0,0 +1,103 @@
package qbit
import (
"bytes"
"cmp"
"encoding/json"
"goBlack/common"
"goBlack/pkg/debrid"
"net/http"
gourl "net/url"
"strconv"
"strings"
)
func (q *QBit) RefreshArr(arr *debrid.Arr) {
if arr.Token == "" || arr.Host == "" {
return
}
url, err := common.JoinURL(arr.Host, "api/v3/command")
if err != nil {
return
}
payload := map[string]string{"name": "RefreshMonitoredDownloads"}
jsonPayload, err := json.Marshal(payload)
if err != nil {
return
}
client := &http.Client{}
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", arr.Token)
resp, reqErr := client.Do(req)
if reqErr == nil {
statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'
if statusOk {
if q.debug {
q.logger.Printf("Refreshed monitored downloads for %s", cmp.Or(arr.Name, arr.Host))
}
}
}
if reqErr != nil {
}
}
func (q *QBit) GetArrHistory(arr *debrid.Arr, downloadId, eventType string) *debrid.ArrHistorySchema {
query := gourl.Values{}
if downloadId != "" {
query.Add("downloadId", downloadId)
}
query.Add("eventType", eventType)
query.Add("pageSize", "100")
url, _ := common.JoinURL(arr.Host, "history")
url += "?" + query.Encode()
resp, err := http.Get(url)
if err != nil {
return nil
}
var data *debrid.ArrHistorySchema
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil
}
return data
}
func (q *QBit) MarkArrAsFailed(torrent *Torrent, arr *debrid.Arr) error {
downloadId := strings.ToUpper(torrent.Hash)
history := q.GetArrHistory(arr, downloadId, "grabbed")
if history == nil {
return nil
}
torrentId := 0
for _, record := range history.Records {
if strings.EqualFold(record.DownloadID, downloadId) {
torrentId = record.ID
break
}
}
if torrentId != 0 {
url, err := common.JoinURL(arr.Host, "history/failed/", strconv.Itoa(torrentId))
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, url, nil)
if err != nil {
return err
}
client := &http.Client{}
_, err = client.Do(req)
if err == nil {
q.logger.Printf("Marked torrent: %s as failed", torrent.Name)
}
}
return nil
}

134
pkg/qbit/downloader.go Normal file
View File

@@ -0,0 +1,134 @@
package qbit
import (
"goBlack/common"
"goBlack/pkg/debrid"
"goBlack/pkg/qbit/downloaders"
"os"
"path/filepath"
"sync"
"time"
)
func (q *QBit) processManualFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr *debrid.Arr) {
q.logger.Printf("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 {
q.logger.Printf("Failed to create directory: %s\n", parent)
q.MarkAsFailed(torrent)
return
}
torrent.TorrentPath = torrentPath
q.downloadFiles(debridTorrent, parent)
q.UpdateTorrent(torrent, debridTorrent)
q.RefreshArr(arr)
}
func (q *QBit) downloadFiles(debridTorrent *debrid.Torrent, parent string) {
var wg sync.WaitGroup
semaphore := make(chan struct{}, 5)
client := downloaders.GetHTTPClient()
for _, link := range debridTorrent.DownloadLinks {
if link.DownloadLink == "" {
q.logger.Printf("No download link found for %s\n", link.Filename)
continue
}
wg.Add(1)
semaphore <- struct{}{}
go func(link debrid.TorrentDownloadLinks) {
defer wg.Done()
defer func() { <-semaphore }()
err := downloaders.NormalHTTP(client, link.DownloadLink, filepath.Join(parent, link.Filename))
if err != nil {
q.logger.Printf("Error downloading %s: %v\n", link.DownloadLink, err)
} else {
q.logger.Printf("Downloaded %s successfully\n", link.DownloadLink)
}
}(link)
}
wg.Wait()
q.logger.Printf("Downloaded all files for %s\n", debridTorrent.Name)
}
func (q *QBit) processSymlink(torrent *Torrent, debridTorrent *debrid.Torrent, arr *debrid.Arr) {
var wg sync.WaitGroup
files := debridTorrent.Files
ready := make(chan debrid.TorrentFile, len(files))
q.logger.Printf("Checking %d files...", len(files))
rCloneBase := q.debrid.GetMountPath()
torrentPath, err := q.getTorrentPath(rCloneBase, debridTorrent) // /MyTVShow/
if err != nil {
q.MarkAsFailed(torrent)
q.logger.Printf("Error: %v", err)
return
}
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentPath) // /mnt/symlinks/{category}/MyTVShow/
err = os.MkdirAll(torrentSymlinkPath, os.ModePerm)
if err != nil {
q.logger.Printf("Failed to create directory: %s\n", torrentSymlinkPath)
q.MarkAsFailed(torrent)
return
}
torrentRclonePath := filepath.Join(rCloneBase, torrentPath)
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.Println("File is ready:", f.Path)
q.createSymLink(torrentSymlinkPath, torrentRclonePath, f)
}
// Update the torrent when all files are ready
torrent.TorrentPath = filepath.Base(torrentPath) // Quite important
q.UpdateTorrent(torrent, debridTorrent)
q.RefreshArr(arr)
}
func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debrid.Torrent) (string, error) {
pathChan := make(chan string)
errChan := make(chan error)
go func() {
for {
torrentPath := debridTorrent.GetMountFolder(rclonePath)
if torrentPath != "" {
pathChan <- torrentPath
return
}
time.Sleep(time.Second)
}
}()
select {
case path := <-pathChan:
return path, nil
case err := <-errChan:
return "", err
}
}
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.Name) // debridFolder/MyTVShow/MyTVShow.S01E01.720p.mkv
err := os.Symlink(torrentFilePath, fullPath)
if err != nil {
q.logger.Printf("Failed to create symlink: %s: %v\n", fullPath, err)
}
// Check if the file exists
if !common.FileReady(fullPath) {
q.logger.Printf("Symlink not ready: %s\n", fullPath)
}
}

View File

@@ -0,0 +1,59 @@
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
}

View File

@@ -0,0 +1,55 @@
package downloaders
import (
"crypto/tls"
"fmt"
"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) error {
req, err := grab.NewRequest(filename, url)
if err != nil {
return err
}
resp := client.Do(req)
if err := resp.Err(); err != nil {
return err
}
t := time.NewTicker(2 * time.Second)
defer t.Stop()
Loop:
for {
select {
case <-t.C:
fmt.Printf(" %s: transferred %d / %d bytes (%.2f%%)\n",
resp.Filename,
resp.BytesComplete(),
resp.Size,
100*resp.Progress())
case <-resp.Done:
// download is complete
break Loop
}
}
if err := resp.Err(); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,44 @@
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
}

View File

@@ -10,7 +10,8 @@ func (q *QBit) AddRoutes(r chi.Router) http.Handler {
r.Post("/auth/login", q.handleLogin) r.Post("/auth/login", q.handleLogin)
r.Group(func(r chi.Router) { r.Group(func(r chi.Router) {
r.Use(q.authMiddleware) //r.Use(q.authMiddleware)
r.Use(q.authContext)
r.Route("/torrents", func(r chi.Router) { r.Route("/torrents", func(r chi.Router) {
r.Use(HashesCtx) r.Use(HashesCtx)
r.Get("/info", q.handleTorrentsInfo) r.Get("/info", q.handleTorrentsInfo)
@@ -23,6 +24,7 @@ func (q *QBit) AddRoutes(r chi.Router) http.Handler {
r.Get("/resume", q.handleTorrentsResume) r.Get("/resume", q.handleTorrentsResume)
r.Get("/recheck", q.handleTorrentRecheck) r.Get("/recheck", q.handleTorrentRecheck)
r.Get("/properties", q.handleTorrentProperties) r.Get("/properties", q.handleTorrentProperties)
r.Get("/files", q.handleTorrentFiles)
}) })
r.Route("/app", func(r chi.Router) { r.Route("/app", func(r chi.Router) {

View File

@@ -7,12 +7,10 @@ import (
func (q *QBit) handleVersion(w http.ResponseWriter, r *http.Request) { func (q *QBit) handleVersion(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("v4.3.2")) _, _ = w.Write([]byte("v4.3.2"))
w.WriteHeader(http.StatusOK)
} }
func (q *QBit) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) { func (q *QBit) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("2.7")) _, _ = w.Write([]byte("2.7"))
w.WriteHeader(http.StatusOK)
} }
func (q *QBit) handlePreferences(w http.ResponseWriter, r *http.Request) { func (q *QBit) handlePreferences(w http.ResponseWriter, r *http.Request) {

View File

@@ -2,49 +2,8 @@ package qbit
import ( import (
"net/http" "net/http"
"time"
) )
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) { func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm() _, _ = w.Write([]byte("Ok."))
if err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
username := r.Form.Get("username")
password := r.Form.Get("password")
// In a real implementation, you'd verify credentials here
// For this mock, we'll accept any non-empty username and password
if username == "" || password == "" {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
if username != q.Username || password != q.Password {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
// Generate a new SID
sid, err := generateSID()
if err != nil {
http.Error(w, "Failed to generate session ID", http.StatusInternalServerError)
return
}
// Set the SID cookie
http.SetCookie(w, &http.Cookie{
Name: cookieName,
Value: sid,
Path: "/",
HttpOnly: true,
MaxAge: 315360000,
})
// Store the session
sessions.Store(sid, time.Now().Add(24*time.Hour))
w.WriteHeader(http.StatusOK)
w.Write([]byte("Ok."))
} }

View File

@@ -1,8 +1,7 @@
package qbit package qbit
import ( import (
"goBlack/common" "context"
"io"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -19,6 +18,7 @@ func (q *QBit) handleTorrentsInfo(w http.ResponseWriter, r *http.Request) {
} }
func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) { func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0] contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0]
switch contentType { switch contentType {
case "multipart/form-data": case "multipart/form-data":
@@ -37,43 +37,37 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
} }
} }
files := r.MultipartForm.File["torrents"] isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
q.logger.Printf("isSymlink: %v\n", isSymlink)
urls := r.FormValue("urls") urls := r.FormValue("urls")
category := r.FormValue("category") category := r.FormValue("category")
if len(files) == 0 && urls == "" {
http.Error(w, "No torrent provided", http.StatusBadRequest)
return
}
var urlList []string var urlList []string
if urls != "" { if urls != "" {
urlList = strings.Split(urls, "\n") urlList = strings.Split(urls, "\n")
} }
ctx = context.WithValue(ctx, "isSymlink", isSymlink)
for _, url := range urlList { for _, url := range urlList {
magnet, err := common.GetMagnetFromUrl(url) if err := q.AddMagnet(ctx, url, category); err != nil {
if err != nil { q.logger.Printf("Error adding magnet: %v\n", err)
q.logger.Printf("Error parsing magnet link: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest) http.Error(w, err.Error(), http.StatusBadRequest)
return return
} }
go q.Process(magnet, category)
} }
for _, fileHeader := range files { if contentType == "multipart/form-data" {
file, _ := fileHeader.Open() files := r.MultipartForm.File["torrents"]
defer file.Close() for _, fileHeader := range files {
var reader io.Reader = file if err := q.AddTorrent(ctx, fileHeader, category); err != nil {
magnet, err := common.GetMagnetFromFile(reader, fileHeader.Filename) q.logger.Printf("Error adding torrent: %v\n", err)
if err != nil { http.Error(w, err.Error(), http.StatusBadRequest)
http.Error(w, err.Error(), http.StatusBadRequest) return
q.logger.Printf("Error reading file: %s", fileHeader.Filename) }
return
} }
go q.Process(magnet, category)
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
} }
@@ -169,3 +163,13 @@ func (q *QBit) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
properties := q.GetTorrentProperties(torrent) properties := q.GetTorrentProperties(torrent)
JSONResponse(w, properties, http.StatusOK) JSONResponse(w, properties, http.StatusOK)
} }
func (q *QBit) handleTorrentFiles(w http.ResponseWriter, r *http.Request) {
hash := r.URL.Query().Get("hash")
torrent := q.storage.Get(hash)
if torrent == nil {
return
}
files := q.GetTorrentFiles(torrent)
JSONResponse(w, files, http.StatusOK)
}

View File

@@ -2,6 +2,7 @@ package qbit
import ( import (
"cmp" "cmp"
"context"
"fmt" "fmt"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
@@ -11,56 +12,68 @@ import (
"net/http" "net/http"
"os" "os"
"sync" "sync"
"time"
) )
type QBit struct { type WorkerType struct {
Username string `json:"username"` ticker *time.Ticker
Password string `json:"password"` ctx context.Context
Port string `json:"port"`
DownloadFolder string `json:"download_folder"`
Categories []string `json:"categories"`
debrid debrid.Service
cache *common.Cache
storage *TorrentStorage
debug bool
logger *log.Logger
} }
var ( type Worker struct {
sessions sync.Map types map[string]WorkerType
) }
const ( type QBit struct {
sidLength = 32 Username string `json:"username"`
cookieName = "SID" Password string `json:"password"`
) Port string `json:"port"`
DownloadFolder string `json:"download_folder"`
Categories []string `json:"categories"`
debrid debrid.Service
cache *common.Cache
storage *TorrentStorage
debug bool
logger *log.Logger
arrs sync.Map // host:token (Used for refreshing in worker)
RefreshInterval int
}
func NewQBit(config *common.Config, deb debrid.Service, cache *common.Cache) *QBit { func NewQBit(config *common.Config, deb debrid.Service, cache *common.Cache) *QBit {
cfg := config.QBitTorrent cfg := config.QBitTorrent
storage := NewTorrentStorage("torrents.json") storage := NewTorrentStorage("torrents.json")
port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8182") port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8182")
refreshInterval := cmp.Or(cfg.RefreshInterval, 10)
return &QBit{ return &QBit{
Username: cfg.Username, Username: cfg.Username,
Password: cfg.Password, Password: cfg.Password,
Port: port, Port: port,
DownloadFolder: cfg.DownloadFolder, DownloadFolder: cfg.DownloadFolder,
Categories: cfg.Categories, Categories: cfg.Categories,
debrid: deb, debrid: deb,
cache: cache, cache: cache,
debug: cfg.Debug, debug: cfg.Debug,
storage: storage, storage: storage,
logger: common.NewLogger("QBit", os.Stdout), logger: common.NewLogger("QBit", os.Stdout),
arrs: sync.Map{},
RefreshInterval: refreshInterval,
} }
} }
func (q *QBit) Start() { func (q *QBit) Start() {
r := chi.NewRouter() r := chi.NewRouter()
r.Use(middleware.Logger) if q.debug {
r.Use(middleware.Logger)
}
r.Use(middleware.Recoverer) r.Use(middleware.Recoverer)
q.AddRoutes(r) q.AddRoutes(r)
ctx := context.Background()
go q.StartWorker(ctx)
q.logger.Printf("Starting QBit server on :%s", q.Port) q.logger.Printf("Starting QBit server on :%s", q.Port)
port := fmt.Sprintf(":%s", q.Port) port := fmt.Sprintf(":%s", q.Port)
q.logger.Fatal(http.ListenAndServe(port, r)) q.logger.Fatal(http.ListenAndServe(port, r))

View File

@@ -3,6 +3,7 @@ package qbit
import ( import (
"context" "context"
"crypto/subtle" "crypto/subtle"
"encoding/base64"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"net/http" "net/http"
"strings" "strings"
@@ -23,6 +24,42 @@ func (q *QBit) authMiddleware(next http.Handler) http.Handler {
}) })
} }
func DecodeAuthHeader(header string) (string, string, error) {
encodedTokens := strings.Split(header, " ")
if len(encodedTokens) != 2 {
return "", "", nil
}
encodedToken := encodedTokens[1]
bytes, err := base64.StdEncoding.DecodeString(encodedToken)
if err != nil {
return "", "", err
}
bearer := string(bytes)
colonIndex := strings.LastIndex(bearer, ":")
host := bearer[:colonIndex]
token := bearer[colonIndex+1:]
return host, token, nil
}
func (q *QBit) authContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, token, err := DecodeAuthHeader(r.Header.Get("Authorization"))
ctx := r.Context()
if err == nil {
ctx = context.WithValue(r.Context(), "host", host)
ctx = context.WithValue(ctx, "token", token)
q.arrs.Store(host, token)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func HashesCtx(next http.Handler) http.Handler { func HashesCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_hashes := chi.URLParam(r, "hashes") _hashes := chi.URLParam(r, "hashes")

View File

@@ -1,28 +1,67 @@
package qbit package qbit
import ( import (
"context"
"fmt"
"github.com/google/uuid" "github.com/google/uuid"
"goBlack/common" "goBlack/common"
"goBlack/pkg/debrid" "goBlack/pkg/debrid"
"os" "io"
"path/filepath" "mime/multipart"
"strings" "strings"
"sync"
"time" "time"
) )
func (q *QBit) Process(magnet *common.Magnet, category string) (*Torrent, error) { func (q *QBit) AddMagnet(ctx context.Context, url, category string) error {
torrent := q.CreateTorrentFromMagnet(magnet, category) magnet, err := common.GetMagnetFromUrl(url)
go q.storage.AddOrUpdate(torrent) if err != nil {
debridTorrent, err := debrid.ProcessQBitTorrent(q.debrid, magnet, category) q.logger.Printf("Error parsing magnet link: %v\n", err)
if err != nil || debridTorrent == nil { return err
// Mark as failed
q.MarkAsFailed(torrent)
return torrent, err
} }
torrent.ID = debridTorrent.Id err = q.Process(ctx, magnet, category)
q.processFiles(torrent, debridTorrent) if err != nil {
return torrent, nil q.logger.Println("Failed to process magnet:", err)
return 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 := common.GetMagnetFromFile(reader, fileHeader.Filename)
if err != nil {
q.logger.Printf("Error reading file: %s", fileHeader.Filename)
return err
}
err = q.Process(ctx, magnet, category)
if err != nil {
q.logger.Println("Failed to process torrent:", err)
return err
}
return nil
}
func (q *QBit) Process(ctx context.Context, magnet *common.Magnet, category string) error {
torrent := q.CreateTorrentFromMagnet(magnet, category)
arr := &debrid.Arr{
Name: category,
Token: ctx.Value("token").(string),
Host: ctx.Value("host").(string),
}
isSymlink := ctx.Value("isSymlink").(bool)
debridTorrent, err := debrid.ProcessQBitTorrent(q.debrid, magnet, arr, isSymlink)
if err != nil || debridTorrent == nil {
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, arr, isSymlink) // We can send async for file processing not to delay the response
return nil
} }
func (q *QBit) CreateTorrentFromMagnet(magnet *common.Magnet, category string) *Torrent { func (q *QBit) CreateTorrentFromMagnet(magnet *common.Magnet, category string) *Torrent {
@@ -33,69 +72,36 @@ func (q *QBit) CreateTorrentFromMagnet(magnet *common.Magnet, category string) *
Size: magnet.Size, Size: magnet.Size,
Category: category, Category: category,
State: "downloading", State: "downloading",
AddedOn: time.Now().Unix(),
MagnetUri: magnet.Link, MagnetUri: magnet.Link,
Tracker: "udp://tracker.opentrackr.org:1337", Tracker: "udp://tracker.opentrackr.org:1337",
UpLimit: -1, UpLimit: -1,
DlLimit: -1, DlLimit: -1,
FlPiecePrio: false, AutoTmm: false,
ForceStart: false, Ratio: 1,
AutoTmm: false, RatioLimit: 1,
Availability: 2,
MaxRatio: -1,
MaxSeedingTime: -1,
NumComplete: 10,
NumIncomplete: 0,
NumLeechs: 1,
Ratio: 1,
RatioLimit: 1,
} }
return torrent return torrent
} }
func (q *QBit) processFiles(torrent *Torrent, debridTorrent *debrid.Torrent) { func (q *QBit) processFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr *debrid.Arr, isSymlink bool) {
var wg sync.WaitGroup for debridTorrent.Status != "downloaded" {
files := debridTorrent.Files progress := debridTorrent.Progress
ready := make(chan debrid.TorrentFile, len(files)) q.logger.Printf("RD Download Progress: %.2f%%", progress)
time.Sleep(5 * time.Second)
q.logger.Printf("Checking %d files...", len(files)) dbT, err := q.debrid.CheckStatus(debridTorrent, isSymlink)
rCloneMountPath := q.debrid.GetMountPath() if err != nil {
path := filepath.Join(q.DownloadFolder, debridTorrent.Arr.CompletedFolder, debridTorrent.Folder) // /mnt/symlinks/{category}/MyTVShow/ q.logger.Printf("Error checking status: %v", err)
err := os.MkdirAll(path, os.ModePerm) q.MarkAsFailed(torrent)
if err != nil { q.RefreshArr(arr)
q.logger.Printf("Failed to create directory: %s\n", path) return
}
debridTorrent = dbT
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
} }
if isSymlink {
for _, file := range files { q.processSymlink(torrent, debridTorrent, arr)
wg.Add(1) } else {
go checkFileLoop(&wg, rCloneMountPath, file, ready) q.processManualFiles(torrent, debridTorrent, arr)
}
go func() {
wg.Wait()
close(ready)
}()
for f := range ready {
q.logger.Println("File is ready:", f.Path)
q.createSymLink(path, debridTorrent, f)
}
// Update the torrent when all files are ready
q.UpdateTorrent(torrent, debridTorrent)
q.logger.Printf("%s COMPLETED \n", debridTorrent.Name)
}
func (q *QBit) createSymLink(path string, torrent *debrid.Torrent, 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
torrentMountPath := filepath.Join(q.debrid.GetMountPath(), torrent.Folder, file.Name) // debridFolder/MyTVShow/MyTVShow.S01E01.720p.mkv
_ = os.Symlink(torrentMountPath, fullPath)
// Check if the file exists
if !fileReady(fullPath) {
q.logger.Printf("Failed to create symlink: %s\n", fullPath)
} }
} }

View File

@@ -77,7 +77,7 @@ func (ts *TorrentStorage) Get(hash string) *Torrent {
func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string) []*Torrent { func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string) []*Torrent {
ts.mu.RLock() ts.mu.RLock()
defer ts.mu.RUnlock() defer ts.mu.RUnlock()
torrents := make([]*Torrent, 0, len(ts.torrents)) torrents := make([]*Torrent, 0)
for _, id := range ts.order { for _, id := range ts.order {
torrent := ts.torrents[id] torrent := ts.torrents[id]
if category != "" && torrent.Category != category { if category != "" && torrent.Category != category {
@@ -95,7 +95,7 @@ func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string
filtered = append(filtered, torrent) filtered = append(filtered, torrent)
} }
} }
return filtered torrents = filtered
} }
return torrents return torrents
} }

View File

@@ -1,5 +1,7 @@
package qbit package qbit
import "goBlack/pkg/debrid"
type BuildInfo struct { type BuildInfo struct {
Libtorrent string `json:"libtorrent"` Libtorrent string `json:"libtorrent"`
Bitness int `json:"bitness"` Bitness int `json:"bitness"`
@@ -167,23 +169,25 @@ type TorrentCategory struct {
} }
type Torrent struct { type Torrent struct {
ID string `json:"-"` ID string `json:"-"`
DebridTorrent *debrid.Torrent `json:"-"`
TorrentPath string `json:"-"`
AddedOn int64 `json:"added_on,omitempty"` AddedOn int64 `json:"added_on,omitempty"`
AmountLeft int64 `json:"amount_left,omitempty"` AmountLeft int64 `json:"amount_left"`
AutoTmm bool `json:"auto_tmm"` AutoTmm bool `json:"auto_tmm"`
Availability float64 `json:"availability"` Availability float64 `json:"availability,omitempty"`
Category string `json:"category,omitempty"` Category string `json:"category,omitempty"`
Completed int64 `json:"completed,omitempty"` Completed int64 `json:"completed"`
CompletionOn int64 `json:"completion_on,omitempty"` CompletionOn int64 `json:"completion_on,omitempty"`
ContentPath string `json:"content_path,omitempty"` ContentPath string `json:"content_path"`
DlLimit int64 `json:"dl_limit,omitempty"` DlLimit int64 `json:"dl_limit"`
Dlspeed int64 `json:"dlspeed,omitempty"` Dlspeed int64 `json:"dlspeed"`
Downloaded int64 `json:"downloaded,omitempty"` Downloaded int64 `json:"downloaded"`
DownloadedSession int64 `json:"downloaded_session,omitempty"` DownloadedSession int64 `json:"downloaded_session"`
Eta int64 `json:"eta,omitempty"` Eta int64 `json:"eta"`
FlPiecePrio bool `json:"f_l_piece_prio"` FlPiecePrio bool `json:"f_l_piece_prio,omitempty"`
ForceStart bool `json:"force_start"` ForceStart bool `json:"force_start,omitempty"`
Hash string `json:"hash"` Hash string `json:"hash"`
LastActivity int64 `json:"last_activity,omitempty"` LastActivity int64 `json:"last_activity,omitempty"`
MagnetUri string `json:"magnet_uri,omitempty"` MagnetUri string `json:"magnet_uri,omitempty"`
@@ -198,7 +202,7 @@ type Torrent struct {
Progress float32 `json:"progress"` Progress float32 `json:"progress"`
Ratio int64 `json:"ratio,omitempty"` Ratio int64 `json:"ratio,omitempty"`
RatioLimit int64 `json:"ratio_limit,omitempty"` RatioLimit int64 `json:"ratio_limit,omitempty"`
SavePath string `json:"save_path,omitempty"` SavePath string `json:"save_path"`
SeedingTimeLimit int64 `json:"seeding_time_limit,omitempty"` SeedingTimeLimit int64 `json:"seeding_time_limit,omitempty"`
SeenComplete int64 `json:"seen_complete,omitempty"` SeenComplete int64 `json:"seen_complete,omitempty"`
SeqDl bool `json:"seq_dl"` SeqDl bool `json:"seq_dl"`
@@ -215,6 +219,10 @@ type Torrent struct {
Upspeed int64 `json:"upspeed,omitempty"` Upspeed int64 `json:"upspeed,omitempty"`
} }
func (t *Torrent) IsReady() bool {
return t.AmountLeft <= 0 && t.TorrentPath != ""
}
type TorrentProperties struct { type TorrentProperties struct {
AdditionDate int64 `json:"addition_date,omitempty"` AdditionDate int64 `json:"addition_date,omitempty"`
Comment string `json:"comment,omitempty"` Comment string `json:"comment,omitempty"`
@@ -251,6 +259,17 @@ type TorrentProperties struct {
UpSpeedAvg int64 `json:"up_speed_avg,omitempty"` UpSpeedAvg int64 `json:"up_speed_avg,omitempty"`
} }
type TorrentFile struct {
Index int `json:"index,omitempty"`
Name string `json:"name,omitempty"`
Size int64 `json:"size,omitempty"`
Progress int64 `json:"progress,omitempty"`
Priority int64 `json:"priority,omitempty"`
IsSeed bool `json:"is_seed,omitempty"`
PieceRange []int64 `json:"piece_range,omitempty"`
Availability float64 `json:"availability,omitempty"`
}
func NewAppPreferences() *AppPreferences { func NewAppPreferences() *AppPreferences {
preferences := &AppPreferences{ preferences := &AppPreferences{
AddTrackers: "", AddTrackers: "",

View File

@@ -16,7 +16,50 @@ func (q *QBit) MarkAsFailed(t *Torrent) *Torrent {
return 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 := float64(debridTorrent.Bytes)
progress := cmp.Or(debridTorrent.Progress, 100.0)
progress = progress / 100.0
sizeCompleted := int64(totalSize * progress)
var speed int64
if debridTorrent.Speed != 0 {
speed = debridTorrent.Speed
}
var eta int64
if speed != 0 {
eta = int64((totalSize - float64(sizeCompleted)) / float64(speed))
}
t.ID = debridTorrent.Id
t.Name = debridTorrent.Name
t.AddedOn = addedOn.Unix()
t.DebridTorrent = debridTorrent
t.Size = int64(totalSize)
t.Completed = sizeCompleted
t.Downloaded = sizeCompleted
t.DownloadedSession = sizeCompleted
t.Uploaded = sizeCompleted
t.UploadedSession = sizeCompleted
t.AmountLeft = int64(totalSize) - sizeCompleted
t.Progress = float32(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 { func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent {
rcLoneMount := q.debrid.GetMountPath()
if debridTorrent == nil && t.ID != "" { if debridTorrent == nil && t.ID != "" {
debridTorrent, _ = q.debrid.GetTorrent(t.ID) debridTorrent, _ = q.debrid.GetTorrent(t.ID)
} }
@@ -24,44 +67,42 @@ func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent
q.logger.Printf("Torrent with ID %s not found in %s", t.ID, q.debrid.GetName()) q.logger.Printf("Torrent with ID %s not found in %s", t.ID, q.debrid.GetName())
return t return t
} }
totalSize := cmp.Or(debridTorrent.Bytes, 1) if debridTorrent.Status != "downloaded" {
progress := int64(cmp.Or(debridTorrent.Progress, 100)) debridTorrent, _ = q.debrid.GetTorrent(t.ID)
progress = progress / 100.0 }
sizeCompleted := totalSize * progress if t.TorrentPath == "" {
t.TorrentPath = filepath.Base(debridTorrent.GetMountFolder(rcLoneMount))
}
savePath := filepath.Join(q.DownloadFolder, t.Category) + string(os.PathSeparator) savePath := filepath.Join(q.DownloadFolder, t.Category) + string(os.PathSeparator)
torrentPath := filepath.Join(savePath, debridTorrent.Folder) + string(os.PathSeparator) torrentPath := filepath.Join(savePath, t.TorrentPath) + string(os.PathSeparator)
t = q.UpdateTorrentMin(t, debridTorrent)
var speed int64
if debridTorrent.Speed != 0 {
speed = int64(debridTorrent.Speed)
}
var eta int64
if speed != 0 {
eta = (totalSize - sizeCompleted) / speed
}
t.Name = debridTorrent.Name
t.Size = debridTorrent.Bytes
t.Completed = sizeCompleted
t.Downloaded = sizeCompleted
t.DownloadedSession = sizeCompleted
t.Uploaded = sizeCompleted
t.UploadedSession = sizeCompleted
t.AmountLeft = totalSize - sizeCompleted
t.Progress = 100
t.SavePath = savePath
t.ContentPath = torrentPath t.ContentPath = torrentPath
t.Eta = eta
t.Dlspeed = speed
t.Upspeed = speed
if t.AmountLeft == 0 { if t.IsReady() {
t.State = "pausedUP" t.State = "pausedUP"
q.storage.Update(t)
return t
} }
go q.storage.AddOrUpdate(t) ticker := time.NewTicker(2 * time.Second)
return t 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 { func (q *QBit) ResumeTorrent(t *Torrent) bool {
@@ -100,3 +141,18 @@ func (q *QBit) GetTorrentProperties(t *Torrent) *TorrentProperties {
ShareRatio: 100, ShareRatio: 100,
} }
} }
func (q *QBit) GetTorrentFiles(t *Torrent) []*TorrentFile {
files := make([]*TorrentFile, 0)
if t.DebridTorrent == nil {
return files
}
for index, file := range t.DebridTorrent.Files {
files = append(files, &TorrentFile{
Index: index,
Name: file.Path,
Size: file.Size,
})
}
return files
}

View File

@@ -1,24 +1,22 @@
package qbit package qbit
import ( import (
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"goBlack/common"
"goBlack/pkg/debrid" "goBlack/pkg/debrid"
"net/http" "net/http"
"os"
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
) )
func generateSID() (string, error) { //func generateSID() (string, error) {
bytes := make([]byte, sidLength) // bytes := make([]byte, sidLength)
if _, err := rand.Read(bytes); err != nil { // if _, err := rand.Read(bytes); err != nil {
return "", err // return "", err
} // }
return hex.EncodeToString(bytes), nil // return hex.EncodeToString(bytes), nil
} //}
func JSONResponse(w http.ResponseWriter, data interface{}, code int) { func JSONResponse(w http.ResponseWriter, data interface{}, code int) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
@@ -26,11 +24,6 @@ func JSONResponse(w http.ResponseWriter, data interface{}, code int) {
json.NewEncoder(w).Encode(data) json.NewEncoder(w).Encode(data)
} }
func fileReady(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err) // Returns true if the file exists
}
func checkFileLoop(wg *sync.WaitGroup, dir string, file debrid.TorrentFile, ready chan<- debrid.TorrentFile) { func checkFileLoop(wg *sync.WaitGroup, dir string, file debrid.TorrentFile, ready chan<- debrid.TorrentFile) {
defer wg.Done() defer wg.Done()
ticker := time.NewTicker(1 * time.Second) // Check every second ticker := time.NewTicker(1 * time.Second) // Check every second
@@ -39,7 +32,7 @@ func checkFileLoop(wg *sync.WaitGroup, dir string, file debrid.TorrentFile, read
for { for {
select { select {
case <-ticker.C: case <-ticker.C:
if fileReady(path) { if common.FileReady(path) {
ready <- file ready <- file
return return
} }

46
pkg/qbit/worker.go Normal file
View File

@@ -0,0 +1,46 @@
package qbit
import (
"context"
"goBlack/pkg/debrid"
"time"
)
func (q *QBit) StartWorker(ctx context.Context) {
q.logger.Println("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.Println("Qbit Refresh Worker stopped")
return
case <-refreshTicker.C:
torrents := q.storage.GetAll("", "", nil)
if len(torrents) > 0 {
q.RefreshArrs()
}
}
}
}
func (q *QBit) RefreshArrs() {
q.arrs.Range(func(key, value interface{}) bool {
host, ok := key.(string)
token, ok2 := value.(string)
if !ok || !ok2 {
return true
}
arr := &debrid.Arr{
Name: "",
Token: token,
Host: host,
}
q.RefreshArr(arr)
return true
})
}