53 Commits

Author SHA1 Message Date
Mukhtar Akere
bba90cb89a Finalize v0.4.0
Some checks failed
Release / goreleaser (push) Failing after 2m18s
2025-01-25 11:40:30 +01:00
Mukhtar Akere
fc5c6e2869 Finalize v0.4.0 2025-01-24 23:33:08 +01:00
Mukhtar Akere
66f4965ec8 Fix arr storage 2025-01-23 03:11:24 +01:00
Mukhtar Akere
dc16f0d8a1 Fix arr storage 2025-01-23 03:06:11 +01:00
Mukhtar Akere
0741ddf999 Fix versionning 2025-01-23 02:31:55 +01:00
Mukhtar Akere
2ae4bd571e Fix Log file permissions 2025-01-23 02:11:37 +01:00
Mukhtar Akere
0b1c1af8b8 Fix Repair checks. Handle false positives 2025-01-23 01:35:28 +01:00
Mukhtar Akere
74a55149fc Features:
- Add file logging, server
- Fix minor repair bug
- Wrap up beta
2025-01-23 00:27:12 +01:00
Mukhtar Akere
cfb0051b04 Fix AllDebrid symlink bug 2025-01-19 08:56:52 +01:00
Mukhtar Akere
a986c4b5d0 Hotfix ui templates 2025-01-18 04:13:56 +01:00
Mukhtar Akere
3841b7751e Changelog v0.4.0 2025-01-18 03:49:05 +01:00
Mukhtar Akere
ea73572557 - Add shinning UI
- Revamp deployment process
- Fix Alldebrid file node bug
2025-01-13 20:18:59 +01:00
Mukhtar Akere
7cb41a0e8b Fix getting mount path 2025-01-11 23:10:05 +01:00
Mukhtar Akere
451c17cdf7 Merge branch 'beta' of github.com:sirrobot01/debrid-blackhole into beta 2025-01-11 23:09:00 +01:00
Mukhtar Akere
c39eebea0d [BETA] Changelog 0.3.4 (#14)
- Add repair worker
- Fix AllDebrid bugs with single movies/series
- Fix Torbox bugs
2025-01-11 07:21:49 -08:00
Mukhtar Akere
03c9657945 Add repair worker 2025-01-09 19:44:38 +01:00
Mukhtar Akere
28e5342c66 Add AllDebrid support 2025-01-01 17:12:18 +01:00
Mukhtar Akere
eeb3a31b05 Fix rar files, remove srt 2024-12-27 22:30:36 +01:00
Mukhtar Akere
e9d3e120f3 Hotfix
Some checks failed
Release / goreleaser (push) Failing after 2m2s
2024-12-25 00:06:47 +01:00
Mukhtar Akere
104df3c33c Changelog 0.3.2 2024-12-25 00:00:47 +01:00
Kai Gohegan
810c9d705e Update storage.go (#10) 2024-12-24 14:59:33 -08:00
Kai Gohegan
4ff00859a3 Update Dockerfile (#9) 2024-12-24 14:59:08 -08:00
Mukhtar Akere
b77dbcc4f4 Fix magnet conversion
Some checks failed
Release / goreleaser (push) Failing after 2m4s
2024-12-19 00:12:29 +01:00
Mukhtar Akere
58c0aafab1 Fix docker.yml 2024-12-18 17:24:40 +01:00
Mukhtar Akere
357da54083 Fix docker.yml 2024-12-18 17:20:59 +01:00
Mukhtar Akere
88a7196eaf Hotfix 2024-12-18 17:01:26 +01:00
Mukhtar Akere
abc86a0460 Changelog 0.3.1 2024-12-18 16:51:00 +01:00
robertRogerPresident
dd0b7efdff fix toboxInfo struct type (#6)
Co-authored-by: Tangui <tanguidaoudal@yahoo.fr>
2024-12-16 11:56:09 -08:00
Mukhtar Akere
7359f280b0 Make sure torrents get deleted on failed 2024-12-12 17:38:53 +01:00
Mukhtar Akere
4eb3539347 Fix docker.yml 2024-11-30 16:06:28 +01:00
Mukhtar Akere
9fb1118475 Fix docker.yml 2024-11-30 16:03:33 +01:00
Mukhtar Akere
07491b43fe Fix docker.yml 2024-11-30 16:01:07 +01:00
Mukhtar Akere
8f7c9a19c5 Fix goreleaser
Some checks failed
Release / goreleaser (push) Failing after 2m44s
2024-11-30 15:50:42 +01:00
Mukhtar Akere
a51364d150 Changelog 0.3.0 2024-11-30 15:46:58 +01:00
Mukhtar Akere
df2aa4e361 0.2.7:
- Add support for multiple debrid providers
- Add Torbox support
- Add support for configurable debrid cache checks
- Add support for configurable debrid download uncached torrents
2024-11-25 16:48:23 +01:00
Mukhtar Akere
b51cb954f8 Merge branch 'beta' 2024-11-25 16:39:47 +01:00
Mukhtar Akere
8bdb2e3547 Hotfix & Updated Readme 2024-11-23 23:41:49 +01:00
Mukhtar Akere
2c9a076cd2 Hotfix 2024-11-23 21:10:42 +01:00
Mukhtar Akere
d2a77620bc Features:
- Add Torbox(Tested)
- Fix RD cache check
- Minor fixes
2024-11-23 19:52:15 +01:00
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
Mukhtar Akere
9511f3e99e Changelog 0.2.0 (#1)
* Changelog 0.2.0
2024-09-11 22:01:10 -07:00
Mukhtar Akere
60c6cb32d3 Changelog: 0.1.4 2024-09-02 02:58:58 +01:00
Mukhtar Akere
d405e0d8e0 Changelog: 0.1.3 2024-09-01 13:17:15 +01:00
Mukhtar Akere
74791d6e62 Features:
- Make Blackhole and Proxy optional
- Query all Hashes at once, speeeding up the process
- refractor the code a little bit
2024-08-26 20:13:48 +01:00
75 changed files with 7418 additions and 957 deletions

52
.air.toml Normal file
View File

@@ -0,0 +1,52 @@
root = "."
testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
bin = "./tmp/main"
cmd = "bash -c 'VERSION=$(git describe --tags --always --abbrev=0 2>/dev/null || echo dev) && go build -ldflags \"-X github.com/sirrobot01/debrid-blackhole/pkg/version.Version=$VERSION -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel=beta\" -o ./tmp/main .'"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "data"]
exclude_file = []
exclude_regex = ["_test.go"]
exclude_unchanged = false
follow_symlink = false
full_bin = ""
include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", ".json"]
include_file = []
kill_delay = "0s"
log = "build-errors.log"
poll = false
poll_interval = 0
post_cmd = []
pre_cmd = []
rerun = false
rerun_delay = 500
send_interrupt = false
stop_on_error = false
[color]
app = ""
build = "yellow"
main = "magenta"
runner = "green"
watcher = "cyan"
[log]
main_only = false
silent = false
time = false
[misc]
clean_on_exit = false
[proxy]
app_port = 0
enabled = false
proxy_port = 0
[screen]
clear_on_rebuild = false
keep_scroll = true

View File

@@ -5,4 +5,5 @@ docker-compose.yml
.DS_Store
**/.idea/
*.magnet
**.torrent
**.torrent
torrents.json

View File

69
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
name: Docker Build and Push
on:
push:
branches:
- main
- beta
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get version
id: get_version
run: |
LATEST_TAG=$(git tag | sort -V | tail -n1)
echo "latest_tag=${LATEST_TAG}" >> $GITHUB_ENV
- name: Set channel
id: set_channel
run: |
if [[ ${{ github.ref }} == 'refs/heads/beta' ]]; then
echo "CHANNEL=beta" >> $GITHUB_ENV
else
echo "CHANNEL=stable" >> $GITHUB_ENV
fi
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push for beta branch
if: github.ref == 'refs/heads/beta'
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: cy01/blackhole:beta
build-args: |
VERSION=${{ env.latest_tag }}
CHANNEL=${{ env.CHANNEL }}
- name: Build and push for main branch
if: github.ref == 'refs/heads/main'
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
cy01/blackhole:latest
cy01/blackhole:${{ env.latest_tag }}
build-args: |
VERSION=${{ env.latest_tag }}
CHANNEL=${{ env.CHANNEL }}

40
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Release
on:
push:
tags:
- '*'
permissions:
contents: write
jobs:
goreleaser:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.22'
- name: Set Release Channel
run: |
if [[ ${{ github.ref }} == refs/tags/beta* ]]; then
echo "RELEASE_CHANNEL=beta" >> $GITHUB_ENV
else
echo "RELEASE_CHANNEL=stable" >> $GITHUB_ENV
fi
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v5
with:
distribution: goreleaser
version: latest
args: release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

3
.gitignore vendored
View File

@@ -9,3 +9,6 @@ docker-compose.yml
*.log
*.log.*
dist/
tmp/**
torrents.json
logs/**

View File

@@ -1,8 +1,7 @@
version: 2
version: 1
before:
hooks:
# You may remove this if you don't use go modules.
- go mod tidy
builds:
@@ -16,6 +15,10 @@ builds:
- amd64
- arm
- arm64
ldflags:
- -s -w
- -X github.com/sirrobot01/debrid-blackhole/pkg/version.Version={{.Version}}
- -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel={{.Env.RELEASE_CHANNEL}}
archives:

130
CHANGELOG.md Normal file
View File

@@ -0,0 +1,130 @@
#### 0.1.0
- Initial Release
- Added Real Debrid Support
- Added Arrs Support
- Added Proxy Support
- Added Basic Authentication for Proxy
- Added Rate Limiting for Debrid Providers
#### 0.1.1
- Added support for "No Blackhole" for Arrs
- Added support for "Cached Only" for Proxy
- Bug Fixes
#### 0.1.2
- Bug fixes
- Code cleanup
- Get available hashes at once
#### 0.1.3
- Searching for infohashes in the xml description/summary/comments
- Added local cache support
- Added max cache size
- Rewrite blackhole.go
- Bug fixes
- Fixed indexer getting disabled
- Fixed blackhole not working
#### 0.1.4
- Rewrote Report log
- Fix YTS, 1337x not grabbing infohash
- Fix Torrent symlink bug
#### 0.2.0-beta
- Switch to QbitTorrent API instead of Blackhole
- Rewrote the whole codebase
### 0.2.0
- Implement 0.2.0-beta changes
- Removed Blackhole
- Added QbitTorrent API
- 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
#### 0.2.7
- Add support for multiple debrid providers
- Add Torbox support
- Add support for configurable debrid cache checks
- Add support for configurable debrid download uncached torrents
#### 0.3.0
- Add UI for adding torrents
- Refraction of the code
- -Fix Torbox bug
- Update CI/CD
- Update Readme
#### 0.3.1
- Add DebridLink Support
- Refactor error handling
#### 0.3.2
- Fix DebridLink not downloading
- Fix Torbox with uncached torrents
- Add new /internal/cached endpoint to check if an hash is cached
- implement per-debrid local cache
- Fix file check for torbox
- Other minor bug fixes
#### 0.3.3
- Add AllDebrid Support
- Fix Torbox not downloading uncached torrents
- Fix Rar files being downloaded
#### 0.4.0
- Add support for multiple debrid providers
- A full-fledged UI for adding torrents, repairing files, viewing config and managing torrents
- Fix issues with Alldebrid
- Fix file transversal bug
- Fix files with no parent directory
- Logging
- Add a more robust logging system
- Add logging to a file
- Add logging to the UI
- Qbittorrent
- Add support for tags(creating, deleting, listing)
- Add support for categories(creating, deleting, listing)
- Fix issues with arr sending torrents using a different content type.

View File

@@ -2,6 +2,8 @@ FROM --platform=$BUILDPLATFORM golang:1.22 as builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG VERSION
ARG CHANNEL
# Set destination for COPY
WORKDIR /app
@@ -15,12 +17,26 @@ RUN go mod download
ADD . .
# Build
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 -ldflags="-X github.com/sirrobot01/debrid-blackhole/pkg/version.Version=${VERSION} -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel=${CHANNEL}" -o /blackhole
FROM alpine as logsetup
RUN mkdir -p /logs && \
touch /logs/decypharr.log && \
chown -R 1000:1000 /logs && \
chmod -R 755 /logs && \
chmod 666 /logs/decypharr.log
FROM scratch
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /blackhole /blackhole
COPY --from=builder /app/README.md /README.md
COPY --from=logsetup /logs /logs
EXPOSE 8181
ENV LOG_PATH=/logs
EXPOSE 8181 8282
VOLUME ["/app"]
# Run
CMD ["/blackhole", "--config", "/app/config.json"]
CMD ["/blackhole", "--config", "/app/config.json"]

258
README.md
View File

@@ -1,33 +1,76 @@
### GoBlackHole(with Debrid Proxy Support)
### DecyphArr(with Debrid Proxy Support)
This is a Golang implementation go Torrent Blackhole with a **Real Debrid Proxy Support**.
![ui](doc/main.png)
#### Uses
- Torrent Blackhole that supports the Arrs.
This is a Golang implementation go Torrent QbitTorrent with a **Multiple Debrid service support**.
### Table of Contents
- [Features](#features)
- [Supported Debrid Providers](#supported-debrid-providers)
- [Installation](#installation)
- [Docker Compose](#docker-compose)
- [Binary](#binary)
- [Usage](#usage)
- [Connecting to Sonarr/Radarr](#connecting-to-sonarrradarr)
- [Sample Config](#sample-config)
- [Config Notes](#config-notes)
- [Log Level](#log-level)
- [Max Cache Size](#max-cache-size)
- [Debrid Config](#debrid-config)
- [Proxy Config](#proxy-config)
- [Qbittorrent Config](#qbittorrent-config)
- [Arrs Config](#arrs-config)
- [Proxy](#proxy)
- [Repair Worker](#repair-worker)
- [Changelog](#changelog)
- [TODO](#todo)
### Features
- Mock Qbittorent API that supports the Arrs(Sonarr, Radarr, etc)
- A Full-fledged UI for managing torrents
- Proxy support for the Arrs
- Real Debrid Support
- Torbox Support
- Debrid Link Support
- Multi-Debrid Providers support
- Repair Worker for missing files (**NEW**)
The proxy is useful in filtering out un-cached Real Debrid torrents
### Supported Debrid Providers
- [Real Debrid](https://real-debrid.com)
- [Torbox](https://torbox.app)
- [Debrid Link](https://debrid-link.com)
- [All Debrid](https://alldebrid.com)
### Installation
#### Installation
##### Docker Compose
```yaml
version: '3.7'
services:
blackhole:
image: cy01/blackhole:latest
image: cy01/blackhole:latest # or cy01/blackhole:beta
container_name: blackhole
ports:
- "8282:8282" # qBittorrent
- "8181:8181" # Proxy
user: "1000:1000"
volumes:
- ./logs:/app/logs
- ~/plex/media:/media
- ~/plex/media/symlinks/:/media/symlinks/
- /mnt/:/mnt
- ~/plex/configs/blackhole/config.json:/app/config.json # Config file, see below
environment:
- PUID=1000
- PGID=1000
- UMASK=002
- 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
restart: unless-stopped
depends_on:
- rclone # If you are using rclone with docker
```
@@ -38,63 +81,184 @@ Download the binary from the releases page and run it with the config file.
./blackhole --config /path/to/config.json
```
#### Config
### Usage
- The UI is available at `http://localhost:8282`
- Setup the config.json file. Scroll down for the sample config file
- Setup docker compose/ binary with the config file
- Start the service
- Connect to Sonarr/Radarr/Lidarr
#### Connecting to Sonarr/Radarr
- Sonarr/Radarr
- Settings -> Download Client -> Add Client -> qBittorrent
- Host: `localhost` # or the IP of the server
- Port: `8282` # or the port set in the config file/ docker-compose env
- Username: `http://sonarr:8989` # Your arr host with http/https
- Password: `sonarr_token` # Your arr token
- Category: e.g `sonarr`, `radarr`
- Use SSL -> `No`
- Sequential Download -> `No`|`Yes` (If you want to download the torrents locally instead of symlink)
- Test
- Save
#### Sample Config
This is the default config file. You can create a `config.json` file in the root directory of the project or mount it in the docker-compose file.
```json
{
"debrid": {
"name": "realdebrid",
"host": "https://api.real-debrid.com/rest/1.0",
"api_key": "realdebrid_api_key",
"folder": "data/realdebrid/torrents/",
"rate_limit": "250/minute"
},
"arrs": [
"debrids": [
{
"watch_folder": "data/sonarr/",
"completed_folder": "data/sonarr/completed/",
"token": "sonarr_api_key",
"url": "http://localhost:8787"
"name": "torbox",
"host": "https://api.torbox.app/v1",
"api_key": "torbox_api_key",
"folder": "/mnt/remote/torbox/torrents/",
"rate_limit": "250/minute",
"download_uncached": false,
"check_cached": true
},
{
"watch_folder": "data/radarr/",
"completed_folder": "data/radarr/completed/",
"token": "radarr_api_key",
"url": "http://localhost:7878"
"name": "realdebrid",
"host": "https://api.real-debrid.com/rest/1.0",
"api_key": "realdebrid_key",
"folder": "/mnt/remote/realdebrid/__all__/",
"rate_limit": "250/minute",
"download_uncached": false,
"check_cached": false
},
{
"watch_folder": "data/radarr4k/",
"completed_folder": "data/radarr4k/completed/",
"token": "radarr4k_api_key",
"url": "http://localhost:7878"
"name": "debridlink",
"host": "https://debrid-link.com/api/v2",
"api_key": "debridlink_key",
"folder": "/mnt/remote/debridlink/torrents/",
"rate_limit": "250/minute",
"download_uncached": false,
"check_cached": false
},
{
"name": "alldebrid",
"host": "http://api.alldebrid.com/v4.1",
"api_key": "alldebrid_key",
"folder": "/mnt/remote/alldebrid/magnet/",
"rate_limit": "600/minute",
"download_uncached": false,
"check_cached": false
}
],
"proxy": {
"enabled": true,
"port": "8181",
"debug": false,
"port": "8100",
"log_level": "info",
"username": "username",
"password": "password"
}
"password": "password",
"cached_only": true
},
"max_cache_size": 1000,
"qbittorrent": {
"port": "8282",
"download_folder": "/mnt/symlinks/",
"categories": ["sonarr", "radarr"],
"refresh_interval": 5,
"log_level": "info"
},
"arrs": [
{
"name": "sonarr",
"host": "http://host:8989",
"token": "arr_key"
},
{
"name": "radarr",
"host": "http://host:7878",
"token": "arr_key"
}
],
"repair": {
"enabled": false,
"interval": "12h",
"run_on_start": false
},
"log_level": "info"
}
```
#### Proxy
#### Config Notes
The proxy is useful in filtering out un-cached Real Debrid torrents.
##### Log Level
- The `log_level` key is used to set the log level of the application. The default value is `info`
- The log level can be set to `debug`, `info`, `warn`, `error`
##### 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
- The `debrids` key is an array of debrid providers
- The `name` key is the name of the debrid provider
- The `host` key is the API endpoint of the debrid provider
- The `api_key` key is the API key of the debrid provider
- The `folder` key is the folder where your debrid folder is mounted(webdav, rclone, zurg etc). e.g `data/realdebrid/torrents/`, `/media/remote/alldebrid/magnets/`
- The `rate_limit` key is the rate limit of the debrid provider(null by default)
- The `download_uncached` bool key is used to download uncached torrents(disabled by default)
- The `check_cached` bool key is used to check if the torrent is cached(disabled by default)
##### Repair Config (**NEW**)
The `repair` key is used to enable the repair worker
- The `enabled` key is used to enable the repair worker
- The `interval` key is the interval in either minutes, seconds, hours, days. Use any of this format, e.g 12:00, 5:00, 1h, 1d, 1m, 1s.
- The `run_on_start` key is used to run the repair worker on start
##### Proxy Config
- The `enabled` key is used to enable the proxy
- The `port` key is the port the proxy will listen on
- The `log_level` key is used to set the log level of the proxy. The default value is `info`
- The `username` and `password` keys are used for basic authentication
- The `cached_only` means only cached torrents will be returned
##### Qbittorrent Config
- The `port` key is the port the qBittorrent will listen on
- 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 `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
##### Arrs Config
This is an array of Arrs(Sonarr, Radarr, etc) that will be used to download the torrents. This is not required if you already set up the Qbittorrent in the Arrs with the host, token.
This is particularly useful if you want to use the Repair tool without using Qbittorent
- The `name` key is the name of the Arr/ Category
- The `host` key is the host of the Arr
- The `token` key is the API token of the Arr
### Proxy
**Note**: Proxy has stopped working for Real Debrid, Debrid Link, and All Debrid. It still works for Torbox. This is due to the changes in the API of the Debrid Providers.
The proxy is useful in filtering out un-cached 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.
The proxy listens on the port `8181` by default. The username and password can be set in the config file.
Setting Up Proxy in Arr
### Repair Worker
The repair worker is a simple worker that checks for missing files in the Arrs(Sonarr, Radarr, etc). It's particularly useful for files either deleted by the Debrid provider or files with bad symlinks.
- Search for broken symlinks/files
- Search for missing files
- Search for deleted/unreadable files
### Changelog
- View the [CHANGELOG.md](CHANGELOG.md) for the latest changes
- Sonarr/Radarr
- Settings -> General -> Use Proxy
- Hostname: `localhost` # or the IP of the server
- Port: `8181` # or the port set in the config file
- Username: `username` # or the username set in the config file
- Password: `password` # or the password set in the config file
- Bypass Proxy for Local Addresses -> `No`
### TODO
- [ ] Add more Debrid Providers
- [ ] Add more Proxy features
- [ ] Add more tests
- [x] A proper name!!!!
- [x] Debrid
- [x] Add more Debrid Providers
- [x] Qbittorrent
- [x] Add more Qbittorrent features
- [x] Persist torrents on restart/server crash
- [ ] Add tests

View File

@@ -1,169 +0,0 @@
package cmd
import (
"fmt"
"github.com/fsnotify/fsnotify"
"goBlack/common"
"goBlack/debrid"
"log"
"os"
"path/filepath"
"sync"
"time"
)
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) {
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:
if fileReady(path) {
ready <- file
return
}
}
}
}
func ProcessFiles(arr *debrid.Arr, torrent *debrid.Torrent) {
var wg sync.WaitGroup
files := torrent.Files
ready := make(chan debrid.TorrentFile, len(files))
log.Println("Checking files...")
for _, file := range files {
wg.Add(1)
go checkFileLoop(&wg, arr.Debrid.Folder, file, ready)
}
go func() {
wg.Wait()
close(ready)
}()
for r := range ready {
log.Println("File is ready:", r.Name)
CreateSymLink(arr, torrent)
}
go torrent.Cleanup(true)
fmt.Printf("%s downloaded", torrent.Name)
}
func CreateSymLink(config *debrid.Arr, torrent *debrid.Torrent) {
path := filepath.Join(config.CompletedFolder, torrent.Folder)
err := os.MkdirAll(path, os.ModePerm)
if err != nil {
log.Printf("Failed to create directory: %s\n", path)
}
for _, file := range torrent.Files {
// Combine the directory and filename to form a full path
fullPath := filepath.Join(config.CompletedFolder, file.Path)
// Create a symbolic link if file doesn't exist
_ = os.Symlink(filepath.Join(config.Debrid.Folder, file.Path), fullPath)
}
}
func watchFiles(watcher *fsnotify.Watcher, events map[string]time.Time) {
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
if event.Op&fsnotify.Write == fsnotify.Write {
if filepath.Ext(event.Name) == ".torrent" || filepath.Ext(event.Name) == ".magnet" {
events[event.Name] = time.Now()
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
log.Println("ERROR:", err)
}
}
}
func processFilesDebounced(arr *debrid.Arr, db debrid.Service, events map[string]time.Time, debouncePeriod time.Duration) {
ticker := time.NewTicker(1 * time.Second) // Check every second
defer ticker.Stop()
for range ticker.C {
for file, lastEventTime := range events {
if time.Since(lastEventTime) >= debouncePeriod {
log.Printf("Torrent file detected: %s", file)
// Process the torrent file
torrent, err := db.Process(arr, file)
if err != nil && torrent != nil {
// remove torrent file
torrent.Cleanup(true)
_ = torrent.MarkAsFailed()
log.Printf("Error processing torrent file: %s", err)
}
if err == nil && torrent != nil && len(torrent.Files) > 0 {
go ProcessFiles(arr, torrent)
}
delete(events, file) // remove file from channel
}
}
}
}
func StartArr(conf *debrid.Arr, db debrid.Service) {
log.Printf("Watching: %s", conf.WatchFolder)
w, err := fsnotify.NewWatcher()
if err != nil {
log.Println(err)
}
defer func(w *fsnotify.Watcher) {
err := w.Close()
if err != nil {
log.Println(err)
}
}(w)
events := make(map[string]time.Time)
go watchFiles(w, events)
if err = w.Add(conf.WatchFolder); err != nil {
log.Println("Error Watching folder:", err)
return
}
processFilesDebounced(conf, db, events, 1*time.Second)
}
func StartBlackhole(config *common.Config, deb debrid.Service) {
var wg sync.WaitGroup
for _, conf := range config.Arrs {
wg.Add(1)
defer wg.Done()
headers := map[string]string{
"X-Api-Key": conf.Token,
}
client := common.NewRLHTTPClient(nil, headers)
arr := &debrid.Arr{
Debrid: config.Debrid,
WatchFolder: conf.WatchFolder,
CompletedFolder: conf.CompletedFolder,
Token: conf.Token,
URL: conf.URL,
Client: client,
}
go StartArr(arr, deb)
}
wg.Wait()
}

View File

@@ -1,18 +1,66 @@
package cmd
import (
"goBlack/common"
"goBlack/debrid"
"cmp"
"context"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/pkg/proxy"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit"
"github.com/sirrobot01/debrid-blackhole/pkg/repair"
"log"
"sync"
)
func Start(config *common.Config) {
func Start(ctx context.Context, config *common.Config) error {
maxCacheSize := cmp.Or(config.MaxCacheSize, 1000)
deb := debrid.NewDebrid(config.Debrids, maxCacheSize)
arrs := arr.NewStorage(config.Arrs)
var wg sync.WaitGroup
errChan := make(chan error, 2)
log.Print("[*] BlackHole running")
deb := debrid.NewDebrid(config.Debrid)
if config.Proxy.Enabled {
go StartProxy(config, deb)
wg.Add(1)
go func() {
defer wg.Done()
if err := proxy.NewProxy(*config, deb).Start(ctx); err != nil {
errChan <- err
}
}()
}
if config.QBitTorrent.Port != "" {
wg.Add(1)
go func() {
defer wg.Done()
if err := qbit.Start(ctx, config, deb, arrs); err != nil {
errChan <- err
}
}()
}
StartBlackhole(config, deb)
if config.Repair.Enabled {
wg.Add(1)
go func() {
defer wg.Done()
if err := repair.Start(ctx, config, arrs); err != nil {
log.Printf("Error during repair: %v", err)
}
}()
}
go func() {
wg.Wait()
close(errChan)
}()
// Wait for context cancellation or completion or error
select {
case err := <-errChan:
return err
case <-ctx.Done():
return ctx.Err()
}
}

View File

@@ -1,272 +0,0 @@
package cmd
import (
"bytes"
"cmp"
"encoding/xml"
"fmt"
"github.com/elazarl/goproxy"
"github.com/elazarl/goproxy/ext/auth"
"github.com/valyala/fastjson"
"goBlack/common"
"goBlack/debrid"
"io"
"log"
"net/http"
"os"
"regexp"
"strings"
"sync"
)
type RSS struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Channel Channel `xml:"channel"`
}
type Channel struct {
XMLName xml.Name `xml:"channel"`
Title string `xml:"title"`
AtomLink AtomLink `xml:"link"`
Items []Item `xml:"item"`
}
type AtomLink struct {
XMLName xml.Name `xml:"link"`
Rel string `xml:"rel,attr"`
Type string `xml:"type,attr"`
}
type Item struct {
XMLName xml.Name `xml:"item"`
Title string `xml:"title"`
Description string `xml:"description"`
GUID string `xml:"guid"`
ProwlarrIndexer ProwlarrIndexer `xml:"prowlarrindexer"`
Comments string `xml:"comments"`
PubDate string `xml:"pubDate"`
Size int64 `xml:"size"`
Link string `xml:"link"`
Categories []string `xml:"category"`
Enclosure Enclosure `xml:"enclosure"`
TorznabAttrs []TorznabAttr `xml:"torznab:attr"`
}
type ProwlarrIndexer struct {
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
}
type Enclosure struct {
URL string `xml:"url,attr"`
Length int64 `xml:"length,attr"`
Type string `xml:"type,attr"`
}
type TorznabAttr struct {
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
}
type SafeItems struct {
mu sync.Mutex
Items []Item
}
func (s *SafeItems) Add(item Item) {
s.mu.Lock()
defer s.mu.Unlock()
s.Items = append(s.Items, item)
}
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 {
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()
if err != nil {
return nil
}
var p fastjson.Parser
v, err := p.ParseBytes(body)
if err != nil {
// If it's not JSON, return the original response
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
// Modify the JSON
// Serialize the modified JSON back to bytes
modifiedBody := v.MarshalTo(nil)
// Set the modified body back to the response
resp.Body = io.NopCloser(bytes.NewReader(modifiedBody))
resp.ContentLength = int64(len(modifiedBody))
resp.Header.Set("Content-Length", string(rune(len(modifiedBody))))
return resp
}
func ProcessResponse(resp *http.Response, deb debrid.Service) *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)
case "application/xml":
return ProcessXMLResponse(resp, deb)
case "application/rss+xml":
return ProcessXMLResponse(resp, deb)
default:
return resp
}
}
func XMLItemIsCached(item Item, deb debrid.Service) bool {
magnetLink := ""
infohash := ""
// Extract magnet link from the link or comments
if strings.Contains(item.Link, "magnet:?") {
magnetLink = item.Link
} else if strings.Contains(item.GUID, "magnet:?") {
magnetLink = item.GUID
}
// Extract infohash from <torznab:attr> elements
for _, attr := range item.TorznabAttrs {
if attr.Name == "infohash" {
infohash = attr.Value
}
}
if magnetLink == "" && infohash == "" {
// We can't check the availability of the torrent without a magnet link or infohash
return false
}
var magnet *common.Magnet
var err error
if infohash == "" {
magnet, err = common.GetMagnetInfo(magnetLink)
if err != nil {
log.Println("Error getting magnet info:", err)
return false
}
} else {
magnet = &common.Magnet{
InfoHash: infohash,
Name: item.Title,
Link: magnetLink,
}
}
if magnet == nil {
log.Println("Error getting magnet info")
return false
}
return deb.IsAvailable(magnet)
}
func ProcessXMLResponse(resp *http.Response, deb debrid.Service) *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()
if err != nil {
return nil
}
var rss RSS
err = xml.Unmarshal(body, &rss)
if err != nil {
log.Printf("Error unmarshalling XML: %v", err)
return resp
}
newItems := &SafeItems{}
var wg sync.WaitGroup
// 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)
}
wg.Wait()
items := newItems.Get()
log.Printf("Report: %d/%d items are cached", len(items), len(rss.Channel.Items))
rss.Channel.Items = items
// rss.Channel.Items = newItems
modifiedBody, err := xml.MarshalIndent(rss, "", " ")
if err != nil {
log.Printf("Error marshalling XML: %v", err)
return resp
}
modifiedBody = append([]byte(xml.Header), modifiedBody...)
// Set the modified body back to the response
resp.Body = io.NopCloser(bytes.NewReader(modifiedBody))
resp.ContentLength = int64(len(modifiedBody))
resp.Header.Set("Content-Length", string(rune(len(modifiedBody))))
return resp
}
func UrlMatches(re *regexp.Regexp) goproxy.ReqConditionFunc {
return func(req *http.Request, ctx *goproxy.ProxyCtx) bool {
return re.MatchString(req.URL.String())
}
}
func StartProxy(config *common.Config, deb debrid.Service) {
username, password := config.Proxy.Username, config.Proxy.Password
cfg := config.Proxy
proxy := goproxy.NewProxyHttpServer()
if username != "" || password != "" {
// Set up basic auth for proxy
auth.ProxyBasic(proxy, "my_realm", func(user, pwd string) bool {
return user == username && password == pwd
})
}
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)
})
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))
}

90
common/cache.go Normal file
View File

@@ -0,0 +1,90 @@
package common
import (
"sync"
)
type Cache struct {
data map[string]struct{}
order []string
maxItems int
mu sync.RWMutex
}
func NewCache(maxItems int) *Cache {
if maxItems <= 0 {
maxItems = 1000
}
return &Cache{
data: make(map[string]struct{}, maxItems),
order: make([]string, 0, maxItems),
maxItems: maxItems,
}
}
func (c *Cache) Add(value string) {
c.mu.Lock()
defer c.mu.Unlock()
if _, exists := c.data[value]; !exists {
if len(c.order) >= c.maxItems {
delete(c.data, c.order[0])
c.order = c.order[1:]
}
c.data[value] = struct{}{}
c.order = append(c.order, value)
}
}
func (c *Cache) AddMultiple(values map[string]bool) {
c.mu.Lock()
defer c.mu.Unlock()
for value, exists := range values {
if !exists {
if _, exists := c.data[value]; !exists {
if len(c.order) >= c.maxItems {
delete(c.data, c.order[0])
c.order = c.order[1:]
}
c.data[value] = struct{}{}
c.order = append(c.order, value)
}
}
}
}
func (c *Cache) Get(index int) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
if index < 0 || index >= len(c.order) {
return "", false
}
return c.order[index], true
}
func (c *Cache) GetMultiple(values []string) map[string]bool {
c.mu.RLock()
defer c.mu.RUnlock()
result := make(map[string]bool, len(values))
for _, value := range values {
if _, exists := c.data[value]; exists {
result[value] = true
}
}
return result
}
func (c *Cache) Exists(value string) bool {
c.mu.RLock()
defer c.mu.RUnlock()
_, exists := c.data[value]
return exists
}
func (c *Cache) Len() int {
c.mu.RLock()
defer c.mu.RUnlock()
return len(c.order)
}

View File

@@ -2,8 +2,11 @@ package common
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"sync"
)
type DebridConfig struct {
@@ -12,25 +15,126 @@ type DebridConfig struct {
APIKey string `json:"api_key"`
Folder string `json:"folder"`
DownloadUncached bool `json:"download_uncached"`
CheckCached bool `json:"check_cached"`
RateLimit string `json:"rate_limit"` // 200/minute or 10/second
}
type ProxyConfig struct {
Port string `json:"port"`
Enabled bool `json:"enabled"`
LogLevel string `json:"log_level"`
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"`
LogLevel string `json:"log_level"`
DownloadFolder string `json:"download_folder"`
Categories []string `json:"categories"`
RefreshInterval int `json:"refresh_interval"`
}
type ArrConfig struct {
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
}
type RepairConfig struct {
Enabled bool `json:"enabled"`
Interval string `json:"interval"`
RunOnStart bool `json:"run_on_start"`
}
type Config struct {
DbDSN string `json:"db_dsn"`
Debrid DebridConfig `json:"debrid"`
Arrs []struct {
WatchFolder string `json:"watch_folder"`
CompletedFolder string `json:"completed_folder"`
Token string `json:"token"`
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"`
LogLevel string `json:"log_level"`
Debrid DebridConfig `json:"debrid"`
Debrids []DebridConfig `json:"debrids"`
Proxy ProxyConfig `json:"proxy"`
MaxCacheSize int `json:"max_cache_size"`
QBitTorrent QBitTorrentConfig `json:"qbittorrent"`
Arrs []ArrConfig `json:"arrs"`
Repair RepairConfig `json:"repair"`
}
func validateDebrids(debrids []DebridConfig) error {
if len(debrids) == 0 {
return errors.New("no debrids configured")
}
errChan := make(chan error, len(debrids))
var wg sync.WaitGroup
for _, debrid := range debrids {
// Basic field validation
if debrid.Host == "" {
return errors.New("debrid host is required")
}
if debrid.APIKey == "" {
return errors.New("debrid api key is required")
}
if debrid.Folder == "" {
return errors.New("debrid folder is required")
}
// Check folder existence concurrently
wg.Add(1)
go func(folder string) {
defer wg.Done()
if _, err := os.Stat(folder); os.IsNotExist(err) {
errChan <- fmt.Errorf("debrid folder does not exist: %s", folder)
}
}(debrid.Folder)
}
// Wait for all checks to complete
go func() {
wg.Wait()
close(errChan)
}()
// Return first error if any
if err := <-errChan; err != nil {
return err
}
return nil
}
func validateQbitTorrent(config *QBitTorrentConfig) error {
if config.DownloadFolder == "" {
return errors.New("qbittorent download folder is required")
}
if _, err := os.Stat(config.DownloadFolder); os.IsNotExist(err) {
return errors.New("qbittorent download folder does not exist")
}
return nil
}
func validateConfig(config *Config) error {
// Run validations concurrently
errChan := make(chan error, 2)
go func() {
errChan <- validateDebrids(config.Debrids)
}()
go func() {
errChan <- validateQbitTorrent(&config.QBitTorrent)
}()
// Check for errors
for i := 0; i < 2; i++ {
if err := <-errChan; err != nil {
return err
}
}
return nil
}
func LoadConfig(path string) (*Config, error) {
@@ -53,5 +157,16 @@ func LoadConfig(path string) (*Config, error) {
return nil, err
}
if config.Debrid.Name != "" {
config.Debrids = append(config.Debrids, config.Debrid)
}
// Validate the config
//if err := validateConfig(config); err != nil {
// return nil, err
//}
return config, nil
}
var CONFIG *Config = nil

80
common/logger.go Normal file
View File

@@ -0,0 +1,80 @@
package common
import (
"fmt"
"github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2"
"os"
"path/filepath"
"strings"
)
func GetLogPath() string {
logsDir := os.Getenv("LOG_PATH")
if logsDir == "" {
// Create the logs directory if it doesn't exist
logsDir = "logs"
}
if err := os.MkdirAll(logsDir, 0755); err != nil {
panic(fmt.Sprintf("Failed to create logs directory: %v", err))
}
return filepath.Join(logsDir, "decypharr.log")
}
func NewLogger(prefix string, level string, output *os.File) zerolog.Logger {
rotatingLogFile := &lumberjack.Logger{
Filename: GetLogPath(),
MaxSize: 10,
MaxBackups: 2,
MaxAge: 28,
Compress: true,
}
consoleWriter := zerolog.ConsoleWriter{
Out: output,
TimeFormat: "2006-01-02 15:04:05",
NoColor: false, // Set to true if you don't want colors
FormatLevel: func(i interface{}) string {
return strings.ToUpper(fmt.Sprintf("| %-6s|", i))
},
FormatMessage: func(i interface{}) string {
return fmt.Sprintf("[%s] %v", prefix, i)
},
}
fileWriter := zerolog.ConsoleWriter{
Out: rotatingLogFile,
TimeFormat: "2006-01-02 15:04:05",
NoColor: true, // No colors in file output
FormatLevel: func(i interface{}) string {
return strings.ToUpper(fmt.Sprintf("| %-6s|", i))
},
FormatMessage: func(i interface{}) string {
return fmt.Sprintf("[%s] %v", prefix, i)
},
}
multi := zerolog.MultiLevelWriter(consoleWriter, fileWriter)
logger := zerolog.New(multi).
With().
Timestamp().
Logger().
Level(zerolog.InfoLevel)
// Set the log level
switch level {
case "debug":
logger = logger.Level(zerolog.DebugLevel)
case "info":
logger = logger.Level(zerolog.InfoLevel)
case "warn":
logger = logger.Level(zerolog.WarnLevel)
case "error":
logger = logger.Level(zerolog.ErrorLevel)
}
return logger
}

View File

@@ -1,12 +1,16 @@
package common
import (
"path/filepath"
"regexp"
"strings"
)
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)$"
SUBMATCH = "(?i)(\\.)(SRT|SUB|SBV|ASS|VTT|TTML|DFXP|STL|SCC|CAP|SMI|TTXT|TDS|USF|JSS|SSA|PSB|RT|LRC|SSB)$"
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)$"
SAMPLEMATCH = `(?i)(^|[\\/]|[._-])(sample|trailer|thumb)s?([._-]|$)`
)
func RegexMatch(regex string, value string) bool {
@@ -14,8 +18,26 @@ func RegexMatch(regex string, value string) bool {
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 {
re := regexp.MustCompile(VIDEOMATCH)
re := regexp.MustCompile(VIDEOMATCH + "|" + SUBMATCH + "|" + SAMPLEMATCH + "|" + MUSICMATCH)
// Find the last index of the matched extension
loc := re.FindStringIndex(value)
@@ -25,3 +47,13 @@ func RemoveExtension(value string) string {
return value
}
}
func RegexFind(regex string, value string) string {
re := regexp.MustCompile(regex)
match := re.FindStringSubmatch(value)
if len(match) > 0 {
return match[0]
} else {
return ""
}
}

View File

@@ -2,6 +2,7 @@ package common
import (
"crypto/tls"
"encoding/json"
"fmt"
"golang.org/x/time/rate"
"io"
@@ -60,11 +61,7 @@ func (c *RLHTTPClient) Do(req *http.Request) (*http.Response, error) {
return resp, fmt.Errorf("max retries exceeded")
}
func (c *RLHTTPClient) MakeRequest(method string, url string, body io.Reader) ([]byte, error) {
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
func (c *RLHTTPClient) MakeRequest(req *http.Request) ([]byte, error) {
if c.Headers != nil {
for key, value := range c.Headers {
req.Header.Set(key, value)
@@ -75,9 +72,12 @@ func (c *RLHTTPClient) MakeRequest(method string, url string, body io.Reader) ([
if err != nil {
return nil, err
}
b, _ := io.ReadAll(res.Body)
statusOk := strconv.Itoa(res.StatusCode)[0] == '2'
if !statusOk {
return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode)
// Add status code error to the body
b = append(b, []byte(fmt.Sprintf("\nstatus code: %d", res.StatusCode))...)
return nil, fmt.Errorf(string(b))
}
defer func(Body io.ReadCloser) {
err := Body.Close()
@@ -85,7 +85,7 @@ func (c *RLHTTPClient) MakeRequest(method string, url string, body io.Reader) ([
log.Println(err)
}
}(res.Body)
return io.ReadAll(res.Body)
return b, nil
}
func NewRLHTTPClient(rl *rate.Limiter, headers map[string]string) *RLHTTPClient {
@@ -128,3 +128,9 @@ func ParseRateLimit(rateStr string) *rate.Limiter {
return nil
}
}
func JSONResponse(w http.ResponseWriter, data interface{}, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(data)
}

View File

@@ -2,12 +2,21 @@ package common
import (
"bufio"
"context"
"encoding/base32"
"encoding/hex"
"fmt"
"github.com/anacrolix/torrent/metainfo"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
type Magnet struct {
@@ -17,6 +26,41 @@ type Magnet struct {
Link string
}
func GetMagnetFromFile(file io.Reader, filePath string) (*Magnet, error) {
if filepath.Ext(filePath) == ".torrent" {
mi, err := metainfo.Load(file)
if err != nil {
return nil, err
}
hash := mi.HashInfoBytes()
infoHash := hash.HexString()
info, err := mi.UnmarshalInfo()
if err != nil {
return nil, err
}
magnet := &Magnet{
InfoHash: infoHash,
Name: info.Name,
Size: info.Length,
Link: mi.Magnet(&hash, &info).String(),
}
return magnet, nil
} else {
// .magnet file
magnetLink := ReadMagnetFile(file)
return GetMagnetInfo(magnetLink)
}
}
func GetMagnetFromUrl(url string) (*Magnet, error) {
if strings.HasPrefix(url, "magnet:") {
return GetMagnetInfo(url)
} else if strings.HasPrefix(url, "http") {
return OpenMagnetHttpURL(url)
}
return nil, fmt.Errorf("invalid url")
}
func OpenMagnetFile(filePath string) string {
file, err := os.Open(filePath)
if err != nil {
@@ -29,13 +73,15 @@ func OpenMagnetFile(filePath string) string {
return
}
}(file) // Ensure the file is closed after the function ends
return ReadMagnetFile(file)
}
// Create a scanner to read the file line by line
func ReadMagnetFile(file io.Reader) string {
scanner := bufio.NewScanner(file)
for scanner.Scan() {
magnetLink := scanner.Text()
if magnetLink != "" {
return magnetLink
content := scanner.Text()
if content != "" {
return content
}
}
@@ -46,14 +92,50 @@ func OpenMagnetFile(filePath string) string {
return ""
}
func OpenMagnetHttpURL(magnetLink string) (*Magnet, error) {
resp, err := http.Get(magnetLink)
if err != nil {
return nil, fmt.Errorf("error making GET request: %v", err)
}
defer func(resp *http.Response) {
err := resp.Body.Close()
if err != nil {
return
}
}(resp) // Ensure the response is closed after the function ends
// Create a scanner to read the file line by line
mi, err := metainfo.Load(resp.Body)
if err != nil {
return nil, err
}
hash := mi.HashInfoBytes()
infoHash := hash.HexString()
info, err := mi.UnmarshalInfo()
if err != nil {
return nil, err
}
log.Println("InfoHash: ", infoHash)
magnet := &Magnet{
InfoHash: infoHash,
Name: info.Name,
Size: info.Length,
Link: mi.Magnet(&hash, &info).String(),
}
return magnet, nil
}
func GetMagnetInfo(magnetLink string) (*Magnet, error) {
if magnetLink == "" {
return nil, fmt.Errorf("error getting magnet from file")
}
magnetURI, err := url.Parse(magnetLink)
if err != nil {
return nil, fmt.Errorf("error parsing magnet link")
}
query := magnetURI.Query()
xt := query.Get("xt")
dn := query.Get("dn")
@@ -81,3 +163,126 @@ func RandomString(length int) string {
}
return string(b)
}
func ExtractInfoHash(magnetDesc string) string {
const prefix = "xt=urn:btih:"
start := strings.Index(magnetDesc, prefix)
if start == -1 {
return ""
}
hash := ""
start += len(prefix)
end := strings.IndexAny(magnetDesc[start:], "&#")
if end == -1 {
hash = magnetDesc[start:]
} else {
hash = magnetDesc[start : start+end]
}
hash, _ = processInfoHash(hash) // Convert to hex if needed
return hash
}
func processInfoHash(input string) (string, error) {
// Regular expression for a valid 40-character hex infohash
hexRegex := regexp.MustCompile("^[0-9a-fA-F]{40}$")
// If it's already a valid hex infohash, return it as is
if hexRegex.MatchString(input) {
return strings.ToLower(input), nil
}
// If it's 32 characters long, it might be Base32 encoded
if len(input) == 32 {
// Ensure the input is uppercase and remove any padding
input = strings.ToUpper(strings.TrimRight(input, "="))
// Try to decode from Base32
decoded, err := base32.StdEncoding.DecodeString(input)
if err == nil && len(decoded) == 20 {
// If successful and the result is 20 bytes, encode to hex
return hex.EncodeToString(decoded), nil
}
}
// If we get here, it's not a valid infohash and we couldn't convert it
return "", fmt.Errorf("invalid infohash: %s", input)
}
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) {
// Split the last path component to separate query parameters
lastPath := paths[len(paths)-1]
parts := strings.Split(lastPath, "?")
paths[len(paths)-1] = parts[0]
joined, err := url.JoinPath(base, paths...)
if err != nil {
return "", err
}
// Add back query parameters if they exist
if len(parts) > 1 {
return joined + "?" + parts[1], nil
}
return joined, nil
}
func FileReady(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err) // Returns true if the file exists
}
func Remove[S ~[]E, E comparable](s S, values ...E) S {
result := make(S, 0, len(s))
outer:
for _, item := range s {
for _, v := range values {
if item == v {
continue outer
}
}
result = append(result, item)
}
return result
}

View File

@@ -1,83 +0,0 @@
package debrid
import (
"github.com/anacrolix/torrent/metainfo"
"goBlack/common"
"path/filepath"
)
type Service interface {
SubmitMagnet(torrent *Torrent) (*Torrent, error)
CheckStatus(torrent *Torrent) (*Torrent, error)
DownloadLink(torrent *Torrent) error
Process(arr *Arr, magnet string) (*Torrent, error)
IsAvailable(magnet *common.Magnet) bool
}
type Debrid struct {
Host string `json:"host"`
APIKey string
DownloadUncached bool
}
func NewDebrid(dc common.DebridConfig) Service {
switch dc.Name {
case "realdebrid":
return NewRealDebrid(dc)
default:
return NewRealDebrid(dc)
}
}
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 := common.OpenMagnetFile(filePath)
magnet, err := common.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
}
magnet := &common.Magnet{
InfoHash: infoHash,
Name: info.Name,
Size: info.Length,
Link: mi.Magnet(&hash, &info).String(),
}
torrent := &Torrent{
InfoHash: infoHash,
Name: info.Name,
Size: info.Length,
Magnet: magnet,
Filename: filePath,
}
return torrent, nil
}

View File

@@ -1,155 +0,0 @@
package debrid
import (
"encoding/json"
"fmt"
"goBlack/common"
"goBlack/debrid/structs"
"log"
"net/http"
gourl "net/url"
"path/filepath"
"strconv"
"strings"
)
type RealDebrid struct {
Host string `json:"host"`
APIKey string
DownloadUncached bool
client *common.RLHTTPClient
}
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 {
if !r.IsAvailable(torrent.Magnet) {
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(magnet *common.Magnet) bool {
url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, magnet.InfoHash)
resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
if err != nil {
return false
}
var data structs.RealDebridAvailabilityResponse
err = json.Unmarshal(resp, &data)
if err != nil {
return false
}
hosters, exists := data[strings.ToLower(magnet.InfoHash)]
if !exists || len(hosters) < 1 {
return false
}
return true
}
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 structs.RealDebridAddMagnetSchema
resp, err := r.client.MakeRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
if err != nil {
return nil, err
}
err = json.Unmarshal(resp, &data)
log.Printf("Torrent: %s added with id: %s\n", torrent.Name, data.Id)
torrent.Id = data.Id
return torrent, nil
}
func (r *RealDebrid) CheckStatus(torrent *Torrent) (*Torrent, error) {
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrent.Id)
for {
resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
if err != nil {
return torrent, err
}
var data structs.RealDebridTorrentInfo
err = json.Unmarshal(resp, &data)
status := data.Status
torrent.Folder = common.RemoveExtension(data.OriginalFilename)
if status == "error" || status == "dead" || status == "magnet_error" {
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
} else if status == "waiting_files_selection" {
files := make([]TorrentFile, 0)
for _, f := range data.Files {
name := f.Path
if !common.RegexMatch(common.VIDEOMATCH, name) && !common.RegexMatch(common.SUBMATCH, name) {
continue
}
fileId := f.ID
file := &TorrentFile{
Name: name,
Path: filepath.Join(torrent.Folder, name),
Size: int64(f.Bytes),
Id: strconv.Itoa(fileId),
}
files = append(files, *file)
}
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())
_, err = r.client.MakeRequest(http.MethodPost, fmt.Sprintf("%s/torrents/selectFiles/%s", r.Host, torrent.Id), payload)
if err != nil {
return torrent, err
}
} else if status == "downloaded" {
log.Printf("Torrent: %s downloaded\n", torrent.Name)
err = r.DownloadLink(torrent)
if err != nil {
return torrent, err
}
break
} else if status == "downloading" {
return torrent, fmt.Errorf("torrent is uncached")
}
}
return torrent, nil
}
func (r *RealDebrid) DownloadLink(torrent *Torrent) error {
return nil
}
func NewRealDebrid(dc common.DebridConfig) *RealDebrid {
rl := common.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
client := common.NewRLHTTPClient(rl, headers)
return &RealDebrid{
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
}
}

View File

@@ -1,41 +0,0 @@
package structs
type RealDebridAvailabilityResponse map[string]Hosters
type Hosters map[string][]FileIDs
type FileIDs map[string]FileVariant
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 int `json:"bytes"`
OriginalBytes int `json:"original_bytes"`
Host string `json:"host"`
Split int `json:"split"`
Progress int `json:"progress"`
Status string `json:"status"`
Added string `json:"added"`
Files []struct {
ID int `json:"id"`
Path string `json:"path"`
Bytes int `json:"bytes"`
Selected int `json:"selected"`
} `json:"files"`
Links []string `json:"links"`
Ended string `json:"ended,omitempty"`
Speed int `json:"speed,omitempty"`
Seeders int `json:"seeders,omitempty"`
}

View File

@@ -1,142 +0,0 @@
package debrid
import (
"encoding/json"
"goBlack/common"
"log"
"net/http"
gourl "net/url"
"os"
"strconv"
"strings"
)
type Arr struct {
WatchFolder string `json:"watch_folder"`
CompletedFolder string `json:"completed_folder"`
Debrid common.DebridConfig `json:"debrid"`
Token string `json:"token"`
URL string `json:"url"`
Client *common.RLHTTPClient
}
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"`
Size int64 `json:"size"`
Magnet *common.Magnet `json:"magnet"`
Files []TorrentFile `json:"files"`
Status string `json:"status"`
Arr *Arr
}
type TorrentFile struct {
Id string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
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 {
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 (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) {
if remove {
err := os.Remove(t.Filename)
if err != nil {
return
}
}
}
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
}

BIN
doc/download.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

BIN
doc/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

14
go.mod
View File

@@ -1,12 +1,15 @@
module goBlack
module github.com/sirrobot01/debrid-blackhole
go 1.22
require (
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/ext v0.0.0-20190711103511-473e67f1d7d2
github.com/fsnotify/fsnotify v1.7.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
golang.org/x/time v0.6.0
)
@@ -14,10 +17,17 @@ require (
require (
github.com/anacrolix/missinggo v1.3.0 // 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/google/go-cmp v0.6.0 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/valyala/bytebufferpool v1.0.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
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
)

31
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/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/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/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=
@@ -44,8 +46,11 @@ 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-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
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/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -62,19 +67,20 @@ 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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
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-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/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
@@ -100,6 +106,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@@ -119,6 +127,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/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/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/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -128,6 +138,11 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -167,6 +182,9 @@ github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqn
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
@@ -185,6 +203,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.1.0/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/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
@@ -229,6 +251,9 @@ 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-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-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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=
@@ -263,6 +288,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

11
main.go
View File

@@ -1,9 +1,10 @@
package main
import (
"context"
"flag"
"goBlack/cmd"
"goBlack/common"
"github.com/sirrobot01/debrid-blackhole/cmd"
"github.com/sirrobot01/debrid-blackhole/common"
"log"
)
@@ -14,9 +15,13 @@ func main() {
// Load the config file
conf, err := common.LoadConfig(configPath)
common.CONFIG = conf
if err != nil {
log.Fatal(err)
}
cmd.Start(conf)
ctx := context.Background()
if err := cmd.Start(ctx, conf); err != nil {
log.Fatal(err)
}
}

123
pkg/arr/arr.go Normal file
View File

@@ -0,0 +1,123 @@
package arr
import (
"bytes"
"encoding/json"
"github.com/sirrobot01/debrid-blackhole/common"
"net/http"
"strings"
"sync"
)
// Type is a type of arr
type Type string
const (
Sonarr Type = "sonarr"
Radarr Type = "radarr"
Lidarr Type = "lidarr"
Readarr Type = "readarr"
)
var (
client *common.RLHTTPClient = common.NewRLHTTPClient(nil, nil)
)
type Arr struct {
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
Type Type `json:"type"`
}
func NewArr(name, host, token string, arrType Type) *Arr {
return &Arr{
Name: name,
Host: host,
Token: token,
Type: arrType,
}
}
func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Response, error) {
if a.Token == "" || a.Host == "" {
return nil, nil
}
url, err := common.JoinURL(a.Host, endpoint)
if err != nil {
return nil, err
}
var jsonPayload []byte
if payload != nil {
jsonPayload, err = json.Marshal(payload)
if err != nil {
return nil, err
}
}
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonPayload))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", a.Token)
return client.Do(req)
}
type Storage struct {
Arrs map[string]*Arr // name -> arr
mu sync.RWMutex
}
func inferType(host, name string) Type {
switch {
case strings.Contains(host, "sonarr") || strings.Contains(name, "sonarr"):
return Sonarr
case strings.Contains(host, "radarr") || strings.Contains(name, "radarr"):
return Radarr
case strings.Contains(host, "lidarr") || strings.Contains(name, "lidarr"):
return Lidarr
case strings.Contains(host, "readarr") || strings.Contains(name, "readarr"):
return Readarr
default:
return ""
}
}
func NewStorage(cfg []common.ArrConfig) *Storage {
arrs := make(map[string]*Arr)
for _, a := range cfg {
name := a.Name
arrs[name] = NewArr(name, a.Host, a.Token, inferType(a.Host, name))
}
return &Storage{
Arrs: arrs,
}
}
func (as *Storage) AddOrUpdate(arr *Arr) {
as.mu.Lock()
defer as.mu.Unlock()
if arr.Name == "" {
return
}
as.Arrs[arr.Name] = arr
}
func (as *Storage) Get(name string) *Arr {
as.mu.RLock()
defer as.mu.RUnlock()
return as.Arrs[name]
}
func (as *Storage) GetAll() []*Arr {
as.mu.RLock()
defer as.mu.RUnlock()
arrs := make([]*Arr, 0, len(as.Arrs))
for _, arr := range as.Arrs {
if arr.Host != "" && arr.Token != "" {
arrs = append(arrs, arr)
}
}
return arrs
}

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

@@ -0,0 +1,103 @@
package arr
import (
"encoding/json"
"fmt"
"net/http"
)
func (a *Arr) GetMedia(tvId string) ([]Content, error) {
// Get series
resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/series?tvdbId=%s", tvId), nil)
if err != nil {
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
// This is Radarr
repairLogger.Info().Msg("Radarr detected")
a.Type = Radarr
return GetMovies(a, tvId)
}
a.Type = Sonarr
defer resp.Body.Close()
type series struct {
Title string `json:"title"`
Id int `json:"id"`
}
var data []series
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}
// Get series files
contents := make([]Content, 0)
for _, d := range data {
resp, err = a.Request(http.MethodGet, fmt.Sprintf("api/v3/episodefile?seriesId=%d", d.Id), nil)
if err != nil {
continue
}
defer resp.Body.Close()
var seriesFiles []seriesFile
if err = json.NewDecoder(resp.Body).Decode(&seriesFiles); err != nil {
continue
}
ct := Content{
Title: d.Title,
Id: d.Id,
}
files := make([]contentFile, 0)
for _, file := range seriesFiles {
files = append(files, contentFile{
Id: file.Id,
Path: file.Path,
})
}
ct.Files = files
contents = append(contents, ct)
}
return contents, nil
}
func GetMovies(a *Arr, tvId string) ([]Content, error) {
resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/movie?tmdbId=%s", tvId), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var movies []Movie
if err = json.NewDecoder(resp.Body).Decode(&movies); err != nil {
return nil, err
}
contents := make([]Content, 0)
for _, movie := range movies {
ct := Content{
Title: movie.Title,
Id: movie.Id,
}
files := make([]contentFile, 0)
files = append(files, contentFile{
Id: movie.MovieFile.Id,
Path: movie.MovieFile.Path,
})
ct.Files = files
contents = append(contents, ct)
}
return contents, nil
}
func (a *Arr) DeleteFile(id int) error {
switch a.Type {
case Sonarr:
_, err := a.Request(http.MethodDelete, fmt.Sprintf("api/v3/episodefile/%d", id), nil)
if err != nil {
return err
}
case Radarr:
_, err := a.Request(http.MethodDelete, fmt.Sprintf("api/v3/moviefile/%d", id), nil)
if err != nil {
return err
}
default:
return fmt.Errorf("unknown arr type: %s", a.Type)
}
return nil
}

41
pkg/arr/history.go Normal file
View File

@@ -0,0 +1,41 @@
package arr
import (
"encoding/json"
"net/http"
gourl "net/url"
)
type HistorySchema 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"`
}
func (a *Arr) GetHistory(downloadId, eventType string) *HistorySchema {
query := gourl.Values{}
if downloadId != "" {
query.Add("downloadId", downloadId)
}
query.Add("eventType", eventType)
query.Add("pageSize", "100")
url := "history" + "?" + query.Encode()
resp, err := a.Request(http.MethodGet, url, nil)
if err != nil {
return nil
}
defer resp.Body.Close()
var data *HistorySchema
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil
}
return data
}

209
pkg/arr/import.go Normal file
View File

@@ -0,0 +1,209 @@
package arr
import (
"encoding/json"
"fmt"
"io"
"net/http"
gourl "net/url"
"strconv"
"time"
)
type ImportResponseSchema struct {
Path string `json:"path"`
RelativePath string `json:"relativePath"`
FolderName string `json:"folderName"`
Name string `json:"name"`
Size int `json:"size"`
Series struct {
Title string `json:"title"`
SortTitle string `json:"sortTitle"`
Status string `json:"status"`
Ended bool `json:"ended"`
Overview string `json:"overview"`
Network string `json:"network"`
AirTime string `json:"airTime"`
Images []struct {
CoverType string `json:"coverType"`
RemoteUrl string `json:"remoteUrl"`
} `json:"images"`
OriginalLanguage struct {
Id int `json:"id"`
Name string `json:"name"`
} `json:"originalLanguage"`
Seasons []struct {
SeasonNumber int `json:"seasonNumber"`
Monitored bool `json:"monitored"`
} `json:"seasons"`
Year int `json:"year"`
Path string `json:"path"`
QualityProfileId int `json:"qualityProfileId"`
SeasonFolder bool `json:"seasonFolder"`
Monitored bool `json:"monitored"`
MonitorNewItems string `json:"monitorNewItems"`
UseSceneNumbering bool `json:"useSceneNumbering"`
Runtime int `json:"runtime"`
TvdbId int `json:"tvdbId"`
TvRageId int `json:"tvRageId"`
TvMazeId int `json:"tvMazeId"`
TmdbId int `json:"tmdbId"`
FirstAired time.Time `json:"firstAired"`
LastAired time.Time `json:"lastAired"`
SeriesType string `json:"seriesType"`
CleanTitle string `json:"cleanTitle"`
ImdbId string `json:"imdbId"`
TitleSlug string `json:"titleSlug"`
Certification string `json:"certification"`
Genres []string `json:"genres"`
Tags []interface{} `json:"tags"`
Added time.Time `json:"added"`
Ratings struct {
Votes int `json:"votes"`
Value float64 `json:"value"`
} `json:"ratings"`
LanguageProfileId int `json:"languageProfileId"`
Id int `json:"id"`
} `json:"series"`
SeasonNumber int `json:"seasonNumber"`
Episodes []struct {
SeriesId int `json:"seriesId"`
TvdbId int `json:"tvdbId"`
EpisodeFileId int `json:"episodeFileId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeNumber int `json:"episodeNumber"`
Title string `json:"title"`
AirDate string `json:"airDate"`
AirDateUtc time.Time `json:"airDateUtc"`
Runtime int `json:"runtime"`
Overview string `json:"overview"`
HasFile bool `json:"hasFile"`
Monitored bool `json:"monitored"`
AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"`
UnverifiedSceneNumbering bool `json:"unverifiedSceneNumbering"`
Id int `json:"id"`
FinaleType string `json:"finaleType,omitempty"`
} `json:"episodes"`
ReleaseGroup string `json:"releaseGroup"`
Quality struct {
Quality struct {
Id int `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Resolution int `json:"resolution"`
} `json:"quality"`
Revision struct {
Version int `json:"version"`
Real int `json:"real"`
IsRepack bool `json:"isRepack"`
} `json:"revision"`
} `json:"quality"`
Languages []struct {
Id int `json:"id"`
Name string `json:"name"`
} `json:"languages"`
QualityWeight int `json:"qualityWeight"`
CustomFormats []interface{} `json:"customFormats"`
CustomFormatScore int `json:"customFormatScore"`
IndexerFlags int `json:"indexerFlags"`
ReleaseType string `json:"releaseType"`
Rejections []struct {
Reason string `json:"reason"`
Type string `json:"type"`
} `json:"rejections"`
Id int `json:"id"`
}
type ManualImportRequestFile struct {
Path string `json:"path"`
SeriesId int `json:"seriesId"`
SeasonNumber int `json:"seasonNumber"`
EpisodeIds []int `json:"episodeIds"`
Quality struct {
Quality struct {
Id int `json:"id"`
Name string `json:"name"`
Source string `json:"source"`
Resolution int `json:"resolution"`
} `json:"quality"`
Revision struct {
Version int `json:"version"`
Real int `json:"real"`
IsRepack bool `json:"isRepack"`
} `json:"revision"`
} `json:"quality"`
Languages []struct {
Id int `json:"id"`
Name string `json:"name"`
} `json:"languages"`
ReleaseGroup string `json:"releaseGroup"`
CustomFormats []interface{} `json:"customFormats"`
CustomFormatScore int `json:"customFormatScore"`
IndexerFlags int `json:"indexerFlags"`
ReleaseType string `json:"releaseType"`
Rejections []struct {
Reason string `json:"reason"`
Type string `json:"type"`
} `json:"rejections"`
}
type ManualImportRequestSchema struct {
Name string `json:"name"`
Files []ManualImportRequestFile `json:"files"`
ImportMode string `json:"importMode"`
}
func (a *Arr) Import(path string, seriesId int, seasons []int) (io.ReadCloser, error) {
query := gourl.Values{}
query.Add("folder", path)
if seriesId != 0 {
query.Add("seriesId", strconv.Itoa(seriesId))
}
url := "api/v3/manualimport" + "?" + query.Encode()
resp, err := a.Request(http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to import, invalid file: %w", err)
}
defer resp.Body.Close()
var data []ImportResponseSchema
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
var files []ManualImportRequestFile
for _, d := range data {
episodesIds := []int{}
for _, e := range d.Episodes {
episodesIds = append(episodesIds, e.Id)
}
file := ManualImportRequestFile{
Path: d.Path,
SeriesId: d.Series.Id,
SeasonNumber: d.SeasonNumber,
EpisodeIds: episodesIds,
Quality: d.Quality,
Languages: d.Languages,
ReleaseGroup: d.ReleaseGroup,
CustomFormats: d.CustomFormats,
CustomFormatScore: d.CustomFormatScore,
IndexerFlags: d.IndexerFlags,
ReleaseType: d.ReleaseType,
Rejections: d.Rejections,
}
files = append(files, file)
}
request := ManualImportRequestSchema{
Name: "ManualImport",
Files: files,
ImportMode: "copy",
}
url = "api/v3/command"
resp, err = a.Request(http.MethodPost, url, request)
if err != nil {
return nil, fmt.Errorf("failed to import: %w", err)
}
defer resp.Body.Close()
return resp.Body, nil
}

54
pkg/arr/refresh.go Normal file
View File

@@ -0,0 +1,54 @@
package arr
import (
"cmp"
"fmt"
"github.com/sirrobot01/debrid-blackhole/common"
"net/http"
"strconv"
"strings"
)
func (a *Arr) Refresh() error {
payload := map[string]string{"name": "RefreshMonitoredDownloads"}
resp, err := a.Request(http.MethodPost, "api/v3/command", payload)
if err == nil && resp != nil {
statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'
if statusOk {
return nil
}
}
return fmt.Errorf("failed to refresh monitored downloads for %s", cmp.Or(a.Name, a.Host))
}
func (a *Arr) MarkAsFailed(infoHash string) error {
downloadId := strings.ToUpper(infoHash)
history := a.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 := common.JoinURL(a.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 {
return fmt.Errorf("failed to mark %s as failed: %v", cmp.Or(a.Name, a.Host), err)
}
}
return nil
}

344
pkg/arr/repair.go Normal file
View File

@@ -0,0 +1,344 @@
package arr
import (
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"sync"
)
var repairLogger *zerolog.Logger
func getLogger() *zerolog.Logger {
if repairLogger == nil {
logger := common.NewLogger("repair", common.CONFIG.LogLevel, os.Stdout)
repairLogger = &logger
}
return repairLogger
}
func (a *Arr) SearchMissing(id int) {
var payload interface{}
switch a.Type {
case Sonarr:
payload = struct {
Name string `json:"name"`
SeriesId int `json:"seriesId"`
}{
Name: "SeriesSearch",
SeriesId: id,
}
case Radarr:
payload = struct {
Name string `json:"name"`
MovieId int `json:"movieId"`
}{
Name: "MoviesSearch",
MovieId: id,
}
default:
getLogger().Info().Msgf("Unknown arr type: %s", a.Type)
return
}
resp, err := a.Request(http.MethodPost, "api/v3/command", payload)
if err != nil {
getLogger().Info().Msgf("Failed to search missing: %v", err)
return
}
if statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'; !statusOk {
getLogger().Info().Msgf("Failed to search missing: %s", resp.Status)
return
}
}
func (a *Arr) Repair(tmdbId string) error {
getLogger().Info().Msgf("Starting repair for %s", a.Name)
media, err := a.GetMedia(tmdbId)
if err != nil {
getLogger().Info().Msgf("Failed to get %s media: %v", a.Type, err)
return err
}
getLogger().Info().Msgf("Found %d %s media", len(media), a.Type)
brokenMedia := a.processMedia(media)
getLogger().Info().Msgf("Found %d %s broken media files", len(brokenMedia), a.Type)
// Automatic search for missing files
for _, m := range brokenMedia {
getLogger().Debug().Msgf("Searching missing for %s", m.Title)
a.SearchMissing(m.Id)
}
getLogger().Info().Msgf("Repair completed for %s", a.Name)
return nil
}
func (a *Arr) processMedia(media []Content) []Content {
if len(media) <= 1 {
var brokenMedia []Content
for _, m := range media {
// Check if media is accessible
if !a.isMediaAccessible(m) {
getLogger().Debug().Msgf("Skipping media check for %s - parent directory not accessible", m.Title)
continue
}
if a.checkMediaFiles(m) {
brokenMedia = append(brokenMedia, m)
}
}
return brokenMedia
}
workerCount := runtime.NumCPU() * 4
if len(media) < workerCount {
workerCount = len(media)
}
jobs := make(chan Content)
results := make(chan Content)
var brokenMedia []Content
var wg sync.WaitGroup
for i := 0; i < workerCount; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for m := range jobs {
// Check if media is accessible
// First check if we can access this media's directory
if !a.isMediaAccessible(m) {
getLogger().Debug().Msgf("Skipping media check for %s - parent directory not accessible", m.Title)
continue
}
if a.checkMediaFilesParallel(m) {
results <- m
}
}
}()
}
go func() {
for _, m := range media {
jobs <- m
}
close(jobs)
}()
go func() {
wg.Wait()
close(results)
}()
for m := range results {
brokenMedia = append(brokenMedia, m)
}
return brokenMedia
}
func (a *Arr) checkMediaFilesParallel(m Content) bool {
if len(m.Files) <= 1 {
return a.checkMediaFiles(m)
}
fileWorkers := runtime.NumCPU() * 2
if len(m.Files) < fileWorkers {
fileWorkers = len(m.Files)
}
fileJobs := make(chan contentFile)
brokenFiles := make(chan bool, len(m.Files))
var fileWg sync.WaitGroup
for i := 0; i < fileWorkers; i++ {
fileWg.Add(1)
go func() {
defer fileWg.Done()
for f := range fileJobs {
getLogger().Debug().Msgf("Checking file: %s", f.Path)
isBroken := false
if fileIsSymlinked(f.Path) {
getLogger().Debug().Msgf("File is symlinked: %s", f.Path)
if !fileIsCorrectSymlink(f.Path) {
getLogger().Debug().Msgf("File is broken: %s", f.Path)
isBroken = true
if err := a.DeleteFile(f.Id); err != nil {
getLogger().Info().Msgf("Failed to delete file: %s %d: %v", f.Path, f.Id, err)
}
}
} else {
getLogger().Debug().Msgf("File is not symlinked: %s", f.Path)
if !fileIsReadable(f.Path) {
getLogger().Debug().Msgf("File is broken: %s", f.Path)
isBroken = true
if err := a.DeleteFile(f.Id); err != nil {
getLogger().Info().Msgf("Failed to delete file: %s %d: %v", f.Path, f.Id, err)
}
}
}
brokenFiles <- isBroken
}
}()
}
go func() {
for _, f := range m.Files {
fileJobs <- f
}
close(fileJobs)
}()
go func() {
fileWg.Wait()
close(brokenFiles)
}()
isBroken := false
for broken := range brokenFiles {
if broken {
isBroken = true
}
}
return isBroken
}
func (a *Arr) checkMediaFiles(m Content) bool {
isBroken := false
for _, f := range m.Files {
if fileIsSymlinked(f.Path) {
if !fileIsCorrectSymlink(f.Path) {
isBroken = true
if err := a.DeleteFile(f.Id); err != nil {
getLogger().Info().Msgf("Failed to delete file: %s %d: %v", f.Path, f.Id, err)
}
}
} else {
if !fileIsReadable(f.Path) {
isBroken = true
if err := a.DeleteFile(f.Id); err != nil {
getLogger().Info().Msgf("Failed to delete file: %s %d: %v", f.Path, f.Id, err)
}
}
}
}
return isBroken
}
func (a *Arr) isMediaAccessible(m Content) bool {
// We're likely to mount the debrid path.
// So instead of checking the arr path, we check the original path
// This is because the arr path is likely to be a symlink
// And we want to check the actual path where the media is stored
// This is to avoid false positives
if len(m.Files) == 0 {
return false
}
// Get the first file to check its target location
file := m.Files[0].Path
var targetPath string
fileInfo, err := os.Lstat(file)
if err != nil {
repairLogger.Debug().Msgf("Cannot stat file %s: %v", file, err)
return false
}
if fileInfo.Mode()&os.ModeSymlink != 0 {
// If it's a symlink, get where it points to
target, err := os.Readlink(file)
if err != nil {
repairLogger.Debug().Msgf("Cannot read symlink %s: %v", file, err)
return false
}
// If the symlink target is relative, make it absolute
if !filepath.IsAbs(target) {
dir := filepath.Dir(file)
target = filepath.Join(dir, target)
}
targetPath = target
} else {
// If it's a regular file, use its path
targetPath = file
}
mediaDir := filepath.Dir(targetPath) // Gets /remote/storage/Movie
parentDir := filepath.Dir(mediaDir) // Gets /remote/storage
_, err = os.Stat(parentDir)
if err != nil {
repairLogger.Debug().Msgf("Parent directory of target not accessible for media %s: %s", m.Title, parentDir)
return false
}
return true
}
func fileIsSymlinked(file string) bool {
info, err := os.Lstat(file)
if err != nil {
return false
}
return info.Mode()&os.ModeSymlink != 0
}
func fileIsCorrectSymlink(file string) bool {
target, err := os.Readlink(file)
if err != nil {
return false
}
if !filepath.IsAbs(target) {
dir := filepath.Dir(file)
target = filepath.Join(dir, target)
}
return fileIsReadable(target)
}
func fileIsReadable(filePath string) bool {
// First check if file exists and is accessible
info, err := os.Stat(filePath)
if err != nil {
return false
}
// Check if it's a regular file
if !info.Mode().IsRegular() {
return false
}
// Try to read the first 1024 bytes
err = checkFileStart(filePath)
if err != nil {
return false
}
return true
}
func checkFileStart(filePath string) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
buffer := make([]byte, 1024)
_, err = io.ReadAtLeast(f, buffer, 1024)
if err != nil && err != io.EOF {
return err
}
return nil
}

34
pkg/arr/structs.go Normal file
View File

@@ -0,0 +1,34 @@
package arr
type Movie struct {
Title string `json:"title"`
OriginalTitle string `json:"originalTitle"`
Path string `json:"path"`
MovieFile struct {
MovieId int `json:"movieId"`
RelativePath string `json:"relativePath"`
Path string `json:"path"`
Size int `json:"size"`
Id int `json:"id"`
} `json:"movieFile"`
Id int `json:"id"`
}
type contentFile struct {
Name string `json:"name"`
Path string `json:"path"`
Id int `json:"id"`
}
type Content struct {
Title string `json:"title"`
Id int `json:"id"`
Files []contentFile `json:"files"`
}
type seriesFile struct {
SeriesId int `json:"seriesId"`
SeasonNumber int `json:"seasonNumber"`
Path string `json:"path"`
Id int `json:"id"`
}

31
pkg/arr/tmdb.go Normal file
View File

@@ -0,0 +1,31 @@
package arr
import (
"encoding/json"
"net/http"
url2 "net/url"
)
type TMDBResponse struct {
Page int `json:"page"`
Results []struct {
ID int `json:"id"`
Name string `json:"name"`
MediaType string `json:"media_type"`
PosterPath string `json:"poster_path"`
} `json:"results"`
}
func SearchTMDB(term string) (*TMDBResponse, error) {
resp, err := http.Get("https://api.themoviedb.org/3/search/multi?api_key=key&query=" + url2.QueryEscape(term))
if err != nil {
return nil, err
}
var data *TMDBResponse
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}
return data, nil
}

5
pkg/arr/utils.go Normal file
View File

@@ -0,0 +1,5 @@
package arr
func Readfile(path string) error {
return nil
}

263
pkg/debrid/alldebrid.go Normal file
View File

@@ -0,0 +1,263 @@
package debrid
import (
"encoding/json"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/structs"
"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 structs.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 []structs.AllDebridMagnetFile, parentPath string, index *int) []TorrentFile {
result := make([]TorrentFile, 0)
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 !common.RegexMatch(common.VIDEOMATCH, fileName) && !common.RegexMatch(common.MUSICMATCH, fileName) {
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 structs.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 structs.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 common.DebridConfig, cache *common.Cache) *AllDebrid {
rl := common.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
client := common.NewRLHTTPClient(rl, headers)
logger := common.NewLogger(dc.Name, common.CONFIG.LogLevel, os.Stdout)
return &AllDebrid{
BaseDebrid: BaseDebrid{
Name: "alldebrid",
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
cache: cache,
MountPath: dc.Folder,
logger: logger,
CheckCached: dc.CheckCached,
},
}
}

192
pkg/debrid/debrid.go Normal file
View File

@@ -0,0 +1,192 @@
package debrid
import (
"fmt"
"github.com/anacrolix/torrent/metainfo"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"path/filepath"
)
type BaseDebrid struct {
Name string
Host string `json:"host"`
APIKey string
DownloadUncached bool
client *common.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(debs []common.DebridConfig, maxCachedSize int) *DebridService {
debrids := make([]Service, 0)
// Divide the cache size by the number of debrids
maxCacheSize := maxCachedSize / len(debs)
for _, dc := range debs {
d := createDebrid(dc, common.NewCache(maxCacheSize))
logger := d.GetLogger()
logger.Info().Msg("Debrid Service started")
debrids = append(debrids, d)
}
d := &DebridService{debrids: debrids, lastUsed: 0}
return d
}
func createDebrid(dc common.DebridConfig, cache *common.Cache) Service {
switch dc.Name {
case "realdebrid":
return NewRealDebrid(dc, cache)
case "torbox":
return NewTorbox(dc, cache)
case "debridlink":
return NewDebridLink(dc, cache)
case "alldebrid":
return NewAllDebrid(dc, cache)
default:
return NewRealDebrid(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 := common.OpenMagnetFile(filePath)
magnet, err := common.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 := &common.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 *common.Magnet, a *arr.Arr, isSymlink bool) (*Torrent, error) {
debridTorrent := &Torrent{
InfoHash: magnet.InfoHash,
Magnet: magnet,
Name: magnet.Name,
Arr: a,
Size: magnet.Size,
}
errs := make([]error, 0)
for index, db := range d.debrids {
logger := db.GetLogger()
logger.Info().Msgf("Processing debrid: %s", db.GetName())
logger.Info().Msgf("Torrent Hash: %s", debridTorrent.InfoHash)
if db.GetCheckCached() {
hash, exists := db.IsAvailable([]string{debridTorrent.InfoHash})[debridTorrent.InfoHash]
if !exists || !hash {
logger.Info().Msgf("Torrent: %s is not cached", debridTorrent.Name)
continue
} else {
logger.Info().Msgf("Torrent: %s is cached(or downloading)", debridTorrent.Name)
}
}
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
return db.CheckStatus(dbt, isSymlink)
}
err := fmt.Errorf("failed to process torrent")
for _, e := range errs {
err = fmt.Errorf("%w\n%w", err, e)
}
return nil, err
}

274
pkg/debrid/debrid_link.go Normal file
View File

@@ -0,0 +1,274 @@
package debrid
import (
"bytes"
"encoding/json"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/structs"
"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 structs.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 structs.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))
for i, f := range data.Files {
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 structs.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 == "error" || status == "dead" || status == "magnet_error" {
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
} else 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
}
}
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 common.DebridConfig, cache *common.Cache) *DebridLink {
rl := common.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
"Content-Type": "application/json",
}
client := common.NewRLHTTPClient(rl, headers)
logger := common.NewLogger(dc.Name, common.CONFIG.LogLevel, os.Stdout)
return &DebridLink{
BaseDebrid: BaseDebrid{
Name: "debridlink",
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
cache: cache,
MountPath: dc.Folder,
logger: logger,
CheckCached: dc.CheckCached,
},
}
}

296
pkg/debrid/realdebrid.go Normal file
View File

@@ -0,0 +1,296 @@
package debrid
import (
"encoding/json"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/structs"
"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 structs.RealDebridTorrentInfo) []TorrentFile {
files := make([]TorrentFile, 0)
for _, f := range data.Files {
name := filepath.Base(f.Path)
if common.RegexMatch(common.SAMPLEMATCH, name) {
// Skip sample files
continue
}
if !common.RegexMatch(common.VIDEOMATCH, name) && !common.RegexMatch(common.MUSICMATCH, name) {
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 structs.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 structs.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 structs.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 structs.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 == "error" || status == "dead" || status == "magnet_error" {
return torrent, fmt.Errorf("torrent: %s has error: %s", torrent.Name, status)
} else 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
}
}
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 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
}
func (r *RealDebrid) GetCheckCached() bool {
return r.CheckCached
}
func NewRealDebrid(dc common.DebridConfig, cache *common.Cache) *RealDebrid {
rl := common.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
client := common.NewRLHTTPClient(rl, headers)
logger := common.NewLogger(dc.Name, common.CONFIG.LogLevel, os.Stdout)
return &RealDebrid{
BaseDebrid: BaseDebrid{
Name: "realdebrid",
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
cache: cache,
MountPath: dc.Folder,
logger: logger,
CheckCached: dc.CheckCached,
},
}
}

22
pkg/debrid/service.go Normal file
View File

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

View File

@@ -0,0 +1,75 @@
package structs
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 int `json:"downloadSpeed"`
UploadSpeed int `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"`
}

View File

@@ -0,0 +1,45 @@
package structs
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 int `json:"downloadSpeed"`
UploadSpeed int `json:"uploadSpeed"`
}
type DebridLinkTorrentInfo DebridLinkAPIResponse[[]debridLinkTorrentInfo]
type DebridLinkSubmitTorrentInfo DebridLinkAPIResponse[debridLinkTorrentInfo]

View File

@@ -0,0 +1,107 @@
package structs
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 int `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"`
}

View File

@@ -0,0 +1,75 @@
package structs
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 int `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]

306
pkg/debrid/torbox.go Normal file
View File

@@ -0,0 +1,306 @@
package debrid
import (
"bytes"
"encoding/json"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/structs"
"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 structs.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 structs.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 structs.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)
for _, f := range data.Files {
fileName := filepath.Base(f.Name)
if common.RegexMatch(common.SAMPLEMATCH, fileName) {
// Skip sample files
continue
}
if !common.RegexMatch(common.VIDEOMATCH, fileName) && !common.RegexMatch(common.MUSICMATCH, fileName) {
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 == "error" || status == "dead" || status == "magnet_error" {
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
} else 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
}
}
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 structs.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 common.DebridConfig, cache *common.Cache) *Torbox {
rl := common.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
client := common.NewRLHTTPClient(rl, headers)
logger := common.NewLogger(dc.Name, common.CONFIG.LogLevel, os.Stdout)
return &Torbox{
BaseDebrid: BaseDebrid{
Name: "torbox",
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
cache: cache,
MountPath: dc.Folder,
logger: logger,
CheckCached: dc.CheckCached,
},
}
}

117
pkg/debrid/torrent.go Normal file
View File

@@ -0,0 +1,117 @@
package debrid
import (
"fmt"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"os"
"path/filepath"
)
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 *common.Magnet `json:"magnet"`
Files []TorrentFile `json:"files"`
Status string `json:"status"`
Added string `json:"added"`
Progress float64 `json:"progress"`
Speed int `json:"speed"`
Seeders int `json:"seeders"`
Links []string `json:"links"`
DownloadLinks []TorrentDownloadLinks `json:"download_links"`
Debrid Service
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 {
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 {
if common.FileReady(filepath.Join(rClonePath, path)) {
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
}
}
}

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
}

55
pkg/downloaders/grab.go Normal file
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%%)",
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
}

44
pkg/downloaders/http.go Normal file
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
}

343
pkg/proxy/proxy.go Normal file
View File

@@ -0,0 +1,343 @@
package proxy
import (
"bytes"
"cmp"
"context"
"encoding/xml"
"errors"
"fmt"
"github.com/elazarl/goproxy"
"github.com/elazarl/goproxy/ext/auth"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/valyala/fastjson"
"io"
"net/http"
"os"
"regexp"
"strings"
"sync"
)
type RSS struct {
XMLName xml.Name `xml:"rss"`
Text string `xml:",chardata"`
Version string `xml:"version,attr"`
Atom string `xml:"atom,attr"`
Torznab string `xml:"torznab,attr"`
Channel struct {
Text string `xml:",chardata"`
Link struct {
Text string `xml:",chardata"`
Rel string `xml:"rel,attr"`
Type string `xml:"type,attr"`
} `xml:"link"`
Title string `xml:"title"`
Items []Item `xml:"item"`
} `xml:"channel"`
}
type Item struct {
Text string `xml:",chardata"`
Title string `xml:"title"`
Description string `xml:"description"`
GUID string `xml:"guid"`
ProwlarrIndexer struct {
Text string `xml:",chardata"`
ID string `xml:"id,attr"`
Type string `xml:"type,attr"`
} `xml:"prowlarrindexer"`
Comments string `xml:"comments"`
PubDate string `xml:"pubDate"`
Size string `xml:"size"`
Link string `xml:"link"`
Category []string `xml:"category"`
Enclosure struct {
Text string `xml:",chardata"`
URL string `xml:"url,attr"`
Length string `xml:"length,attr"`
Type string `xml:"type,attr"`
} `xml:"enclosure"`
TorznabAttrs []struct {
Text string `xml:",chardata"`
Name string `xml:"name,attr"`
Value string `xml:"value,attr"`
} `xml:"attr"`
}
type Proxy struct {
port string
enabled bool
debug bool
username string
password string
cachedOnly bool
debrid debrid.Service
logger zerolog.Logger
}
func NewProxy(config common.Config, deb *debrid.DebridService) *Proxy {
cfg := config.Proxy
port := cmp.Or(os.Getenv("PORT"), cfg.Port, "8181")
return &Proxy{
port: port,
enabled: cfg.Enabled,
username: cfg.Username,
password: cfg.Password,
cachedOnly: *cfg.CachedOnly,
debrid: deb.Get(),
logger: common.NewLogger("Proxy", cfg.LogLevel, os.Stdout),
}
}
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 {
return resp
}
err = resp.Body.Close()
if err != nil {
return nil
}
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))
return resp
}
// Modify the JSON
// Serialize the modified JSON back to bytes
modifiedBody := v.MarshalTo(nil)
// Set the modified body back to the response
resp.Body = io.NopCloser(bytes.NewReader(modifiedBody))
resp.ContentLength = int64(len(modifiedBody))
resp.Header.Set("Content-Length", string(rune(len(modifiedBody))))
return resp
}
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 resp // p.ProcessJSONResponse(resp)
case "application/xml":
return p.ProcessXMLResponse(resp)
case "application/rss+xml":
return p.ProcessXMLResponse(resp)
default:
return resp
}
}
func getItemsHash(items []Item) map[string]string {
var wg sync.WaitGroup
idHashMap := sync.Map{} // Use sync.Map for concurrent access
for _, item := range items {
wg.Add(1)
go func(item Item) {
defer wg.Done()
hash := strings.ToLower(item.getHash())
if hash != "" {
idHashMap.Store(item.GUID, hash) // Store directly into sync.Map
}
}(item)
}
wg.Wait()
// Convert sync.Map to regular map
finalMap := make(map[string]string)
idHashMap.Range(func(key, value interface{}) bool {
finalMap[key.(string)] = value.(string)
return true
})
return finalMap
}
func (item Item) getHash() string {
infohash := ""
for _, attr := range item.TorznabAttrs {
if attr.Name == "infohash" {
return attr.Value
}
}
if strings.Contains(item.GUID, "magnet:?") {
magnet, err := common.GetMagnetInfo(item.GUID)
if err == nil && magnet != nil && magnet.InfoHash != "" {
return magnet.InfoHash
}
}
magnetLink := item.Link
if magnetLink == "" {
// We can't check the availability of the torrent without a magnet link or infohash
return ""
}
if strings.Contains(magnetLink, "magnet:?") {
magnet, err := common.GetMagnetInfo(magnetLink)
if err == nil && magnet != nil && magnet.InfoHash != "" {
return magnet.InfoHash
}
}
//Check Description for infohash
hash := common.ExtractInfoHash(item.Description)
if hash == "" {
// Check Title for infohash
hash = common.ExtractInfoHash(item.Comments)
}
infohash = hash
if infohash == "" {
if strings.Contains(magnetLink, "http") {
h, _ := common.GetInfohashFromURL(magnetLink)
if h != "" {
infohash = h
}
}
}
return infohash
}
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 {
p.logger.Info().Msgf("Error reading response body: %v", err)
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
err = resp.Body.Close()
if err != nil {
return nil
}
var rss RSS
err = xml.Unmarshal(body, &rss)
if err != nil {
p.logger.Info().Msgf("Error unmarshalling XML: %v", err)
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
indexer := ""
if len(rss.Channel.Items) > 0 {
indexer = rss.Channel.Items[0].ProwlarrIndexer.Text
} else {
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
// Step 4: Extract infohash or magnet URI, manipulate data
IdsHashMap := getItemsHash(rss.Channel.Items)
hashes := make([]string, 0)
for _, hash := range IdsHashMap {
if hash != "" {
hashes = append(hashes, hash)
}
}
availableHashesMap := p.debrid.IsAvailable(hashes)
newItems := make([]Item, 0, len(rss.Channel.Items))
if len(hashes) > 0 {
for _, item := range rss.Channel.Items {
hash := IdsHashMap[item.GUID]
if hash == "" {
continue
}
isCached, exists := availableHashesMap[hash]
if !exists || !isCached {
continue
}
newItems = append(newItems, item)
}
}
if len(newItems) > 0 {
p.logger.Info().Msgf("[%s Report]: %d/%d items are cached || Found %d infohash", indexer, len(newItems), len(rss.Channel.Items), len(hashes))
} else {
// This will prevent the indexer from being disabled by the arr
p.logger.Info().Msgf("[%s Report]: No Items are cached; Return only first item with [UnCached]", indexer)
item := rss.Channel.Items[0]
item.Title = fmt.Sprintf("%s [UnCached]", item.Title)
newItems = append(newItems, item)
}
rss.Channel.Items = newItems
modifiedBody, err := xml.MarshalIndent(rss, "", " ")
if err != nil {
p.logger.Info().Msgf("Error marshalling XML: %v", err)
resp.Body = io.NopCloser(bytes.NewReader(body))
return resp
}
modifiedBody = append([]byte(xml.Header), modifiedBody...)
// Set the modified body back to the response
resp.Body = io.NopCloser(bytes.NewReader(modifiedBody))
return resp
}
func UrlMatches(re *regexp.Regexp) goproxy.ReqConditionFunc {
return func(req *http.Request, ctx *goproxy.ProxyCtx) bool {
return re.MatchString(req.URL.String())
}
}
func (p *Proxy) Start(ctx context.Context) error {
username, password := p.username, p.password
proxy := goproxy.NewProxyHttpServer()
if username != "" || password != "" {
// Set up basic auth for proxy
auth.ProxyBasic(proxy, "my_realm", func(user, pwd string) bool {
return user == username && password == pwd
})
}
proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile("^.443$"))).HandleConnect(goproxy.AlwaysMitm)
proxy.OnResponse(
UrlMatches(regexp.MustCompile("^.*/api\\?t=(search|tvsearch|movie)(&.*)?$")),
goproxy.StatusCodeIs(http.StatusOK, http.StatusAccepted)).DoFunc(
func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
return p.ProcessResponse(resp)
})
proxy.Verbose = p.debug
portFmt := fmt.Sprintf(":%s", p.port)
srv := &http.Server{
Addr: portFmt,
Handler: proxy,
}
p.logger.Info().Msgf("Starting proxy server on %s", portFmt)
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
p.logger.Info().Msgf("Error starting proxy server: %v", err)
}
}()
<-ctx.Done()
p.logger.Info().Msg("Shutting down gracefully...")
return srv.Shutdown(context.Background())
}

