Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bba90cb89a | ||
|
|
fc5c6e2869 | ||
|
|
66f4965ec8 | ||
|
|
dc16f0d8a1 | ||
|
|
0741ddf999 | ||
|
|
2ae4bd571e | ||
|
|
0b1c1af8b8 | ||
|
|
74a55149fc | ||
|
|
cfb0051b04 | ||
|
|
a986c4b5d0 | ||
|
|
3841b7751e | ||
|
|
ea73572557 | ||
|
|
7cb41a0e8b | ||
|
|
451c17cdf7 | ||
|
|
c39eebea0d | ||
|
|
03c9657945 | ||
|
|
28e5342c66 | ||
|
|
eeb3a31b05 | ||
|
|
e9d3e120f3 | ||
|
|
104df3c33c | ||
|
|
810c9d705e | ||
|
|
4ff00859a3 | ||
|
|
b77dbcc4f4 | ||
|
|
58c0aafab1 | ||
|
|
357da54083 | ||
|
|
88a7196eaf | ||
|
|
abc86a0460 | ||
|
|
dd0b7efdff | ||
|
|
7359f280b0 | ||
|
|
4eb3539347 | ||
|
|
9fb1118475 | ||
|
|
07491b43fe | ||
|
|
8f7c9a19c5 | ||
|
|
a51364d150 | ||
|
|
df2aa4e361 | ||
|
|
b51cb954f8 | ||
|
|
8bdb2e3547 | ||
|
|
2c9a076cd2 | ||
|
|
d2a77620bc | ||
|
|
4b8f1ccfb6 |
52
.air.toml
Normal file
52
.air.toml
Normal 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
|
||||
@@ -5,4 +5,5 @@ docker-compose.yml
|
||||
.DS_Store
|
||||
**/.idea/
|
||||
*.magnet
|
||||
**.torrent
|
||||
**.torrent
|
||||
torrents.json
|
||||
|
||||
69
.github/workflows/docker.yml
vendored
Normal file
69
.github/workflows/docker.yml
vendored
Normal 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
40
.github/workflows/release.yml
vendored
Normal 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
3
.gitignore
vendored
@@ -9,3 +9,6 @@ docker-compose.yml
|
||||
*.log
|
||||
*.log.*
|
||||
dist/
|
||||
tmp/**
|
||||
torrents.json
|
||||
logs/**
|
||||
|
||||
@@ -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:
|
||||
|
||||
59
CHANGELOG.md
59
CHANGELOG.md
@@ -39,7 +39,7 @@
|
||||
- Rewrote the whole codebase
|
||||
|
||||
|
||||
#### 0.2.0
|
||||
### 0.2.0
|
||||
- Implement 0.2.0-beta changes
|
||||
- Removed Blackhole
|
||||
- Added QbitTorrent API
|
||||
@@ -72,4 +72,59 @@
|
||||
#### 0.2.5
|
||||
- Fix ContentPath not being set prior
|
||||
- Rewrote Readme
|
||||
- Cleaned up the code
|
||||
- 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.
|
||||
21
Dockerfile
21
Dockerfile
@@ -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,13 +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"]
|
||||
|
||||
298
README.md
298
README.md
@@ -1,19 +1,53 @@
|
||||
### GoBlackHole(with Debrid Proxy Support)
|
||||
### DecyphArr(with Debrid Proxy Support)
|
||||
|
||||
This is a Golang implementation go Torrent QbitTorrent with a **Real Debrid Proxy Support**.
|
||||

|
||||
|
||||
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
|
||||
|
||||
#### Uses
|
||||
- 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
|
||||
|
||||
### Changelog
|
||||
|
||||
- View the [CHANGELOG.md](CHANGELOG.md) for the latest changes
|
||||
### 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'
|
||||
@@ -26,9 +60,7 @@ services:
|
||||
- "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
|
||||
@@ -37,6 +69,8 @@ services:
|
||||
- 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
|
||||
|
||||
```
|
||||
|
||||
@@ -47,78 +81,14 @@ Download the binary from the releases page and run it with the config file.
|
||||
./blackhole --config /path/to/config.json
|
||||
```
|
||||
|
||||
#### Config
|
||||
```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"
|
||||
},
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"port": "8181",
|
||||
"debug": false,
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"cached_only": true
|
||||
},
|
||||
"max_cache_size": 1000,
|
||||
"qbittorrent": {
|
||||
"port": "8282",
|
||||
"download_folder": "/media/symlinks/",
|
||||
"categories": ["sonarr", "radarr"],
|
||||
"refresh_interval": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
### 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
|
||||
|
||||
#### Config Notes
|
||||
##### Max Cache Size
|
||||
- The `max_cache_size` key is used to set the maximum number of infohashes that can be stored in the availability cache. This is used to prevent round trip to the debrid provider when using the proxy/Qbittorrent
|
||||
- The default value is `1000`
|
||||
- The cache is stored in memory and is not persisted on restart
|
||||
|
||||
##### Debrid Config
|
||||
- This config key is important as it's used for both Blackhole and Proxy
|
||||
|
||||
##### Proxy Config
|
||||
- The `enabled` key is used to enable the proxy
|
||||
- The `port` key is the port the proxy will listen on
|
||||
- The `debug` key is used to enable debug logs
|
||||
- The `username` and `password` keys are used for basic authentication
|
||||
- The `cached_only` means only cached torrents will be returned
|
||||
|
||||
|
||||
##### 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
|
||||
|
||||
### Proxy
|
||||
|
||||
The proxy is useful in filtering out un-cached Real Debrid torrents.
|
||||
The proxy is a simple HTTP proxy that requires basic authentication. The proxy can be enabled by setting the `proxy.enabled` to `true` in the config file.
|
||||
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
|
||||
|
||||
- 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`
|
||||
|
||||
### Qbittorrent
|
||||
|
||||
The qBittorrent is a mock qBittorrent API that supports the Arrs(Sonarr, Radarr, etc).
|
||||
|
||||
Setting Up Qbittorrent in Arr
|
||||
#### Connecting to Sonarr/Radarr
|
||||
|
||||
- Sonarr/Radarr
|
||||
- Settings -> Download Client -> Add Client -> qBittorrent
|
||||
@@ -132,15 +102,163 @@ Setting Up Qbittorrent in Arr
|
||||
- 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
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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
|
||||
},
|
||||
{
|
||||
"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": "8100",
|
||||
"log_level": "info",
|
||||
"username": "username",
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
#### Config Notes
|
||||
|
||||
##### 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.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
### TODO
|
||||
- [ ] A proper name!!!!
|
||||
- [ ] Debrid
|
||||
- [ ] Add more Debrid Providers
|
||||
- [x] A proper name!!!!
|
||||
- [x] Debrid
|
||||
- [x] Add more Debrid Providers
|
||||
|
||||
- [ ] Proxy
|
||||
- [ ] Add more Proxy features
|
||||
|
||||
- [ ] Qbittorrent
|
||||
- [ ] Add more Qbittorrent features
|
||||
- [ ] Persist torrents on restart/server crash
|
||||
- [x] Qbittorrent
|
||||
- [x] Add more Qbittorrent features
|
||||
- [x] Persist torrents on restart/server crash
|
||||
- [ ] Add tests
|
||||
52
cmd/main.go
52
cmd/main.go
@@ -2,39 +2,65 @@ package cmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"goBlack/pkg/proxy"
|
||||
"goBlack/pkg/qbit"
|
||||
"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)
|
||||
cache := common.NewCache(maxCacheSize)
|
||||
|
||||
deb := debrid.NewDebrid(config.Debrid, cache)
|
||||
deb := debrid.NewDebrid(config.Debrids, maxCacheSize)
|
||||
arrs := arr.NewStorage(config.Arrs)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
if config.Proxy.Enabled {
|
||||
p := proxy.NewProxy(*config, deb, cache)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p.Start()
|
||||
if err := proxy.NewProxy(*config, deb).Start(ctx); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
if config.QBitTorrent.Port != "" {
|
||||
qb := qbit.NewQBit(config, deb, cache)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
qb.Start()
|
||||
if err := qbit.Start(ctx, config, deb, arrs); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait indefinitely
|
||||
wg.Wait()
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,14 +40,16 @@ func (c *Cache) AddMultiple(values map[string]bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for value := range values {
|
||||
if _, exists := c.data[value]; !exists {
|
||||
if len(c.order) >= c.maxItems {
|
||||
delete(c.data, c.order[0])
|
||||
c.order = c.order[1:]
|
||||
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)
|
||||
}
|
||||
c.data[value] = struct{}{}
|
||||
c.order = append(c.order, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
113
common/config.go
113
common/config.go
@@ -2,8 +2,11 @@ package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type DebridConfig struct {
|
||||
@@ -12,13 +15,14 @@ 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"`
|
||||
Debug bool `json:"debug"`
|
||||
LogLevel string `json:"log_level"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CachedOnly *bool `json:"cached_only"`
|
||||
@@ -28,17 +32,109 @@ type QBitTorrentConfig struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Port string `json:"port"`
|
||||
Debug bool `json:"debug"`
|
||||
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 {
|
||||
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) {
|
||||
@@ -60,10 +156,17 @@ func LoadConfig(path string) (*Config, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.Proxy.CachedOnly == nil {
|
||||
config.Proxy.CachedOnly = new(bool)
|
||||
*config.Proxy.CachedOnly = true
|
||||
|
||||
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
80
common/logger.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -209,11 +208,6 @@ func processInfoHash(input string) (string, error) {
|
||||
return "", fmt.Errorf("invalid infohash: %s", input)
|
||||
}
|
||||
|
||||
func NewLogger(prefix string, output *os.File) *log.Logger {
|
||||
f := fmt.Sprintf("[%s] ", prefix)
|
||||
return log.New(output, f, log.LstdFlags)
|
||||
}
|
||||
|
||||
func GetInfohashFromURL(url string) (string, error) {
|
||||
// Download the torrent file
|
||||
var magnetLink string
|
||||
@@ -256,20 +250,39 @@ func GetInfohashFromURL(url string) (string, error) {
|
||||
}
|
||||
|
||||
func JoinURL(base string, paths ...string) (string, error) {
|
||||
// Parse the base URL
|
||||
u, err := url.Parse(base)
|
||||
// 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
|
||||
}
|
||||
|
||||
// Join the path components
|
||||
u.Path = path.Join(u.Path, path.Join(paths...))
|
||||
// Add back query parameters if they exist
|
||||
if len(parts) > 1 {
|
||||
return joined + "?" + parts[1], nil
|
||||
}
|
||||
|
||||
// Return the resulting URL as a string
|
||||
return u.String(), 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
|
||||
}
|
||||
|
||||
BIN
doc/download.png
Normal file
BIN
doc/download.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
BIN
doc/main.png
Normal file
BIN
doc/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
7
go.mod
7
go.mod
@@ -1,4 +1,4 @@
|
||||
module goBlack
|
||||
module github.com/sirrobot01/debrid-blackhole
|
||||
|
||||
go 1.22
|
||||
|
||||
@@ -22,7 +22,12 @@ require (
|
||||
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
|
||||
)
|
||||
|
||||
17
go.sum
17
go.sum
@@ -50,6 +50,7 @@ github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIH
|
||||
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=
|
||||
@@ -79,6 +80,7 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
|
||||
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=
|
||||
@@ -136,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=
|
||||
@@ -175,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=
|
||||
@@ -241,6 +251,11 @@ 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=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
@@ -273,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
11
main.go
@@ -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
123
pkg/arr/arr.go
Normal 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
103
pkg/arr/content.go
Normal 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
41
pkg/arr/history.go
Normal 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
209
pkg/arr/import.go
Normal 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
54
pkg/arr/refresh.go
Normal 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
344
pkg/arr/repair.go
Normal 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
34
pkg/arr/structs.go
Normal 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
31
pkg/arr/tmdb.go
Normal 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
5
pkg/arr/utils.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package arr
|
||||
|
||||
func Readfile(path string) error {
|
||||
return nil
|
||||
}
|
||||
263
pkg/debrid/alldebrid.go
Normal file
263
pkg/debrid/alldebrid.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,11 +3,24 @@ package debrid
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"goBlack/common"
|
||||
"log"
|
||||
"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)
|
||||
@@ -15,26 +28,37 @@ type Service interface {
|
||||
DeleteTorrent(torrent *Torrent)
|
||||
IsAvailable(infohashes []string) map[string]bool
|
||||
GetMountPath() string
|
||||
GetDownloadUncached() bool
|
||||
GetCheckCached() bool
|
||||
GetTorrent(id string) (*Torrent, error)
|
||||
GetName() string
|
||||
GetLogger() *log.Logger
|
||||
GetLogger() zerolog.Logger
|
||||
}
|
||||
|
||||
type Debrid struct {
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *common.RLHTTPClient
|
||||
cache *common.Cache
|
||||
MountPath string
|
||||
logger *log.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 NewDebrid(dc common.DebridConfig, cache *common.Cache) Service {
|
||||
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)
|
||||
}
|
||||
@@ -77,16 +101,17 @@ func getTorrentInfo(filePath string) (*Torrent, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infoLength := info.Length
|
||||
magnet := &common.Magnet{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Length,
|
||||
Size: infoLength,
|
||||
Link: mi.Magnet(&hash, &info).String(),
|
||||
}
|
||||
torrent := &Torrent{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Length,
|
||||
Size: infoLength,
|
||||
Magnet: magnet,
|
||||
Filename: filePath,
|
||||
}
|
||||
@@ -95,7 +120,7 @@ func getTorrentInfo(filePath string) (*Torrent, error) {
|
||||
|
||||
func GetLocalCache(infohashes []string, cache *common.Cache) ([]string, map[string]bool) {
|
||||
result := make(map[string]bool)
|
||||
hashes := make([]string, len(infohashes))
|
||||
hashes := make([]string, 0)
|
||||
|
||||
if len(infohashes) == 0 {
|
||||
return hashes, result
|
||||
@@ -117,32 +142,51 @@ func GetLocalCache(infohashes []string, cache *common.Cache) ([]string, map[stri
|
||||
}
|
||||
}
|
||||
|
||||
return hashes, result
|
||||
return infohashes, result
|
||||
}
|
||||
|
||||
func ProcessQBitTorrent(d Service, magnet *common.Magnet, arr *Arr, isSymlink bool) (*Torrent, error) {
|
||||
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: arr,
|
||||
Arr: a,
|
||||
Size: magnet.Size,
|
||||
}
|
||||
logger := d.GetLogger()
|
||||
logger.Printf("Torrent Hash: %s", debridTorrent.InfoHash)
|
||||
if !d.GetDownloadUncached() {
|
||||
hash, exists := d.IsAvailable([]string{debridTorrent.InfoHash})[debridTorrent.InfoHash]
|
||||
if !exists || !hash {
|
||||
return debridTorrent, fmt.Errorf("torrent: %s is not cached", debridTorrent.Name)
|
||||
} else {
|
||||
logger.Printf("Torrent: %s is cached(or downloading)", debridTorrent.Name)
|
||||
}
|
||||
}
|
||||
|
||||
debridTorrent, err := d.SubmitMagnet(debridTorrent)
|
||||
if err != nil || debridTorrent.Id == "" {
|
||||
logger.Printf("Error submitting magnet: %s", err)
|
||||
return nil, err
|
||||
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)
|
||||
}
|
||||
return d.CheckStatus(debridTorrent, 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
274
pkg/debrid/debrid_link.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -3,25 +3,20 @@ package debrid
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid/structs"
|
||||
"log"
|
||||
"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 {
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *common.RLHTTPClient
|
||||
cache *common.Cache
|
||||
MountPath string
|
||||
logger *log.Logger
|
||||
BaseDebrid
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetMountPath() string {
|
||||
@@ -29,10 +24,10 @@ func (r *RealDebrid) GetMountPath() string {
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetName() string {
|
||||
return "realdebrid"
|
||||
return r.Name
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetLogger() *log.Logger {
|
||||
func (r *RealDebrid) GetLogger() zerolog.Logger {
|
||||
return r.logger
|
||||
}
|
||||
|
||||
@@ -40,19 +35,21 @@ func GetTorrentFiles(data structs.RealDebridTorrentInfo) []TorrentFile {
|
||||
files := make([]TorrentFile, 0)
|
||||
for _, f := range data.Files {
|
||||
name := filepath.Base(f.Path)
|
||||
if (!common.RegexMatch(common.VIDEOMATCH, name) &&
|
||||
!common.RegexMatch(common.SUBMATCH, name) &&
|
||||
!common.RegexMatch(common.MUSICMATCH, name)) || common.RegexMatch(common.SAMPLEMATCH, name) {
|
||||
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{
|
||||
file := TorrentFile{
|
||||
Name: name,
|
||||
Path: name,
|
||||
Size: int64(f.Bytes),
|
||||
Size: f.Bytes,
|
||||
Id: strconv.Itoa(fileId),
|
||||
}
|
||||
files = append(files, *file)
|
||||
files = append(files, file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
@@ -89,15 +86,16 @@ func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool {
|
||||
|
||||
hashStr := strings.Join(validHashes, "/")
|
||||
url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, hashStr)
|
||||
resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
log.Println("Error checking availability:", err)
|
||||
r.logger.Info().Msgf("Error checking availability: %v", err)
|
||||
return result
|
||||
}
|
||||
var data structs.RealDebridAvailabilityResponse
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
log.Println("Error marshalling availability:", err)
|
||||
r.logger.Info().Msgf("Error marshalling availability: %v", err)
|
||||
return result
|
||||
}
|
||||
for _, h := range hashes[i:end] {
|
||||
@@ -117,12 +115,13 @@ func (r *RealDebrid) SubmitMagnet(torrent *Torrent) (*Torrent, error) {
|
||||
"magnet": {torrent.Magnet.Link},
|
||||
}
|
||||
var data structs.RealDebridAddMagnetSchema
|
||||
resp, err := r.client.MakeRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
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)
|
||||
log.Printf("Torrent: %s added with id: %s\n", torrent.Name, data.Id)
|
||||
r.logger.Info().Msgf("Torrent: %s added with id: %s", torrent.Name, data.Id)
|
||||
torrent.Id = data.Id
|
||||
|
||||
return torrent, nil
|
||||
@@ -131,7 +130,8 @@ func (r *RealDebrid) SubmitMagnet(torrent *Torrent) (*Torrent, error) {
|
||||
func (r *RealDebrid) GetTorrent(id string) (*Torrent, error) {
|
||||
torrent := &Torrent{}
|
||||
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, id)
|
||||
resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
@@ -152,6 +152,7 @@ func (r *RealDebrid) GetTorrent(id string) (*Torrent, error) {
|
||||
torrent.Filename = data.Filename
|
||||
torrent.OriginalFilename = data.OriginalFilename
|
||||
torrent.Links = data.Links
|
||||
torrent.Debrid = r
|
||||
files := GetTorrentFiles(data)
|
||||
torrent.Files = files
|
||||
return torrent, nil
|
||||
@@ -159,10 +160,11 @@ func (r *RealDebrid) GetTorrent(id string) (*Torrent, error) {
|
||||
|
||||
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(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
log.Println("ERROR Checking file: ", err)
|
||||
r.logger.Info().Msgf("ERROR Checking file: %v", err)
|
||||
return torrent, err
|
||||
}
|
||||
var data structs.RealDebridTorrentInfo
|
||||
@@ -179,8 +181,10 @@ func (r *RealDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, er
|
||||
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", torrent.Name)
|
||||
return torrent, fmt.Errorf("torrent: %s has error: %s", torrent.Name, status)
|
||||
} else if status == "waiting_files_selection" {
|
||||
files := GetTorrentFiles(data)
|
||||
torrent.Files = files
|
||||
@@ -195,25 +199,24 @@ func (r *RealDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, er
|
||||
"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)
|
||||
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
|
||||
log.Printf("Torrent: %s downloaded to RD\n", torrent.Name)
|
||||
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 status == "downloading" {
|
||||
} else if slices.Contains(downloadingStatus, status) {
|
||||
if !r.DownloadUncached {
|
||||
go r.DeleteTorrent(torrent)
|
||||
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
|
||||
}
|
||||
// Break out of the loop if the torrent is downloading.
|
||||
@@ -227,11 +230,12 @@ func (r *RealDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, er
|
||||
|
||||
func (r *RealDebrid) DeleteTorrent(torrent *Torrent) {
|
||||
url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrent.Id)
|
||||
_, err := r.client.MakeRequest(http.MethodDelete, url, nil)
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
_, err := r.client.MakeRequest(req)
|
||||
if err == nil {
|
||||
r.logger.Printf("Torrent: %s deleted\n", torrent.Name)
|
||||
r.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
|
||||
} else {
|
||||
r.logger.Printf("Error deleting torrent: %s", err)
|
||||
r.logger.Info().Msgf("Error deleting torrent: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +249,8 @@ func (r *RealDebrid) GetDownloadLinks(torrent *Torrent) error {
|
||||
payload := gourl.Values{
|
||||
"link": {link},
|
||||
}
|
||||
resp, err := r.client.MakeRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -264,8 +269,8 @@ func (r *RealDebrid) GetDownloadLinks(torrent *Torrent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadUncached() bool {
|
||||
return r.DownloadUncached
|
||||
func (r *RealDebrid) GetCheckCached() bool {
|
||||
return r.CheckCached
|
||||
}
|
||||
|
||||
func NewRealDebrid(dc common.DebridConfig, cache *common.Cache) *RealDebrid {
|
||||
@@ -274,14 +279,18 @@ func NewRealDebrid(dc common.DebridConfig, cache *common.Cache) *RealDebrid {
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
}
|
||||
client := common.NewRLHTTPClient(rl, headers)
|
||||
logger := common.NewLogger(dc.Name, os.Stdout)
|
||||
logger := common.NewLogger(dc.Name, common.CONFIG.LogLevel, os.Stdout)
|
||||
return &RealDebrid{
|
||||
Host: dc.Host,
|
||||
APIKey: dc.APIKey,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
cache: cache,
|
||||
MountPath: dc.Folder,
|
||||
logger: logger,
|
||||
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
22
pkg/debrid/service.go
Normal 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
|
||||
}
|
||||
75
pkg/debrid/structs/alldebrid.go
Normal file
75
pkg/debrid/structs/alldebrid.go
Normal 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"`
|
||||
}
|
||||
45
pkg/debrid/structs/debrid_link.go
Normal file
45
pkg/debrid/structs/debrid_link.go
Normal 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]
|
||||
@@ -75,7 +75,7 @@ type RealDebridTorrentInfo struct {
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Hash string `json:"hash"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
OriginalBytes int `json:"original_bytes"`
|
||||
OriginalBytes int64 `json:"original_bytes"`
|
||||
Host string `json:"host"`
|
||||
Split int `json:"split"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -84,12 +84,12 @@ type RealDebridTorrentInfo struct {
|
||||
Files []struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Bytes int `json:"bytes"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Selected int `json:"selected"`
|
||||
} `json:"files"`
|
||||
Links []string `json:"links"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
Speed int64 `json:"speed,omitempty"`
|
||||
Speed int `json:"speed,omitempty"`
|
||||
Seeders int `json:"seeders,omitempty"`
|
||||
}
|
||||
|
||||
@@ -97,11 +97,11 @@ type RealDebridUnrestrictResponse struct {
|
||||
Id string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
Filesize int `json:"filesize"`
|
||||
Link string `json:"link"`
|
||||
Host string `json:"host"`
|
||||
Chunks int64 `json:"chunks"`
|
||||
Crc int64 `json:"crc"`
|
||||
Chunks int `json:"chunks"`
|
||||
Crc int `json:"crc"`
|
||||
Download string `json:"download"`
|
||||
Streamable int `json:"streamable"`
|
||||
}
|
||||
|
||||
75
pkg/debrid/structs/torbox.go
Normal file
75
pkg/debrid/structs/torbox.go
Normal 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
306
pkg/debrid/torbox.go
Normal 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,16 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"goBlack/common"
|
||||
"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:"token"`
|
||||
Token string `json:"-"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
@@ -38,13 +40,13 @@ type Torrent struct {
|
||||
Status string `json:"status"`
|
||||
Added string `json:"added"`
|
||||
Progress float64 `json:"progress"`
|
||||
Speed int64 `json:"speed"`
|
||||
Speed int `json:"speed"`
|
||||
Seeders int `json:"seeders"`
|
||||
Links []string `json:"links"`
|
||||
DownloadLinks []TorrentDownloadLinks `json:"download_links"`
|
||||
|
||||
Debrid *Debrid
|
||||
Arr *Arr
|
||||
Debrid Service
|
||||
Arr *arr.Arr
|
||||
}
|
||||
|
||||
type TorrentDownloadLinks struct {
|
||||
@@ -57,17 +59,27 @@ func (t *Torrent) GetSymlinkFolder(parent string) string {
|
||||
return filepath.Join(parent, t.Arr.Name, t.Folder)
|
||||
}
|
||||
|
||||
func (t *Torrent) GetMountFolder(rClonePath string) string {
|
||||
pathWithNoExt := common.RemoveExtension(t.OriginalFilename)
|
||||
if common.FileReady(filepath.Join(rClonePath, t.OriginalFilename)) {
|
||||
return t.OriginalFilename
|
||||
} else if common.FileReady(filepath.Join(rClonePath, t.Filename)) {
|
||||
return t.Filename
|
||||
} else if common.FileReady(filepath.Join(rClonePath, pathWithNoExt)) {
|
||||
return pathWithNoExt
|
||||
} else {
|
||||
return ""
|
||||
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 {
|
||||
@@ -75,6 +87,7 @@ type TorrentFile struct {
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Path string `json:"path"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
func getEventId(eventType string) int {
|
||||
|
||||
@@ -37,10 +37,10 @@ Loop:
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
fmt.Printf(" %s: transferred %d / %d bytes (%.2f%%)\n",
|
||||
fmt.Printf(" %s: transferred %d / %d bytes (%.2f%%)",
|
||||
resp.Filename,
|
||||
resp.BytesComplete(),
|
||||
resp.Size,
|
||||
resp.Size(),
|
||||
100*resp.Progress())
|
||||
|
||||
case <-resp.Done:
|
||||
@@ -3,15 +3,17 @@ 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"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -73,23 +75,20 @@ type Proxy struct {
|
||||
password string
|
||||
cachedOnly bool
|
||||
debrid debrid.Service
|
||||
cache *common.Cache
|
||||
logger *log.Logger
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func NewProxy(config common.Config, deb debrid.Service, cache *common.Cache) *Proxy {
|
||||
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,
|
||||
debug: cfg.Debug,
|
||||
username: cfg.Username,
|
||||
password: cfg.Password,
|
||||
cachedOnly: *cfg.CachedOnly,
|
||||
debrid: deb,
|
||||
cache: cache,
|
||||
logger: common.NewLogger("Proxy", os.Stdout),
|
||||
debrid: deb.Get(),
|
||||
logger: common.NewLogger("Proxy", cfg.LogLevel, os.Stdout),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,7 +228,7 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
p.logger.Println("Error reading response body:", err)
|
||||
p.logger.Info().Msgf("Error reading response body: %v", err)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
@@ -241,7 +240,7 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
var rss RSS
|
||||
err = xml.Unmarshal(body, &rss)
|
||||
if err != nil {
|
||||
p.logger.Printf("Error unmarshalling XML: %v", err)
|
||||
p.logger.Info().Msgf("Error unmarshalling XML: %v", err)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
@@ -279,10 +278,10 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
}
|
||||
|
||||
if len(newItems) > 0 {
|
||||
p.logger.Printf("[%s Report]: %d/%d items are cached || Found %d infohash", indexer, len(newItems), len(rss.Channel.Items), len(hashes))
|
||||
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.Printf("[%s Report]: No Items are cached; Return only first item with [UnCached]", indexer)
|
||||
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)
|
||||
@@ -291,7 +290,7 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
rss.Channel.Items = newItems
|
||||
modifiedBody, err := xml.MarshalIndent(rss, "", " ")
|
||||
if err != nil {
|
||||
p.logger.Printf("Error marshalling XML: %v", err)
|
||||
p.logger.Info().Msgf("Error marshalling XML: %v", err)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
@@ -308,7 +307,7 @@ func UrlMatches(re *regexp.Regexp) goproxy.ReqConditionFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) Start() {
|
||||
func (p *Proxy) Start(ctx context.Context) error {
|
||||
username, password := p.username, p.password
|
||||
proxy := goproxy.NewProxyHttpServer()
|
||||
if username != "" || password != "" {
|
||||
@@ -328,6 +327,17 @@ func (p *Proxy) Start() {
|
||||
|
||||
proxy.Verbose = p.debug
|
||||
portFmt := fmt.Sprintf(":%s", p.port)
|
||||
p.logger.Printf("[*] Starting proxy server on %s\n", portFmt)
|
||||
p.logger.Fatal(http.ListenAndServe(fmt.Sprintf("%s", portFmt), proxy))
|
||||
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())
|
||||
}
|
||||
|
||||
103
pkg/qbit/arr.go
103
pkg/qbit/arr.go
@@ -1,103 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (q *QBit) RefreshArr(arr *debrid.Arr) {
|
||||
if arr.Token == "" || arr.Host == "" {
|
||||
return
|
||||
}
|
||||
url, err := common.JoinURL(arr.Host, "api/v3/command")
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]string{"name": "RefreshMonitoredDownloads"}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Api-Key", arr.Token)
|
||||
|
||||
resp, reqErr := client.Do(req)
|
||||
if reqErr == nil {
|
||||
statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'
|
||||
if statusOk {
|
||||
if q.debug {
|
||||
q.logger.Printf("Refreshed monitored downloads for %s", cmp.Or(arr.Name, arr.Host))
|
||||
}
|
||||
}
|
||||
}
|
||||
if reqErr != nil {
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) GetArrHistory(arr *debrid.Arr, downloadId, eventType string) *debrid.ArrHistorySchema {
|
||||
query := gourl.Values{}
|
||||
if downloadId != "" {
|
||||
query.Add("downloadId", downloadId)
|
||||
}
|
||||
query.Add("eventType", eventType)
|
||||
query.Add("pageSize", "100")
|
||||
url, _ := common.JoinURL(arr.Host, "history")
|
||||
url += "?" + query.Encode()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var data *debrid.ArrHistorySchema
|
||||
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
|
||||
}
|
||||
|
||||
func (q *QBit) MarkArrAsFailed(torrent *Torrent, arr *debrid.Arr) error {
|
||||
downloadId := strings.ToUpper(torrent.Hash)
|
||||
history := q.GetArrHistory(arr, downloadId, "grabbed")
|
||||
if history == nil {
|
||||
return nil
|
||||
}
|
||||
torrentId := 0
|
||||
for _, record := range history.Records {
|
||||
if strings.EqualFold(record.DownloadID, downloadId) {
|
||||
torrentId = record.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if torrentId != 0 {
|
||||
url, err := common.JoinURL(arr.Host, "history/failed/", strconv.Itoa(torrentId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{}
|
||||
_, err = client.Do(req)
|
||||
if err == nil {
|
||||
q.logger.Printf("Marked torrent: %s as failed", torrent.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"goBlack/pkg/qbit/downloaders"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (q *QBit) processManualFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr *debrid.Arr) {
|
||||
q.logger.Printf("Downloading %d files...", len(debridTorrent.DownloadLinks))
|
||||
torrentPath := common.RemoveExtension(debridTorrent.OriginalFilename)
|
||||
parent := common.RemoveInvalidChars(filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentPath))
|
||||
err := os.MkdirAll(parent, os.ModePerm)
|
||||
if err != nil {
|
||||
q.logger.Printf("Failed to create directory: %s\n", parent)
|
||||
q.MarkAsFailed(torrent)
|
||||
return
|
||||
}
|
||||
torrent.TorrentPath = torrentPath
|
||||
q.downloadFiles(debridTorrent, parent)
|
||||
q.UpdateTorrent(torrent, debridTorrent)
|
||||
q.RefreshArr(arr)
|
||||
}
|
||||
|
||||
func (q *QBit) downloadFiles(debridTorrent *debrid.Torrent, parent string) {
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 5)
|
||||
client := downloaders.GetHTTPClient()
|
||||
for _, link := range debridTorrent.DownloadLinks {
|
||||
if link.DownloadLink == "" {
|
||||
q.logger.Printf("No download link found for %s\n", link.Filename)
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
semaphore <- struct{}{}
|
||||
go func(link debrid.TorrentDownloadLinks) {
|
||||
defer wg.Done()
|
||||
defer func() { <-semaphore }()
|
||||
err := downloaders.NormalHTTP(client, link.DownloadLink, filepath.Join(parent, link.Filename))
|
||||
if err != nil {
|
||||
q.logger.Printf("Error downloading %s: %v\n", link.DownloadLink, err)
|
||||
} else {
|
||||
q.logger.Printf("Downloaded %s successfully\n", link.DownloadLink)
|
||||
}
|
||||
}(link)
|
||||
}
|
||||
wg.Wait()
|
||||
q.logger.Printf("Downloaded all files for %s\n", debridTorrent.Name)
|
||||
}
|
||||
|
||||
func (q *QBit) processSymlink(torrent *Torrent, debridTorrent *debrid.Torrent, arr *debrid.Arr) {
|
||||
var wg sync.WaitGroup
|
||||
files := debridTorrent.Files
|
||||
ready := make(chan debrid.TorrentFile, len(files))
|
||||
|
||||
q.logger.Printf("Checking %d files...", len(files))
|
||||
rCloneBase := q.debrid.GetMountPath()
|
||||
torrentPath, err := q.getTorrentPath(rCloneBase, debridTorrent) // /MyTVShow/
|
||||
if err != nil {
|
||||
q.MarkAsFailed(torrent)
|
||||
q.logger.Printf("Error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentPath) // /mnt/symlinks/{category}/MyTVShow/
|
||||
err = os.MkdirAll(torrentSymlinkPath, os.ModePerm)
|
||||
if err != nil {
|
||||
q.logger.Printf("Failed to create directory: %s\n", torrentSymlinkPath)
|
||||
q.MarkAsFailed(torrent)
|
||||
return
|
||||
}
|
||||
torrentRclonePath := filepath.Join(rCloneBase, torrentPath)
|
||||
for _, file := range files {
|
||||
wg.Add(1)
|
||||
go checkFileLoop(&wg, torrentRclonePath, file, ready)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(ready)
|
||||
}()
|
||||
|
||||
for f := range ready {
|
||||
q.logger.Println("File is ready:", f.Path)
|
||||
q.createSymLink(torrentSymlinkPath, torrentRclonePath, f)
|
||||
}
|
||||
// Update the torrent when all files are ready
|
||||
torrent.TorrentPath = filepath.Base(torrentPath) // Quite important
|
||||
q.UpdateTorrent(torrent, debridTorrent)
|
||||
q.RefreshArr(arr)
|
||||
}
|
||||
|
||||
func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debrid.Torrent) (string, error) {
|
||||
pathChan := make(chan string)
|
||||
errChan := make(chan error)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
torrentPath := debridTorrent.GetMountFolder(rclonePath)
|
||||
if torrentPath != "" {
|
||||
pathChan <- torrentPath
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case path := <-pathChan:
|
||||
return path, nil
|
||||
case err := <-errChan:
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) createSymLink(path string, torrentMountPath string, file debrid.TorrentFile) {
|
||||
|
||||
// Combine the directory and filename to form a full path
|
||||
fullPath := filepath.Join(path, file.Name) // /mnt/symlinks/{category}/MyTVShow/MyTVShow.S01E01.720p.mkv
|
||||
// Create a symbolic link if file doesn't exist
|
||||
torrentFilePath := filepath.Join(torrentMountPath, file.Name) // debridFolder/MyTVShow/MyTVShow.S01E01.720p.mkv
|
||||
err := os.Symlink(torrentFilePath, fullPath)
|
||||
if err != nil {
|
||||
q.logger.Printf("Failed to create symlink: %s: %v\n", fullPath, err)
|
||||
}
|
||||
// Check if the file exists
|
||||
if !common.FileReady(fullPath) {
|
||||
q.logger.Printf("Symlink not ready: %s\n", fullPath)
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (q *QBit) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("v4.3.2"))
|
||||
}
|
||||
|
||||
func (q *QBit) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("2.7"))
|
||||
}
|
||||
|
||||
func (q *QBit) handlePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
preferences := NewAppPreferences()
|
||||
|
||||
preferences.WebUiUsername = q.Username
|
||||
preferences.SavePath = q.DownloadFolder
|
||||
preferences.TempPath = filepath.Join(q.DownloadFolder, "temp")
|
||||
|
||||
JSONResponse(w, preferences, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleBuildInfo(w http.ResponseWriter, r *http.Request) {
|
||||
res := BuildInfo{
|
||||
Bitness: 64,
|
||||
Boost: "1.75.0",
|
||||
Libtorrent: "1.2.11.0",
|
||||
Openssl: "1.1.1i",
|
||||
Qt: "5.15.2",
|
||||
Zlib: "1.2.11",
|
||||
}
|
||||
JSONResponse(w, res, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) shutdown(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (q *QBit) handleTorrentsInfo(w http.ResponseWriter, r *http.Request) {
|
||||
//log all url params
|
||||
ctx := r.Context()
|
||||
category := strings.Trim(r.URL.Query().Get("category"), "")
|
||||
filter := strings.Trim(r.URL.Query().Get("filter"), "")
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
torrents := q.storage.GetAll(category, filter, hashes)
|
||||
JSONResponse(w, torrents, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0]
|
||||
switch contentType {
|
||||
case "multipart/form-data":
|
||||
err := r.ParseMultipartForm(32 << 20) // 32MB max memory
|
||||
if err != nil {
|
||||
q.logger.Printf("Error parsing form: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
case "application/x-www-form-urlencoded":
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
q.logger.Printf("Error parsing form: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
|
||||
q.logger.Printf("isSymlink: %v\n", isSymlink)
|
||||
urls := r.FormValue("urls")
|
||||
category := r.FormValue("category")
|
||||
|
||||
var urlList []string
|
||||
if urls != "" {
|
||||
urlList = strings.Split(urls, "\n")
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, "isSymlink", isSymlink)
|
||||
|
||||
for _, url := range urlList {
|
||||
if err := q.AddMagnet(ctx, url, category); err != nil {
|
||||
q.logger.Printf("Error adding magnet: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if contentType == "multipart/form-data" {
|
||||
files := r.MultipartForm.File["torrents"]
|
||||
for _, fileHeader := range files {
|
||||
if err := q.AddTorrent(ctx, fileHeader, category); err != nil {
|
||||
q.logger.Printf("Error adding torrent: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
if len(hashes) == 0 {
|
||||
http.Error(w, "No hashes provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, hash := range hashes {
|
||||
q.storage.Delete(hash)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsPause(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.storage.Get(hash)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.PauseTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsResume(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.storage.Get(hash)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.ResumeTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.storage.Get(hash)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.RefreshTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||
var categories = map[string]TorrentCategory{}
|
||||
for _, cat := range q.Categories {
|
||||
path := filepath.Join(q.DownloadFolder, cat)
|
||||
categories[cat] = TorrentCategory{
|
||||
Name: cat,
|
||||
SavePath: path,
|
||||
}
|
||||
}
|
||||
JSONResponse(w, categories, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
name := r.Form.Get("category")
|
||||
if name == "" {
|
||||
http.Error(w, "No name provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
q.Categories = append(q.Categories, name)
|
||||
|
||||
JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
torrent := q.storage.Get(hash)
|
||||
properties := q.GetTorrentProperties(torrent)
|
||||
JSONResponse(w, properties, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentFiles(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
torrent := q.storage.Get(hash)
|
||||
if torrent == nil {
|
||||
return
|
||||
}
|
||||
files := q.GetTorrentFiles(torrent)
|
||||
JSONResponse(w, files, http.StatusOK)
|
||||
}
|
||||
@@ -1,80 +1,18 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
"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"
|
||||
)
|
||||
|
||||
type WorkerType struct {
|
||||
ticker *time.Ticker
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
types map[string]WorkerType
|
||||
}
|
||||
|
||||
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.Service
|
||||
cache *common.Cache
|
||||
storage *TorrentStorage
|
||||
debug bool
|
||||
logger *log.Logger
|
||||
arrs sync.Map // host:token (Used for refreshing in worker)
|
||||
RefreshInterval int
|
||||
}
|
||||
|
||||
func NewQBit(config *common.Config, deb debrid.Service, cache *common.Cache) *QBit {
|
||||
cfg := config.QBitTorrent
|
||||
storage := NewTorrentStorage("torrents.json")
|
||||
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,
|
||||
cache: cache,
|
||||
debug: cfg.Debug,
|
||||
storage: storage,
|
||||
logger: common.NewLogger("QBit", os.Stdout),
|
||||
arrs: sync.Map{},
|
||||
RefreshInterval: refreshInterval,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) Start() {
|
||||
|
||||
r := chi.NewRouter()
|
||||
if q.debug {
|
||||
r.Use(middleware.Logger)
|
||||
}
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
q.AddRoutes(r)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
go q.StartWorker(ctx)
|
||||
|
||||
q.logger.Printf("Starting QBit server on :%s", q.Port)
|
||||
port := fmt.Sprintf(":%s", q.Port)
|
||||
q.logger.Fatal(http.ListenAndServe(port, r))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (q *QBit) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(user), []byte(q.Username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(q.Password)) != 1 {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func DecodeAuthHeader(header string) (string, string, error) {
|
||||
encodedTokens := strings.Split(header, " ")
|
||||
if len(encodedTokens) != 2 {
|
||||
return "", "", nil
|
||||
}
|
||||
encodedToken := encodedTokens[1]
|
||||
|
||||
bytes, err := base64.StdEncoding.DecodeString(encodedToken)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
bearer := string(bytes)
|
||||
|
||||
colonIndex := strings.LastIndex(bearer, ":")
|
||||
host := bearer[:colonIndex]
|
||||
token := bearer[colonIndex+1:]
|
||||
|
||||
return host, token, nil
|
||||
}
|
||||
|
||||
func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, token, err := DecodeAuthHeader(r.Header.Get("Authorization"))
|
||||
ctx := r.Context()
|
||||
if err == nil {
|
||||
ctx = context.WithValue(r.Context(), "host", host)
|
||||
ctx = context.WithValue(ctx, "token", token)
|
||||
q.arrs.Store(host, token)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func HashesCtx(next http.Handler) http.Handler {
|
||||
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"]
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "hashes", hashes)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
107
pkg/qbit/qbit.go
107
pkg/qbit/qbit.go
@@ -1,107 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (q *QBit) AddMagnet(ctx context.Context, url, category string) error {
|
||||
magnet, err := common.GetMagnetFromUrl(url)
|
||||
if err != nil {
|
||||
q.logger.Printf("Error parsing magnet link: %v\n", err)
|
||||
return err
|
||||
}
|
||||
err = q.Process(ctx, magnet, category)
|
||||
if err != nil {
|
||||
q.logger.Println("Failed to process magnet:", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) AddTorrent(ctx context.Context, fileHeader *multipart.FileHeader, category string) error {
|
||||
file, _ := fileHeader.Open()
|
||||
defer file.Close()
|
||||
var reader io.Reader = file
|
||||
magnet, err := common.GetMagnetFromFile(reader, fileHeader.Filename)
|
||||
if err != nil {
|
||||
q.logger.Printf("Error reading file: %s", fileHeader.Filename)
|
||||
return err
|
||||
}
|
||||
err = q.Process(ctx, magnet, category)
|
||||
if err != nil {
|
||||
q.logger.Println("Failed to process torrent:", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) Process(ctx context.Context, magnet *common.Magnet, category string) error {
|
||||
torrent := q.CreateTorrentFromMagnet(magnet, category)
|
||||
arr := &debrid.Arr{
|
||||
Name: category,
|
||||
Token: ctx.Value("token").(string),
|
||||
Host: ctx.Value("host").(string),
|
||||
}
|
||||
isSymlink := ctx.Value("isSymlink").(bool)
|
||||
debridTorrent, err := debrid.ProcessQBitTorrent(q.debrid, magnet, arr, isSymlink)
|
||||
if err != nil || debridTorrent == nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("failed to process torrent")
|
||||
}
|
||||
return err
|
||||
}
|
||||
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||
q.storage.AddOrUpdate(torrent)
|
||||
go q.processFiles(torrent, debridTorrent, arr, isSymlink) // We can send async for file processing not to delay the response
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) CreateTorrentFromMagnet(magnet *common.Magnet, category string) *Torrent {
|
||||
torrent := &Torrent{
|
||||
ID: uuid.NewString(),
|
||||
Hash: strings.ToLower(magnet.InfoHash),
|
||||
Name: magnet.Name,
|
||||
Size: magnet.Size,
|
||||
Category: category,
|
||||
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 *debrid.Arr, isSymlink bool) {
|
||||
for debridTorrent.Status != "downloaded" {
|
||||
progress := debridTorrent.Progress
|
||||
q.logger.Printf("RD Download Progress: %.2f%%", progress)
|
||||
time.Sleep(5 * time.Second)
|
||||
dbT, err := q.debrid.CheckStatus(debridTorrent, isSymlink)
|
||||
if err != nil {
|
||||
q.logger.Printf("Error checking status: %v", err)
|
||||
q.MarkAsFailed(torrent)
|
||||
q.RefreshArr(arr)
|
||||
return
|
||||
}
|
||||
debridTorrent = dbT
|
||||
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||
}
|
||||
if isSymlink {
|
||||
q.processSymlink(torrent, debridTorrent, arr)
|
||||
} else {
|
||||
q.processManualFiles(torrent, debridTorrent, arr)
|
||||
}
|
||||
}
|
||||
84
pkg/qbit/server/import.go
Normal file
84
pkg/qbit/server/import.go
Normal 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
|
||||
}
|
||||
382
pkg/qbit/server/qbit_handlers.go
Normal file
382
pkg/qbit/server/qbit_handlers.go
Normal 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)
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
package qbit
|
||||
package server
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (q *QBit) AddRoutes(r chi.Router) http.Handler {
|
||||
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.authMiddleware)
|
||||
r.Use(q.authContext)
|
||||
r.Route("/torrents", func(r chi.Router) {
|
||||
r.Use(HashesCtx)
|
||||
@@ -19,7 +22,11 @@ func (q *QBit) AddRoutes(r chi.Router) http.Handler {
|
||||
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)
|
||||
97
pkg/qbit/server/server.go
Normal file
97
pkg/qbit/server/server.go
Normal 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
|
||||
}
|
||||
}
|
||||
310
pkg/qbit/server/templates/config.html
Normal file
310
pkg/qbit/server/templates/config.html
Normal 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 }}
|
||||
74
pkg/qbit/server/templates/download.html
Normal file
74
pkg/qbit/server/templates/download.html
Normal 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 }}
|
||||
149
pkg/qbit/server/templates/index.html
Normal file
149
pkg/qbit/server/templates/index.html
Normal 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 }}
|
||||
142
pkg/qbit/server/templates/layout.html
Normal file
142
pkg/qbit/server/templates/layout.html
Normal 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 }}
|
||||
94
pkg/qbit/server/templates/repair.html
Normal file
94
pkg/qbit/server/templates/repair.html
Normal 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 }}
|
||||
248
pkg/qbit/server/ui_handlers.go
Normal file
248
pkg/qbit/server/ui_handlers.go
Normal 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)
|
||||
}
|
||||
27
pkg/qbit/server/ui_routes.go
Normal file
27
pkg/qbit/server/ui_routes.go
Normal 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
|
||||
}
|
||||
118
pkg/qbit/shared/downloader.go
Normal file
118
pkg/qbit/shared/downloader.go
Normal 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
43
pkg/qbit/shared/qbit.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package qbit
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -10,6 +10,7 @@ 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) {
|
||||
@@ -25,7 +26,7 @@ func loadTorrentsFromJSON(filename string) (map[string]*Torrent, error) {
|
||||
}
|
||||
|
||||
func NewTorrentStorage(filename string) *TorrentStorage {
|
||||
// Open the json file and read the data
|
||||
// Open the JSON file and read the data
|
||||
torrents, err := loadTorrentsFromJSON(filename)
|
||||
if err != nil {
|
||||
torrents = make(map[string]*Torrent)
|
||||
@@ -38,6 +39,7 @@ func NewTorrentStorage(filename string) *TorrentStorage {
|
||||
return &TorrentStorage{
|
||||
torrents: torrents,
|
||||
order: order,
|
||||
filename: filename,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +48,7 @@ func (ts *TorrentStorage) Add(torrent *Torrent) {
|
||||
defer ts.mu.Unlock()
|
||||
ts.torrents[torrent.Hash] = torrent
|
||||
ts.order = append(ts.order, torrent.Hash)
|
||||
_ = ts.saveToFile()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) AddOrUpdate(torrent *Torrent) {
|
||||
@@ -55,6 +58,7 @@ func (ts *TorrentStorage) AddOrUpdate(torrent *Torrent) {
|
||||
ts.order = append(ts.order, torrent.Hash)
|
||||
}
|
||||
ts.torrents[torrent.Hash] = torrent
|
||||
_ = ts.saveToFile()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) GetByID(id string) *Torrent {
|
||||
@@ -104,6 +108,7 @@ 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) {
|
||||
@@ -122,16 +127,25 @@ func (ts *TorrentStorage) Delete(hash string) {
|
||||
}
|
||||
// Delete the torrent folder
|
||||
if torrent.ContentPath != "" {
|
||||
os.RemoveAll(torrent.ContentPath)
|
||||
err := os.RemoveAll(torrent.ContentPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
_ = ts.saveToFile()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Save(filename string) error {
|
||||
func (ts *TorrentStorage) Save() error {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
data, err := json.Marshal(ts.torrents)
|
||||
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(filename, data, 0644)
|
||||
return os.WriteFile(ts.filename, data, 0644)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
package qbit
|
||||
package shared
|
||||
|
||||
import "goBlack/pkg/debrid"
|
||||
import "github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
|
||||
type BuildInfo struct {
|
||||
Libtorrent string `json:"libtorrent"`
|
||||
@@ -14,40 +14,40 @@ type BuildInfo struct {
|
||||
type AppPreferences struct {
|
||||
AddTrackers string `json:"add_trackers"`
|
||||
AddTrackersEnabled bool `json:"add_trackers_enabled"`
|
||||
AltDlLimit int64 `json:"alt_dl_limit"`
|
||||
AltUpLimit int64 `json:"alt_up_limit"`
|
||||
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 int64 `json:"async_io_threads"`
|
||||
AutoDeleteMode int64 `json:"auto_delete_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 int64 `json:"bittorrent_protocol"`
|
||||
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 int64 `json:"checking_memory_use"`
|
||||
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 int64 `json:"disk_cache"`
|
||||
DiskCacheTtl int64 `json:"disk_cache_ttl"`
|
||||
DlLimit int64 `json:"dl_limit"`
|
||||
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 int64 `json:"dyndns_service"`
|
||||
DyndnsService int `json:"dyndns_service"`
|
||||
DyndnsUsername string `json:"dyndns_username"`
|
||||
EmbeddedTrackerPort int64 `json:"embedded_tracker_port"`
|
||||
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"`
|
||||
@@ -55,10 +55,10 @@ type AppPreferences struct {
|
||||
EnablePieceExtentAffinity bool `json:"enable_piece_extent_affinity"`
|
||||
EnableSuperSeeding bool `json:"enable_super_seeding"`
|
||||
EnableUploadSuggestions bool `json:"enable_upload_suggestions"`
|
||||
Encryption int64 `json:"encryption"`
|
||||
Encryption int `json:"encryption"`
|
||||
ExportDir string `json:"export_dir"`
|
||||
ExportDirFin string `json:"export_dir_fin"`
|
||||
FilePoolSize int64 `json:"file_pool_size"`
|
||||
FilePoolSize int `json:"file_pool_size"`
|
||||
IncompleteFilesExt bool `json:"incomplete_files_ext"`
|
||||
IpFilterEnabled bool `json:"ip_filter_enabled"`
|
||||
IpFilterPath string `json:"ip_filter_path"`
|
||||
@@ -66,7 +66,7 @@ type AppPreferences struct {
|
||||
LimitLanPeers bool `json:"limit_lan_peers"`
|
||||
LimitTcpOverhead bool `json:"limit_tcp_overhead"`
|
||||
LimitUtpRate bool `json:"limit_utp_rate"`
|
||||
ListenPort int64 `json:"listen_port"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
Locale string `json:"locale"`
|
||||
Lsd bool `json:"lsd"`
|
||||
MailNotificationAuthEnabled bool `json:"mail_notification_auth_enabled"`
|
||||
@@ -77,79 +77,79 @@ type AppPreferences struct {
|
||||
MailNotificationSmtp string `json:"mail_notification_smtp"`
|
||||
MailNotificationSslEnabled bool `json:"mail_notification_ssl_enabled"`
|
||||
MailNotificationUsername string `json:"mail_notification_username"`
|
||||
MaxActiveDownloads int64 `json:"max_active_downloads"`
|
||||
MaxActiveTorrents int64 `json:"max_active_torrents"`
|
||||
MaxActiveUploads int64 `json:"max_active_uploads"`
|
||||
MaxConnec int64 `json:"max_connec"`
|
||||
MaxConnecPerTorrent int64 `json:"max_connec_per_torrent"`
|
||||
MaxRatio int64 `json:"max_ratio"`
|
||||
MaxRatioAct int64 `json:"max_ratio_act"`
|
||||
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 int64 `json:"max_seeding_time"`
|
||||
MaxSeedingTime int `json:"max_seeding_time"`
|
||||
MaxSeedingTimeEnabled bool `json:"max_seeding_time_enabled"`
|
||||
MaxUploads int64 `json:"max_uploads"`
|
||||
MaxUploadsPerTorrent int64 `json:"max_uploads_per_torrent"`
|
||||
OutgoingPortsMax int64 `json:"outgoing_ports_max"`
|
||||
OutgoingPortsMin int64 `json:"outgoing_ports_min"`
|
||||
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 int64 `json:"proxy_port"`
|
||||
ProxyPort int `json:"proxy_port"`
|
||||
ProxyTorrentsOnly bool `json:"proxy_torrents_only"`
|
||||
ProxyType int64 `json:"proxy_type"`
|
||||
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 int64 `json:"rss_max_articles_per_feed"`
|
||||
RssMaxArticlesPerFeed int `json:"rss_max_articles_per_feed"`
|
||||
RssProcessingEnabled bool `json:"rss_processing_enabled"`
|
||||
RssRefreshInterval int64 `json:"rss_refresh_interval"`
|
||||
RssRefreshInterval int `json:"rss_refresh_interval"`
|
||||
SavePath string `json:"save_path"`
|
||||
SavePathChangedTmmEnabled bool `json:"save_path_changed_tmm_enabled"`
|
||||
SaveResumeDataInterval int64 `json:"save_resume_data_interval"`
|
||||
SaveResumeDataInterval int `json:"save_resume_data_interval"`
|
||||
ScanDirs ScanDirs `json:"scan_dirs"`
|
||||
ScheduleFromHour int64 `json:"schedule_from_hour"`
|
||||
ScheduleFromMin int64 `json:"schedule_from_min"`
|
||||
ScheduleToHour int64 `json:"schedule_to_hour"`
|
||||
ScheduleToMin int64 `json:"schedule_to_min"`
|
||||
SchedulerDays int64 `json:"scheduler_days"`
|
||||
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 int64 `json:"send_buffer_low_watermark"`
|
||||
SendBufferWatermark int64 `json:"send_buffer_watermark"`
|
||||
SendBufferWatermarkFactor int64 `json:"send_buffer_watermark_factor"`
|
||||
SlowTorrentDlRateThreshold int64 `json:"slow_torrent_dl_rate_threshold"`
|
||||
SlowTorrentInactiveTimer int64 `json:"slow_torrent_inactive_timer"`
|
||||
SlowTorrentUlRateThreshold int64 `json:"slow_torrent_ul_rate_threshold"`
|
||||
SocketBacklogSize int64 `json:"socket_backlog_size"`
|
||||
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 int64 `json:"stop_tracker_timeout"`
|
||||
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 int64 `json:"up_limit"`
|
||||
UploadChokingAlgorithm int64 `json:"upload_choking_algorithm"`
|
||||
UploadSlotsBehavior int64 `json:"upload_slots_behavior"`
|
||||
UpLimit int `json:"up_limit"`
|
||||
UploadChokingAlgorithm int `json:"upload_choking_algorithm"`
|
||||
UploadSlotsBehavior int `json:"upload_slots_behavior"`
|
||||
Upnp bool `json:"upnp"`
|
||||
UpnpLeaseDuration int64 `json:"upnp_lease_duration"`
|
||||
UpnpLeaseDuration int `json:"upnp_lease_duration"`
|
||||
UseHttps bool `json:"use_https"`
|
||||
UtpTcpMixedMode int64 `json:"utp_tcp_mixed_mode"`
|
||||
UtpTcpMixedMode int `json:"utp_tcp_mixed_mode"`
|
||||
WebUiAddress string `json:"web_ui_address"`
|
||||
WebUiBanDuration int64 `json:"web_ui_ban_duration"`
|
||||
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 int64 `json:"web_ui_max_auth_fail_count"`
|
||||
WebUiPort int64 `json:"web_ui_port"`
|
||||
WebUiMaxAuthFailCount int `json:"web_ui_max_auth_fail_count"`
|
||||
WebUiPort int `json:"web_ui_port"`
|
||||
WebUiSecureCookieEnabled bool `json:"web_ui_secure_cookie_enabled"`
|
||||
WebUiSessionTimeout int64 `json:"web_ui_session_timeout"`
|
||||
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"`
|
||||
@@ -171,6 +171,7 @@ type TorrentCategory struct {
|
||||
type Torrent struct {
|
||||
ID string `json:"-"`
|
||||
DebridTorrent *debrid.Torrent `json:"-"`
|
||||
Debrid string `json:"debrid"`
|
||||
TorrentPath string `json:"-"`
|
||||
|
||||
AddedOn int64 `json:"added_on,omitempty"`
|
||||
@@ -179,44 +180,45 @@ type Torrent struct {
|
||||
Availability float64 `json:"availability,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Completed int64 `json:"completed"`
|
||||
CompletionOn int64 `json:"completion_on,omitempty"`
|
||||
CompletionOn int `json:"completion_on,omitempty"`
|
||||
ContentPath string `json:"content_path"`
|
||||
DlLimit int64 `json:"dl_limit"`
|
||||
Dlspeed int64 `json:"dlspeed"`
|
||||
DlLimit int `json:"dl_limit"`
|
||||
Dlspeed int `json:"dlspeed"`
|
||||
Downloaded int64 `json:"downloaded"`
|
||||
DownloadedSession int64 `json:"downloaded_session"`
|
||||
Eta int64 `json:"eta"`
|
||||
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 int64 `json:"max_ratio,omitempty"`
|
||||
MaxSeedingTime int64 `json:"max_seeding_time,omitempty"`
|
||||
MaxRatio int `json:"max_ratio,omitempty"`
|
||||
MaxSeedingTime int `json:"max_seeding_time,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
NumComplete int64 `json:"num_complete,omitempty"`
|
||||
NumIncomplete int64 `json:"num_incomplete,omitempty"`
|
||||
NumLeechs int64 `json:"num_leechs,omitempty"`
|
||||
NumSeeds int64 `json:"num_seeds,omitempty"`
|
||||
Priority int64 `json:"priority,omitempty"`
|
||||
Progress float32 `json:"progress"`
|
||||
Ratio int64 `json:"ratio,omitempty"`
|
||||
RatioLimit int64 `json:"ratio_limit,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 int64 `json:"seeding_time_limit,omitempty"`
|
||||
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 int64 `json:"time_active,omitempty"`
|
||||
TimeActive int `json:"time_active,omitempty"`
|
||||
TotalSize int64 `json:"total_size,omitempty"`
|
||||
Tracker string `json:"tracker,omitempty"`
|
||||
UpLimit int64 `json:"up_limit,omitempty"`
|
||||
Uploaded int64 `json:"uploaded,omitempty"`
|
||||
UploadedSession int64 `json:"uploaded_session,omitempty"`
|
||||
Upspeed int64 `json:"upspeed,omitempty"`
|
||||
Upspeed int `json:"upspeed,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
}
|
||||
|
||||
func (t *Torrent) IsReady() bool {
|
||||
@@ -229,24 +231,24 @@ type TorrentProperties struct {
|
||||
CompletionDate int64 `json:"completion_date,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
CreationDate int64 `json:"creation_date,omitempty"`
|
||||
DlLimit int64 `json:"dl_limit,omitempty"`
|
||||
DlSpeed int64 `json:"dl_speed,omitempty"`
|
||||
DlSpeedAvg int64 `json:"dl_speed_avg,omitempty"`
|
||||
Eta int64 `json:"eta,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 int64 `json:"nb_connections,omitempty"`
|
||||
NbConnectionsLimit int64 `json:"nb_connections_limit,omitempty"`
|
||||
Peers int64 `json:"peers,omitempty"`
|
||||
PeersTotal int64 `json:"peers_total,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 int64 `json:"reannounce,omitempty"`
|
||||
Reannounce int `json:"reannounce,omitempty"`
|
||||
SavePath string `json:"save_path,omitempty"`
|
||||
SeedingTime int64 `json:"seeding_time,omitempty"`
|
||||
Seeds int64 `json:"seeds,omitempty"`
|
||||
SeedsTotal int64 `json:"seeds_total,omitempty"`
|
||||
ShareRatio int64 `json:"share_ratio,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"`
|
||||
@@ -254,19 +256,19 @@ type TorrentProperties struct {
|
||||
TotalUploaded int64 `json:"total_uploaded,omitempty"`
|
||||
TotalUploadedSession int64 `json:"total_uploaded_session,omitempty"`
|
||||
TotalWasted int64 `json:"total_wasted,omitempty"`
|
||||
UpLimit int64 `json:"up_limit,omitempty"`
|
||||
UpSpeed int64 `json:"up_speed,omitempty"`
|
||||
UpSpeedAvg int64 `json:"up_speed_avg,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 int64 `json:"progress,omitempty"`
|
||||
Priority int64 `json:"priority,omitempty"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
IsSeed bool `json:"is_seed,omitempty"`
|
||||
PieceRange []int64 `json:"piece_range,omitempty"`
|
||||
PieceRange []int `json:"piece_range,omitempty"`
|
||||
Availability float64 `json:"availability,omitempty"`
|
||||
}
|
||||
|
||||
321
pkg/qbit/shared/torrent.go
Normal file
321
pkg/qbit/shared/torrent.go
Normal 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
25
pkg/qbit/shared/utils.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,12 @@
|
||||
package qbit
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"goBlack/pkg/debrid"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (q *QBit) StartWorker(ctx context.Context) {
|
||||
q.logger.Println("Qbit Worker started")
|
||||
q.logger.Info().Msg("Qbit Worker started")
|
||||
q.StartRefreshWorker(ctx)
|
||||
}
|
||||
|
||||
@@ -17,10 +16,10 @@ func (q *QBit) StartRefreshWorker(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case <-refreshCtx.Done():
|
||||
q.logger.Println("Qbit Refresh Worker stopped")
|
||||
q.logger.Info().Msg("Qbit Refresh Worker stopped")
|
||||
return
|
||||
case <-refreshTicker.C:
|
||||
torrents := q.storage.GetAll("", "", nil)
|
||||
torrents := q.Storage.GetAll("", "", nil)
|
||||
if len(torrents) > 0 {
|
||||
q.RefreshArrs()
|
||||
}
|
||||
@@ -29,18 +28,10 @@ func (q *QBit) StartRefreshWorker(ctx context.Context) {
|
||||
}
|
||||
|
||||
func (q *QBit) RefreshArrs() {
|
||||
q.arrs.Range(func(key, value interface{}) bool {
|
||||
host, ok := key.(string)
|
||||
token, ok2 := value.(string)
|
||||
if !ok || !ok2 {
|
||||
return true
|
||||
for _, arr := range q.Arrs.GetAll() {
|
||||
err := arr.Refresh()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
arr := &debrid.Arr{
|
||||
Name: "",
|
||||
Token: token,
|
||||
Host: host,
|
||||
}
|
||||
q.RefreshArr(arr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,158 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"goBlack/pkg/debrid"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// All torrent related helpers goes here
|
||||
|
||||
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 := float64(debridTorrent.Bytes)
|
||||
progress := cmp.Or(debridTorrent.Progress, 100.0)
|
||||
progress = progress / 100.0
|
||||
sizeCompleted := int64(totalSize * progress)
|
||||
|
||||
var speed int64
|
||||
if debridTorrent.Speed != 0 {
|
||||
speed = debridTorrent.Speed
|
||||
}
|
||||
var eta int64
|
||||
if speed != 0 {
|
||||
eta = int64((totalSize - float64(sizeCompleted)) / float64(speed))
|
||||
}
|
||||
t.ID = debridTorrent.Id
|
||||
t.Name = debridTorrent.Name
|
||||
t.AddedOn = addedOn.Unix()
|
||||
t.DebridTorrent = debridTorrent
|
||||
t.Size = int64(totalSize)
|
||||
t.Completed = sizeCompleted
|
||||
t.Downloaded = sizeCompleted
|
||||
t.DownloadedSession = sizeCompleted
|
||||
t.Uploaded = sizeCompleted
|
||||
t.UploadedSession = sizeCompleted
|
||||
t.AmountLeft = int64(totalSize) - sizeCompleted
|
||||
t.Progress = float32(progress)
|
||||
t.Eta = eta
|
||||
t.Dlspeed = speed
|
||||
t.Upspeed = speed
|
||||
t.SavePath = filepath.Join(q.DownloadFolder, t.Category) + string(os.PathSeparator)
|
||||
t.ContentPath = filepath.Join(t.SavePath, t.Name) + string(os.PathSeparator)
|
||||
return t
|
||||
}
|
||||
|
||||
func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent {
|
||||
rcLoneMount := q.debrid.GetMountPath()
|
||||
if debridTorrent == nil && t.ID != "" {
|
||||
debridTorrent, _ = q.debrid.GetTorrent(t.ID)
|
||||
}
|
||||
if debridTorrent == nil {
|
||||
q.logger.Printf("Torrent with ID %s not found in %s", t.ID, q.debrid.GetName())
|
||||
return t
|
||||
}
|
||||
if debridTorrent.Status != "downloaded" {
|
||||
debridTorrent, _ = q.debrid.GetTorrent(t.ID)
|
||||
}
|
||||
|
||||
if t.TorrentPath == "" {
|
||||
t.TorrentPath = filepath.Base(debridTorrent.GetMountFolder(rcLoneMount))
|
||||
}
|
||||
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 index, file := range t.DebridTorrent.Files {
|
||||
files = append(files, &TorrentFile{
|
||||
Index: index,
|
||||
Name: file.Path,
|
||||
Size: file.Size,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//func generateSID() (string, error) {
|
||||
// bytes := make([]byte, sidLength)
|
||||
// if _, err := rand.Read(bytes); err != nil {
|
||||
// return "", err
|
||||
// }
|
||||
// return hex.EncodeToString(bytes), 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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
pkg/repair/repair.go
Normal file
72
pkg/repair/repair.go
Normal 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
70
pkg/repair/utils.go
Normal 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
18
pkg/version/version.go
Normal 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
57
scripts/deploy.sh
Executable 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"
|
||||
Reference in New Issue
Block a user