diff --git a/README.md b/README.md index 32c10ed..19a2129 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ This is a Golang implementation go Torrent Blackhole with a **Real Debrid Proxy Support**. #### Uses -- Torrent Blackhole that supports the Arrs. +- Torrent Blackhole that supports the Arrs(Sonarr, Radarr, etc) - Proxy support for the Arrs The proxy is useful in filtering out un-cached Real Debrid torrents @@ -73,12 +73,30 @@ Download the binary from the releases page and run it with the config file. "port": "8181", "debug": false, "username": "username", - "password": "password" + "password": "password", + "cached_only": true } } ``` -#### Proxy +#### Config Notes +##### Debrid Config +- This config key is important as it's used for both Blackhole and Proxy + +##### Arrs Config +- An empty array will disable Blackhole for the Arrs +- The `watch_folder` is the folder where the Blackhole will watch for torrents +- The `completed_folder` is the folder where the Blackhole will move the completed torrents +- The `token` is the API key for the Arr(This is optional, I think) + +##### Proxy Config +- The `enabled` key is used to enable the proxy +- The `port` key is the port the proxy will listen on +- The `debug` key is used to enable debug logs +- The `username` and `password` keys are used for basic authentication +- The `cached_only` means only cached torrents will be returned +- +### Proxy The proxy is useful in filtering out un-cached Real Debrid torrents. The proxy is a simple HTTP proxy that requires basic authentication. The proxy can be enabled by setting the `proxy.enabled` to `true` in the config file. diff --git a/cmd/blackhole.go b/cmd/blackhole.go index d503ef1..fe47885 100644 --- a/cmd/blackhole.go +++ b/cmd/blackhole.go @@ -146,6 +146,7 @@ func StartArr(conf *debrid.Arr, db debrid.Service) { } func StartBlackhole(config *common.Config, deb debrid.Service) { + log.Println("[*] Starting Blackhole") var wg sync.WaitGroup for _, conf := range config.Arrs { wg.Add(1) diff --git a/cmd/main.go b/cmd/main.go index 24e8242..9f08461 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -3,16 +3,33 @@ package cmd import ( "goBlack/common" "goBlack/debrid" - "log" + "sync" ) func Start(config *common.Config) { - log.Print("[*] BlackHole running") deb := debrid.NewDebrid(config.Debrid) + + var wg sync.WaitGroup + if config.Proxy.Enabled { - go StartProxy(config, deb) + proxy := NewProxy(*config, deb) + wg.Add(1) + go func() { + defer wg.Done() + proxy.Start() + }() } - StartBlackhole(config, deb) + + if len(config.Arrs) > 0 { + wg.Add(1) + go func() { + defer wg.Done() + StartBlackhole(config, deb) + }() + } + + // Wait indefinitely + wg.Wait() } diff --git a/cmd/proxy.go b/cmd/proxy.go index 01cc15c..a4f2787 100644 --- a/cmd/proxy.go +++ b/cmd/proxy.go @@ -16,7 +16,6 @@ import ( "os" "regexp" "strings" - "sync" ) type RSS struct { @@ -70,31 +69,37 @@ type TorznabAttr struct { Value string `xml:"value,attr"` } -type SafeItems struct { - mu sync.Mutex - Items []Item +type Proxy 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"` + Debrid debrid.Service } -func (s *SafeItems) Add(item Item) { - s.mu.Lock() - defer s.mu.Unlock() - s.Items = append(s.Items, item) +func NewProxy(config common.Config, deb debrid.Service) *Proxy { + cfg := config.Proxy + port := cmp.Or(os.Getenv("PORT"), cfg.Port, "8181") + return &Proxy{ + Port: port, + Enabled: cfg.Enabled, + Debug: cfg.Debug, + Username: cfg.Username, + Password: cfg.Password, + CachedOnly: cfg.CachedOnly, + Debrid: deb, + } } -func (s *SafeItems) Get() []Item { - s.mu.Lock() - defer s.mu.Unlock() - return s.Items -} - -func ProcessJSONResponse(resp *http.Response, deb debrid.Service) *http.Response { +func (p *Proxy) ProcessJSONResponse(resp *http.Response) *http.Response { if resp == nil || resp.Body == nil { return resp } body, err := io.ReadAll(resp.Body) if err != nil { - log.Println("Error reading response body:", err) return resp } err = resp.Body.Close() @@ -102,8 +107,8 @@ func ProcessJSONResponse(resp *http.Response, deb debrid.Service) *http.Response return nil } - var p fastjson.Parser - v, err := p.ParseBytes(body) + var par fastjson.Parser + v, err := par.ParseBytes(body) if err != nil { // If it's not JSON, return the original response resp.Body = io.NopCloser(bytes.NewReader(body)) @@ -124,24 +129,33 @@ func ProcessJSONResponse(resp *http.Response, deb debrid.Service) *http.Response } -func ProcessResponse(resp *http.Response, deb debrid.Service) *http.Response { +func (p *Proxy) ProcessResponse(resp *http.Response) *http.Response { if resp == nil || resp.Body == nil { return resp } contentType := resp.Header.Get("Content-Type") switch contentType { case "application/json": - return ProcessJSONResponse(resp, deb) + return resp // p.ProcessJSONResponse(resp) case "application/xml": - return ProcessXMLResponse(resp, deb) + return p.ProcessXMLResponse(resp) case "application/rss+xml": - return ProcessXMLResponse(resp, deb) + return p.ProcessXMLResponse(resp) default: return resp } } -func XMLItemIsCached(item Item, deb debrid.Service) bool { +func getItemsHash(items []Item) map[string]string { + IdHashMap := make(map[string]string) + for _, item := range items { + hash := getItemHash(item) + IdHashMap[item.GUID] = hash + } + return IdHashMap +} + +func getItemHash(item Item) string { magnetLink := "" infohash := "" @@ -160,40 +174,33 @@ func XMLItemIsCached(item Item, deb debrid.Service) bool { } if magnetLink == "" && infohash == "" { // We can't check the availability of the torrent without a magnet link or infohash - return false + return "" } var magnet *common.Magnet var err error if infohash == "" { magnet, err = common.GetMagnetInfo(magnetLink) - if err != nil { + if err != nil || magnet == nil || magnet.InfoHash == "" { log.Println("Error getting magnet info:", err) - return false - } - } else { - magnet = &common.Magnet{ - InfoHash: infohash, - Name: item.Title, - Link: magnetLink, + return "" } + infohash = magnet.InfoHash } - if magnet == nil { - log.Println("Error getting magnet info") - return false - } - return deb.IsAvailable(magnet) + return infohash } -func ProcessXMLResponse(resp *http.Response, deb debrid.Service) *http.Response { +func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response { if resp == nil || resp.Body == nil { return resp } body, err := io.ReadAll(resp.Body) if err != nil { - log.Println("Error reading response body:", err) + if p.Debug { + log.Println("Error reading response body:", err) + } return resp } err = resp.Body.Close() @@ -204,31 +211,51 @@ func ProcessXMLResponse(resp *http.Response, deb debrid.Service) *http.Response var rss RSS err = xml.Unmarshal(body, &rss) if err != nil { - log.Printf("Error unmarshalling XML: %v", err) + if p.Debug { + log.Printf("Error unmarshalling XML: %v", err) + } return resp } - newItems := &SafeItems{} - var wg sync.WaitGroup + newItems := make([]Item, 0) // Step 4: Extract infohash or magnet URI, manipulate data - for _, item := range rss.Channel.Items { - wg.Add(1) - go func(item Item) { - defer wg.Done() - if XMLItemIsCached(item, deb) { - newItems.Add(item) - } - }(item) + IdsHashMap := getItemsHash(rss.Channel.Items) + hashes := make([]string, 0) + for _, hash := range IdsHashMap { + if hash != "" { + hashes = append(hashes, hash) + } } - wg.Wait() - items := newItems.Get() - log.Printf("Report: %d/%d items are cached", len(items), len(rss.Channel.Items)) - rss.Channel.Items = items + if len(hashes) == 0 { + // No infohashes or magnet links found, should we return the original response? + return resp + } + availableHashes := p.Debrid.IsAvailable(hashes) + for _, item := range rss.Channel.Items { + hash := IdsHashMap[item.GUID] + if hash == "" { + // newItems = append(newItems, item) + continue + } + isCached, exists := availableHashes[hash] + if !exists { + // newItems = append(newItems, item) + continue + } + if !isCached { + continue + } + newItems = append(newItems, item) + } + log.Printf("Report: %d/%d items are cached", len(newItems), len(rss.Channel.Items)) + rss.Channel.Items = newItems // rss.Channel.Items = newItems modifiedBody, err := xml.MarshalIndent(rss, "", " ") if err != nil { - log.Printf("Error marshalling XML: %v", err) + if p.Debug { + log.Printf("Error marshalling XML: %v", err) + } return resp } modifiedBody = append([]byte(xml.Header), modifiedBody...) @@ -247,9 +274,8 @@ func UrlMatches(re *regexp.Regexp) goproxy.ReqConditionFunc { } } -func StartProxy(config *common.Config, deb debrid.Service) { - username, password := config.Proxy.Username, config.Proxy.Password - cfg := config.Proxy +func (p *Proxy) Start() { + username, password := p.Username, p.Password proxy := goproxy.NewProxyHttpServer() if username != "" || password != "" { // Set up basic auth for proxy @@ -261,12 +287,11 @@ func StartProxy(config *common.Config, deb debrid.Service) { proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile("^.443$"))).HandleConnect(goproxy.AlwaysMitm) proxy.OnResponse(UrlMatches(regexp.MustCompile("^.*/api\\?t=(search|tvsearch|movie)(&.*)?$"))).DoFunc( func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response { - return ProcessResponse(resp, deb) + return p.ProcessResponse(resp) }) - port := cmp.Or(os.Getenv("PORT"), cfg.Port, "8181") - proxy.Verbose = cfg.Debug - port = fmt.Sprintf(":%s", port) - log.Printf("Starting proxy server on %s\n", port) - log.Fatal(http.ListenAndServe(fmt.Sprintf("%s", port), proxy)) + proxy.Verbose = p.Debug + portFmt := fmt.Sprintf(":%s", p.Port) + log.Printf("[*] Starting proxy server on %s\n", portFmt) + log.Fatal(http.ListenAndServe(fmt.Sprintf("%s", portFmt), proxy)) } diff --git a/common/config.go b/common/config.go index a364eeb..17d9742 100644 --- a/common/config.go +++ b/common/config.go @@ -25,11 +25,12 @@ type Config struct { URL string `json:"url"` } `json:"arrs"` Proxy struct { - Port string `json:"port"` - Enabled bool `json:"enabled"` - Debug bool `json:"debug"` - Username string `json:"username"` - Password string `json:"password"` + 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"` } } diff --git a/debrid/debrid.go b/debrid/debrid.go index 441ebce..cc128ea 100644 --- a/debrid/debrid.go +++ b/debrid/debrid.go @@ -11,7 +11,7 @@ type Service interface { CheckStatus(torrent *Torrent) (*Torrent, error) DownloadLink(torrent *Torrent) error Process(arr *Arr, magnet string) (*Torrent, error) - IsAvailable(magnet *common.Magnet) bool + IsAvailable(infohashes []string) map[string]bool } type Debrid struct { diff --git a/debrid/realdebrid.go b/debrid/realdebrid.go index 2d88a33..342e27a 100644 --- a/debrid/realdebrid.go +++ b/debrid/realdebrid.go @@ -28,7 +28,7 @@ func (r *RealDebrid) Process(arr *Arr, magnet string) (*Torrent, error) { } log.Printf("Torrent Name: %s", torrent.Name) if !r.DownloadUncached { - if !r.IsAvailable(torrent.Magnet) { + if !r.IsAvailable([]string{torrent.InfoHash})[torrent.InfoHash] { return torrent, fmt.Errorf("torrent is not cached") } log.Printf("Torrent: %s is cached", torrent.Name) @@ -41,22 +41,31 @@ func (r *RealDebrid) Process(arr *Arr, magnet string) (*Torrent, error) { return r.CheckStatus(torrent) } -func (r *RealDebrid) IsAvailable(magnet *common.Magnet) bool { - url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, magnet.InfoHash) +func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool { + hashes := strings.Join(infohashes, "/") + result := make(map[string]bool) + url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, hashes) resp, err := r.client.MakeRequest(http.MethodGet, url, nil) if err != nil { - return false + log.Println(url) + log.Println("Error checking availability:", err) + return result } var data structs.RealDebridAvailabilityResponse err = json.Unmarshal(resp, &data) if err != nil { - return false + log.Println("Error marshalling availability:", err) + return result } - hosters, exists := data[strings.ToLower(magnet.InfoHash)] - if !exists || len(hosters) < 1 { - return false + for _, h := range infohashes { + hosters, exists := data[strings.ToLower(h)] + if !exists || len(hosters.Rd) < 1 { + result[h] = false + } else { + result[h] = true + } } - return true + return result } func (r *RealDebrid) SubmitMagnet(torrent *Torrent) (*Torrent, error) { diff --git a/debrid/structs/realdebrid.go b/debrid/structs/realdebrid.go index 4b6758b..4e3eed9 100644 --- a/debrid/structs/realdebrid.go +++ b/debrid/structs/realdebrid.go @@ -1,10 +1,36 @@ package structs -type RealDebridAvailabilityResponse map[string]Hosters +import ( + "encoding/json" + "fmt" +) -type Hosters map[string][]FileIDs +type RealDebridAvailabilityResponse map[string]Hoster -type FileIDs map[string]FileVariant +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"` @@ -39,3 +65,5 @@ type RealDebridTorrentInfo struct { Speed int `json:"speed,omitempty"` Seeders int `json:"seeders,omitempty"` } + +// 5e6e2e77fd3921a7903a41336c844cc409bf8788/14527C07BDFDDFC642963238BB6E7507B9742947/66A1CD1A5C7F4014877A51AC2620E857E3BB4D16