18
pkg/qbit/main.go Normal file
View File

@@ -0,0 +1,18 @@
package qbit
import (
"context"
"fmt"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/server"
)
func Start(ctx context.Context, config *common.Config, deb *debrid.DebridService, arrs *arr.Storage) error {
srv := server.NewServer(config, deb, arrs)
if err := srv.Start(ctx); err != nil {
return fmt.Errorf("failed to start qbit server: %w", err)
}
return nil
}

84
pkg/qbit/server/import.go Normal file
View File

@@ -0,0 +1,84 @@
package server
import (
"fmt"
"github.com/google/uuid"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
"time"
)
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 := common.GetMagnetFromUrl(i.URI)
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
}

View File

@@ -0,0 +1,382 @@
package server
import (
"context"
"encoding/base64"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"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")
common.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",
}
common.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)
common.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,
}
}
common.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)
common.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)
common.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)
common.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)
}
common.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)
}
common.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)
}
common.JSONResponse(w, nil, http.StatusOK)
}
func (q *qbitHandler) handleGetTags(w http.ResponseWriter, r *http.Request) {
common.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)
common.JSONResponse(w, nil, http.StatusOK)
}

View File

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

97
pkg/qbit/server/server.go Normal file
View File

@@ -0,0 +1,97 @@
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/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
"io"
"net/http"
"os"
"os/signal"
"syscall"
)
type Server struct {
qbit *shared.QBit
logger zerolog.Logger
}
func NewServer(config *common.Config, deb *debrid.DebridService, arrs *arr.Storage) *Server {
logger := common.NewLogger("QBit", config.QBitTorrent.LogLevel, os.Stdout)
q := shared.NewQBit(config, deb, logger, arrs)
return &Server{
qbit: q,
logger: logger,
}
}
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: common.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 := common.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 file.Close()
// 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 {
http.Error(w, "Error streaming log file", http.StatusInternalServerError)
return
}
}

View File

@@ -0,0 +1,310 @@
{{ 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>
</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">
<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;
});
// 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());
alert('Configuration saved successfully!');
} catch (error) {
alert(`Error saving configuration: ${error.message}`);
}
});
// 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++;
}
});
</script>
{{ end }}

View File

@@ -0,0 +1,74 @@
{{ 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">
<div class="mb-3">
<label for="magnetURI" class="form-label">Magnet Link or Torrent URL</label>
<textarea class="form-control" id="magnetURI" rows="3" placeholder="Paste your magnet link here..."></textarea>
</div>
<div class="mb-3">
<label for="category" class="form-label">Enter Category</label>
<input type="text" class="form-control" id="category" 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">
<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', () => {
// 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 response = await fetch('/internal/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: document.getElementById('magnetURI').value,
arr: document.getElementById('category').value,
notSymlink: document.getElementById('isSymlink').checked
})
});
if (!response.ok) throw new Error(await response.text());
alert('Download added successfully!');
document.getElementById('magnetURI').value = '';
} catch (error) {
alert(`Error adding download: ${error.message}`);
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
});
</script>
{{ end }}

View File

@@ -0,0 +1,149 @@
{{ define "index" }}
<div class="container mt-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-table me-2"></i>Active Torrents</h4>
<div>
<button class="btn btn-outline-secondary btn-sm me-2" id="refreshBtn">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<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>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>
const torrentRowTemplate = (torrent) => `
<tr>
<td class="text-break">${torrent.name}</td>
<td>${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';
}
let refreshInterval;
async function loadTorrents() {
try {
const response = await fetch('/internal/torrents');
const torrents = await response.json();
const tbody = document.getElementById('torrentsList');
tbody.innerHTML = torrents.map(torrent => torrentRowTemplate(torrent)).join('');
// Update category filter options
let category = document.getElementById('categoryFilter').value;
document.querySelectorAll('#torrentsList tr').forEach(row => {
const rowCategory = row.querySelector('td:nth-child(5)').textContent;
row.style.display = (!category || rowCategory.includes(category)) ? '' : 'none';
});
updateCategoryFilter(torrents);
} catch (error) {
console.error('Error loading torrents:', error);
}
}
function updateCategoryFilter(torrents) {
const categories = [...new Set(torrents.map(t => t.category).filter(Boolean))];
const select = document.getElementById('categoryFilter');
const currentValue = select.value;
select.innerHTML = '<option value="">All Categories</option>' +
categories.map(cat => `<option value="${cat}" ${cat === currentValue ? 'selected' : ''}>${cat}</option>`).join('');
}
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();
} catch (error) {
console.error('Error deleting torrent:', error);
alert('Failed to delete torrent');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadTorrents();
refreshInterval = setInterval(loadTorrents, 5000); // Refresh every 5 seconds
document.getElementById('refreshBtn').addEventListener('click', loadTorrents);
document.getElementById('categoryFilter').addEventListener('change', (e) => {
const category = e.target.value;
document.querySelectorAll('#torrentsList tr').forEach(row => {
const rowCategory = row.querySelector('td:nth-child(5)').textContent;
row.style.display = (!category || rowCategory.includes(category)) ? '' : 'none';
});
});
});
window.addEventListener('beforeunload', () => {
clearInterval(refreshInterval);
});
</script>
{{ end }}

View File

@@ -0,0 +1,142 @@
{{ 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>
<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" . }}
{{ 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>
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 }}

View File

@@ -0,0 +1,94 @@
{{ define "repair" }}
<div class="container mt-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0"><i class="bi bi-tools me-2"></i>Repair Media</h4>
</div>
<div class="card-body">
<form id="repairForm">
<div class="mb-3">
<label for="arrSelect" class="form-label">Select Arr Instance</label>
<select class="form-select" id="arrSelect" required>
<option value="">Select an Arr instance</option>
</select>
</div>
<div class="mb-3">
<label for="mediaIds" class="form-label">Media IDs</label>
<input type="text" class="form-control" id="mediaIds"
placeholder="Enter IDs (comma-separated)">
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isAsync" checked>
<label class="form-check-label" for="isAsync">
Run repair in background
</label>
</div>
</div>
<button type="submit" class="btn btn-primary" id="submitRepair">
<i class="bi bi-wrench me-2"></i>Start Repair
</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Load Arr instances
fetch('/internal/arrs')
.then(response => response.json())
.then(arrs => {
const select = document.getElementById('arrSelect');
arrs.forEach(arr => {
const option = document.createElement('option');
option.value = arr.name;
option.textContent = arr.name;
select.appendChild(option);
});
});
// Handle form submission
document.getElementById('repairForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submitRepair');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Repairing...';
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
let arr = document.getElementById('arrSelect').value;
if (!arr) {
alert('Please select an Arr instance');
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());
alert('Repair process initiated successfully!');
} catch (error) {
alert(`Error starting repair: ${error.message}`);
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
});
</script>
{{ end }}

View File

@@ -0,0 +1,248 @@
package server
import (
"embed"
"encoding/json"
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"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"
"html/template"
"net/http"
"strings"
)
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 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",
))
}
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) {
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, u.qbit.Arrs.GetAll(), http.StatusOK)
}
func (u *uiHandler) handleAddContent(w http.ResponseWriter, r *http.Request) {
var req AddRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_arr := u.qbit.Arrs.Get(req.Arr)
if _arr == nil {
_arr = arr.NewArr(req.Arr, "", "", arr.Sonarr)
}
importReq := NewImportRequest(req.Url, _arr, !req.NotSymlink)
err := importReq.Process(u.qbit)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
common.JSONResponse(w, importReq, http.StatusOK)
}
func (u *uiHandler) handleCheckCached(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_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
}
common.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
}
mediaIds := req.MediaIds
if len(mediaIds) == 0 {
mediaIds = []string{""}
}
if req.Async {
for _, tvId := range mediaIds {
go func() {
err := _arr.Repair(tvId)
if err != nil {
u.logger.Info().Msgf("Failed to repair: %v", err)
}
}()
}
common.JSONResponse(w, "Repair process started", http.StatusOK)
return
}
var errs []error
for _, tvId := range mediaIds {
if err := _arr.Repair(tvId); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
combinedErr := errors.Join(errs...)
http.Error(w, fmt.Sprintf("Failed to repair: %v", combinedErr), http.StatusInternalServerError)
return
}
common.JSONResponse(w, "Repair completed", http.StatusOK)
}
func (u *uiHandler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
v := version.GetInfo()
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, v, http.StatusOK)
}
func (u *uiHandler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
common.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) {
w.Header().Set("Content-Type", "application/json")
config := common.CONFIG
arrCfgs := make([]common.ArrConfig, 0)
for _, a := range u.qbit.Arrs.GetAll() {
arrCfgs = append(arrCfgs, common.ArrConfig{Host: a.Host, Name: a.Name, Token: a.Token})
}
config.Arrs = arrCfgs
common.JSONResponse(w, config, http.StatusOK)
}

View File

@@ -0,0 +1,27 @@
package server
import (
"github.com/go-chi/chi/v5"
"net/http"
)
func (u *uiHandler) Routes(r chi.Router) http.Handler {
r.Group(func(r chi.Router) {
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
}

View File

@@ -0,0 +1,118 @@
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) processManualFiles(debridTorrent *debrid.Torrent) (string, error) {
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(debridTorrent, parent)
return torrentPath, nil
}
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.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 }()
err := downloaders.NormalHTTP(client, link.DownloadLink, filepath.Join(parent, link.Filename))
if err != nil {
q.logger.Info().Msgf("Error downloading %s: %v", link.DownloadLink, err)
} else {
q.logger.Info().Msgf("Downloaded %s successfully", link.DownloadLink)
}
}(link)
}
wg.Wait()
q.logger.Info().Msgf("Downloaded all files for %s", debridTorrent.Name)
}
func (q *QBit) ProcessSymlink(debridTorrent *debrid.Torrent) (string, error) {
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)
}
}

43
pkg/qbit/shared/qbit.go Normal file
View File

@@ -0,0 +1,43 @@
package shared
import (
"cmp"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"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
Storage *TorrentStorage
debug bool
logger zerolog.Logger
Arrs *arr.Storage
Tags []string
RefreshInterval int
}
func NewQBit(config *common.Config, deb *debrid.DebridService, logger zerolog.Logger, arrs *arr.Storage) *QBit {
cfg := config.QBitTorrent
port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8182")
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("torrents.json"),
logger: logger,
Arrs: arrs,
RefreshInterval: refreshInterval,
}
}

151
pkg/qbit/shared/storage.go Normal file
View File

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

425
pkg/qbit/shared/structs.go Normal file
View File

@@ -0,0 +1,425 @@
package shared
import "github.com/sirrobot01/debrid-blackhole/pkg/debrid"
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 int `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 int `json:"upspeed,omitempty"`
Source string `json:"source,omitempty"`
}
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 int `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 int `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
}

321
pkg/qbit/shared/torrent.go Normal file
View File

@@ -0,0 +1,321 @@
package shared
import (
"cmp"
"context"
"fmt"
"github.com/google/uuid"
"github.com/sirrobot01/debrid-blackhole/common"
"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 := common.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 := common.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 *common.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 *common.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(debridTorrent)
} else {
torrentPath, err = q.processManualFiles(debridTorrent)
}
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 int
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 := common.Remove(torrentTags, tags...)
q.Tags = common.Remove(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 = common.Remove(q.Tags, tags...)
return true
}

25
pkg/qbit/shared/utils.go Normal file
View File

@@ -0,0 +1,25 @@
package shared
import (
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"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:
if common.FileReady(path) {
ready <- file
return
}
}
}
}

37
pkg/qbit/shared/worker.go Normal file
View File

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

72
pkg/repair/repair.go Normal file
View File

@@ -0,0 +1,72 @@
package repair
import (
"context"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"log"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
func Start(ctx context.Context, config *common.Config, arrs *arr.Storage) error {
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
logger := common.NewLogger("Repair", config.LogLevel, os.Stdout)
defer stop()
duration, err := parseSchedule(config.Repair.Interval)
if err != nil {
log.Fatalf("Failed to parse schedule: %v", err)
}
if config.Repair.RunOnStart {
logger.Info().Msgf("Running initial repair")
if err := repair(arrs); err != nil {
log.Printf("Error during initial repair: %v", err)
return err
}
}
ticker := time.NewTicker(duration)
defer ticker.Stop()
if strings.Contains(config.Repair.Interval, ":") {
logger.Info().Msgf("Starting repair worker, scheduled daily at %s", config.Repair.Interval)
} else {
logger.Info().Msgf("Starting repair worker with %v interval", duration)
}
for {
select {
case <-ctx.Done():
logger.Info().Msg("Repair worker stopped")
return nil
case t := <-ticker.C:
logger.Info().Msgf("Running repair at %v", t.Format("15:04:05"))
if err := repair(arrs); err != nil {
logger.Info().Msgf("Error during repair: %v", err)
return err
}
// If using time-of-day schedule, reset the ticker for next day
if strings.Contains(config.Repair.Interval, ":") {
nextDuration, err := parseSchedule(config.Repair.Interval)
if err != nil {
logger.Info().Msgf("Error calculating next schedule: %v", err)
return err
}
ticker.Reset(nextDuration)
}
}
}
}
func repair(arrs *arr.Storage) error {
for _, a := range arrs.GetAll() {
go a.Repair("")
}
return nil
}

70
pkg/repair/utils.go Normal file
View File

@@ -0,0 +1,70 @@
package repair
import (
"fmt"
"strconv"
"strings"
"time"
)
func parseSchedule(schedule string) (time.Duration, error) {
if schedule == "" {
return time.Hour, nil // default 60m
}
// Check if it's a time-of-day format (HH:MM)
if strings.Contains(schedule, ":") {
return parseTimeOfDay(schedule)
}
// Otherwise treat as duration interval
return parseDurationInterval(schedule)
}
func parseTimeOfDay(schedule string) (time.Duration, error) {
now := time.Now()
scheduledTime, err := time.Parse("15:04", schedule)
if err != nil {
return 0, fmt.Errorf("invalid time format: %s. Use HH:MM in 24-hour format", schedule)
}
// Convert scheduled time to today
scheduleToday := time.Date(
now.Year(), now.Month(), now.Day(),
scheduledTime.Hour(), scheduledTime.Minute(), 0, 0,
now.Location(),
)
if scheduleToday.Before(now) {
scheduleToday = scheduleToday.Add(24 * time.Hour)
}
return scheduleToday.Sub(now), nil
}
func parseDurationInterval(interval string) (time.Duration, error) {
if len(interval) < 2 {
return 0, fmt.Errorf("invalid interval format: %s", interval)
}
numStr := interval[:len(interval)-1]
unit := interval[len(interval)-1]
num, err := strconv.Atoi(numStr)
if err != nil {
return 0, fmt.Errorf("invalid number in interval: %s", numStr)
}
switch unit {
case 'm':
return time.Duration(num) * time.Minute, nil
case 'h':
return time.Duration(num) * time.Hour, nil
case 'd':
return time.Duration(num) * 24 * time.Hour, nil
case 's':
return time.Duration(num) * time.Second, nil
default:
return 0, fmt.Errorf("invalid unit in interval: %c", unit)
}
}

18
pkg/version/version.go Normal file
View File

@@ -0,0 +1,18 @@
package version
type Info struct {
Version string `json:"version"`
Channel string `json:"channel"`
}
var (
Version = ""
Channel = ""
)
func GetInfo() Info {
return Info{
Version: Version,
Channel: Channel,
}
}

57
scripts/deploy.sh Executable file
View File

@@ -0,0 +1,57 @@
#!/bin/bash
# deploy.sh
# Function to display usage
usage() {
echo "Usage: $0 [-b|--beta] <version>"
echo "Example for main: $0 v1.0.0"
echo "Example for beta: $0 -b v1.0.0"
exit 1
}
# Parse arguments
BETA=false
while [[ "$#" -gt 0 ]]; do
case $1 in
-b|--beta) BETA=true; shift ;;
-*) echo "Unknown parameter: $1"; usage ;;
*) VERSION="$1"; shift ;;
esac
done
# Check if version is provided
if [ -z "$VERSION" ]; then
echo "Error: Version is required"
usage
fi
# Validate version format
if ! [[ $VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Error: Version must be in format v1.0.0"
exit 1
fi
# Set tag based on branch
if [ "$BETA" = true ]; then
TAG="$VERSION-beta"
BRANCH="beta"
else
TAG="$VERSION"
BRANCH="main"
fi
echo "Deploying version $VERSION to $BRANCH branch..."
# Ensure we're on the right branch
git checkout $BRANCH || exit 1
# Create and push tag
echo "Creating tag $TAG..."
git tag "$TAG" || exit 1
git push origin "$TAG" || exit 1
echo "Deployment initiated successfully!"
echo "GitHub Actions will handle the release process."
echo "Check the progress at: https://github.com/sirrobot01/debrid-blackhole/actions"