59 Commits

Author SHA1 Message Date
Mukhtar Akere
e0e71b0f7e Wraps up v0.4.1
Some checks failed
Release / goreleaser (push) Has been cancelled
2025-02-24 00:02:28 +01:00
Mukhtar Akere
3b463adf09 Hotfix 2025-02-22 00:37:38 +01:00
Mukhtar Akere
7af0de76cc Add support for same infohashes but different categories 2025-02-22 00:14:13 +01:00
Mukhtar Akere
108da305b3 - Update Readme
- Add funding.yml
- Add Arr Queue cleanner worker
- Rewrote worker
2025-02-19 23:52:53 +01:00
Mukhtar Akere
9a7bff04ef - Fix alldebrid bug
- Minor cleanup
- speedgains
2025-02-19 01:20:05 +01:00
Mukhtar Akere
325e6c912c finalize beta-0.4.1 2025-02-14 02:42:45 +01:00
Mukhtar Akere
6a24c372f5 Hotfix for Proxy 2025-02-13 17:31:54 +01:00
Mukhtar Akere
32ecc08a72 Merge branch 'feat/add-auth' into beta 2025-02-13 14:25:56 +01:00
Mukhtar Akere
4b2f601df2 hotfix qbittorent 2025-02-13 14:19:07 +01:00
Mukhtar Akere
bfd2596367 fix mounts; backward compatibility 2025-02-13 05:07:14 +01:00
Mukhtar Akere
6f4f72d781 hotfix auth checks 2025-02-13 02:20:45 +01:00
Mukhtar Akere
14341d30bc More cleanup, more refractor, more energy, more passion, more footwork 2025-02-13 02:08:18 +01:00
Mukhtar Akere
878f78468f init adding rclone 2025-02-12 22:11:58 +01:00
Mukhtar Akere
c386495d3d Add auth 2025-02-09 23:47:02 +01:00
Mukhtar Akere
1614e29f8f Add Speed to callbacks 2025-02-09 19:17:20 +01:00
Mukhtar Akere
186a24cc4a Fix repair worker 2025-02-07 23:42:09 +01:00
Mukhtar Akere
16c825d5ba feat: restructure code; add size and ext checks (#39)
- Refractor code
- Add file size and extension checkers
- Change repair workflow to use zurg
2025-02-04 02:07:19 -08:00
Mukhtar Akere
8ca3cb32f3 Merge branch 'beta' of github.com:sirrobot01/debrid-blackhole into beta 2025-02-01 04:29:27 +01:00
Mukhtar Akere
c5b975a721 Fix Radarr Movie search 2025-02-01 04:29:01 +01:00
Elias Benbourenane
2fa6737f31 Ability to upload torrent files (#24)
- Upload Torrent file OR magnet URI
2025-01-31 18:41:00 -08:00
Mukhtar Akere
f40cd9ba56 Hotfix: Improve final docker image 2025-02-01 03:37:37 +01:00
Mukhtar Akere
bca96dd858 Hotfix: Dockerfile log file perm 2025-02-01 03:05:42 +01:00
Elias Benbourenane
1b9b7e203e Use toast notifications over JavaScript alerts (#37)
Implement UI toast notifications
2025-01-31 16:31:59 -08:00
Elias Benbourenane
99b4a3152d Torrent list state filtering (#33)
* perf: Switched from DOM-based to state-based in the main render loop logic

This removes the need to make complicated CSS selectors that would slow down the app.

It also improves debugability and readability.

* feat: Client-side state filtering

* style: Don't wrap the torrent list's header on small screens

* perf: Keep a dictionary of DOM element references
2025-01-31 14:46:44 -08:00
Mukhtar Akere
297715bf6e Add Download Progress tracking; early errors for invalid debrid torrent status (#35) 2025-01-31 14:45:49 -08:00
Mukhtar Akere
64995d0bf3 Add Healthcheck; Search missing immediately (#36) 2025-01-31 14:45:30 -08:00
Elias Benbourenane
92504cc8e0 feat: Remember the last used download options (#30) 2025-01-30 05:23:37 +01:00
Elias Benbourenane
12f89b3047 feat: Selectable torrent list items with a 'delete selected' button (#29) 2025-01-30 05:03:02 +01:00
Elias Benbourenane
5d7ddbd208 fix: Don't immediately download torrents with the magnet link handler (#28) 2025-01-30 05:02:28 +01:00
Elias Benbourenane
84b70464da feat: Keep track of magnet link handler state (#26) 2025-01-30 05:02:08 +01:00
Elias Benbourenane
092a028ad9 Disabled text wrapping on the torrent list table to enhance mobile view (#23) 2025-01-29 03:00:41 +01:00
Elias Benbourenane
530de20276 Handle multiple torrent submissions (#16)
* feat: Handle multiple torrent submissions
2025-01-28 13:12:43 -08:00
Mukhtar Akere
e2eb11056d Remove Healthcheck #22
* Move image to use distroless with PGID and PUID

* remove healthcheck
2025-01-28 13:12:22 -08:00
Mukhtar Akere
1a2504ff6c Move image to use distroless with PGID and PUID (#21) 2025-01-28 12:15:40 -08:00
Jamie Isaksen
07d632309a add healthcheck to monitor the app (#20) 2025-01-28 12:10:22 -08:00
Elias Benbourenane
d9b06fb518 Magnet link handler (#15)
* feat: Magnet link handler registration on the config page
2025-01-28 12:02:33 -08:00
Jamie Isaksen
d58b327957 correct typo (#17) 2025-01-26 19:11:30 +01:00
Mukhtar Akere
bba90cb89a Finalize v0.4.0
Some checks failed
Release / goreleaser (push) Failing after 2m18s
2025-01-25 11:40:30 +01:00
Mukhtar Akere
fc5c6e2869 Finalize v0.4.0 2025-01-24 23:33:08 +01:00
Mukhtar Akere
66f4965ec8 Fix arr storage 2025-01-23 03:11:24 +01:00
Mukhtar Akere
dc16f0d8a1 Fix arr storage 2025-01-23 03:06:11 +01:00
Mukhtar Akere
0741ddf999 Fix versionning 2025-01-23 02:31:55 +01:00
Mukhtar Akere
2ae4bd571e Fix Log file permissions 2025-01-23 02:11:37 +01:00
Mukhtar Akere
0b1c1af8b8 Fix Repair checks. Handle false positives 2025-01-23 01:35:28 +01:00
Mukhtar Akere
74a55149fc Features:
- Add file logging, server
- Fix minor repair bug
- Wrap up beta
2025-01-23 00:27:12 +01:00
Mukhtar Akere
cfb0051b04 Fix AllDebrid symlink bug 2025-01-19 08:56:52 +01:00
Mukhtar Akere
a986c4b5d0 Hotfix ui templates 2025-01-18 04:13:56 +01:00
Mukhtar Akere
3841b7751e Changelog v0.4.0 2025-01-18 03:49:05 +01:00
Mukhtar Akere
ea73572557 - Add shinning UI
- Revamp deployment process
- Fix Alldebrid file node bug
2025-01-13 20:18:59 +01:00
Mukhtar Akere
7cb41a0e8b Fix getting mount path 2025-01-11 23:10:05 +01:00
Mukhtar Akere
451c17cdf7 Merge branch 'beta' of github.com:sirrobot01/debrid-blackhole into beta 2025-01-11 23:09:00 +01:00
Mukhtar Akere
c39eebea0d [BETA] Changelog 0.3.4 (#14)
- Add repair worker
- Fix AllDebrid bugs with single movies/series
- Fix Torbox bugs
2025-01-11 07:21:49 -08:00
Mukhtar Akere
03c9657945 Add repair worker 2025-01-09 19:44:38 +01:00
Mukhtar Akere
28e5342c66 Add AllDebrid support 2025-01-01 17:12:18 +01:00
Mukhtar Akere
eeb3a31b05 Fix rar files, remove srt 2024-12-27 22:30:36 +01:00
Mukhtar Akere
e9d3e120f3 Hotfix
Some checks failed
Release / goreleaser (push) Failing after 2m2s
2024-12-25 00:06:47 +01:00
Mukhtar Akere
104df3c33c Changelog 0.3.2 2024-12-25 00:00:47 +01:00
Kai Gohegan
810c9d705e Update storage.go (#10) 2024-12-24 14:59:33 -08:00
Kai Gohegan
4ff00859a3 Update Dockerfile (#9) 2024-12-24 14:59:08 -08:00
107 changed files with 8191 additions and 3362 deletions

View File

@@ -3,9 +3,9 @@ testdata_dir = "testdata"
tmp_dir = "tmp"
[build]
args_bin = []
args_bin = ["--config", "data/"]
bin = "./tmp/main"
cmd = "go build -o ./tmp/main ."
cmd = "bash -c 'go build -ldflags \"-X github.com/sirrobot01/debrid-blackhole/pkg/version.Version=0.0.4 -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel=beta\" -o ./tmp/main .'"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "data"]
exclude_file = []
@@ -49,4 +49,4 @@ tmp_dir = "tmp"
[screen]
clear_on_rebuild = false
keep_scroll = true
keep_scroll = true

View File

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

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
github: sirrobot01
buy_me_a_coffee: sirrobot01

View File

@@ -21,6 +21,15 @@ jobs:
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
@@ -41,6 +50,9 @@ jobs:
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'
@@ -51,4 +63,7 @@ jobs:
push: true
tags: |
cy01/blackhole:latest
cy01/blackhole:${{ env.latest_tag }}
cy01/blackhole:${{ env.latest_tag }}
build-args: |
VERSION=${{ env.latest_tag }}
CHANNEL=${{ env.CHANNEL }}

View File

@@ -21,6 +21,14 @@ jobs:
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

3
.gitignore vendored
View File

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

View File

@@ -2,7 +2,6 @@ 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:

View File

@@ -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
@@ -96,4 +96,44 @@
#### 0.3.1
- Add DebridLink Support
- Refactor error handling
- 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.
#### 0.4.1
- Adds optional UI authentication
- Downloaded Torrents persist on restart
- Fixes
- Fix Alldebrid struggling to find the correct file
- Minor bug fixes or speed-gains
- A new cleanup worker to clean up ARR queues

View File

@@ -1,28 +1,65 @@
FROM --platform=$BUILDPLATFORM golang:1.22 as builder
# Stage 1: Build binaries
FROM --platform=$BUILDPLATFORM golang:1.23-alpine as builder
ARG TARGETPLATFORM
ARG BUILDPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG VERSION=0.0.0
ARG CHANNEL=dev
# Set destination for COPY
WORKDIR /app
# Download Go modules
COPY go.mod go.sum ./
RUN go mod download
RUN --mount=type=cache,target=/go/pkg/mod \
go mod download -x
# Copy the source code. Note the slash at the end, as explained in
# https://docs.docker.com/reference/dockerfile/#copy
ADD . .
COPY . .
# Build
RUN CGO_ENABLED=0 GOOS=$(echo $TARGETPLATFORM | cut -d '/' -f1) GOARCH=$(echo $TARGETPLATFORM | cut -d '/' -f2) go build -o /blackhole
# Build main binary
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath \
-ldflags="-w -s -X github.com/sirrobot01/debrid-blackhole/pkg/version.Version=${VERSION} -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel=${CHANNEL}" \
-o /blackhole
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
# Build healthcheck (optimized)
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -trimpath -ldflags="-w -s" \
-o /healthcheck cmd/healthcheck/main.go
EXPOSE 8181
# Stage 2: Create directory structure
FROM alpine:3.19 as dirsetup
RUN mkdir -p /app/logs && \
chmod 777 /app/logs && \
touch /app/logs/decypharr.log && \
chmod 666 /app/logs/decypharr.log
# Run
CMD ["/blackhole", "--config", "/app/config.json"]
# Stage 3: Final image
FROM gcr.io/distroless/static-debian12:nonroot
LABEL version = "${VERSION}-${CHANNEL}"
LABEL org.opencontainers.image.source = "https://github.com/sirrobot01/debrid-blackhole"
LABEL org.opencontainers.image.title = "debrid-blackhole"
LABEL org.opencontainers.image.authors = "sirrobot01"
LABEL org.opencontainers.image.documentation = "https://github.com/sirrobot01/debrid-blackhole/blob/main/README.md"
# Copy binaries
COPY --from=builder --chown=nonroot:nonroot /blackhole /usr/bin/blackhole
COPY --from=builder --chown=nonroot:nonroot /healthcheck /usr/bin/healthcheck
# Copy pre-made directory structure
COPY --from=dirsetup --chown=nonroot:nonroot /app /app
# Metadata
ENV LOG_PATH=/app/logs
EXPOSE 8181 8282
VOLUME ["/app"]
USER nonroot:nonroot
HEALTHCHECK CMD ["/usr/bin/healthcheck"]
CMD ["/usr/bin/blackhole", "--config", "/app"]

293
README.md
View File

@@ -1,31 +1,53 @@
### GoBlackHole(with Debrid Proxy Support)
### DecyphArr(Qbittorent, but with Debrid Proxy Support)
This is a Golang implementation go Torrent QbitTorrent with a **Real Debrid & Torbox Support**.
![ui](doc/main.png)
This is an implementation of QbitTorrent with a **Multiple Debrid service support**. Written in Go.
### Table of Contents
- [Features](#features)
- [Supported Debrid Providers](#supported-debrid-providers)
- [Installation](#installation)
- [Docker Compose](#docker-compose)
- [Binary](#binary)
- [Usage](#usage)
- [Connecting to Sonarr/Radarr](#connecting-to-sonarrradarr)
- [Sample Config](#sample-config)
- [Config Notes](#config-notes)
- [Log Level](#log-level)
- [Max Cache Size](#max-cache-size)
- [Debrid Config](#debrid-config)
- [Proxy Config](#proxy-config)
- [Qbittorrent Config](#qbittorrent-config)
- [Arrs Config](#arrs-config)
- [Proxy](#proxy)
- [Repair Worker](#repair-worker)
- [Changelog](#changelog)
- [TODO](#todo)
### Features
- Mock Qbittorent API that supports the Arrs(Sonarr, Radarr, etc)
- A Full-fledged UI for managing torrents
- Proxy support for the Arrs
- Real Debrid Support
- Torbox Support
- Debrid Link Support
- Multi-Debrid Providers support
- UI for adding torrents directly to *arrs
- Repair Worker for missing files (**NEW**)
The proxy is useful in filtering out un-cached Real Debrid torrents
The proxy is useful for filtering out un-cached Debrid torrents
### Supported Debrid Providers
- Real Debrid
- Torbox
- Debrid Link
### Changelog
- View the [CHANGELOG.md](CHANGELOG.md) for the latest changes
- [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'
@@ -38,10 +60,8 @@ services:
- "8181:8181" # Proxy
user: "1000:1000"
volumes:
- ./logs:/app/logs
- ~/plex/media:/media
- ~/plex/media/symlinks/:/media/symlinks/
- ~/plex/configs/blackhole/config.json:/app/config.json # Config file, see below
- /mnt/:/mnt
- ~/plex/configs/blackhole/:/app # config.json must be in this directory
environment:
- PUID=1000
- PGID=1000
@@ -58,110 +78,17 @@ services:
Download the binary from the releases page and run it with the config file.
```bash
./blackhole --config /path/to/config.json
./blackhole --config /app
```
#### Config
```json
{
"debrids": [
{
"name": "torbox",
"host": "https://api.torbox.app/v1",
"api_key": "torbox_api_key",
"folder": "data/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": "data/realdebrid/torrents/",
"rate_limit": "250/minute",
"download_uncached": false,
"check_cached": false
},
{
"name": "debridlink",
"host": "https://debrid-link.com/api/v2",
"api_key": "debridlink_key",
"folder": "data/debridlink/torrents/",
"rate_limit": "250/minute",
"download_uncached": false,
"check_cached": false
}
],
"proxy": {
"enabled": true,
"port": "8100",
"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
- 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 the torrents will be downloaded. e.g `data/realdebrid/torrents/`
- 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)
##### 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
@@ -172,21 +99,129 @@ Setting Up Qbittorrent in Arr
- Category: e.g `sonarr`, `radarr`
- Use SSL -> `No`
- Sequential Download -> `No`|`Yes` (If you want to download the torrents locally instead of symlink)
- Test
- Save
- Click Test
- Click Save
### UI for adding torrents
#### Basic Sample Config
![UI](./doc/ui.png)
This is the default config file. You can create a `config.json` file in the root directory of the project or mount it to /app in the docker-compose file.
```json
{
"debrids": [
{
"name": "realdebrid",
"host": "https://api.real-debrid.com/rest/1.0",
"api_key": "realdebrid_key",
"folder": "/mnt/remote/realdebrid/__all__/"
}
],
"proxy": {
"enabled": true,
"port": "8100",
"username": "username",
"password": "password"
},
"qbittorrent": {
"port": "8282",
"download_folder": "/mnt/symlinks/",
"categories": ["sonarr", "radarr"]
},
"repair": {
"enabled": false,
"interval": "12h",
"run_on_start": false
},
"use_auth": false
}
```
Full config are [here](doc/config.full.json)
<details>
<summary>
Click Here for the full config notes
</summary>
- The `log_level` key is used to set the log level of the application. The default value is `info`. log level can be set to `debug`, `info`, `warn`, `error`
- 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 `allowed_file_types` key is an array of allowed file types that can be downloaded. By default, all movie, tv show and music file types are allowed
- The `use_auth` is used to enable basic authentication for the UI. The default value is `false`
##### 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 (**BETA**)
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
- The `zurg_url` is the url of the zurg server. Typically `http://localhost:9999` or `http://zurg:9999`
- The `skip_deletion`: true if you don't want to delete the files
##### 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
- THe `cleanup` key is used to cleanup your arr queues. This is usually for removing dangling queues(downloads that all the files have been import, sometimes, some incomplete season packs)
</details>
### 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.
**Note**: If you're using zurg, set the `zurg_url` under repair config. This will speed up the repair process, exponentially.
- 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
The UI is a simple web interface that allows you to add torrents directly to the Arrs(Sonarr, Radarr, etc)
### TODO
- [ ] A proper name!!!!
- [ ] Debrid
- [ ] Add more Debrid Providers
- [x] A proper name!!!!
- [x] Debrid
- [x] Add more Debrid Providers
- [ ] Qbittorrent
- [ ] Add more Qbittorrent features
- [ ] Persist torrents on restart/server crash
- [ ] Add tests
- [x] Qbittorrent
- [x] Add more Qbittorrent features
- [x] Persist torrents on restart/server crash
- [ ] Add tests

88
cmd/decypharr/main.go Normal file
View File

@@ -0,0 +1,88 @@
package decypharr
import (
"context"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/pkg/proxy"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit"
"github.com/sirrobot01/debrid-blackhole/pkg/server"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"github.com/sirrobot01/debrid-blackhole/pkg/version"
"github.com/sirrobot01/debrid-blackhole/pkg/web"
"github.com/sirrobot01/debrid-blackhole/pkg/worker"
"log"
"sync"
)
func Start(ctx context.Context) error {
cfg := config.GetConfig()
var wg sync.WaitGroup
errChan := make(chan error)
_log := logger.GetLogger(cfg.LogLevel)
_log.Info().Msgf("Version: %s", version.GetInfo().String())
_log.Debug().Msgf("Config Loaded: %s", cfg.JsonFile())
_log.Debug().Msgf("Default Log Level: %s", cfg.LogLevel)
svc := service.New()
_qbit := qbit.New()
srv := server.New()
webRoutes := web.New(_qbit).Routes()
qbitRoutes := _qbit.Routes()
// Register routes
srv.Mount("/", webRoutes)
srv.Mount("/api/v2", qbitRoutes)
if cfg.Proxy.Enabled {
wg.Add(1)
go func() {
defer wg.Done()
if err := proxy.NewProxy().Start(ctx); err != nil {
errChan <- err
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
if err := srv.Start(ctx); err != nil {
errChan <- err
}
}()
wg.Add(1)
go func() {
defer wg.Done()
if err := worker.Start(ctx); err != nil {
errChan <- err
}
}()
if cfg.Repair.Enabled {
wg.Add(1)
go func() {
defer wg.Done()
if err := svc.Repair.Start(ctx); 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()
}
}

22
cmd/healthcheck/main.go Normal file
View File

@@ -0,0 +1,22 @@
package main
import (
"cmp"
"net/http"
"os"
)
func main() {
port := cmp.Or(os.Getenv("QBIT_PORT"), "8282")
resp, err := http.Get("http://localhost:" + port + "/api/v2/app/version")
if err != nil {
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
os.Exit(1)
}
os.Exit(0)
}

View File

@@ -1,53 +0,0 @@
package cmd
import (
"cmp"
"context"
"goBlack/common"
"goBlack/pkg/debrid"
"goBlack/pkg/proxy"
"goBlack/pkg/qbit"
"sync"
)
func Start(ctx context.Context, config *common.Config) error {
maxCacheSize := cmp.Or(config.MaxCacheSize, 1000)
cache := common.NewCache(maxCacheSize)
deb := debrid.NewDebrid(config.Debrids, cache)
var wg sync.WaitGroup
errChan := make(chan error, 2)
if config.Proxy.Enabled {
wg.Add(1)
go func() {
defer wg.Done()
if err := proxy.NewProxy(*config, deb, cache).Start(ctx); err != nil {
errChan <- err
}
}()
}
if config.QBitTorrent.Port != "" {
wg.Add(1)
go func() {
defer wg.Done()
if err := qbit.Start(ctx, config, deb, cache); err != nil {
errChan <- err
}
}()
}
go func() {
wg.Wait()
close(errChan)
}()
// Wait for context cancellation or completion or error
select {
case err := <-errChan:
return err
case <-ctx.Done():
return ctx.Err()
}
}

View File

@@ -1,155 +0,0 @@
package common
import (
"encoding/json"
"errors"
"fmt"
"log"
"os"
"sync"
)
type DebridConfig struct {
Name string `json:"name"`
Host string `json:"host"`
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"`
Username string `json:"username"`
Password string `json:"password"`
CachedOnly *bool `json:"cached_only"`
}
type QBitTorrentConfig struct {
Username string `json:"username"`
Password string `json:"password"`
Port string `json:"port"`
Debug bool `json:"debug"`
DownloadFolder string `json:"download_folder"`
Categories []string `json:"categories"`
RefreshInterval int `json:"refresh_interval"`
}
type Config struct {
Debrid DebridConfig `json:"debrid"`
Debrids []DebridConfig `json:"debrids"`
Proxy ProxyConfig `json:"proxy"`
MaxCacheSize int `json:"max_cache_size"`
QBitTorrent QBitTorrentConfig `json:"qbittorrent"`
}
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) {
// Load the config file
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
log.Fatal(err)
}
}(file)
decoder := json.NewDecoder(file)
config := &Config{}
err = decoder.Decode(config)
if err != nil {
return nil, err
}
if config.Debrid.Name != "" {
config.Debrids = append(config.Debrids, config.Debrid)
}
// Validate the config
//if err := validateConfig(config); err != nil {
// return nil, err
//}
return config, nil
}

View File

@@ -1,14 +0,0 @@
package common
import (
"fmt"
"log"
"os"
)
func NewLogger(prefix string, output *os.File) *log.Logger {
f := fmt.Sprintf("[%s] ", prefix)
return log.New(output, f, log.LstdFlags)
}
var Logger = NewLogger("Main", os.Stdout)

View File

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

82
doc/config.full.json Normal file
View File

@@ -0,0 +1,82 @@
{
"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",
"cleanup": false
},
{
"name": "radarr",
"host": "http://host:7878",
"token": "arr_key",
"cleanup": false
}
],
"repair": {
"enabled": false,
"interval": "12h",
"run_on_start": false,
"zurg_url": "http://zurg:9999",
"skip_deletion": false
},
"log_level": "info",
"min_file_size": "",
"max_file_size": "",
"allowed_file_types": [],
"use_auth": false
}

BIN
doc/download.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

BIN
doc/main.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

28
go.mod
View File

@@ -1,6 +1,8 @@
module goBlack
module github.com/sirrobot01/debrid-blackhole
go 1.22
go 1.23
toolchain go1.23.2
require (
github.com/anacrolix/torrent v1.55.0
@@ -9,20 +11,28 @@ require (
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2
github.com/go-chi/chi/v5 v5.1.0
github.com/google/uuid v1.6.0
github.com/valyala/fasthttp v1.55.0
github.com/gorilla/sessions v1.4.0
github.com/rs/zerolog v1.33.0
github.com/valyala/fastjson v1.6.4
golang.org/x/time v0.6.0
golang.org/x/crypto v0.33.0
golang.org/x/net v0.33.0
golang.org/x/time v0.8.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)
require (
github.com/anacrolix/missinggo v1.3.0 // indirect
github.com/anacrolix/missinggo/v2 v2.7.3 // indirect
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/huandu/xstrings v1.3.2 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
golang.org/x/net v0.27.0 // indirect
golang.org/x/text v0.16.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/stretchr/testify v1.10.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
)

60
go.sum
View File

@@ -35,8 +35,6 @@ github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pm
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
github.com/anacrolix/torrent v1.55.0 h1:s9yh/YGdPmbN9dTa+0Inh2dLdrLQRvEAj1jdFW/Hdd8=
github.com/anacrolix/torrent v1.55.0/go.mod h1:sBdZHBSZNj4de0m+EbYg7vvs/G/STubxu/GzzNbojsE=
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -50,9 +48,11 @@ 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=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
@@ -79,6 +79,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=
@@ -104,6 +105,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
@@ -112,6 +115,10 @@ github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORR
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
@@ -125,8 +132,6 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -136,6 +141,12 @@ 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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/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=
@@ -153,8 +164,9 @@ github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
@@ -173,8 +185,11 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
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/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
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=
@@ -188,15 +203,11 @@ github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
@@ -206,6 +217,8 @@ go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -221,8 +234,8 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -241,12 +254,17 @@ 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.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.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=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -273,6 +291,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=

View File

@@ -1,4 +1,4 @@
package common
package cache
import (
"sync"
@@ -11,7 +11,7 @@ type Cache struct {
mu sync.RWMutex
}
func NewCache(maxItems int) *Cache {
func New(maxItems int) *Cache {
if maxItems <= 0 {
maxItems = 1000
}
@@ -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)
}
}
}

287
internal/config/config.go Normal file
View File

@@ -0,0 +1,287 @@
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sync"
)
var (
instance *Config
once sync.Once
configPath string
)
type Debrid struct {
Name string `json:"name"`
Host string `json:"host"`
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 Proxy struct {
Port string `json:"port"`
Enabled bool `json:"enabled"`
LogLevel string `json:"log_level"`
Username string `json:"username"`
Password string `json:"password"`
CachedOnly bool `json:"cached_only"`
}
type QBitTorrent struct {
Username string `json:"username"`
Password string `json:"password"`
Port string `json:"port"`
LogLevel string `json:"log_level"`
DownloadFolder string `json:"download_folder"`
Categories []string `json:"categories"`
RefreshInterval int `json:"refresh_interval"`
}
type Arr struct {
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
Cleanup bool `json:"cleanup"`
}
type Repair struct {
Enabled bool `json:"enabled"`
Interval string `json:"interval"`
RunOnStart bool `json:"run_on_start"`
ZurgURL string `json:"zurg_url"`
SkipDeletion bool `json:"skip_deletion"`
}
type Auth struct {
Username string `json:"username"`
Password string `json:"password"`
}
type Config struct {
LogLevel string `json:"log_level"`
Debrid Debrid `json:"debrid"`
Debrids []Debrid `json:"debrids"`
Proxy Proxy `json:"proxy"`
MaxCacheSize int `json:"max_cache_size"`
QBitTorrent QBitTorrent `json:"qbittorrent"`
Arrs []Arr `json:"arrs"`
Repair Repair `json:"repair"`
AllowedExt []string `json:"allowed_file_types"`
MinFileSize string `json:"min_file_size"` // Minimum file size to download, 10MB, 1GB, etc
MaxFileSize string `json:"max_file_size"` // Maximum file size to download (0 means no limit)
Path string `json:"-"` // Path to save the config file
UseAuth bool `json:"use_auth"`
Auth *Auth `json:"-"`
}
func (c *Config) JsonFile() string {
return filepath.Join(c.Path, "config.json")
}
func (c *Config) AuthFile() string {
return filepath.Join(c.Path, "auth.json")
}
func (c *Config) loadConfig() error {
// Load the config file
if configPath == "" {
return fmt.Errorf("config path not set")
}
c.Path = configPath
file, err := os.ReadFile(c.JsonFile())
if err != nil {
return err
}
if err := json.Unmarshal(file, &c); err != nil {
return fmt.Errorf("error unmarshaling config: %w", err)
}
if c.Debrid.Name != "" {
c.Debrids = append(c.Debrids, c.Debrid)
}
if len(c.AllowedExt) == 0 {
c.AllowedExt = getDefaultExtensions()
}
// Load the auth file
c.Auth = c.GetAuth()
//Validate the config
//if err := validateConfig(c); err != nil {
// return err
//}
return nil
}
func validateDebrids(debrids []Debrid) 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 *QBitTorrent) 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 SetConfigPath(path string) error {
configPath = path
return nil
}
func GetConfig() *Config {
once.Do(func() {
instance = &Config{} // Initialize instance first
if err := instance.loadConfig(); err != nil {
_, err := fmt.Fprintf(os.Stderr, "configuration Error: %v\n", err)
if err != nil {
return
}
os.Exit(1)
}
})
return instance
}
func (c *Config) GetMinFileSize() int64 {
// 0 means no limit
if c.MinFileSize == "" {
return 0
}
s, err := parseSize(c.MinFileSize)
if err != nil {
return 0
}
return s
}
func (c *Config) GetMaxFileSize() int64 {
// 0 means no limit
if c.MaxFileSize == "" {
return 0
}
s, err := parseSize(c.MaxFileSize)
if err != nil {
return 0
}
return s
}
func (c *Config) IsSizeAllowed(size int64) bool {
if size == 0 {
return true // Maybe the debrid hasn't reported the size yet
}
if c.GetMinFileSize() > 0 && size < c.GetMinFileSize() {
return false
}
if c.GetMaxFileSize() > 0 && size > c.GetMaxFileSize() {
return false
}
return true
}
func (c *Config) GetAuth() *Auth {
if !c.UseAuth {
return nil
}
if c.Auth == nil {
c.Auth = &Auth{}
if _, err := os.Stat(c.AuthFile()); err == nil {
file, err := os.ReadFile(c.AuthFile())
if err == nil {
_ = json.Unmarshal(file, c.Auth)
}
}
}
return c.Auth
}
func (c *Config) SaveAuth(auth *Auth) error {
c.Auth = auth
data, err := json.Marshal(auth)
if err != nil {
return err
}
return os.WriteFile(c.AuthFile(), data, 0644)
}
func (c *Config) NeedsSetup() bool {
if c.UseAuth {
return c.GetAuth().Username == ""
}
return false
}

75
internal/config/misc.go Normal file
View File

@@ -0,0 +1,75 @@
package config
import (
"path/filepath"
"sort"
"strconv"
"strings"
)
func (c *Config) IsAllowedFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
if ext == "" {
return false
}
// Remove the leading dot
ext = ext[1:]
for _, allowed := range c.AllowedExt {
if ext == allowed {
return true
}
}
return false
}
func getDefaultExtensions() []string {
videoExts := strings.Split("YUV,WMV,WEBM,VOB,VIV,SVI,ROQ,RMVB,RM,OGV,OGG,NSV,MXF,MPG,MPEG,M2V,MP2,MPE,MPV,MP4,M4P,M4V,MOV,QT,MNG,MKV,FLV,DRC,AVI,ASF,AMV,MKA,F4V,3GP,3G2,DIVX,X264,X265", ",")
musicExts := strings.Split("MP3,WAV,FLAC,AAC,OGG,WMA,AIFF,ALAC,M4A,APE,AC3,DTS,M4P,MID,MIDI,MKA,MP2,MPA,RA,VOC,WV,AMR", ",")
// Combine both slices
allExts := append(videoExts, musicExts...)
// Convert to lowercase
for i, ext := range allExts {
allExts[i] = strings.ToLower(ext)
}
// Remove duplicates
seen := make(map[string]bool)
var unique []string
for _, ext := range allExts {
if !seen[ext] {
seen[ext] = true
unique = append(unique, ext)
}
}
sort.Strings(unique)
return unique
}
func parseSize(sizeStr string) (int64, error) {
sizeStr = strings.ToUpper(strings.TrimSpace(sizeStr))
// Absolute size-based cache
multiplier := 1.0
if strings.HasSuffix(sizeStr, "GB") {
multiplier = 1024 * 1024 * 1024
sizeStr = strings.TrimSuffix(sizeStr, "GB")
} else if strings.HasSuffix(sizeStr, "MB") {
multiplier = 1024 * 1024
sizeStr = strings.TrimSuffix(sizeStr, "MB")
} else if strings.HasSuffix(sizeStr, "KB") {
multiplier = 1024
sizeStr = strings.TrimSuffix(sizeStr, "KB")
}
size, err := strconv.ParseFloat(sizeStr, 64)
if err != nil {
return 0, err
}
return int64(size * multiplier), nil
}

93
internal/logger/logger.go Normal file
View File

@@ -0,0 +1,93 @@
package logger
import (
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"gopkg.in/natefinch/lumberjack.v2"
"os"
"path/filepath"
"strings"
"sync"
)
var (
once sync.Once
logger zerolog.Logger
)
func GetLogPath() string {
cfg := config.GetConfig()
logsDir := filepath.Join(cfg.Path, "logs")
if _, err := os.Stat(logsDir); os.IsNotExist(err) {
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: 2,
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
}
func GetLogger(level string) zerolog.Logger {
once.Do(func() {
logger = NewLogger("decypharr", level, os.Stdout)
})
return logger
}

View File

@@ -1,4 +1,4 @@
package common
package request
import (
"crypto/tls"
@@ -8,11 +8,32 @@ import (
"io"
"log"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
func JoinURL(base string, paths ...string) (string, error) {
// Split the last path component to separate query parameters
lastPath := paths[len(paths)-1]
parts := strings.Split(lastPath, "?")
paths[len(paths)-1] = parts[0]
joined, err := url.JoinPath(base, paths...)
if err != nil {
return "", err
}
// Add back query parameters if they exist
if len(parts) > 1 {
return joined + "?" + parts[1], nil
}
return joined, nil
}
type RLHTTPClient struct {
client *http.Client
Ratelimiter *rate.Limiter
@@ -72,25 +93,32 @@ func (c *RLHTTPClient) MakeRequest(req *http.Request) ([]byte, error) {
if err != nil {
return nil, err
}
b, _ := io.ReadAll(res.Body)
statusOk := strconv.Itoa(res.StatusCode)[0] == '2'
if !statusOk {
// 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()
if err != nil {
log.Println(err)
}
}(res.Body)
b, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
statusOk := res.StatusCode >= 200 && res.StatusCode < 300
if !statusOk {
// Add status code error to the body
b = append(b, []byte(fmt.Sprintf("\nstatus code: %d", res.StatusCode))...)
return nil, fmt.Errorf(string(b))
}
return b, nil
}
func NewRLHTTPClient(rl *rate.Limiter, headers map[string]string) *RLHTTPClient {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
}
c := &RLHTTPClient{
client: &http.Client{

2
internal/utils/file.go Normal file
View File

@@ -0,0 +1,2 @@
package utils

View File

@@ -1,7 +1,8 @@
package common
package utils
import (
"bufio"
"bytes"
"context"
"encoding/base32"
"encoding/hex"
@@ -9,11 +10,9 @@ import (
"github.com/anacrolix/torrent/metainfo"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
@@ -29,23 +28,11 @@ type Magnet struct {
func GetMagnetFromFile(file io.Reader, filePath string) (*Magnet, error) {
if filepath.Ext(filePath) == ".torrent" {
mi, err := metainfo.Load(file)
torrentData, err := io.ReadAll(file)
if err != nil {
return nil, err
}
hash := mi.HashInfoBytes()
infoHash := hash.HexString()
info, err := mi.UnmarshalInfo()
if err != nil {
return nil, err
}
magnet := &Magnet{
InfoHash: infoHash,
Name: info.Name,
Size: info.Length,
Link: mi.Magnet(&hash, &info).String(),
}
return magnet, nil
return GetMagnetFromBytes(torrentData)
} else {
// .magnet file
magnetLink := ReadMagnetFile(file)
@@ -62,6 +49,28 @@ func GetMagnetFromUrl(url string) (*Magnet, error) {
return nil, fmt.Errorf("invalid url")
}
func GetMagnetFromBytes(torrentData []byte) (*Magnet, error) {
// Create a scanner to read the file line by line
mi, err := metainfo.Load(bytes.NewReader(torrentData))
if err != nil {
return nil, err
}
hash := mi.HashInfoBytes()
infoHash := hash.HexString()
info, err := mi.UnmarshalInfo()
if err != nil {
return nil, err
}
log.Println("InfoHash: ", infoHash)
magnet := &Magnet{
InfoHash: infoHash,
Name: info.Name,
Size: info.Length,
Link: mi.Magnet(&hash, &info).String(),
}
return magnet, nil
}
func OpenMagnetFile(filePath string) string {
file, err := os.Open(filePath)
if err != nil {
@@ -104,27 +113,11 @@ func OpenMagnetHttpURL(magnetLink string) (*Magnet, error) {
return
}
}(resp) // Ensure the response is closed after the function ends
// Create a scanner to read the file line by line
mi, err := metainfo.Load(resp.Body)
torrentData, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
return nil, fmt.Errorf("error reading response body: %v", err)
}
hash := mi.HashInfoBytes()
infoHash := hash.HexString()
info, err := mi.UnmarshalInfo()
if err != nil {
return nil, err
}
log.Println("InfoHash: ", infoHash)
magnet := &Magnet{
InfoHash: infoHash,
Name: info.Name,
Size: info.Length,
Link: mi.Magnet(&hash, &info).String(),
}
return magnet, nil
return GetMagnetFromBytes(torrentData)
}
func GetMagnetInfo(magnetLink string) (*Magnet, error) {
@@ -156,15 +149,6 @@ func GetMagnetInfo(magnetLink string) (*Magnet, error) {
return magnet, nil
}
func RandomString(length int) string {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, length)
for i := range b {
b[i] = charset[rand.Intn(len(charset))]
}
return string(b)
}
func ExtractInfoHash(magnetDesc string) string {
const prefix = "xt=urn:btih:"
start := strings.Index(magnetDesc, prefix)
@@ -249,22 +233,3 @@ func GetInfohashFromURL(url string) (string, error) {
infoHash := hash.HexString()
return infoHash, nil
}
func JoinURL(base string, paths ...string) (string, error) {
// Parse the base URL
u, err := url.Parse(base)
if err != nil {
return "", err
}
// Join the path components
u.Path = path.Join(u.Path, path.Join(paths...))
// Return the resulting URL as a string
return u.String(), nil
}
func FileReady(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err) // Returns true if the file exists
}

15
internal/utils/misc.go Normal file
View File

@@ -0,0 +1,15 @@
package utils
func RemoveItem[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
}

58
internal/utils/regex.go Normal file
View File

@@ -0,0 +1,58 @@
package utils
import (
"path/filepath"
"regexp"
"strings"
)
var (
VIDEOMATCH = "(?i)(\\.)(YUV|WMV|WEBM|VOB|VIV|SVI|ROQ|RMVB|RM|OGV|OGG|NSV|MXF|MPG|MPEG|M2V|MP2|MPE|MPV|MP4|M4P|M4V|MOV|QT|MNG|MKV|FLV|DRC|AVI|ASF|AMV|MKA|F4V|3GP|3G2|DIVX|X264|X265)$"
MUSICMATCH = "(?i)(\\.)(?:MP3|WAV|FLAC|AAC|OGG|WMA|AIFF|ALAC|M4A|APE|AC3|DTS|M4P|MID|MIDI|MKA|MP2|MPA|RA|VOC|WV|AMR)$"
)
var SAMPLEMATCH = `(?i)(^|[\\/]|[._-])(sample|trailer|thumb)s?([._-]|$)`
func RegexMatch(regex string, value string) bool {
re := regexp.MustCompile(regex)
return re.MatchString(value)
}
func RemoveInvalidChars(value string) string {
return strings.Map(func(r rune) rune {
if r == filepath.Separator || r == ':' {
return r
}
if filepath.IsAbs(string(r)) {
return r
}
if strings.ContainsRune(filepath.VolumeName("C:"+string(r)), r) {
return r
}
if r < 32 || strings.ContainsRune(`<>:"/\|?*`, r) {
return -1
}
return r
}, value)
}
func RemoveExtension(value string) string {
re := regexp.MustCompile(VIDEOMATCH + "|" + SAMPLEMATCH + "|" + MUSICMATCH)
// Find the last index of the matched extension
loc := re.FindStringIndex(value)
if loc != nil {
return value[:loc[0]]
} else {
return value
}
}
func IsMediaFile(path string) bool {
mediaPattern := VIDEOMATCH + "|" + MUSICMATCH
return RegexMatch(mediaPattern, path)
}
func IsSampleFile(path string) bool {
return RegexMatch(SAMPLEMATCH, path)
}

13
main.go
View File

@@ -3,23 +3,22 @@ package main
import (
"context"
"flag"
"goBlack/cmd"
"goBlack/common"
"github.com/sirrobot01/debrid-blackhole/cmd/decypharr"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"log"
)
func main() {
var configPath string
flag.StringVar(&configPath, "config", "config.json", "path to the config file")
flag.StringVar(&configPath, "config", "/data", "path to the data folder")
flag.Parse()
// Load the config file
conf, err := common.LoadConfig(configPath)
if err != nil {
if err := config.SetConfigPath(configPath); err != nil {
log.Fatal(err)
}
config.GetConfig()
ctx := context.Background()
if err := cmd.Start(ctx, conf); err != nil {
if err := decypharr.Start(ctx); err != nil {
log.Fatal(err)
}

View File

@@ -3,10 +3,9 @@ package arr
import (
"bytes"
"encoding/json"
"goBlack/common"
"log"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"net/http"
"os"
"strings"
"sync"
)
@@ -22,23 +21,24 @@ const (
)
var (
client *common.RLHTTPClient = common.NewRLHTTPClient(nil, nil)
logger *log.Logger = common.NewLogger("QBit", os.Stdout)
client *request.RLHTTPClient = request.NewRLHTTPClient(nil, nil)
)
type Arr struct {
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
Type Type `json:"type"`
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
Type Type `json:"type"`
Cleanup bool `json:"cleanup"`
}
func NewArr(name, host, token string, arrType Type) *Arr {
func New(name, host, token string, cleanup bool) *Arr {
return &Arr{
Name: name,
Host: host,
Token: token,
Type: arrType,
Name: name,
Host: host,
Token: token,
Type: InferType(host, name),
Cleanup: cleanup,
}
}
@@ -46,7 +46,7 @@ func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Respo
if a.Token == "" || a.Host == "" {
return nil, nil
}
url, err := common.JoinURL(a.Host, endpoint)
url, err := request.JoinURL(a.Host, endpoint)
if err != nil {
return nil, err
}
@@ -72,7 +72,7 @@ type Storage struct {
mu sync.RWMutex
}
func inferType(host, name string) Type {
func InferType(host, name string) Type {
switch {
case strings.Contains(host, "sonarr") || strings.Contains(name, "sonarr"):
return Sonarr
@@ -89,9 +89,10 @@ func inferType(host, name string) Type {
func NewStorage() *Storage {
arrs := make(map[string]*Arr)
//for name, arrCfg := range cfg {
// arrs[name] = NewArr(name, arrCfg.Host, arrCfg.Token, inferType(arrCfg.Host, name))
//}
for _, a := range config.GetConfig().Arrs {
name := a.Name
arrs[name] = New(name, a.Host, a.Token, a.Cleanup)
}
return &Storage{
Arrs: arrs,
}
@@ -100,7 +101,10 @@ func NewStorage() *Storage {
func (as *Storage) AddOrUpdate(arr *Arr) {
as.mu.Lock()
defer as.mu.Unlock()
as.Arrs[arr.Host] = arr
if arr.Name == "" {
return
}
as.Arrs[arr.Name] = arr
}
func (as *Storage) Get(name string) *Arr {
@@ -114,7 +118,9 @@ func (as *Storage) GetAll() []*Arr {
defer as.mu.RUnlock()
arrs := make([]*Arr, 0, len(as.Arrs))
for _, arr := range as.Arrs {
arrs = append(arrs, arr)
if arr.Host != "" && arr.Token != "" {
arrs = append(arrs, arr)
}
}
return arrs
}

View File

@@ -4,26 +4,189 @@ import (
"encoding/json"
"fmt"
"net/http"
"strconv"
)
type ContentRequest struct {
ID string `json:"id"`
Title string `json:"name"`
Arr string `json:"arr"`
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 likely 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,
}
type episode struct {
Id int `json:"id"`
EpisodeFileID int `json:"episodeFileId"`
}
resp, err = a.Request(http.MethodGet, fmt.Sprintf("api/v3/episode?seriesId=%d", d.Id), nil)
if err != nil {
continue
}
defer resp.Body.Close()
var episodes []episode
if err = json.NewDecoder(resp.Body).Decode(&episodes); err != nil {
continue
}
episodeFileIDMap := make(map[int]int)
for _, e := range episodes {
episodeFileIDMap[e.EpisodeFileID] = e.Id
}
files := make([]ContentFile, 0)
for _, file := range seriesFiles {
eId, ok := episodeFileIDMap[file.Id]
if !ok {
eId = 0
}
files = append(files, ContentFile{
FileId: file.Id,
Path: file.Path,
Id: eId,
})
}
ct.Files = files
contents = append(contents, ct)
}
return contents, nil
}
func (a *Arr) GetContents() *ContentRequest {
resp, err := a.Request(http.MethodGet, "api/v3/series", 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
return nil, err
}
if resp.StatusCode == http.StatusNotFound {
// This is likely Lidarr or Readarr
return nil, fmt.Errorf("failed to get movies: %s", resp.Status)
}
a.Type = Radarr
defer resp.Body.Close()
var data *ContentRequest
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
fmt.Printf("Error: %v\n", err)
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{
FileId: movie.MovieFile.Id,
Id: movie.Id,
Path: movie.MovieFile.Path,
})
ct.Files = files
contents = append(contents, ct)
}
return contents, nil
}
func (a *Arr) search(ids []int) error {
var payload interface{}
switch a.Type {
case Sonarr:
payload = struct {
Name string `json:"name"`
EpisodeIds []int `json:"episodeIds"`
}{
Name: "EpisodeSearch",
EpisodeIds: ids,
}
case Radarr:
payload = struct {
Name string `json:"name"`
MovieIds []int `json:"movieIds"`
}{
Name: "MoviesSearch",
MovieIds: ids,
}
default:
return fmt.Errorf("unknown arr type: %s", a.Type)
}
resp, err := a.Request(http.MethodPost, "api/v3/command", payload)
if err != nil {
return fmt.Errorf("failed to automatic search: %v", err)
}
if statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'; !statusOk {
return fmt.Errorf("failed to automatic search. Status Code: %s", resp.Status)
}
return nil
}
func (a *Arr) SearchMissing(files []ContentFile) error {
ids := make([]int, 0)
for _, f := range files {
ids = append(ids, f.Id)
}
if len(ids) == 0 {
return nil
}
fmt.Printf("Data: %v\n", data)
//data.Arr = a.Name
return data
return a.search(ids)
}
func (a *Arr) DeleteFiles(files []ContentFile) error {
ids := make([]int, 0)
for _, f := range files {
ids = append(ids, f.FileId)
}
var payload interface{}
switch a.Type {
case Sonarr:
payload = struct {
EpisodeFileIds []int `json:"episodeFileIds"`
}{
EpisodeFileIds: ids,
}
_, err := a.Request(http.MethodDelete, "api/v3/episodefile/bulk", payload)
if err != nil {
return err
}
case Radarr:
payload = struct {
MovieFileIds []int `json:"movieFileIds"`
}{
MovieFileIds: ids,
}
_, err := a.Request(http.MethodDelete, "api/v3/moviefile/bulk", payload)
if err != nil {
return err
}
default:
return fmt.Errorf("unknown arr type: %s", a.Type)
}
return nil
}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"net/http"
gourl "net/url"
"strings"
)
type HistorySchema struct {
@@ -18,6 +19,37 @@ type HistorySchema struct {
} `json:"records"`
}
type QueueResponseScheme struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
SortKey string `json:"sortKey"`
SortDirection string `json:"sortDirection"`
TotalRecords int `json:"totalRecords"`
Records []QueueSchema `json:"records"`
}
type QueueSchema struct {
SeriesId int `json:"seriesId"`
EpisodeId int `json:"episodeId"`
SeasonNumber int `json:"seasonNumber"`
Title string `json:"title"`
Status string `json:"status"`
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
TrackedDownloadState string `json:"trackedDownloadState"`
StatusMessages []struct {
Title string `json:"title"`
Messages []string `json:"messages"`
} `json:"statusMessages"`
DownloadId string `json:"downloadId"`
Protocol string `json:"protocol"`
DownloadClient string `json:"downloadClient"`
DownloadClientHasPostImportCategory bool `json:"downloadClientHasPostImportCategory"`
Indexer string `json:"indexer"`
OutputPath string `json:"outputPath"`
EpisodeHasFile bool `json:"episodeHasFile"`
Id int `json:"id"`
}
func (a *Arr) GetHistory(downloadId, eventType string) *HistorySchema {
query := gourl.Values{}
if downloadId != "" {
@@ -25,7 +57,7 @@ func (a *Arr) GetHistory(downloadId, eventType string) *HistorySchema {
}
query.Add("eventType", eventType)
query.Add("pageSize", "100")
url := "history" + "?" + query.Encode()
url := "api/v3/history" + "?" + query.Encode()
resp, err := a.Request(http.MethodGet, url, nil)
if err != nil {
return nil
@@ -39,3 +71,98 @@ func (a *Arr) GetHistory(downloadId, eventType string) *HistorySchema {
return data
}
func (a *Arr) GetQueue() []QueueSchema {
query := gourl.Values{}
query.Add("page", "1")
query.Add("pageSize", "200")
results := make([]QueueSchema, 0)
for {
url := "api/v3/queue" + "?" + query.Encode()
resp, err := a.Request(http.MethodGet, url, nil)
if err != nil {
break
}
defer resp.Body.Close()
var data QueueResponseScheme
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
break
}
if len(results) < data.TotalRecords {
results = append(results, data.Records...)
query.Set("page", string(rune(data.Page+1)))
} else {
break
}
}
return results
}
func (a *Arr) CleanupQueue() error {
queue := a.GetQueue()
type messedUp struct {
id int
episodeId int
seasonNum int
}
cleanups := make(map[int][]messedUp)
for _, q := range queue {
isMessedUp := false
if q.Protocol == "torrent" && q.Status == "completed" && q.TrackedDownloadStatus == "warning" && q.TrackedDownloadState == "importPending" {
messages := q.StatusMessages
if len(messages) > 0 {
for _, m := range messages {
if strings.Contains(strings.Join(m.Messages, " "), "No files found are eligible for import in") {
isMessedUp = true
break
}
}
}
}
if isMessedUp {
cleanups[q.SeriesId] = append(cleanups[q.SeriesId], messedUp{
id: q.Id,
episodeId: q.EpisodeId,
seasonNum: q.SeasonNumber,
})
}
}
if len(cleanups) == 0 {
return nil
}
queueIds := make([]int, 0)
episodesIds := make([]int, 0)
for _, c := range cleanups {
// Delete the messed up episodes from queue
for _, m := range c {
queueIds = append(queueIds, m.id)
episodesIds = append(episodesIds, m.episodeId)
}
}
// Delete the messed up episodes from queue
payload := struct {
Ids []int `json:"ids"`
}{
Ids: queueIds,
}
// Blocklist that hash(it's typically not complete, then research the episode)
query := gourl.Values{}
query.Add("removeFromClient", "true")
query.Add("blocklist", "true")
query.Add("skipRedownload", "false")
query.Add("changeCategory", "false")
url := "api/v3/queue/bulk" + "?" + query.Encode()
_, err := a.Request(http.MethodDelete, url, payload)
if err != nil {
return err
}
return nil
}

View File

@@ -3,7 +3,7 @@ package arr
import (
"cmp"
"fmt"
"goBlack/common"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"net/http"
"strconv"
"strings"
@@ -22,7 +22,7 @@ func (a *Arr) Refresh() error {
return fmt.Errorf("failed to refresh monitored downloads for %s", cmp.Or(a.Name, a.Host))
}
func (a *Arr) MarkAsFailed(infoHash string) error {
func (a *Arr) Blacklist(infoHash string) error {
downloadId := strings.ToUpper(infoHash)
history := a.GetHistory(downloadId, "grabbed")
if history == nil {
@@ -36,7 +36,7 @@ func (a *Arr) MarkAsFailed(infoHash string) error {
}
}
if torrentId != 0 {
url, err := common.JoinURL(a.Host, "history/failed/", strconv.Itoa(torrentId))
url, err := request.JoinURL(a.Host, "history/failed/", strconv.Itoa(torrentId))
if err != nil {
return err
}

38
pkg/arr/types.go Normal file
View File

@@ -0,0 +1,38 @@
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"`
FileId int `json:"fileId"`
TargetPath string `json:"targetPath"`
IsSymlink bool `json:"isSymlink"`
IsBroken bool `json:"isBroken"`
}
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"`
}

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

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

View File

@@ -0,0 +1,298 @@
package alldebrid
import (
"encoding/json"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/cache"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"net/http"
gourl "net/url"
"os"
"path/filepath"
"strconv"
)
type AllDebrid struct {
Name string
Host string `json:"host"`
APIKey string
DownloadUncached bool
client *request.RLHTTPClient
cache *cache.Cache
MountPath string
logger zerolog.Logger
CheckCached bool
}
func (ad *AllDebrid) GetName() string {
return ad.Name
}
func (ad *AllDebrid) GetLogger() zerolog.Logger {
return ad.logger
}
func (ad *AllDebrid) IsAvailable(infohashes []string) map[string]bool {
// Check if the infohashes are available in the local cache
hashes, result := torrent.GetLocalCache(infohashes, ad.cache)
if len(hashes) == 0 {
// Either all the infohashes are locally cached or none are
ad.cache.AddMultiple(result)
return result
}
// Divide hashes into groups of 100
// AllDebrid does not support checking cached infohashes
return result
}
func (ad *AllDebrid) SubmitMagnet(torrent *torrent.Torrent) (*torrent.Torrent, error) {
url := fmt.Sprintf("%s/magnet/upload", ad.Host)
query := gourl.Values{}
query.Add("magnets[]", torrent.Magnet.Link)
url += "?" + query.Encode()
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
if err != nil {
return nil, err
}
var data UploadMagnetResponse
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)
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 []MagnetFile, parentPath string, index *int) []torrent.File {
result := make([]torrent.File, 0)
cfg := config.GetConfig()
for _, f := range files {
currentPath := f.Name
if parentPath != "" {
currentPath = filepath.Join(parentPath, f.Name)
}
if f.Elements != nil {
// This is a folder, recurse into it
result = append(result, flattenFiles(f.Elements, currentPath, index)...)
} else {
// This is a file
fileName := filepath.Base(f.Name)
// Skip sample files
if utils.IsSampleFile(fileName) {
continue
}
if !cfg.IsAllowedFile(fileName) {
continue
}
if !cfg.IsSizeAllowed(f.Size) {
continue
}
*index++
file := torrent.File{
Id: strconv.Itoa(*index),
Name: fileName,
Size: f.Size,
Path: currentPath,
}
result = append(result, file)
}
}
return result
}
func (ad *AllDebrid) GetTorrent(id string) (*torrent.Torrent, error) {
t := &torrent.Torrent{}
url := fmt.Sprintf("%s/magnet/status?id=%s", ad.Host, id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
if err != nil {
return t, err
}
var res TorrentInfoResponse
err = json.Unmarshal(resp, &res)
if err != nil {
ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
return t, err
}
data := res.Data.Magnets
status := getAlldebridStatus(data.StatusCode)
name := data.Filename
t.Id = id
t.Name = name
t.Status = status
t.Filename = name
t.OriginalFilename = name
t.Folder = name
t.MountPath = ad.MountPath
t.Debrid = ad.Name
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
if status == "downloaded" {
t.Bytes = data.Size
t.Progress = float64((data.Downloaded / data.Size) * 100)
t.Speed = data.DownloadSpeed
t.Seeders = data.Seeders
index := -1
files := flattenFiles(data.Files, "", &index)
t.Files = files
}
return t, nil
}
func (ad *AllDebrid) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
for {
tb, err := ad.GetTorrent(torrent.Id)
torrent = tb
if err != nil || tb == nil {
return tb, err
}
status := torrent.Status
if status == "downloaded" {
ad.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
if !isSymlink {
err = ad.GetDownloadLinks(torrent)
if err != nil {
return torrent, err
}
}
break
} else if status == "downloading" {
if !ad.DownloadUncached {
go ad.DeleteTorrent(torrent)
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
}
// Break out of the loop if the torrent is downloading.
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
break
} else {
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
}
}
return torrent, nil
}
func (ad *AllDebrid) DeleteTorrent(torrent *torrent.Torrent) {
url := fmt.Sprintf("%s/magnet/delete?id=%s", ad.Host, torrent.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
_, err := ad.client.MakeRequest(req)
if err == nil {
ad.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
} else {
ad.logger.Info().Msgf("Error deleting torrent: %s", err)
}
}
func (ad *AllDebrid) GetDownloadLinks(t *torrent.Torrent) error {
downloadLinks := make(map[string]torrent.DownloadLinks)
for _, file := range t.Files {
url := fmt.Sprintf("%s/link/unlock", ad.Host)
query := gourl.Values{}
query.Add("link", file.Link)
url += "?" + query.Encode()
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
if err != nil {
return err
}
var data DownloadLink
if err = json.Unmarshal(resp, &data); err != nil {
return err
}
link := data.Data.Link
dl := torrent.DownloadLinks{
Link: file.Link,
Filename: data.Data.Filename,
DownloadLink: link,
}
downloadLinks[file.Id] = dl
}
t.DownloadLinks = downloadLinks
return nil
}
func (ad *AllDebrid) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
url := fmt.Sprintf("%s/link/unlock", ad.Host)
query := gourl.Values{}
query.Add("link", file.Link)
url += "?" + query.Encode()
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
if err != nil {
return nil
}
var data DownloadLink
if err = json.Unmarshal(resp, &data); err != nil {
return nil
}
link := data.Data.Link
return &torrent.DownloadLinks{
DownloadLink: link,
Link: file.Link,
Filename: data.Data.Filename,
}
}
func (ad *AllDebrid) GetCheckCached() bool {
return ad.CheckCached
}
func (ad *AllDebrid) GetTorrents() ([]*torrent.Torrent, error) {
return nil, fmt.Errorf("not implemented")
}
func New(dc config.Debrid, cache *cache.Cache) *AllDebrid {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
client := request.NewRLHTTPClient(rl, headers)
return &AllDebrid{
Name: "alldebrid",
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
cache: cache,
MountPath: dc.Folder,
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
CheckCached: dc.CheckCached,
}
}

View File

@@ -0,0 +1,75 @@
package alldebrid
type errorResponse struct {
Code string `json:"code"`
Message string `json:"message"`
}
type MagnetFile struct {
Name string `json:"n"`
Size int64 `json:"s"`
Link string `json:"l"`
Elements []MagnetFile `json:"e"`
}
type magnetInfo struct {
Id int `json:"id"`
Filename string `json:"filename"`
Size int64 `json:"size"`
Hash string `json:"hash"`
Status string `json:"status"`
StatusCode int `json:"statusCode"`
UploadDate int `json:"uploadDate"`
Downloaded int64 `json:"downloaded"`
Uploaded int64 `json:"uploaded"`
DownloadSpeed int64 `json:"downloadSpeed"`
UploadSpeed int64 `json:"uploadSpeed"`
Seeders int `json:"seeders"`
CompletionDate int `json:"completionDate"`
Type string `json:"type"`
Notified bool `json:"notified"`
Version int `json:"version"`
NbLinks int `json:"nbLinks"`
Files []MagnetFile `json:"files"`
}
type TorrentInfoResponse struct {
Status string `json:"status"`
Data struct {
Magnets magnetInfo `json:"magnets"`
} `json:"data"`
Error *errorResponse `json:"error"`
}
type UploadMagnetResponse 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 DownloadLink 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"`
}

360
pkg/debrid/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,360 @@
package cache
import (
"bufio"
"encoding/json"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"os"
"path/filepath"
"runtime"
"sync"
"time"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
)
type DownloadLinkCache struct {
Link string `json:"download_link"`
}
type CachedTorrent struct {
*torrent.Torrent
LastRead time.Time `json:"last_read"`
IsComplete bool `json:"is_complete"`
DownloadLinks map[string]DownloadLinkCache `json:"download_links"`
}
var (
_logInstance zerolog.Logger
once sync.Once
)
func getLogger() zerolog.Logger {
once.Do(func() {
_logInstance = logger.NewLogger("cache", "info", os.Stdout)
})
return _logInstance
}
type Cache struct {
dir string
client engine.Service
torrents *sync.Map // key: torrent.Id, value: *CachedTorrent
torrentsNames *sync.Map // key: torrent.Name, value: torrent.Id
LastUpdated time.Time `json:"last_updated"`
}
type Manager struct {
caches map[string]*Cache
}
func NewManager(debridService *engine.Engine) *Manager {
cfg := config.GetConfig()
cm := &Manager{
caches: make(map[string]*Cache),
}
for _, debrid := range debridService.GetDebrids() {
c := New(debrid, cfg.Path)
cm.caches[debrid.GetName()] = c
}
return cm
}
func (m *Manager) GetCaches() map[string]*Cache {
return m.caches
}
func (m *Manager) GetCache(debridName string) *Cache {
return m.caches[debridName]
}
func New(debridService engine.Service, basePath string) *Cache {
return &Cache{
dir: filepath.Join(basePath, "cache", debridService.GetName(), "torrents"),
torrents: &sync.Map{},
torrentsNames: &sync.Map{},
client: debridService,
}
}
func (c *Cache) Start() error {
_logger := getLogger()
_logger.Info().Msg("Starting cache for: " + c.client.GetName())
if err := c.Load(); err != nil {
return fmt.Errorf("failed to load cache: %v", err)
}
if err := c.Sync(); err != nil {
return fmt.Errorf("failed to sync cache: %v", err)
}
return nil
}
func (c *Cache) Load() error {
_logger := getLogger()
if err := os.MkdirAll(c.dir, 0755); err != nil {
return fmt.Errorf("failed to create cache directory: %w", err)
}
files, err := os.ReadDir(c.dir)
if err != nil {
return fmt.Errorf("failed to read cache directory: %w", err)
}
for _, file := range files {
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
continue
}
filePath := filepath.Join(c.dir, file.Name())
data, err := os.ReadFile(filePath)
if err != nil {
_logger.Debug().Err(err).Msgf("Failed to read file: %s", filePath)
continue
}
var ct CachedTorrent
if err := json.Unmarshal(data, &ct); err != nil {
_logger.Debug().Err(err).Msgf("Failed to unmarshal file: %s", filePath)
continue
}
if len(ct.Files) > 0 {
c.torrents.Store(ct.Torrent.Id, &ct)
c.torrentsNames.Store(ct.Torrent.Name, ct.Torrent.Id)
}
}
return nil
}
func (c *Cache) GetTorrent(id string) *CachedTorrent {
if value, ok := c.torrents.Load(id); ok {
return value.(*CachedTorrent)
}
return nil
}
func (c *Cache) GetTorrentByName(name string) *CachedTorrent {
if id, ok := c.torrentsNames.Load(name); ok {
return c.GetTorrent(id.(string))
}
return nil
}
func (c *Cache) SaveTorrent(ct *CachedTorrent) error {
data, err := json.MarshalIndent(ct, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal torrent: %w", err)
}
fileName := ct.Torrent.Id + ".json"
filePath := filepath.Join(c.dir, fileName)
tmpFile := filePath + ".tmp"
f, err := os.Create(tmpFile)
if err != nil {
return fmt.Errorf("failed to create temp file: %w", err)
}
defer f.Close()
w := bufio.NewWriter(f)
if _, err := w.Write(data); err != nil {
return fmt.Errorf("failed to write data: %w", err)
}
if err := w.Flush(); err != nil {
return fmt.Errorf("failed to flush data: %w", err)
}
return os.Rename(tmpFile, filePath)
}
func (c *Cache) SaveAll() error {
const batchSize = 100
var wg sync.WaitGroup
_logger := getLogger()
tasks := make(chan *CachedTorrent, batchSize)
for i := 0; i < runtime.NumCPU(); i++ {
wg.Add(1)
go func() {
defer wg.Done()
for ct := range tasks {
if err := c.SaveTorrent(ct); err != nil {
_logger.Error().Err(err).Msg("failed to save torrent")
}
}
}()
}
c.torrents.Range(func(_, value interface{}) bool {
tasks <- value.(*CachedTorrent)
return true
})
close(tasks)
wg.Wait()
c.LastUpdated = time.Now()
return nil
}
func (c *Cache) Sync() error {
_logger := getLogger()
torrents, err := c.client.GetTorrents()
if err != nil {
return fmt.Errorf("failed to sync torrents: %v", err)
}
workers := runtime.NumCPU() * 200
workChan := make(chan *torrent.Torrent, len(torrents))
errChan := make(chan error, len(torrents))
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for t := range workChan {
if err := c.processTorrent(t); err != nil {
errChan <- err
}
}
}()
}
for _, t := range torrents {
workChan <- t
}
close(workChan)
wg.Wait()
close(errChan)
for err := range errChan {
_logger.Error().Err(err).Msg("sync error")
}
_logger.Info().Msgf("Synced %d torrents", len(torrents))
return nil
}
func (c *Cache) processTorrent(t *torrent.Torrent) error {
if existing, ok := c.torrents.Load(t.Id); ok {
ct := existing.(*CachedTorrent)
if ct.IsComplete {
return nil
}
}
c.AddTorrent(t)
return nil
}
func (c *Cache) AddTorrent(t *torrent.Torrent) {
_logger := getLogger()
if len(t.Files) == 0 {
tNew, err := c.client.GetTorrent(t.Id)
_logger.Debug().Msgf("Getting torrent files for %s", t.Id)
if err != nil {
_logger.Debug().Msgf("Failed to get torrent files for %s: %v", t.Id, err)
return
}
t = tNew
}
if len(t.Files) == 0 {
_logger.Debug().Msgf("No files found for %s", t.Id)
return
}
ct := &CachedTorrent{
Torrent: t,
LastRead: time.Now(),
IsComplete: len(t.Files) > 0,
DownloadLinks: make(map[string]DownloadLinkCache),
}
c.torrents.Store(t.Id, ct)
c.torrentsNames.Store(t.Name, t.Id)
go func() {
if err := c.SaveTorrent(ct); err != nil {
_logger.Debug().Err(err).Msgf("Failed to save torrent %s", t.Id)
}
}()
}
func (c *Cache) RefreshTorrent(torrentId string) *CachedTorrent {
_logger := getLogger()
t, err := c.client.GetTorrent(torrentId)
if err != nil {
_logger.Debug().Msgf("Failed to get torrent files for %s: %v", torrentId, err)
return nil
}
if len(t.Files) == 0 {
return nil
}
ct := &CachedTorrent{
Torrent: t,
LastRead: time.Now(),
IsComplete: len(t.Files) > 0,
DownloadLinks: make(map[string]DownloadLinkCache),
}
c.torrents.Store(t.Id, ct)
c.torrentsNames.Store(t.Name, t.Id)
go func() {
if err := c.SaveTorrent(ct); err != nil {
_logger.Debug().Err(err).Msgf("Failed to save torrent %s", t.Id)
}
}()
return ct
}
func (c *Cache) GetFileDownloadLink(t *CachedTorrent, file *torrent.File) (string, error) {
_logger := getLogger()
if linkCache, ok := t.DownloadLinks[file.Id]; ok {
return linkCache.Link, nil
}
if file.Link == "" {
t = c.RefreshTorrent(t.Id)
if t == nil {
return "", fmt.Errorf("torrent not found")
}
file = t.Torrent.GetFile(file.Id)
}
_logger.Debug().Msgf("Getting download link for %s", t.Name)
link := c.client.GetDownloadLink(t.Torrent, file)
if link == nil {
return "", fmt.Errorf("download link not found")
}
t.DownloadLinks[file.Id] = DownloadLinkCache{
Link: link.DownloadLink,
}
go func() {
if err := c.SaveTorrent(t); err != nil {
_logger.Debug().Err(err).Msgf("Failed to save torrent %s", t.Id)
}
}()
return link.DownloadLink, nil
}
func (c *Cache) GetTorrents() *sync.Map {
return c.torrents
}

View File

@@ -1,145 +1,54 @@
package debrid
import (
"cmp"
"fmt"
"github.com/anacrolix/torrent/metainfo"
"goBlack/common"
"goBlack/pkg/arr"
"log"
"path/filepath"
"github.com/sirrobot01/debrid-blackhole/internal/cache"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/alldebrid"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/debrid_link"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/realdebrid"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torbox"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
)
type BaseDebrid struct {
Name string
Host string `json:"host"`
APIKey string
DownloadUncached bool
client *common.RLHTTPClient
cache *common.Cache
MountPath string
logger *log.Logger
CheckCached bool
}
func New() *engine.Engine {
cfg := config.GetConfig()
maxCachedSize := cmp.Or(cfg.MaxCacheSize, 1000)
debrids := make([]engine.Service, 0)
// Divide the cache size by the number of debrids
maxCacheSize := maxCachedSize / len(cfg.Debrids)
type Service interface {
SubmitMagnet(torrent *Torrent) (*Torrent, error)
CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error)
GetDownloadLinks(torrent *Torrent) error
DeleteTorrent(torrent *Torrent)
IsAvailable(infohashes []string) map[string]bool
GetMountPath() string
GetCheckCached() bool
GetTorrent(id string) (*Torrent, error)
GetName() string
GetLogger() *log.Logger
}
func NewDebrid(debs []common.DebridConfig, cache *common.Cache) *DebridService {
debrids := make([]Service, 0)
for _, dc := range debs {
d := createDebrid(dc, cache)
d.GetLogger().Println("Debrid Service started")
for _, dc := range cfg.Debrids {
d := createDebrid(dc, cache.New(maxCacheSize))
logger := d.GetLogger()
logger.Info().Msg("Debrid Service started")
debrids = append(debrids, d)
}
d := &DebridService{debrids: debrids, lastUsed: 0}
d := &engine.Engine{Debrids: debrids, LastUsed: 0}
return d
}
func createDebrid(dc common.DebridConfig, cache *common.Cache) Service {
func createDebrid(dc config.Debrid, cache *cache.Cache) engine.Service {
switch dc.Name {
case "realdebrid":
return NewRealDebrid(dc, cache)
return realdebrid.New(dc, cache)
case "torbox":
return NewTorbox(dc, cache)
return torbox.New(dc, cache)
case "debridlink":
return NewDebridLink(dc, cache)
return debrid_link.New(dc, cache)
case "alldebrid":
return alldebrid.New(dc, cache)
default:
return NewRealDebrid(dc, cache)
return realdebrid.New(dc, cache)
}
}
func GetTorrentInfo(filePath string) (*Torrent, error) {
// Open and read the .torrent file
if filepath.Ext(filePath) == ".torrent" {
return getTorrentInfo(filePath)
} else {
return torrentFromMagnetFile(filePath)
}
}
func torrentFromMagnetFile(filePath string) (*Torrent, error) {
magnetLink := common.OpenMagnetFile(filePath)
magnet, err := common.GetMagnetInfo(magnetLink)
if err != nil {
return nil, err
}
torrent := &Torrent{
InfoHash: magnet.InfoHash,
Name: magnet.Name,
Size: magnet.Size,
Magnet: magnet,
Filename: filePath,
}
return torrent, nil
}
func getTorrentInfo(filePath string) (*Torrent, error) {
mi, err := metainfo.LoadFromFile(filePath)
if err != nil {
return nil, err
}
hash := mi.HashInfoBytes()
infoHash := hash.HexString()
info, err := mi.UnmarshalInfo()
if err != nil {
return nil, err
}
infoLength := info.Length
magnet := &common.Magnet{
InfoHash: infoHash,
Name: info.Name,
Size: infoLength,
Link: mi.Magnet(&hash, &info).String(),
}
torrent := &Torrent{
InfoHash: infoHash,
Name: info.Name,
Size: infoLength,
Magnet: magnet,
Filename: filePath,
}
return torrent, nil
}
func GetLocalCache(infohashes []string, cache *common.Cache) ([]string, map[string]bool) {
result := make(map[string]bool)
//if len(infohashes) == 0 {
// return hashes, result
//}
//if len(infohashes) == 1 {
// if cache.Exists(infohashes[0]) {
// return hashes, map[string]bool{infohashes[0]: true}
// }
// return infohashes, result
//}
//
//cachedHashes := cache.GetMultiple(infohashes)
//for _, h := range infohashes {
// _, exists := cachedHashes[h]
// if !exists {
// hashes = append(hashes, h)
// } else {
// result[h] = true
// }
//}
return infohashes, result
}
func ProcessTorrent(d *DebridService, magnet *common.Magnet, a *arr.Arr, isSymlink bool) (*Torrent, error) {
debridTorrent := &Torrent{
func ProcessTorrent(d *engine.Engine, magnet *utils.Magnet, a *arr.Arr, isSymlink bool) (*torrent.Torrent, error) {
debridTorrent := &torrent.Torrent{
InfoHash: magnet.InfoHash,
Magnet: magnet,
Name: magnet.Name,
@@ -149,31 +58,31 @@ func ProcessTorrent(d *DebridService, magnet *common.Magnet, a *arr.Arr, isSymli
errs := make([]error, 0)
for index, db := range d.debrids {
log.Println("Processing debrid: ", db.GetName())
for index, db := range d.Debrids {
logger := db.GetLogger()
logger.Printf("Torrent Hash: %s", debridTorrent.InfoHash)
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.Printf("Torrent: %s is not cached", debridTorrent.Name)
logger.Info().Msgf("Torrent: %s is not cached", debridTorrent.Name)
continue
} else {
logger.Printf("Torrent: %s is cached(or downloading)", debridTorrent.Name)
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.Printf("Torrent: %s submitted to %s", dbt.Name, db.GetName())
d.lastUsed = index
logger.Info().Msgf("Torrent: %s(id=%s) submitted to %s", dbt.Name, dbt.Id, db.GetName())
d.LastUsed = index
return db.CheckStatus(dbt, isSymlink)
}
err := fmt.Errorf("failed to process torrent")

View File

@@ -1,271 +0,0 @@
package debrid
import (
"bytes"
"encoding/json"
"fmt"
"goBlack/common"
"goBlack/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() *log.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 += 200 {
end := i + 200
if end > len(hashes) {
end = len(hashes)
}
// Filter out empty strings
validHashes := make([]string, 0, end-i)
for _, hash := range hashes[i:end] {
if hash != "" {
validHashes = append(validHashes, hash)
}
}
// If no valid hashes in this batch, continue to the next batch
if len(validHashes) == 0 {
continue
}
hashStr := strings.Join(validHashes, ",")
url := fmt.Sprintf("%s/seedbox/cached/%s", r.Host, hashStr)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
log.Println("Error checking availability:", err)
return result
}
var data structs.DebridLinkAvailableResponse
err = json.Unmarshal(resp, &data)
if err != nil {
log.Println("Error marshalling availability:", 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,
}
}
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\n", 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,
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.Printf("Torrent: %s downloaded\n", 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.Printf("Torrent: %s deleted\n", torrent.Name)
} else {
r.logger.Printf("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, 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,
},
}
}

View File

@@ -0,0 +1,293 @@
package debrid_link
import (
"bytes"
"encoding/json"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/cache"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"net/http"
"os"
"strings"
)
type DebridLink struct {
Name string
Host string `json:"host"`
APIKey string
DownloadUncached bool
client *request.RLHTTPClient
cache *cache.Cache
MountPath string
logger zerolog.Logger
CheckCached bool
}
func (dl *DebridLink) GetName() string {
return dl.Name
}
func (dl *DebridLink) GetLogger() zerolog.Logger {
return dl.logger
}
func (dl *DebridLink) IsAvailable(infohashes []string) map[string]bool {
// Check if the infohashes are available in the local cache
hashes, result := torrent.GetLocalCache(infohashes, dl.cache)
if len(hashes) == 0 {
// Either all the infohashes are locally cached or none are
dl.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", dl.Host, hashStr)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := dl.client.MakeRequest(req)
if err != nil {
dl.logger.Info().Msgf("Error checking availability: %v", err)
return result
}
var data AvailableResponse
err = json.Unmarshal(resp, &data)
if err != nil {
dl.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
}
}
}
dl.cache.AddMultiple(result) // Add the results to the cache
return result
}
func (dl *DebridLink) GetTorrent(id string) (*torrent.Torrent, error) {
t := &torrent.Torrent{}
url := fmt.Sprintf("%s/seedbox/list?ids=%s", dl.Host, id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := dl.client.MakeRequest(req)
if err != nil {
return t, err
}
var res TorrentInfo
err = json.Unmarshal(resp, &res)
if err != nil {
return t, err
}
if res.Success == false {
return t, fmt.Errorf("error getting torrent")
}
if res.Value == nil {
return t, fmt.Errorf("torrent not found")
}
dt := *res.Value
if len(dt) == 0 {
return t, fmt.Errorf("torrent not found")
}
data := dt[0]
status := "downloading"
if data.Status == 100 {
status = "downloaded"
}
name := utils.RemoveInvalidChars(data.Name)
t.Id = data.ID
t.Name = name
t.Bytes = data.TotalSize
t.Folder = name
t.Progress = data.DownloadPercent
t.Status = status
t.Speed = data.DownloadSpeed
t.Seeders = data.PeersConnected
t.Filename = name
t.OriginalFilename = name
files := make([]torrent.File, len(data.Files))
cfg := config.GetConfig()
for i, f := range data.Files {
if !cfg.IsSizeAllowed(f.Size) {
continue
}
files[i] = torrent.File{
Id: f.ID,
Name: f.Name,
Size: f.Size,
Path: f.Name,
}
}
t.Files = files
return t, nil
}
func (dl *DebridLink) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error) {
url := fmt.Sprintf("%s/seedbox/add", dl.Host)
payload := map[string]string{"url": t.Magnet.Link}
jsonPayload, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonPayload))
resp, err := dl.client.MakeRequest(req)
if err != nil {
return nil, err
}
var res SubmitTorrentInfo
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"
name := utils.RemoveInvalidChars(data.Name)
t.Id = data.ID
t.Name = name
t.Bytes = data.TotalSize
t.Folder = name
t.Progress = data.DownloadPercent
t.Status = status
t.Speed = data.DownloadSpeed
t.Seeders = data.PeersConnected
t.Filename = name
t.OriginalFilename = name
t.MountPath = dl.MountPath
t.Debrid = dl.Name
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
files := make([]torrent.File, len(data.Files))
for i, f := range data.Files {
files[i] = torrent.File{
Id: f.ID,
Name: f.Name,
Size: f.Size,
Path: f.Name,
Link: f.DownloadURL,
}
}
t.Files = files
return t, nil
}
func (dl *DebridLink) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
for {
t, err := dl.GetTorrent(torrent.Id)
torrent = t
if err != nil || torrent == nil {
return torrent, err
}
status := torrent.Status
if status == "downloaded" {
dl.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
err = dl.GetDownloadLinks(torrent)
if err != nil {
return torrent, err
}
break
} else if status == "downloading" {
if !dl.DownloadUncached {
go dl.DeleteTorrent(torrent)
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
}
// Break out of the loop if the torrent is downloading.
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
break
} else {
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
}
}
return torrent, nil
}
func (dl *DebridLink) DeleteTorrent(torrent *torrent.Torrent) {
url := fmt.Sprintf("%s/seedbox/%s/remove", dl.Host, torrent.Id)
req, _ := http.NewRequest(http.MethodDelete, url, nil)
_, err := dl.client.MakeRequest(req)
if err == nil {
dl.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
} else {
dl.logger.Info().Msgf("Error deleting torrent: %s", err)
}
}
func (dl *DebridLink) GetDownloadLinks(t *torrent.Torrent) error {
downloadLinks := make(map[string]torrent.DownloadLinks)
for _, f := range t.Files {
dl := torrent.DownloadLinks{
Link: f.Link,
Filename: f.Name,
}
downloadLinks[f.Id] = dl
}
t.DownloadLinks = downloadLinks
return nil
}
func (dl *DebridLink) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
dlLink, ok := t.DownloadLinks[file.Id]
if !ok {
return nil
}
return &dlLink
}
func (dl *DebridLink) GetCheckCached() bool {
return dl.CheckCached
}
func New(dc config.Debrid, cache *cache.Cache) *DebridLink {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
"Content-Type": "application/json",
}
client := request.NewRLHTTPClient(rl, headers)
return &DebridLink{
Name: "debridlink",
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
cache: cache,
MountPath: dc.Folder,
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
CheckCached: dc.CheckCached,
}
}
func (dl *DebridLink) GetTorrents() ([]*torrent.Torrent, error) {
return nil, fmt.Errorf("not implemented")
}

View File

@@ -1,11 +1,11 @@
package structs
package debrid_link
type DebridLinkAPIResponse[T any] struct {
type APIResponse[T any] struct {
Success bool `json:"success"`
Value *T `json:"value"` // Use pointer to allow nil
}
type DebridLinkAvailableResponse DebridLinkAPIResponse[map[string]map[string]struct {
type AvailableResponse APIResponse[map[string]map[string]struct {
Name string `json:"name"`
HashString string `json:"hashString"`
Files []struct {
@@ -36,10 +36,10 @@ type debridLinkTorrentInfo struct {
} `json:"trackers"`
Created int64 `json:"created"`
DownloadPercent float64 `json:"downloadPercent"`
DownloadSpeed int `json:"downloadSpeed"`
UploadSpeed int `json:"uploadSpeed"`
DownloadSpeed int64 `json:"downloadSpeed"`
UploadSpeed int64 `json:"uploadSpeed"`
}
type DebridLinkTorrentInfo DebridLinkAPIResponse[[]debridLinkTorrentInfo]
type TorrentInfo APIResponse[[]debridLinkTorrentInfo]
type DebridLinkSubmitTorrentInfo DebridLinkAPIResponse[debridLinkTorrentInfo]
type SubmitTorrentInfo APIResponse[debridLinkTorrentInfo]

1
pkg/debrid/engine.go Normal file
View File

@@ -0,0 +1 @@
package debrid

View File

@@ -0,0 +1,26 @@
package engine
type Engine struct {
Debrids []Service
LastUsed int
}
func (d *Engine) Get() Service {
if d.LastUsed == 0 {
return d.Debrids[0]
}
return d.Debrids[d.LastUsed]
}
func (d *Engine) GetByName(name string) Service {
for _, deb := range d.Debrids {
if deb.GetName() == name {
return deb
}
}
return nil
}
func (d *Engine) GetDebrids() []Service {
return d.Debrids
}

View File

@@ -0,0 +1,20 @@
package engine
import (
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
)
type Service interface {
SubmitMagnet(tr *torrent.Torrent) (*torrent.Torrent, error)
CheckStatus(tr *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error)
GetDownloadLinks(tr *torrent.Torrent) error
GetDownloadLink(tr *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks
DeleteTorrent(tr *torrent.Torrent)
IsAvailable(infohashes []string) map[string]bool
GetCheckCached() bool
GetTorrent(id string) (*torrent.Torrent, error)
GetTorrents() ([]*torrent.Torrent, error)
GetName() string
GetLogger() zerolog.Logger
}

View File

@@ -1,295 +0,0 @@
package debrid
import (
"encoding/json"
"fmt"
"goBlack/common"
"goBlack/pkg/debrid/structs"
"log"
"net/http"
gourl "net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
)
type RealDebrid struct {
BaseDebrid
}
func (r *RealDebrid) GetMountPath() string {
return r.MountPath
}
func (r *RealDebrid) GetName() string {
return r.Name
}
func (r *RealDebrid) GetLogger() *log.Logger {
return r.logger
}
func GetTorrentFiles(data structs.RealDebridTorrentInfo) []TorrentFile {
files := make([]TorrentFile, 0)
for _, f := range data.Files {
name := filepath.Base(f.Path)
if (!common.RegexMatch(common.VIDEOMATCH, name) &&
!common.RegexMatch(common.SUBMATCH, name) &&
!common.RegexMatch(common.MUSICMATCH, name)) || common.RegexMatch(common.SAMPLEMATCH, name) {
continue
}
fileId := f.ID
file := TorrentFile{
Name: name,
Path: name,
Size: f.Bytes,
Id: strconv.Itoa(fileId),
}
files = append(files, file)
}
return files
}
func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool {
// Check if the infohashes are available in the local cache
hashes, result := GetLocalCache(infohashes, r.cache)
if len(hashes) == 0 {
// Either all the infohashes are locally cached or none are
r.cache.AddMultiple(result)
return result
}
// Divide hashes into groups of 100
for i := 0; i < len(hashes); i += 200 {
end := i + 200
if end > len(hashes) {
end = len(hashes)
}
// Filter out empty strings
validHashes := make([]string, 0, end-i)
for _, hash := range hashes[i:end] {
if hash != "" {
validHashes = append(validHashes, hash)
}
}
// If no valid hashes in this batch, continue to the next batch
if len(validHashes) == 0 {
continue
}
hashStr := strings.Join(validHashes, "/")
url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, hashStr)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
log.Println("Error checking availability:", err)
return result
}
var data structs.RealDebridAvailabilityResponse
err = json.Unmarshal(resp, &data)
if err != nil {
log.Println("Error marshalling availability:", err)
return result
}
for _, h := range hashes[i:end] {
hosters, exists := data[strings.ToLower(h)]
if exists && len(hosters.Rd) > 0 {
result[h] = true
}
}
}
r.cache.AddMultiple(result) // Add the results to the cache
return result
}
func (r *RealDebrid) SubmitMagnet(torrent *Torrent) (*Torrent, error) {
url := fmt.Sprintf("%s/torrents/addMagnet", r.Host)
payload := gourl.Values{
"magnet": {torrent.Magnet.Link},
}
var data structs.RealDebridAddMagnetSchema
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
resp, err := r.client.MakeRequest(req)
if err != nil {
return nil, err
}
err = json.Unmarshal(resp, &data)
log.Printf("Torrent: %s added with id: %s\n", torrent.Name, data.Id)
torrent.Id = data.Id
return torrent, nil
}
func (r *RealDebrid) GetTorrent(id string) (*Torrent, error) {
torrent := &Torrent{}
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
return torrent, err
}
var data structs.RealDebridTorrentInfo
err = json.Unmarshal(resp, &data)
if err != nil {
return torrent, err
}
name := common.RemoveInvalidChars(data.OriginalFilename)
torrent.Id = id
torrent.Name = name
torrent.Bytes = data.Bytes
torrent.Folder = name
torrent.Progress = data.Progress
torrent.Status = data.Status
torrent.Speed = data.Speed
torrent.Seeders = data.Seeders
torrent.Filename = data.Filename
torrent.OriginalFilename = data.OriginalFilename
torrent.Links = data.Links
torrent.Debrid = r
files := GetTorrentFiles(data)
torrent.Files = files
return torrent, nil
}
func (r *RealDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error) {
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrent.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
for {
resp, err := r.client.MakeRequest(req)
if err != nil {
log.Println("ERROR Checking file: ", err)
return torrent, err
}
var data structs.RealDebridTorrentInfo
err = json.Unmarshal(resp, &data)
status := data.Status
fmt.Println("RD STATUS: ", status)
name := common.RemoveInvalidChars(data.OriginalFilename)
torrent.Name = name // Important because some magnet changes the name
torrent.Folder = name
torrent.Filename = data.Filename
torrent.OriginalFilename = data.OriginalFilename
torrent.Bytes = data.Bytes
torrent.Progress = data.Progress
torrent.Speed = data.Speed
torrent.Seeders = data.Seeders
torrent.Links = data.Links
torrent.Status = status
torrent.Debrid = r
downloading_status := []string{"downloading", "magnet_conversion", "queued", "compressing", "uploading"}
if status == "error" || status == "dead" || status == "magnet_error" {
return torrent, fmt.Errorf("torrent: %s has error: %s", torrent.Name, status)
} else if status == "waiting_files_selection" {
files := GetTorrentFiles(data)
torrent.Files = files
if len(files) == 0 {
return torrent, fmt.Errorf("no video files found")
}
filesId := make([]string, 0)
for _, f := range files {
filesId = append(filesId, f.Id)
}
p := gourl.Values{
"files": {strings.Join(filesId, ",")},
}
payload := strings.NewReader(p.Encode())
req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/torrents/selectFiles/%s", r.Host, torrent.Id), payload)
_, err = r.client.MakeRequest(req)
if err != nil {
return torrent, err
}
} else if status == "downloaded" {
files := GetTorrentFiles(data)
torrent.Files = files
log.Printf("Torrent: %s downloaded to RD\n", torrent.Name)
if !isSymlink {
err = r.GetDownloadLinks(torrent)
if err != nil {
return torrent, err
}
}
break
} else if slices.Contains(downloading_status, status) {
if !r.DownloadUncached {
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
}
// Break out of the loop if the torrent is downloading.
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
break
}
}
return torrent, nil
}
func (r *RealDebrid) DeleteTorrent(torrent *Torrent) {
url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrent.Id)
req, _ := http.NewRequest(http.MethodDelete, url, nil)
_, err := r.client.MakeRequest(req)
if err == nil {
r.logger.Printf("Torrent: %s deleted\n", torrent.Name)
} else {
r.logger.Printf("Error deleting torrent: %s", err)
}
}
func (r *RealDebrid) GetDownloadLinks(torrent *Torrent) error {
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
downloadLinks := make([]TorrentDownloadLinks, 0)
for _, link := range torrent.Links {
if link == "" {
continue
}
payload := gourl.Values{
"link": {link},
}
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
resp, err := r.client.MakeRequest(req)
if err != nil {
return err
}
var data structs.RealDebridUnrestrictResponse
if err = json.Unmarshal(resp, &data); err != nil {
return err
}
download := TorrentDownloadLinks{
Link: data.Link,
Filename: data.Filename,
DownloadLink: data.Download,
}
downloadLinks = append(downloadLinks, download)
}
torrent.DownloadLinks = downloadLinks
return nil
}
func (r *RealDebrid) GetCheckCached() bool {
return r.CheckCached
}
func NewRealDebrid(dc common.DebridConfig, cache *common.Cache) *RealDebrid {
rl := common.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
client := common.NewRLHTTPClient(rl, headers)
logger := common.NewLogger(dc.Name, os.Stdout)
return &RealDebrid{
BaseDebrid: BaseDebrid{
Name: "realdebrid",
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
cache: cache,
MountPath: dc.Folder,
logger: logger,
CheckCached: dc.CheckCached,
},
}
}

View File

@@ -0,0 +1,400 @@
package realdebrid
import (
"encoding/json"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/cache"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"net/http"
gourl "net/url"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
)
type RealDebrid struct {
Name string
Host string `json:"host"`
APIKey string
DownloadUncached bool
client *request.RLHTTPClient
cache *cache.Cache
MountPath string
logger zerolog.Logger
CheckCached bool
}
func (r *RealDebrid) GetName() string {
return r.Name
}
func (r *RealDebrid) GetLogger() zerolog.Logger {
return r.logger
}
// GetTorrentFiles returns a list of torrent files from the torrent info
// validate is used to determine if the files should be validated
// if validate is false, selected files will be returned
func GetTorrentFiles(data TorrentInfo, validate bool) []torrent.File {
files := make([]torrent.File, 0)
cfg := config.GetConfig()
idx := 0
for _, f := range data.Files {
name := filepath.Base(f.Path)
if validate {
if utils.RegexMatch(utils.SAMPLEMATCH, name) {
// Skip sample files
continue
}
if !cfg.IsAllowedFile(name) {
continue
}
if !cfg.IsSizeAllowed(f.Bytes) {
continue
}
} else {
if f.Selected == 0 {
continue
}
}
fileId := f.ID
_link := ""
if len(data.Links) > idx {
_link = data.Links[idx]
}
file := torrent.File{
Name: name,
Path: name,
Size: f.Bytes,
Id: strconv.Itoa(fileId),
Link: _link,
}
files = append(files, file)
idx++
}
return files
}
func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool {
// Check if the infohashes are available in the local cache
hashes, result := torrent.GetLocalCache(infohashes, r.cache)
if len(hashes) == 0 {
// Either all the infohashes are locally cached or none are
r.cache.AddMultiple(result)
return result
}
// Divide hashes into groups of 100
for i := 0; i < len(hashes); i += 200 {
end := i + 200
if end > len(hashes) {
end = len(hashes)
}
// Filter out empty strings
validHashes := make([]string, 0, end-i)
for _, hash := range hashes[i:end] {
if hash != "" {
validHashes = append(validHashes, hash)
}
}
// If no valid hashes in this batch, continue to the next batch
if len(validHashes) == 0 {
continue
}
hashStr := strings.Join(validHashes, "/")
url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, hashStr)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
r.logger.Info().Msgf("Error checking availability: %v", err)
return result
}
var data AvailabilityResponse
err = json.Unmarshal(resp, &data)
if err != nil {
r.logger.Info().Msgf("Error marshalling availability: %v", err)
return result
}
for _, h := range hashes[i:end] {
hosters, exists := data[strings.ToLower(h)]
if exists && len(hosters.Rd) > 0 {
result[h] = true
}
}
}
r.cache.AddMultiple(result) // Add the results to the cache
return result
}
func (r *RealDebrid) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error) {
url := fmt.Sprintf("%s/torrents/addMagnet", r.Host)
payload := gourl.Values{
"magnet": {t.Magnet.Link},
}
var data AddMagnetSchema
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)
t.Id = data.Id
t.Debrid = r.Name
t.MountPath = r.MountPath
return t, nil
}
func (r *RealDebrid) GetTorrent(id string) (*torrent.Torrent, error) {
t := &torrent.Torrent{}
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
return t, err
}
var data TorrentInfo
err = json.Unmarshal(resp, &data)
if err != nil {
return t, err
}
name := utils.RemoveInvalidChars(data.OriginalFilename)
t.Id = id
t.Name = name
t.Bytes = data.Bytes
t.Folder = name
t.Progress = data.Progress
t.Status = data.Status
t.Speed = data.Speed
t.Seeders = data.Seeders
t.Filename = data.Filename
t.OriginalFilename = data.OriginalFilename
t.Links = data.Links
t.MountPath = r.MountPath
t.Debrid = r.Name
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
files := GetTorrentFiles(data, false) // Get selected files
t.Files = files
return t, nil
}
func (r *RealDebrid) CheckStatus(t *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, t.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
for {
resp, err := r.client.MakeRequest(req)
if err != nil {
r.logger.Info().Msgf("ERROR Checking file: %v", err)
return t, err
}
var data TorrentInfo
err = json.Unmarshal(resp, &data)
status := data.Status
name := utils.RemoveInvalidChars(data.OriginalFilename)
t.Name = name // Important because some magnet changes the name
t.Folder = name
t.Filename = data.Filename
t.OriginalFilename = data.OriginalFilename
t.Bytes = data.Bytes
t.Progress = data.Progress
t.Speed = data.Speed
t.Seeders = data.Seeders
t.Links = data.Links
t.Status = status
t.Debrid = r.Name
t.MountPath = r.MountPath
downloadingStatus := []string{"downloading", "magnet_conversion", "queued", "compressing", "uploading"}
if status == "waiting_files_selection" {
files := GetTorrentFiles(data, true) // Validate files to be selected
t.Files = files
if len(files) == 0 {
return t, fmt.Errorf("no video files found")
}
filesId := make([]string, 0)
for _, f := range files {
filesId = append(filesId, f.Id)
}
p := gourl.Values{
"files": {strings.Join(filesId, ",")},
}
payload := strings.NewReader(p.Encode())
req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/torrents/selectFiles/%s", r.Host, t.Id), payload)
_, err = r.client.MakeRequest(req)
if err != nil {
return t, err
}
} else if status == "downloaded" {
files := GetTorrentFiles(data, false) // Get selected files
t.Files = files
r.logger.Info().Msgf("Torrent: %s downloaded to RD", t.Name)
if !isSymlink {
err = r.GetDownloadLinks(t)
if err != nil {
return t, err
}
}
break
} else if slices.Contains(downloadingStatus, status) {
if !r.DownloadUncached {
go r.DeleteTorrent(t)
return t, fmt.Errorf("torrent: %s not cached", t.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 t, fmt.Errorf("torrent: %s has error: %s", t.Name, status)
}
}
return t, nil
}
func (r *RealDebrid) DeleteTorrent(torrent *torrent.Torrent) {
url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrent.Id)
req, _ := http.NewRequest(http.MethodDelete, url, nil)
_, err := r.client.MakeRequest(req)
if err == nil {
r.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
} else {
r.logger.Info().Msgf("Error deleting torrent: %s", err)
}
}
func (r *RealDebrid) GetDownloadLinks(t *torrent.Torrent) error {
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
downloadLinks := make(map[string]torrent.DownloadLinks)
for _, f := range t.Files {
dlLink := t.DownloadLinks[f.Id]
if f.Link == "" || dlLink.DownloadLink != "" {
continue
}
payload := gourl.Values{
"link": {f.Link},
}
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
resp, err := r.client.MakeRequest(req)
if err != nil {
return err
}
var data UnrestrictResponse
if err = json.Unmarshal(resp, &data); err != nil {
return err
}
download := torrent.DownloadLinks{
Link: data.Link,
Filename: data.Filename,
DownloadLink: data.Download,
}
downloadLinks[f.Id] = download
}
t.DownloadLinks = downloadLinks
return nil
}
func (r *RealDebrid) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
payload := gourl.Values{
"link": {file.Link},
}
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
resp, err := r.client.MakeRequest(req)
if err != nil {
return nil
}
var data UnrestrictResponse
if err = json.Unmarshal(resp, &data); err != nil {
return nil
}
return &torrent.DownloadLinks{
Link: data.Link,
Filename: data.Filename,
DownloadLink: data.Download,
}
}
func (r *RealDebrid) GetCheckCached() bool {
return r.CheckCached
}
func (r *RealDebrid) getTorrents(offset int, limit int) ([]*torrent.Torrent, error) {
url := fmt.Sprintf("%s/torrents?limit=%d", r.Host, limit)
if offset > 0 {
url = fmt.Sprintf("%s&offset=%d", url, offset)
}
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
return nil, err
}
var data []TorrentsResponse
if err = json.Unmarshal(resp, &data); err != nil {
return nil, err
}
torrents := make([]*torrent.Torrent, 0)
for _, t := range data {
torrents = append(torrents, &torrent.Torrent{
Id: t.Id,
Name: t.Filename,
Bytes: t.Bytes,
Progress: t.Progress,
Status: t.Status,
Filename: t.Filename,
OriginalFilename: t.Filename,
Links: t.Links,
})
}
return torrents, nil
}
func (r *RealDebrid) GetTorrents() ([]*torrent.Torrent, error) {
torrents := make([]*torrent.Torrent, 0)
offset := 0
limit := 5000
for {
ts, err := r.getTorrents(offset, limit)
if err != nil {
break
}
if len(ts) == 0 {
break
}
torrents = append(torrents, ts...)
offset = len(torrents)
}
return torrents, nil
}
func New(dc config.Debrid, cache *cache.Cache) *RealDebrid {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
client := request.NewRLHTTPClient(rl, headers)
return &RealDebrid{
Name: "realdebrid",
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
cache: cache,
MountPath: dc.Folder,
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
CheckCached: dc.CheckCached,
}
}

View File

@@ -1,13 +1,14 @@
package structs
package realdebrid
import (
"encoding/json"
"fmt"
"time"
)
type RealDebridAvailabilityResponse map[string]Hoster
type AvailabilityResponse map[string]Hoster
func (r *RealDebridAvailabilityResponse) UnmarshalJSON(data []byte) error {
func (r *AvailabilityResponse) UnmarshalJSON(data []byte) error {
// First, try to unmarshal as an object
var objectData map[string]Hoster
err := json.Unmarshal(data, &objectData)
@@ -64,12 +65,12 @@ type FileVariant struct {
Filesize int `json:"filesize"`
}
type RealDebridAddMagnetSchema struct {
type AddMagnetSchema struct {
Id string `json:"id"`
Uri string `json:"uri"`
}
type RealDebridTorrentInfo struct {
type TorrentInfo struct {
ID string `json:"id"`
Filename string `json:"filename"`
OriginalFilename string `json:"original_filename"`
@@ -89,11 +90,11 @@ type RealDebridTorrentInfo struct {
} `json:"files"`
Links []string `json:"links"`
Ended string `json:"ended,omitempty"`
Speed int `json:"speed,omitempty"`
Speed int64 `json:"speed,omitempty"`
Seeders int `json:"seeders,omitempty"`
}
type RealDebridUnrestrictResponse struct {
type UnrestrictResponse struct {
Id string `json:"id"`
Filename string `json:"filename"`
MimeType string `json:"mimeType"`
@@ -105,3 +106,17 @@ type RealDebridUnrestrictResponse struct {
Download string `json:"download"`
Streamable int `json:"streamable"`
}
type TorrentsResponse struct {
Id string `json:"id"`
Filename string `json:"filename"`
Hash string `json:"hash"`
Bytes int64 `json:"bytes"`
Host string `json:"host"`
Split int64 `json:"split"`
Progress float64 `json:"progress"`
Status string `json:"status"`
Added time.Time `json:"added"`
Links []string `json:"links"`
Ended time.Time `json:"ended"`
}

View File

@@ -1,13 +0,0 @@
package debrid
type DebridService struct {
debrids []Service
lastUsed int
}
func (d *DebridService) Get() Service {
if d.lastUsed == 0 {
return d.debrids[0]
}
return d.debrids[d.lastUsed]
}

View File

@@ -1,303 +0,0 @@
package debrid
import (
"bytes"
"encoding/json"
"fmt"
"goBlack/common"
"goBlack/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() *log.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 += 200 {
end := i + 200
if end > len(hashes) {
end = len(hashes)
}
// Filter out empty strings
validHashes := make([]string, 0, end-i)
for _, hash := range hashes[i:end] {
if hash != "" {
validHashes = append(validHashes, hash)
}
}
// If no valid hashes in this batch, continue to the next batch
if len(validHashes) == 0 {
continue
}
hashStr := strings.Join(validHashes, ",")
url := fmt.Sprintf("%s/api/torrents/checkcached?hash=%s", r.Host, hashStr)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
log.Println("Error checking availability:", err)
return result
}
var res structs.TorBoxAvailableResponse
err = json.Unmarshal(resp, &res)
if err != nil {
log.Println("Error marshalling availability:", 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\n", torrent.Name, torrentId)
torrent.Id = torrentId
return torrent, nil
}
func getStatus(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 = getStatus(data.DownloadState, data.DownloadFinished)
torrent.Speed = data.DownloadSpeed
torrent.Seeders = data.Seeds
torrent.Filename = name
torrent.OriginalFilename = name
files := make([]TorrentFile, 0)
if len(data.Files) == 0 {
return torrent, fmt.Errorf("no files found for torrent: %s", name)
}
for _, f := range data.Files {
fileName := filepath.Base(f.Name)
if (!common.RegexMatch(common.VIDEOMATCH, fileName) &&
!common.RegexMatch(common.SUBMATCH, fileName) &&
!common.RegexMatch(common.MUSICMATCH, fileName)) || common.RegexMatch(common.SAMPLEMATCH, fileName) {
continue
}
file := TorrentFile{
Id: strconv.Itoa(f.Id),
Name: fileName,
Size: f.Size,
Path: fileName,
}
files = append(files, file)
}
if len(files) == 0 {
return torrent, fmt.Errorf("no video files found")
}
cleanPath := path.Clean(data.Files[0].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.Printf("Torrent: %s downloaded\n", 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.Printf("Torrent: %s deleted\n", torrent.Name)
} else {
r.logger.Printf("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, 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,
},
}
}

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

@@ -0,0 +1,351 @@
package torbox
import (
"bytes"
"encoding/json"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/cache"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"mime/multipart"
"net/http"
gourl "net/url"
"os"
"path"
"path/filepath"
"slices"
"strconv"
"strings"
)
type Torbox struct {
Name string
Host string `json:"host"`
APIKey string
DownloadUncached bool
client *request.RLHTTPClient
cache *cache.Cache
MountPath string
logger zerolog.Logger
CheckCached bool
}
func (tb *Torbox) GetName() string {
return tb.Name
}
func (tb *Torbox) GetLogger() zerolog.Logger {
return tb.logger
}
func (tb *Torbox) IsAvailable(infohashes []string) map[string]bool {
// Check if the infohashes are available in the local cache
hashes, result := torrent.GetLocalCache(infohashes, tb.cache)
if len(hashes) == 0 {
// Either all the infohashes are locally cached or none are
tb.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", tb.Host, hashStr)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := tb.client.MakeRequest(req)
if err != nil {
tb.logger.Info().Msgf("Error checking availability: %v", err)
return result
}
var res AvailableResponse
err = json.Unmarshal(resp, &res)
if err != nil {
tb.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
}
}
}
tb.cache.AddMultiple(result) // Add the results to the cache
return result
}
func (tb *Torbox) SubmitMagnet(torrent *torrent.Torrent) (*torrent.Torrent, error) {
url := fmt.Sprintf("%s/api/torrents/createtorrent", tb.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 := tb.client.MakeRequest(req)
if err != nil {
return nil, err
}
var data AddMagnetResponse
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)
torrent.Id = torrentId
torrent.MountPath = tb.MountPath
torrent.Debrid = tb.Name
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 (tb *Torbox) GetTorrent(id string) (*torrent.Torrent, error) {
t := &torrent.Torrent{}
url := fmt.Sprintf("%s/api/torrents/mylist/?id=%s", tb.Host, id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := tb.client.MakeRequest(req)
if err != nil {
return t, err
}
var res InfoResponse
err = json.Unmarshal(resp, &res)
if err != nil {
return t, err
}
data := res.Data
name := data.Name
t.Id = id
t.Name = name
t.Bytes = data.Size
t.Folder = name
t.Progress = data.Progress * 100
t.Status = getTorboxStatus(data.DownloadState, data.DownloadFinished)
t.Speed = data.DownloadSpeed
t.Seeders = data.Seeds
t.Filename = name
t.OriginalFilename = name
t.MountPath = tb.MountPath
t.Debrid = tb.Name
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
files := make([]torrent.File, 0)
cfg := config.GetConfig()
for _, f := range data.Files {
fileName := filepath.Base(f.Name)
if utils.IsSampleFile(fileName) {
// Skip sample files
continue
}
if !cfg.IsAllowedFile(fileName) {
continue
}
if !cfg.IsSizeAllowed(f.Size) {
continue
}
file := torrent.File{
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)
}
t.OriginalFilename = strings.Split(cleanPath, "/")[0]
t.Files = files
//t.Debrid = tb
return t, nil
}
func (tb *Torbox) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
for {
t, err := tb.GetTorrent(torrent.Id)
torrent = t
if err != nil || t == nil {
return t, err
}
status := torrent.Status
if status == "downloaded" {
tb.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
if !isSymlink {
err = tb.GetDownloadLinks(torrent)
if err != nil {
return torrent, err
}
}
break
} else if status == "downloading" {
if !tb.DownloadUncached {
go tb.DeleteTorrent(torrent)
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
}
// Break out of the loop if the torrent is downloading.
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
break
} else {
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
}
}
return torrent, nil
}
func (tb *Torbox) DeleteTorrent(torrent *torrent.Torrent) {
url := fmt.Sprintf("%s/api/torrents/controltorrent/%s", tb.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 := tb.client.MakeRequest(req)
if err == nil {
tb.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
} else {
tb.logger.Info().Msgf("Error deleting torrent: %s", err)
}
}
func (tb *Torbox) GetDownloadLinks(t *torrent.Torrent) error {
downloadLinks := make(map[string]torrent.DownloadLinks)
for _, file := range t.Files {
url := fmt.Sprintf("%s/api/torrents/requestdl/", tb.Host)
query := gourl.Values{}
query.Add("torrent_id", t.Id)
query.Add("token", tb.APIKey)
query.Add("file_id", file.Id)
url += "?" + query.Encode()
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := tb.client.MakeRequest(req)
if err != nil {
return err
}
var data DownloadLinksResponse
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 := torrent.DownloadLinks{
Link: link,
Filename: t.Files[idx].Name,
DownloadLink: link,
}
downloadLinks[file.Id] = dl
}
t.DownloadLinks = downloadLinks
return nil
}
func (tb *Torbox) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
url := fmt.Sprintf("%s/api/torrents/requestdl/", tb.Host)
query := gourl.Values{}
query.Add("torrent_id", t.Id)
query.Add("token", tb.APIKey)
query.Add("file_id", file.Id)
url += "?" + query.Encode()
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := tb.client.MakeRequest(req)
if err != nil {
return nil
}
var data DownloadLinksResponse
if err = json.Unmarshal(resp, &data); err != nil {
return nil
}
if data.Data == nil {
return nil
}
link := *data.Data
return &torrent.DownloadLinks{
Link: file.Link,
Filename: file.Name,
DownloadLink: link,
}
}
func (tb *Torbox) GetCheckCached() bool {
return tb.CheckCached
}
func (tb *Torbox) GetTorrents() ([]*torrent.Torrent, error) {
return nil, fmt.Errorf("not implemented")
}
func New(dc config.Debrid, cache *cache.Cache) *Torbox {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
}
client := request.NewRLHTTPClient(rl, headers)
return &Torbox{
Name: "torbox",
Host: dc.Host,
APIKey: dc.APIKey,
DownloadUncached: dc.DownloadUncached,
client: client,
cache: cache,
MountPath: dc.Folder,
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
CheckCached: dc.CheckCached,
}
}

View File

@@ -1,21 +1,21 @@
package structs
package torbox
import "time"
type TorboxAPIResponse[T any] struct {
type APIResponse[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 {
type AvailableResponse APIResponse[map[string]struct {
Name string `json:"name"`
Size int `json:"size"`
Hash string `json:"hash"`
}]
type TorBoxAddMagnetResponse TorboxAPIResponse[struct {
type AddMagnetResponse APIResponse[struct {
Id int `json:"torrent_id"`
Hash string `json:"hash"`
}]
@@ -36,7 +36,7 @@ type torboxInfo struct {
Peers int `json:"peers"`
Ratio float64 `json:"ratio"`
Progress float64 `json:"progress"`
DownloadSpeed int `json:"download_speed"`
DownloadSpeed int64 `json:"download_speed"`
UploadSpeed int `json:"upload_speed"`
Eta int `json:"eta"`
TorrentFile bool `json:"torrent_file"`
@@ -70,6 +70,6 @@ type torboxInfo struct {
TrackerMessage interface{} `json:"tracker_message"`
}
type TorboxInfoResponse TorboxAPIResponse[torboxInfo]
type InfoResponse APIResponse[torboxInfo]
type TorBoxDownloadLinksResponse TorboxAPIResponse[string]
type DownloadLinksResponse APIResponse[string]

View File

@@ -1,116 +0,0 @@
package debrid
import (
"goBlack/common"
"goBlack/pkg/arr"
"os"
"path/filepath"
)
type Arr struct {
Name string `json:"name"`
Token string `json:"-"`
Host string `json:"host"`
}
type ArrHistorySchema struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
SortKey string `json:"sortKey"`
SortDirection string `json:"sortDirection"`
TotalRecords int `json:"totalRecords"`
Records []struct {
ID int `json:"id"`
DownloadID string `json:"downloadId"`
} `json:"records"`
}
type Torrent struct {
Id string `json:"id"`
InfoHash string `json:"info_hash"`
Name string `json:"name"`
Folder string `json:"folder"`
Filename string `json:"filename"`
OriginalFilename string `json:"original_filename"`
Size int64 `json:"size"`
Bytes int64 `json:"bytes"` // Size of only the files that are downloaded
Magnet *common.Magnet `json:"magnet"`
Files []TorrentFile `json:"files"`
Status string `json:"status"`
Added string `json:"added"`
Progress float64 `json:"progress"`
Speed int `json:"speed"`
Seeders int `json:"seeders"`
Links []string `json:"links"`
DownloadLinks []TorrentDownloadLinks `json:"download_links"`
Debrid Service
Arr *arr.Arr
}
type TorrentDownloadLinks struct {
Filename string `json:"filename"`
Link string `json:"link"`
DownloadLink string `json:"download_link"`
}
func (t *Torrent) GetSymlinkFolder(parent string) string {
return filepath.Join(parent, t.Arr.Name, t.Folder)
}
func (t *Torrent) GetMountFolder(rClonePath string) string {
possiblePaths := []string{
t.OriginalFilename,
t.Filename,
common.RemoveExtension(t.OriginalFilename),
}
for _, path := range possiblePaths {
if path != "" && common.FileReady(filepath.Join(rClonePath, path)) {
return path
}
}
return ""
}
func (t *Torrent) Delete() {
if t.Debrid == nil {
return
}
t.Debrid.DeleteTorrent(t)
}
type TorrentFile struct {
Id string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Path string `json:"path"`
Link string `json:"link"`
}
func getEventId(eventType string) int {
switch eventType {
case "grabbed":
return 1
case "seriesFolderDownloaded":
return 2
case "DownloadFolderImported":
return 3
case "DownloadFailed":
return 4
case "DownloadIgnored":
return 7
default:
return 0
}
}
func (t *Torrent) Cleanup(remove bool) {
if remove {
err := os.Remove(t.Filename)
if err != nil {
return
}
}
}

View File

@@ -0,0 +1,135 @@
package torrent
import (
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/cache"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"os"
"path/filepath"
"sync"
)
type Arr struct {
Name string `json:"name"`
Token string `json:"-"`
Host string `json:"host"`
}
type ArrHistorySchema struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
SortKey string `json:"sortKey"`
SortDirection string `json:"sortDirection"`
TotalRecords int `json:"totalRecords"`
Records []struct {
ID int `json:"id"`
DownloadID string `json:"downloadId"`
} `json:"records"`
}
type Torrent struct {
Id string `json:"id"`
InfoHash string `json:"info_hash"`
Name string `json:"name"`
Folder string `json:"folder"`
Filename string `json:"filename"`
OriginalFilename string `json:"original_filename"`
Size int64 `json:"size"`
Bytes int64 `json:"bytes"` // Size of only the files that are downloaded
Magnet *utils.Magnet `json:"magnet"`
Files []File `json:"files"`
Status string `json:"status"`
Added string `json:"added"`
Progress float64 `json:"progress"`
Speed int64 `json:"speed"`
Seeders int `json:"seeders"`
Links []string `json:"links"`
DownloadLinks map[string]DownloadLinks `json:"download_links"`
MountPath string `json:"mount_path"`
Debrid string `json:"debrid"`
Arr *arr.Arr `json:"arr"`
Mu sync.Mutex `json:"-"`
SizeDownloaded int64 `json:"-"` // This is used for local download
}
type DownloadLinks struct {
Filename string `json:"filename"`
Link string `json:"link"`
DownloadLink string `json:"download_link"`
}
func (t *Torrent) GetSymlinkFolder(parent string) string {
return filepath.Join(parent, t.Arr.Name, t.Folder)
}
func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
possiblePaths := []string{
t.OriginalFilename,
t.Filename,
utils.RemoveExtension(t.OriginalFilename),
}
for _, path := range possiblePaths {
_, err := os.Stat(filepath.Join(rClonePath, path))
if !os.IsNotExist(err) {
return path, nil
}
}
return "", fmt.Errorf("no path found")
}
type File struct {
Id string `json:"id"`
Name string `json:"name"`
Size int64 `json:"size"`
Path string `json:"path"`
Link string `json:"link"`
}
func (t *Torrent) Cleanup(remove bool) {
if remove {
err := os.Remove(t.Filename)
if err != nil {
return
}
}
}
func (t *Torrent) GetFile(id string) *File {
for _, f := range t.Files {
if f.Id == id {
return &f
}
}
return nil
}
func GetLocalCache(infohashes []string, cache *cache.Cache) ([]string, map[string]bool) {
result := make(map[string]bool)
hashes := make([]string, 0)
if len(infohashes) == 0 {
return hashes, result
}
if len(infohashes) == 1 {
if cache.Exists(infohashes[0]) {
return hashes, map[string]bool{infohashes[0]: true}
}
return infohashes, result
}
cachedHashes := cache.GetMultiple(infohashes)
for _, h := range infohashes {
_, exists := cachedHashes[h]
if !exists {
hashes = append(hashes, h)
} else {
result[h] = true
}
}
return infohashes, result
}

2
pkg/downloader/grab.go Normal file
View File

@@ -0,0 +1,2 @@
package downloader

View File

@@ -1,59 +0,0 @@
package downloaders
import (
"crypto/tls"
"fmt"
"github.com/valyala/fasthttp"
"io"
"os"
)
func GetFastHTTPClient() *fasthttp.Client {
return &fasthttp.Client{
TLSConfig: &tls.Config{InsecureSkipVerify: true},
StreamResponseBody: true,
}
}
func NormalFastHTTP(client *fasthttp.Client, url, filename string) error {
req := fasthttp.AcquireRequest()
resp := fasthttp.AcquireResponse()
defer fasthttp.ReleaseRequest(req)
defer fasthttp.ReleaseResponse(resp)
req.SetRequestURI(url)
req.Header.SetMethod(fasthttp.MethodGet)
if err := client.Do(req, resp); err != nil {
return err
}
// Check the response status code
if resp.StatusCode() != fasthttp.StatusOK {
return fmt.Errorf("unexpected status code: %d", resp.StatusCode())
}
file, err := os.Create(filename)
if err != nil {
return err
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
fmt.Println("Error closing file:", err)
return
}
}(file)
bodyStream := resp.BodyStream()
if bodyStream == nil {
// Write to memory and then to file
_, err := file.Write(resp.Body())
if err != nil {
return err
}
} else {
if _, err := io.Copy(file, bodyStream); err != nil {
return err
}
}
return nil
}

View File

@@ -1,55 +0,0 @@
package downloaders
import (
"crypto/tls"
"fmt"
"github.com/cavaliergopher/grab/v3"
"net/http"
"time"
)
func GetGrabClient() *grab.Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
}
return &grab.Client{
UserAgent: "qBitTorrent",
HTTPClient: &http.Client{
Transport: tr,
},
}
}
func NormalGrab(client *grab.Client, url, filename string) error {
req, err := grab.NewRequest(filename, url)
if err != nil {
return err
}
resp := client.Do(req)
if err := resp.Err(); err != nil {
return err
}
t := time.NewTicker(2 * time.Second)
defer t.Stop()
Loop:
for {
select {
case <-t.C:
fmt.Printf(" %s: transferred %d / %d bytes (%.2f%%)\n",
resp.Filename,
resp.BytesComplete(),
resp.Size(),
100*resp.Progress())
case <-resp.Done:
// download is complete
break Loop
}
}
if err := resp.Err(); err != nil {
return err
}
return nil
}

View File

@@ -1,44 +0,0 @@
package downloaders
import (
"crypto/tls"
"fmt"
"io"
"net/http"
"os"
)
func GetHTTPClient() *http.Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
return &http.Client{Transport: tr}
}
func NormalHTTP(client *http.Client, url, filename string) error {
file, err := os.Create(filename)
if err != nil {
return err
}
defer file.Close()
// Send the HTTP GET request
resp, err := client.Get(url)
if err != nil {
fmt.Println("Error downloading file:", err)
return err
}
defer resp.Body.Close()
// Check server response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("server returned non-200 status: %d %s", resp.StatusCode, resp.Status)
}
// Write the response body to file
_, err = io.Copy(file, resp.Body)
if err != nil {
return err
}
return nil
}

View File

@@ -9,11 +9,13 @@ import (
"fmt"
"github.com/elazarl/goproxy"
"github.com/elazarl/goproxy/ext/auth"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"github.com/valyala/fastjson"
"goBlack/common"
"goBlack/pkg/debrid"
"io"
"log"
"net/http"
"os"
"regexp"
@@ -74,24 +76,19 @@ type Proxy struct {
username string
password string
cachedOnly bool
debrid debrid.Service
cache *common.Cache
logger *log.Logger
logger zerolog.Logger
}
func NewProxy(config common.Config, deb *debrid.DebridService, cache *common.Cache) *Proxy {
cfg := config.Proxy
func NewProxy() *Proxy {
cfg := config.GetConfig().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.Get(),
cache: cache,
logger: common.NewLogger("Proxy", os.Stdout),
cachedOnly: cfg.CachedOnly,
logger: logger.NewLogger("proxy", cfg.LogLevel, os.Stdout),
}
}
@@ -185,7 +182,7 @@ func (item Item) getHash() string {
}
if strings.Contains(item.GUID, "magnet:?") {
magnet, err := common.GetMagnetInfo(item.GUID)
magnet, err := utils.GetMagnetInfo(item.GUID)
if err == nil && magnet != nil && magnet.InfoHash != "" {
return magnet.InfoHash
}
@@ -199,22 +196,22 @@ func (item Item) getHash() string {
}
if strings.Contains(magnetLink, "magnet:?") {
magnet, err := common.GetMagnetInfo(magnetLink)
magnet, err := utils.GetMagnetInfo(magnetLink)
if err == nil && magnet != nil && magnet.InfoHash != "" {
return magnet.InfoHash
}
}
//Check Description for infohash
hash := common.ExtractInfoHash(item.Description)
hash := utils.ExtractInfoHash(item.Description)
if hash == "" {
// Check Title for infohash
hash = common.ExtractInfoHash(item.Comments)
hash = utils.ExtractInfoHash(item.Comments)
}
infohash = hash
if infohash == "" {
if strings.Contains(magnetLink, "http") {
h, _ := common.GetInfohashFromURL(magnetLink)
h, _ := utils.GetInfohashFromURL(magnetLink)
if h != "" {
infohash = h
}
@@ -229,9 +226,11 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
return resp
}
svc := service.GetService()
body, err := io.ReadAll(resp.Body)
if err != nil {
p.logger.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
}
@@ -243,7 +242,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
}
@@ -263,7 +262,7 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
hashes = append(hashes, hash)
}
}
availableHashesMap := p.debrid.IsAvailable(hashes)
availableHashesMap := svc.Debrid.Get().IsAvailable(hashes)
newItems := make([]Item, 0, len(rss.Channel.Items))
if len(hashes) > 0 {
@@ -281,10 +280,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)
@@ -293,7 +292,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
}
@@ -334,13 +333,13 @@ func (p *Proxy) Start(ctx context.Context) error {
Addr: portFmt,
Handler: proxy,
}
p.logger.Printf("[*] Starting proxy server on %s\n", portFmt)
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.Printf("Error starting proxy server: %v\n", err)
p.logger.Info().Msgf("Error starting proxy server: %v", err)
}
}()
<-ctx.Done()
p.logger.Println("Shutting down gracefully...")
p.logger.Info().Msg("Shutting down gracefully...")
return srv.Shutdown(context.Background())
}

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

@@ -0,0 +1,205 @@
package qbit
import (
"crypto/tls"
"fmt"
"github.com/cavaliergopher/grab/v3"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
debrid "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
func Download(client *grab.Client, url, filename string, progressCallback func(int64, int64)) error {
req, err := grab.NewRequest(filename, url)
if err != nil {
return err
}
resp := client.Do(req)
t := time.NewTicker(time.Second)
defer t.Stop()
var lastReported int64
Loop:
for {
select {
case <-t.C:
current := resp.BytesComplete()
speed := int64(resp.BytesPerSecond())
if current != lastReported {
if progressCallback != nil {
progressCallback(current-lastReported, speed)
}
lastReported = current
}
case <-resp.Done:
break Loop
}
}
// Report final bytes
if progressCallback != nil {
progressCallback(resp.BytesComplete()-lastReported, 0)
}
return resp.Err()
}
func (q *QBit) ProcessManualFile(torrent *Torrent) (string, error) {
debridTorrent := torrent.DebridTorrent
q.logger.Info().Msgf("Downloading %d files...", len(debridTorrent.DownloadLinks))
torrentPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, utils.RemoveExtension(debridTorrent.OriginalFilename))
torrentPath = utils.RemoveInvalidChars(torrentPath)
err := os.MkdirAll(torrentPath, os.ModePerm)
if err != nil {
// add previous error to the error and return
return "", fmt.Errorf("failed to create directory: %s: %v", torrentPath, err)
}
q.downloadFiles(torrent, torrentPath)
return torrentPath, nil
}
func (q *QBit) downloadFiles(torrent *Torrent, parent string) {
debridTorrent := torrent.DebridTorrent
var wg sync.WaitGroup
semaphore := make(chan struct{}, 5)
totalSize := int64(0)
for _, file := range debridTorrent.Files {
totalSize += file.Size
}
debridTorrent.Mu.Lock()
debridTorrent.SizeDownloaded = 0 // Reset downloaded bytes
debridTorrent.Progress = 0 // Reset progress
debridTorrent.Mu.Unlock()
progressCallback := func(downloaded int64, speed int64) {
debridTorrent.Mu.Lock()
defer debridTorrent.Mu.Unlock()
torrent.Mu.Lock()
defer torrent.Mu.Unlock()
// Update total downloaded bytes
debridTorrent.SizeDownloaded += downloaded
debridTorrent.Speed = speed
// Calculate overall progress
if totalSize > 0 {
debridTorrent.Progress = float64(debridTorrent.SizeDownloaded) / float64(totalSize) * 100
}
q.UpdateTorrentMin(torrent, debridTorrent)
}
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
}
client := &grab.Client{
UserAgent: "qBitTorrent",
HTTPClient: &http.Client{
Transport: tr,
},
}
for _, link := range debridTorrent.DownloadLinks {
if link.DownloadLink == "" {
q.logger.Info().Msgf("No download link found for %s", link.Filename)
continue
}
wg.Add(1)
semaphore <- struct{}{}
go func(link debrid.DownloadLinks) {
defer wg.Done()
defer func() { <-semaphore }()
filename := link.Filename
err := Download(
client,
link.DownloadLink,
filepath.Join(parent, filename),
progressCallback,
)
if err != nil {
q.logger.Error().Msgf("Failed to download %s: %v", filename, err)
} else {
q.logger.Info().Msgf("Downloaded %s", filename)
}
}(link)
}
wg.Wait()
q.logger.Info().Msgf("Downloaded all files for %s", debridTorrent.Name)
}
func (q *QBit) ProcessSymlink(torrent *Torrent) (string, error) {
debridTorrent := torrent.DebridTorrent
files := debridTorrent.Files
if len(files) == 0 {
return "", fmt.Errorf("no video files found")
}
q.logger.Info().Msgf("Checking symlinks for %d files...", len(files))
rCloneBase := debridTorrent.MountPath
torrentPath, err := q.getTorrentPath(rCloneBase, debridTorrent) // /MyTVShow/
// This returns filename.ext for alldebrid instead of the parent folder filename/
torrentFolder := torrentPath
if err != nil {
return "", fmt.Errorf("failed to get torrent path: %v", err)
}
// Check if the torrent path is a file
torrentRclonePath := filepath.Join(rCloneBase, torrentPath) // leave it as is
if debridTorrent.Debrid == "alldebrid" && utils.IsMediaFile(torrentPath) {
// Alldebrid hotfix for single file torrents
torrentFolder = utils.RemoveExtension(torrentFolder)
torrentRclonePath = rCloneBase // /mnt/rclone/magnets/ // Remove the filename since it's in the root folder
}
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentFolder) // /mnt/symlinks/{category}/MyTVShow/
err = os.MkdirAll(torrentSymlinkPath, os.ModePerm)
if err != nil {
return "", fmt.Errorf("failed to create directory: %s: %v", torrentSymlinkPath, err)
}
pending := make(map[string]debrid.File)
for _, file := range files {
pending[file.Path] = file
}
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for len(pending) > 0 {
<-ticker.C
for path, file := range pending {
fullFilePath := filepath.Join(torrentRclonePath, file.Path)
if _, err := os.Stat(fullFilePath); !os.IsNotExist(err) {
q.logger.Info().Msgf("File is ready: %s", file.Path)
q.createSymLink(torrentSymlinkPath, torrentRclonePath, file)
delete(pending, path)
}
}
}
return torrentSymlinkPath, nil
}
func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debrid.Torrent) (string, error) {
for {
torrentPath, err := debridTorrent.GetMountFolder(rclonePath)
if err == nil {
q.logger.Debug().Msgf("Found torrent path: %s", torrentPath)
return torrentPath, err
}
time.Sleep(100 * time.Millisecond)
}
}
func (q *QBit) createSymLink(path string, torrentMountPath string, file debrid.File) {
// 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 {
// It's okay if the symlink already exists
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fullPath, err)
}
}

386
pkg/qbit/http.go Normal file
View File

@@ -0,0 +1,386 @@
package qbit
import (
"context"
"encoding/base64"
"github.com/go-chi/chi/v5"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"net/http"
"path/filepath"
"strings"
)
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) 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 *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"))
category := r.Context().Value("category").(string)
svc := service.GetService()
// Check if arr exists
a := svc.Arr.Get(category)
if a == nil {
a = arr.New(category, "", "", false)
}
if err == nil {
a.Host = strings.TrimSpace(host)
a.Token = strings.TrimSpace(token)
}
svc.Arr.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 *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Ok."))
}
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")
request.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",
}
request.JSONResponse(w, res, http.StatusOK)
}
func (q *QBit) handleShutdown(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (q *QBit) 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.Storage.GetAll(category, filter, hashes)
request.JSONResponse(w, torrents, http.StatusOK)
}
func (q *QBit) 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.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.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 *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
}
category := ctx.Value("category").(string)
for _, hash := range hashes {
q.Storage.Delete(hash, category)
}
w.WriteHeader(http.StatusOK)
}
func (q *QBit) handleTorrentsPause(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
category := ctx.Value("category").(string)
for _, hash := range hashes {
torrent := q.Storage.Get(hash, category)
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)
category := ctx.Value("category").(string)
for _, hash := range hashes {
torrent := q.Storage.Get(hash, category)
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)
category := ctx.Value("category").(string)
for _, hash := range hashes {
torrent := q.Storage.Get(hash, category)
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,
}
}
request.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)
request.JSONResponse(w, nil, http.StatusOK)
}
func (q *QBit) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hash := r.URL.Query().Get("hash")
torrent := q.Storage.Get(hash, ctx.Value("category").(string))
properties := q.GetTorrentProperties(torrent)
request.JSONResponse(w, properties, http.StatusOK)
}
func (q *QBit) handleTorrentFiles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hash := r.URL.Query().Get("hash")
torrent := q.Storage.Get(hash, ctx.Value("category").(string))
if torrent == nil {
return
}
files := q.GetTorrentFiles(torrent)
request.JSONResponse(w, files, http.StatusOK)
}
func (q *QBit) handleSetCategory(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
category := ctx.Value("category").(string)
hashes, _ := ctx.Value("hashes").([]string)
torrents := q.Storage.GetAll("", "", hashes)
for _, torrent := range torrents {
torrent.Category = category
q.Storage.AddOrUpdate(torrent)
}
request.JSONResponse(w, nil, http.StatusOK)
}
func (q *QBit) 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.Storage.GetAll("", "", hashes)
for _, t := range torrents {
q.SetTorrentTags(t, tags)
}
request.JSONResponse(w, nil, http.StatusOK)
}
func (q *QBit) 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.Storage.GetAll("", "", hashes)
for _, torrent := range torrents {
q.RemoveTorrentTags(torrent, tags)
}
request.JSONResponse(w, nil, http.StatusOK)
}
func (q *QBit) handleGetTags(w http.ResponseWriter, r *http.Request) {
request.JSONResponse(w, q.Tags, http.StatusOK)
}
func (q *QBit) 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.AddTags(tags)
request.JSONResponse(w, nil, http.StatusOK)
}

91
pkg/qbit/import.go Normal file
View File

@@ -0,0 +1,91 @@
package qbit
import (
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"time"
"github.com/google/uuid"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
)
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 *QBit) (err error) {
// Use this for now.
// This sends the torrent to the arr
svc := service.GetService()
magnet, err := utils.GetMagnetFromUrl(i.URI)
if err != nil {
return fmt.Errorf("error parsing magnet link: %w", err)
}
torrent := CreateTorrentFromMagnet(magnet, i.Arr.Name, "manual")
debridTorrent, err := debrid.ProcessTorrent(svc.Debrid, magnet, i.Arr, i.IsSymlink)
if err != nil || debridTorrent == nil {
fmt.Println("Error deleting torrent: ", err)
if debridTorrent != nil {
dbClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
go dbClient.DeleteTorrent(debridTorrent)
}
if err == nil {
err = fmt.Errorf("failed to process torrent")
}
return err
}
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
q.Storage.AddOrUpdate(torrent)
go q.ProcessFiles(torrent, debridTorrent, i.Arr, i.IsSymlink)
return nil
}

View File

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

50
pkg/qbit/misc.go Normal file
View File

@@ -0,0 +1,50 @@
package qbit
import (
"github.com/google/uuid"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
debrid "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
func checkFileLoop(wg *sync.WaitGroup, dir string, file debrid.File, ready chan<- debrid.File) {
defer wg.Done()
ticker := time.NewTicker(1 * time.Second) // Check every second
defer ticker.Stop()
path := filepath.Join(dir, file.Path)
for {
select {
case <-ticker.C:
_, err := os.Stat(path)
if !os.IsNotExist(err) {
ready <- file
return
}
}
}
}
func CreateTorrentFromMagnet(magnet *utils.Magnet, category, source string) *Torrent {
torrent := &Torrent{
ID: uuid.NewString(),
Hash: strings.ToLower(magnet.InfoHash),
Name: magnet.Name,
Size: magnet.Size,
Category: category,
Source: source,
State: "downloading",
MagnetUri: magnet.Link,
Tracker: "udp://tracker.opentrackr.org:1337",
UpLimit: -1,
DlLimit: -1,
AutoTmm: false,
Ratio: 1,
RatioLimit: 1,
}
return torrent
}

View File

@@ -1,12 +1,12 @@
package shared
package qbit
import (
"cmp"
"goBlack/common"
"goBlack/pkg/arr"
"goBlack/pkg/debrid"
"log"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"os"
"path/filepath"
)
type QBit struct {
@@ -15,32 +15,26 @@ type QBit struct {
Port string `json:"port"`
DownloadFolder string `json:"download_folder"`
Categories []string `json:"categories"`
Debrid *debrid.DebridService
cache *common.Cache
Storage *TorrentStorage
debug bool
logger *log.Logger
Arrs *arr.Storage
logger zerolog.Logger
Tags []string
RefreshInterval int
}
func NewQBit(config *common.Config, deb *debrid.DebridService, cache *common.Cache, logger *log.Logger) *QBit {
cfg := config.QBitTorrent
port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8182")
func New() *QBit {
_cfg := config.GetConfig()
cfg := _cfg.QBitTorrent
port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8282")
refreshInterval := cmp.Or(cfg.RefreshInterval, 10)
arrs := arr.NewStorage()
return &QBit{
Username: cfg.Username,
Password: cfg.Password,
Port: port,
DownloadFolder: cfg.DownloadFolder,
Categories: cfg.Categories,
Debrid: deb,
cache: cache,
debug: cfg.Debug,
Storage: NewTorrentStorage("torrents.json"),
logger: logger,
Arrs: arrs,
Storage: NewTorrentStorage(filepath.Join(_cfg.Path, "torrents.json")),
logger: logger.NewLogger("qbit", cfg.LogLevel, os.Stdout),
RefreshInterval: refreshInterval,
}
}

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

@@ -0,0 +1,43 @@
package qbit
import (
"github.com/go-chi/chi/v5"
"net/http"
)
func (q *QBit) Routes() http.Handler {
r := chi.NewRouter()
r.Use(q.CategoryContext)
r.Post("/auth/login", q.handleLogin)
r.Group(func(r chi.Router) {
r.Use(q.authContext)
r.Route("/torrents", func(r chi.Router) {
r.Use(HashesCtx)
r.Get("/info", q.handleTorrentsInfo)
r.Post("/add", q.handleTorrentsAdd)
r.Post("/delete", q.handleTorrentsDelete)
r.Get("/categories", q.handleCategories)
r.Post("/createCategory", q.handleCreateCategory)
r.Post("/setCategory", q.handleSetCategory)
r.Post("/addTags", q.handleAddTorrentTags)
r.Post("/removeTags", q.handleRemoveTorrentTags)
r.Post("/createTags", q.handleCreateTags)
r.Get("/tags", q.handleGetTags)
r.Get("/pause", q.handleTorrentsPause)
r.Get("/resume", q.handleTorrentsResume)
r.Get("/recheck", q.handleTorrentRecheck)
r.Get("/properties", q.handleTorrentProperties)
r.Get("/files", q.handleTorrentFiles)
})
r.Route("/app", func(r chi.Router) {
r.Get("/version", q.handleVersion)
r.Get("/webapiVersion", q.handleWebAPIVersion)
r.Get("/preferences", q.handlePreferences)
r.Get("/buildInfo", q.handleBuildInfo)
r.Get("/shutdown", q.handleShutdown)
})
})
return r
}

View File

@@ -1,42 +0,0 @@
package server
import (
"goBlack/common"
"goBlack/pkg/qbit/shared"
"net/http"
"path/filepath"
)
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("v4.3.2"))
}
func (s *Server) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("2.7"))
}
func (s *Server) handlePreferences(w http.ResponseWriter, r *http.Request) {
preferences := shared.NewAppPreferences()
preferences.WebUiUsername = s.qbit.Username
preferences.SavePath = s.qbit.DownloadFolder
preferences.TempPath = filepath.Join(s.qbit.DownloadFolder, "temp")
common.JSONResponse(w, preferences, http.StatusOK)
}
func (s *Server) 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 (s *Server) shutdown(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}

View File

@@ -1,7 +0,0 @@
package server
import "net/http"
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Ok."))
}

View File

@@ -1,178 +0,0 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"goBlack/common"
"goBlack/pkg/arr"
"goBlack/pkg/debrid"
"sync"
"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(s *Server) (err error) {
// Use this for now.
// This sends the torrent to the arr
q := s.qbit
magnet, err := common.GetMagnetFromUrl(i.URI)
torrent := q.CreateTorrentFromMagnet(magnet, i.Arr.Name)
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
}
func (i *ImportRequest) BetaProcess(s *Server) (err error) {
// THis actually imports the torrent into the arr. Needs more work
if i.Arr == nil {
return errors.New("invalid arr")
}
q := s.qbit
magnet, err := common.GetMagnetFromUrl(i.URI)
if err != nil {
return fmt.Errorf("error parsing magnet link: %w", err)
}
debridTorrent, err := debrid.ProcessTorrent(q.Debrid, magnet, i.Arr, true)
if err != nil || debridTorrent == nil {
if debridTorrent != nil {
go debridTorrent.Delete()
}
if err == nil {
err = fmt.Errorf("failed to process torrent")
}
return err
}
debridTorrent.Arr = i.Arr
torrentPath, err := q.ProcessSymlink(debridTorrent)
if err != nil {
return fmt.Errorf("failed to process symlink: %w", err)
}
i.Path = torrentPath
body, err := i.Arr.Import(torrentPath, i.SeriesId, i.Seasons)
if err != nil {
return fmt.Errorf("failed to import: %w", err)
}
defer body.Close()
var resp ManualImportResponseSchema
if err := json.NewDecoder(body).Decode(&resp); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if resp.Status != "success" {
return fmt.Errorf("failed to import: %s", resp.Result)
}
i.Complete()
return
}
type ImportStore struct {
Imports map[string]*ImportRequest
mu sync.RWMutex
}
func NewImportStore() *ImportStore {
return &ImportStore{
Imports: make(map[string]*ImportRequest),
}
}
func (s *ImportStore) AddImport(i *ImportRequest) {
s.mu.Lock()
defer s.mu.Unlock()
s.Imports[i.ID] = i
}
func (s *ImportStore) GetImport(id string) *ImportRequest {
s.mu.RLock()
defer s.mu.RUnlock()
return s.Imports[id]
}
func (s *ImportStore) GetAllImports() []*ImportRequest {
s.mu.RLock()
defer s.mu.RUnlock()
var imports []*ImportRequest
for _, i := range s.Imports {
imports = append(imports, i)
}
return imports
}
func (s *ImportStore) DeleteImport(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.Imports, id)
}
func (s *ImportStore) UpdateImport(i *ImportRequest) {
s.mu.Lock()
defer s.mu.Unlock()
s.Imports[i.ID] = i
}

View File

@@ -1,84 +0,0 @@
package server
import (
"context"
"encoding/base64"
"github.com/go-chi/chi/v5"
"goBlack/pkg/arr"
"net/http"
"strings"
)
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 (s *Server) 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(0)
category = r.FormValue("category")
}
}
ctx := r.Context()
ctx = context.WithValue(r.Context(), "category", category)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) 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 = host
a.Token = token
}
s.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"]
}
ctx := context.WithValue(r.Context(), "hashes", hashes)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -1,50 +0,0 @@
package server
import (
"github.com/go-chi/chi/v5"
"net/http"
)
func (s *Server) Routes(r chi.Router) http.Handler {
r.Route("/api/v2", func(r chi.Router) {
r.Use(s.CategoryContext)
r.Post("/auth/login", s.handleLogin)
r.Group(func(r chi.Router) {
r.Use(s.authContext)
r.Route("/torrents", func(r chi.Router) {
r.Use(HashesCtx)
r.Get("/info", s.handleTorrentsInfo)
r.Post("/add", s.handleTorrentsAdd)
r.Post("/delete", s.handleTorrentsDelete)
r.Get("/categories", s.handleCategories)
r.Post("/createCategory", s.handleCreateCategory)
r.Get("/pause", s.handleTorrentsPause)
r.Get("/resume", s.handleTorrentsResume)
r.Get("/recheck", s.handleTorrentRecheck)
r.Get("/properties", s.handleTorrentProperties)
r.Get("/files", s.handleTorrentFiles)
})
r.Route("/app", func(r chi.Router) {
r.Get("/version", s.handleVersion)
r.Get("/webapiVersion", s.handleWebAPIVersion)
r.Get("/preferences", s.handlePreferences)
r.Get("/buildInfo", s.handleBuildInfo)
r.Get("/shutdown", s.shutdown)
})
})
})
r.Get("/", s.handleHome)
r.Route("/internal", func(r chi.Router) {
r.Get("/arrs", s.handleGetArrs)
r.Get("/content", s.handleContent)
r.Get("/seasons/{contentId}", s.handleSeasons)
r.Get("/episodes/{contentId}", s.handleEpisodes)
r.Post("/add", s.handleAddContent)
r.Get("/search", s.handleSearch)
})
return r
}

View File

@@ -1,66 +0,0 @@
package server
import (
"context"
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"goBlack/common"
"goBlack/pkg/debrid"
"goBlack/pkg/qbit/shared"
"log"
"net/http"
"os"
"os/signal"
"syscall"
)
type Server struct {
qbit *shared.QBit
logger *log.Logger
debug bool
}
func NewServer(config *common.Config, deb *debrid.DebridService, cache *common.Cache) *Server {
logger := common.NewLogger("QBit", os.Stdout)
q := shared.NewQBit(config, deb, cache, logger)
return &Server{
qbit: q,
logger: logger,
debug: config.QBitTorrent.Debug,
}
}
func (s *Server) Start(ctx context.Context) error {
r := chi.NewRouter()
if s.debug {
r.Use(middleware.Logger)
}
r.Use(middleware.Recoverer)
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
s.Routes(r)
go s.qbit.StartWorker(context.Background())
s.logger.Printf("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) {
fmt.Printf("Error starting server: %v\n", err)
stop()
}
}()
<-ctx.Done()
fmt.Println("Shutting down gracefully...")
return srv.Shutdown(context.Background())
}

View File

@@ -1,334 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debrid Manager</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<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"/>
<!-- Select2 Bootstrap 5 Theme CSS -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css"/>
<style>
.select2-container--bootstrap-5 .select2-results__option {
padding: 0.5rem;
}
.select2-result img {
border-radius: 4px;
}
.select2-container--bootstrap-5 .select2-results__option--highlighted {
background-color: #f8f9fa !important;
color: #000 !important;
}
.select2-container--bootstrap-5 .select2-results__option--selected {
background-color: #e9ecef !important;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<span class="navbar-brand">Debrid Manager</span>
</div>
</nav>
<div class="container mt-4">
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="magnetURI" class="form-label">Magnet Link</label>
<textarea class="form-control" id="magnetURI" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="selectArr" class="form-label">Enter Category</label>
<input type="email" class="form-control" id="selectArr" placeholder="Enter Category(e.g sonarr, radarr, radarr4k)">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="isSymlink">
<label class="form-check-label" for="isSymlink">
Not Symlink(Download real files instead of symlinks from Debrid)
</label>
</div>
<div class="mt-3">
<button class="btn btn-primary" id="addToArr">
Add to Arr
</button>
</div>
</div>
<!-- <div class="col-md-6">-->
<!-- <div class="mb-3 d-none">-->
<!-- <select class="form-select mb-3 select2-ajax" id="selectContent">-->
<!-- <option></option>-->
<!-- </select>-->
<!-- </div>-->
<!-- <div class="mb-3 d-none">-->
<!-- <select class="form-select mb-3 select2-multi" id="selectSeason" multiple-->
<!-- style="width: 100%; display: none;">-->
<!-- <option value="all">Select All</option>-->
<!-- </select>-->
<!-- </div>-->
<!-- <div class="mb-4 d-none">-->
<!-- <select class="form-select mb-3 select2-multi" id="selectEpisode" multiple-->
<!-- style="width: 100%; display: none;">-->
<!-- <option value="all">Select All</option>-->
<!-- </select>-->
<!-- </div>-->
<!-- </div>-->
</div>
</div>
<!-- Bootstrap JS and Popper.js -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<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).ready(function () {
let $selectArr = $('#selectArr');
let $selectContent = $('#selectContent');
let $selectSeason = $('#selectSeason');
let $selectEpisode = $('#selectEpisode');
let $addBtn = $('#addToArr');
const $contentSearch = $('#contentSearch');
const $searchResults = $('#searchResults');
let isSonarr = true;
let searchTimeout;
let selectedArr, selectedContent, selectedSeasons, selectedEpisodes;
// Initially show only selectArr, hide others
$selectSeason.hide().closest('.mb-3').hide();
$selectEpisode.hide().closest('.mb-3').hide();
// Initialize Select2
$('.select2-multi').select2({
theme: 'bootstrap-5',
width: '100%',
placeholder: 'Select options',
allowClear: true
});
// Also hide the Select2 containers
$('.select2-container--bootstrap-5').hide();
$selectContent.select2({
theme: 'bootstrap-5',
width: '100%',
placeholder: 'Search shows/movies...',
allowClear: true,
minimumInputLength: 2,
ajax: {
url: '/internal/search',
dataType: 'json',
delay: 250,
data: function (params) {
return {
term: params.term
};
},
processResults: function (data) {
return {
results: data.map(function (item) {
return {
id: item.id,
text: item.media_type === 'movie' ? item.title : item.name,
media_type: item.media_type,
poster: item.poster_path ?
'https://image.tmdb.org/t/p/w92' + item.poster_path : null,
year: item.media_type === 'movie' ?
(item.release_date ? item.release_date.substring(0, 4) : '') :
(item.first_air_date ? item.first_air_date.substring(0, 4) : '')
};
})
};
},
cache: true
},
templateResult: formatResult,
templateSelection: formatSelection
});
function formatResult(item) {
if (!item.id) return item.text;
return $(`
<div class="select2-result d-flex align-items-center gap-2">
${item.poster ?
`<img src="${item.poster}" style="width: 45px; height: 68px; object-fit: cover;">` :
'<div style="width: 45px; height: 68px; background: #eee;"></div>'
}
<div>
<div class="fw-bold">${item.text}</div>
<small class="text-muted">
${item.year}${item.media_type === 'movie' ? 'Movie' : 'TV Series'}
</small>
</div>
</div>
`);
}
function formatSelection(item) {
if (!item.id) return item.text;
return item.text + (item.year ? ` (${item.year})` : '');
}
// Handle selection
$selectContent.on('select2:select', function (e) {
selectedContent = e.params.data.id;
const mediaType = e.params.data.media_type;
if (mediaType === 'tv') {
$selectSeason.show().closest('.mb-3').show();
$selectSeason.next('.select2-container--bootstrap-5').show();
// Fetch seasons (your existing seasons fetch code)
fetch(`/internal/seasons/${selectedContent}`)
.then(response => response.json())
.then(seasons => {
$selectSeason.empty().append('<option value="all">Select All</option>');
seasons.forEach(season => {
$selectSeason.append(`<option value="${season}">Season ${season}</option>`);
});
$selectSeason.trigger('change.select2');
})
.catch(error => console.error('Error fetching seasons:', error));
} else {
// For movies, show the Add to Arr button directly
$selectSeason.hide().closest('.mb-3').hide();
$selectSeason.next('.select2-container--bootstrap-5').hide();
$selectEpisode.hide().closest('.mb-3').hide();
$selectEpisode.next('.select2-container--bootstrap-5').hide();
$addBtn.show();
}
});
// Fetch Arrs
function fetchArrs() {
fetch('/internal/arrs')
.then(response => response.json())
.then(arrs => {
$selectArr.empty().append('<option value="">Select Arr</option>');
arrs.forEach(arr => {
$selectArr.append(`<option value="${arr.name}">${arr.name}</option>`);
});
})
.catch(error => console.error('Error fetching arrs:', error));
}
// Handle content selection
$selectContent.change(function () {
selectedContent = $(this).val();
selectedArr = $selectArr.val();
if (!selectedContent) {
$selectSeason.hide().closest('.mb-3').hide();
$selectSeason.next('.select2-container--bootstrap-5').hide();
$selectEpisode.hide().closest('.mb-3').hide();
$selectEpisode.next('.select2-container--bootstrap-5').hide();
return;
}
if (isSonarr) {
$selectSeason.show().closest('.mb-3').show();
$selectSeason.next('.select2-container--bootstrap-5').show();
// Fetch seasons
fetch(`/internal/seasons/${selectedContent}`)
.then(response => response.json())
.then(seasons => {
$selectSeason.empty().append('<option value="all">Select All</option>');
seasons.forEach(season => {
$selectSeason.append(`<option value="${season}">Season ${season}</option>`);
});
$selectSeason.trigger('change.select2');
})
.catch(error => console.error('Error fetching seasons:', error));
} else {
// For Radarr, show the Add to Arr button directly
$selectSeason.hide().closest('.mb-3').hide();
$selectSeason.next('.select2-container--bootstrap-5').hide();
$selectEpisode.hide().closest('.mb-3').hide();
$selectEpisode.next('.select2-container--bootstrap-5').hide();
$addBtn.show();
}
});
// Handle season selection
$selectSeason.change(function () {
selectedSeasons = $(this).val();
console.log('Selected seasons:', selectedSeasons);
if (!selectedSeasons || selectedSeasons.includes('all')) {
$selectEpisode.hide().closest('.mb-3').hide();
$selectEpisode.next('.select2-container--bootstrap-5').hide();
$addBtn.show();
return;
}
$selectEpisode.show().closest('.mb-3').show();
$selectEpisode.next('.select2-container--bootstrap-5').show();
fetch(`/internal/episodes/${selectedContent}?seasons=${selectedSeasons.join(',')}`)
.then(response => response.json())
.then(episodes => {
$selectEpisode.empty().append('<option value="all">Select All</option>');
episodes.forEach(episode => {
$selectEpisode.append(`<option value="${episode}">Episode ${episode}</option>`);
});
$selectEpisode.trigger('change.select2');
})
.catch(error => console.error('Error fetching episodes:', error));
$addBtn.show();
});
$addBtn.click(function () {
let oldText = $(this).text();
$(this).prop('disabled', true).prepend('<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>');
let magnet = $('#magnetURI').val();
if (!magnet) {
$(this).prop('disabled', false).text(oldText);
alert('Please provide a magnet link or upload a torrent file!');
return;
}
let data = {
arr: $selectArr.val(),
url: magnet,
notSymlink: $('#isSymlink').is(':checked'),
};
console.log('Adding to Arr:', data);
fetch('/internal/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(async response => {
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}
return response.json();
})
.then(result => {
console.log('Added to Arr:', result);
$(this).prop('disabled', false).text(oldText);
alert('Added to Arr successfully!');
})
.catch(error => {
$(this).prop('disabled', false).text(oldText);
alert(`Error adding to Arr: ${error.message || error}`);
});
});
// Initial fetch of Arrs
//fetchArrs();
});
</script>
</body>
</html>

View File

@@ -1,176 +0,0 @@
package server
import (
"context"
"goBlack/common"
"goBlack/pkg/qbit/shared"
"net/http"
"path/filepath"
"strings"
)
func (s *Server) 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 := s.qbit.Storage.GetAll(category, filter, hashes)
common.JSONResponse(w, torrents, http.StatusOK)
}
func (s *Server) 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 {
s.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 {
s.logger.Printf("Error parsing form: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
s.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 := s.qbit.AddMagnet(ctx, url, category); err != nil {
s.logger.Printf("Error adding magnet: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
if contentType == "multipart/form-data" && len(r.MultipartForm.File["torrents"]) > 0 {
files := r.MultipartForm.File["torrents"]
for _, fileHeader := range files {
if err := s.qbit.AddTorrent(ctx, fileHeader, category); err != nil {
s.logger.Printf("Error adding torrent: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) 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 {
s.qbit.Storage.Delete(hash)
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleTorrentsPause(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
for _, hash := range hashes {
torrent := s.qbit.Storage.Get(hash)
if torrent == nil {
continue
}
go s.qbit.PauseTorrent(torrent)
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleTorrentsResume(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
for _, hash := range hashes {
torrent := s.qbit.Storage.Get(hash)
if torrent == nil {
continue
}
go s.qbit.ResumeTorrent(torrent)
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleTorrentRecheck(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
for _, hash := range hashes {
torrent := s.qbit.Storage.Get(hash)
if torrent == nil {
continue
}
go s.qbit.RefreshTorrent(torrent)
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
var categories = map[string]shared.TorrentCategory{}
for _, cat := range s.qbit.Categories {
path := filepath.Join(s.qbit.DownloadFolder, cat)
categories[cat] = shared.TorrentCategory{
Name: cat,
SavePath: path,
}
}
common.JSONResponse(w, categories, http.StatusOK)
}
func (s *Server) 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
}
s.qbit.Categories = append(s.qbit.Categories, name)
common.JSONResponse(w, nil, http.StatusOK)
}
func (s *Server) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
hash := r.URL.Query().Get("hash")
torrent := s.qbit.Storage.Get(hash)
properties := s.qbit.GetTorrentProperties(torrent)
common.JSONResponse(w, properties, http.StatusOK)
}
func (s *Server) handleTorrentFiles(w http.ResponseWriter, r *http.Request) {
hash := r.URL.Query().Get("hash")
torrent := s.qbit.Storage.Get(hash)
if torrent == nil {
return
}
files := s.qbit.GetTorrentFiles(torrent)
common.JSONResponse(w, files, http.StatusOK)
}

View File

@@ -1,114 +0,0 @@
package server
import (
"embed"
"encoding/json"
"goBlack/common"
"goBlack/pkg/arr"
"html/template"
"net/http"
)
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"`
}
//go:embed static/index.html
var content embed.FS
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFS(content, "static/index.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
err = tmpl.Execute(w, nil)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (s *Server) handleGetArrs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, s.qbit.Arrs.GetAll(), http.StatusOK)
}
func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) {
arrName := r.URL.Query().Get("arr")
_arr := s.qbit.Arrs.Get(arrName)
if _arr == nil {
http.Error(w, "Invalid arr", http.StatusBadRequest)
return
}
contents := _arr.GetContents()
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, contents, http.StatusOK)
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
// arrName := r.URL.Query().Get("arr")
term := r.URL.Query().Get("term")
results, err := arr.SearchTMDB(term)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, results.Results, http.StatusOK)
}
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
// arrId := r.URL.Query().Get("arrId")
// contentId := chi.URLParam(r, "contentId")
seasons := []string{"Season 1", "Season 2", "Season 3", "Season 4", "Season 5"}
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, seasons, http.StatusOK)
}
func (s *Server) handleEpisodes(w http.ResponseWriter, r *http.Request) {
// arrId := r.URL.Query().Get("arrId")
// contentId := chi.URLParam(r, "contentId")
// seasonIds := strings.Split(r.URL.Query().Get("seasons"), ",")
episodes := []string{"Episode 1", "Episode 2", "Episode 3", "Episode 4", "Episode 5"}
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, episodes, http.StatusOK)
}
func (s *Server) 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 := s.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(s)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
common.JSONResponse(w, importReq, http.StatusOK)
}

View File

@@ -1,111 +0,0 @@
package shared
import (
"fmt"
"goBlack/common"
"goBlack/pkg/debrid"
"goBlack/pkg/downloaders"
"os"
"path/filepath"
"sync"
"time"
)
func (q *QBit) processManualFiles(debridTorrent *debrid.Torrent) (string, error) {
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 {
// 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.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(debridTorrent *debrid.Torrent) (string, error) {
var wg sync.WaitGroup
files := debridTorrent.Files
ready := make(chan debrid.TorrentFile, len(files))
q.logger.Printf("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)
}
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentPath) // /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)
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)
}
return torrentPath, nil
}
func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debrid.Torrent) (string, error) {
for {
torrentPath := debridTorrent.GetMountFolder(rclonePath)
if torrentPath != "" {
return torrentPath, nil
}
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.Name) // debridFolder/MyTVShow/MyTVShow.S01E01.720p.mkv
err := os.Symlink(torrentFilePath, fullPath)
if err != nil {
q.logger.Printf("Failed to create symlink: %s: %v\n", fullPath, err)
}
// Check if the file exists
if !common.FileReady(fullPath) {
q.logger.Printf("Symlink not ready: %s\n", fullPath)
}
}

View File

@@ -1,140 +0,0 @@
package shared
import (
"encoding/json"
"os"
"sync"
)
type TorrentStorage struct {
torrents map[string]*Torrent
mu sync.RWMutex
order []string
}
func loadTorrentsFromJSON(filename string) (map[string]*Torrent, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
torrents := make(map[string]*Torrent)
if err := json.Unmarshal(data, &torrents); err != nil {
return nil, err
}
return torrents, nil
}
func NewTorrentStorage(filename string) *TorrentStorage {
// Open the json file and read the data
torrents, err := loadTorrentsFromJSON(filename)
if err != nil {
torrents = make(map[string]*Torrent)
}
order := make([]string, 0, len(torrents))
for id := range torrents {
order = append(order, id)
}
// Create a new TorrentStorage
return &TorrentStorage{
torrents: torrents,
order: order,
}
}
func (ts *TorrentStorage) Add(torrent *Torrent) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.torrents[torrent.Hash] = torrent
ts.order = append(ts.order, torrent.Hash)
}
func (ts *TorrentStorage) AddOrUpdate(torrent *Torrent) {
ts.mu.Lock()
defer ts.mu.Unlock()
if _, exists := ts.torrents[torrent.Hash]; !exists {
ts.order = append(ts.order, torrent.Hash)
}
ts.torrents[torrent.Hash] = torrent
}
func (ts *TorrentStorage) GetByID(id string) *Torrent {
ts.mu.RLock()
defer ts.mu.RUnlock()
for _, torrent := range ts.torrents {
if torrent.ID == id {
return torrent
}
}
return nil
}
func (ts *TorrentStorage) Get(hash string) *Torrent {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.torrents[hash]
}
func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string) []*Torrent {
ts.mu.RLock()
defer ts.mu.RUnlock()
torrents := make([]*Torrent, 0)
for _, id := range ts.order {
torrent := ts.torrents[id]
if category != "" && torrent.Category != category {
continue
}
if filter != "" && torrent.State != filter {
continue
}
torrents = append(torrents, torrent)
}
if len(hashes) > 0 {
filtered := make([]*Torrent, 0, len(torrents))
for _, hash := range hashes {
if torrent := ts.torrents[hash]; torrent != nil {
filtered = append(filtered, torrent)
}
}
torrents = filtered
}
return torrents
}
func (ts *TorrentStorage) Update(torrent *Torrent) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.torrents[torrent.Hash] = torrent
}
func (ts *TorrentStorage) Delete(hash string) {
ts.mu.Lock()
defer ts.mu.Unlock()
torrent, exists := ts.torrents[hash]
if !exists {
return
}
delete(ts.torrents, hash)
for i, id := range ts.order {
if id == hash {
ts.order = append(ts.order[:i], ts.order[i+1:]...)
break
}
}
// Delete the torrent folder
if torrent.ContentPath != "" {
err := os.RemoveAll(torrent.ContentPath)
if err != nil {
return
}
}
}
func (ts *TorrentStorage) Save(filename string) error {
ts.mu.RLock()
defer ts.mu.RUnlock()
data, err := json.Marshal(ts.torrents)
if err != nil {
return err
}
return os.WriteFile(filename, data, 0644)
}

View File

@@ -1,25 +0,0 @@
package shared
import (
"goBlack/common"
"goBlack/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
}
}
}
}

166
pkg/qbit/storage.go Normal file
View File

@@ -0,0 +1,166 @@
package qbit
import (
"encoding/json"
"fmt"
"os"
"sync"
)
func keyPair(hash, category string) string {
if category == "" {
category = "uncategorized"
}
return fmt.Sprintf("%s|%s", hash, category)
}
type Torrents = map[string]*Torrent
type TorrentStorage struct {
torrents Torrents
mu sync.RWMutex
filename string // Added to store the filename for persistence
}
func loadTorrentsFromJSON(filename string) (Torrents, error) {
data, err := os.ReadFile(filename)
if err != nil {
return nil, err
}
torrents := make(Torrents)
if err := json.Unmarshal(data, &torrents); err != nil {
return nil, err
}
return torrents, nil
}
func NewTorrentStorage(filename string) *TorrentStorage {
// Open the JSON file and read the data
torrents, err := loadTorrentsFromJSON(filename)
if err != nil {
torrents = make(Torrents)
}
// Create a new TorrentStorage
return &TorrentStorage{
torrents: torrents,
filename: filename,
}
}
func (ts *TorrentStorage) Add(torrent *Torrent) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
_ = ts.saveToFile()
}
func (ts *TorrentStorage) AddOrUpdate(torrent *Torrent) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
_ = ts.saveToFile()
}
func (ts *TorrentStorage) Get(hash, category string) *Torrent {
ts.mu.RLock()
defer ts.mu.RUnlock()
torrent, exists := ts.torrents[keyPair(hash, category)]
if !exists && category == "" {
// Try to find the torrent without knowing the category
for _, t := range ts.torrents {
if t.Hash == hash {
return t
}
}
}
return torrent
}
func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string) []*Torrent {
ts.mu.RLock()
defer ts.mu.RUnlock()
torrents := make([]*Torrent, 0)
for _, torrent := range ts.torrents {
if category != "" && torrent.Category != category {
continue
}
if filter != "" && torrent.State != filter {
continue
}
torrents = append(torrents, torrent)
}
if len(hashes) > 0 {
filtered := make([]*Torrent, 0)
for _, hash := range hashes {
for _, torrent := range torrents {
if torrent.Hash == hash {
filtered = append(filtered, torrent)
}
}
}
return filtered
}
return torrents
}
func (ts *TorrentStorage) Update(torrent *Torrent) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
_ = ts.saveToFile()
}
func (ts *TorrentStorage) Delete(hash, category string) {
ts.mu.Lock()
defer ts.mu.Unlock()
key := keyPair(hash, category)
torrent, exists := ts.torrents[key]
if !exists && category == "" {
// Remove the torrent without knowing the category
for k, t := range ts.torrents {
if t.Hash == hash {
key = k
torrent = t
break
}
}
}
delete(ts.torrents, key)
// Delete the torrent folder
if torrent.ContentPath != "" {
err := os.RemoveAll(torrent.ContentPath)
if err != nil {
return
}
}
_ = ts.saveToFile()
}
func (ts *TorrentStorage) DeleteMultiple(hashes []string) {
ts.mu.Lock()
defer ts.mu.Unlock()
for _, hash := range hashes {
for key, torrent := range ts.torrents {
if torrent.Hash == hash {
delete(ts.torrents, key)
}
}
}
_ = ts.saveToFile()
}
func (ts *TorrentStorage) Save() error {
ts.mu.RLock()
defer ts.mu.RUnlock()
return ts.saveToFile()
}
// saveToFile is a helper function to write the current state to the JSON file
func (ts *TorrentStorage) saveToFile() error {
data, err := json.MarshalIndent(ts.torrents, "", " ")
if err != nil {
return err
}
return os.WriteFile(ts.filename, data, 0644)
}

View File

@@ -1,17 +1,19 @@
package shared
package qbit
import (
"cmp"
"context"
"fmt"
"github.com/google/uuid"
"goBlack/common"
"goBlack/pkg/arr"
"goBlack/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
db "github.com/sirrobot01/debrid-blackhole/pkg/debrid"
debrid "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"io"
"mime/multipart"
"os"
"path/filepath"
"slices"
"strings"
"time"
)
@@ -19,7 +21,7 @@ import (
// All torrent related helpers goes here
func (q *QBit) AddMagnet(ctx context.Context, url, category string) error {
magnet, err := common.GetMagnetFromUrl(url)
magnet, err := utils.GetMagnetFromUrl(url)
if err != nil {
return fmt.Errorf("error parsing magnet link: %w", err)
}
@@ -34,7 +36,7 @@ func (q *QBit) AddTorrent(ctx context.Context, fileHeader *multipart.FileHeader,
file, _ := fileHeader.Open()
defer file.Close()
var reader io.Reader = file
magnet, err := common.GetMagnetFromFile(reader, fileHeader.Filename)
magnet, err := utils.GetMagnetFromFile(reader, fileHeader.Filename)
if err != nil {
return fmt.Errorf("error reading file: %s \n %w", fileHeader.Filename, err)
}
@@ -45,17 +47,19 @@ func (q *QBit) AddTorrent(ctx context.Context, fileHeader *multipart.FileHeader,
return nil
}
func (q *QBit) Process(ctx context.Context, magnet *common.Magnet, category string) error {
torrent := q.CreateTorrentFromMagnet(magnet, category)
func (q *QBit) Process(ctx context.Context, magnet *utils.Magnet, category string) error {
svc := service.GetService()
torrent := 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)
debridTorrent, err := db.ProcessTorrent(svc.Debrid, magnet, a, isSymlink)
if err != nil || debridTorrent == nil {
if debridTorrent != nil {
go debridTorrent.Delete()
dbClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
go dbClient.DeleteTorrent(debridTorrent)
}
if err == nil {
err = fmt.Errorf("failed to process torrent")
@@ -68,35 +72,16 @@ func (q *QBit) Process(ctx context.Context, magnet *common.Magnet, category stri
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 *arr.Arr, isSymlink bool) {
debridClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
for debridTorrent.Status != "downloaded" {
progress := debridTorrent.Progress
q.logger.Printf("%s Download Progress: %.2f%%", debridTorrent.Debrid.GetName(), progress)
time.Sleep(5 * time.Second)
dbT, err := debridTorrent.Debrid.CheckStatus(debridTorrent, isSymlink)
q.logger.Debug().Msgf("%s -> (%s) Download Progress: %.2f%%", debridTorrent.Debrid, debridTorrent.Name, progress)
time.Sleep(10 * time.Second)
dbT, err := debridClient.CheckStatus(debridTorrent, isSymlink)
if err != nil {
q.logger.Printf("Error checking status: %v", err)
go debridTorrent.Delete()
q.logger.Error().Msgf("Error checking status: %v", err)
go debridClient.DeleteTorrent(debridTorrent)
q.MarkAsFailed(torrent)
_ = arr.Refresh()
return
@@ -105,22 +90,22 @@ func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
}
var (
torrentPath string
err error
torrentSymlinkPath string
err error
)
debridTorrent.Arr = arr
if isSymlink {
torrentPath, err = q.ProcessSymlink(debridTorrent)
torrentSymlinkPath, err = q.ProcessSymlink(torrent) // /mnt/symlinks/{category}/MyTVShow/
} else {
torrentPath, err = q.processManualFiles(debridTorrent)
torrentSymlinkPath, err = q.ProcessManualFile(torrent)
}
if err != nil {
q.MarkAsFailed(torrent)
go debridTorrent.Delete()
q.logger.Printf("Error: %v", err)
go debridClient.DeleteTorrent(debridTorrent)
q.logger.Info().Msgf("Error: %v", err)
return
}
torrent.TorrentPath = filepath.Base(torrentPath)
torrent.TorrentPath = torrentSymlinkPath
q.UpdateTorrent(torrent, debridTorrent)
_ = arr.Refresh()
}
@@ -145,18 +130,19 @@ func (q *QBit) UpdateTorrentMin(t *Torrent, debridTorrent *debrid.Torrent) *Torr
progress = progress / 100.0
sizeCompleted := int64(float64(totalSize) * progress)
var speed int
var speed int64
if debridTorrent.Speed != 0 {
speed = debridTorrent.Speed
}
var eta int
if speed != 0 {
eta = int(totalSize-sizeCompleted) / speed
eta = int((totalSize - sizeCompleted) / speed)
}
t.ID = debridTorrent.Id
t.Name = debridTorrent.Name
t.AddedOn = addedOn.Unix()
t.DebridTorrent = debridTorrent
t.Debrid = debridTorrent.Debrid
t.Size = totalSize
t.Completed = sizeCompleted
t.Downloaded = sizeCompleted
@@ -174,26 +160,19 @@ func (q *QBit) UpdateTorrentMin(t *Torrent, debridTorrent *debrid.Torrent) *Torr
}
func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent {
db := debridTorrent.Debrid
rcLoneMount := db.GetMountPath()
_db := service.GetDebrid().GetByName(debridTorrent.Debrid)
if debridTorrent == nil && t.ID != "" {
debridTorrent, _ = db.GetTorrent(t.ID)
debridTorrent, _ = _db.GetTorrent(t.ID)
}
if debridTorrent == nil {
q.logger.Printf("Torrent with ID %s not found in %s", t.ID, db.GetName())
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)
debridTorrent, _ = _db.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
t.ContentPath = t.TorrentPath + string(os.PathSeparator)
if t.IsReady() {
t.State = "pausedUP"
@@ -263,12 +242,55 @@ func (q *QBit) GetTorrentFiles(t *Torrent) []*TorrentFile {
if t.DebridTorrent == nil {
return files
}
for index, file := range t.DebridTorrent.Files {
for _, file := range t.DebridTorrent.Files {
files = append(files, &TorrentFile{
Index: index,
Name: file.Path,
Size: file.Size,
Name: file.Path,
Size: file.Size,
})
}
return files
}
func (q *QBit) SetTorrentTags(t *Torrent, tags []string) bool {
torrentTags := strings.Split(t.Tags, ",")
for _, tag := range tags {
if tag == "" {
continue
}
if !slices.Contains(torrentTags, tag) {
torrentTags = append(torrentTags, tag)
}
if !slices.Contains(q.Tags, tag) {
q.Tags = append(q.Tags, tag)
}
}
t.Tags = strings.Join(torrentTags, ",")
q.Storage.Update(t)
return true
}
func (q *QBit) RemoveTorrentTags(t *Torrent, tags []string) bool {
torrentTags := strings.Split(t.Tags, ",")
newTorrentTags := utils.RemoveItem(torrentTags, tags...)
q.Tags = utils.RemoveItem(q.Tags, tags...)
t.Tags = strings.Join(newTorrentTags, ",")
q.Storage.Update(t)
return true
}
func (q *QBit) AddTags(tags []string) bool {
for _, tag := range tags {
if tag == "" {
continue
}
if !slices.Contains(q.Tags, tag) {
q.Tags = append(q.Tags, tag)
}
}
return true
}
func (q *QBit) RemoveTags(tags []string) bool {
q.Tags = utils.RemoveItem(q.Tags, tags...)
return true
}

View File

@@ -1,6 +1,9 @@
package shared
package qbit
import "goBlack/pkg/debrid"
import (
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"sync"
)
type BuildInfo struct {
Libtorrent string `json:"libtorrent"`
@@ -169,9 +172,10 @@ type TorrentCategory struct {
}
type Torrent struct {
ID string `json:"-"`
DebridTorrent *debrid.Torrent `json:"-"`
TorrentPath string `json:"-"`
ID string `json:"id"`
DebridTorrent *torrent.Torrent `json:"-"`
Debrid string `json:"debrid"`
TorrentPath string `json:"-"`
AddedOn int64 `json:"added_on,omitempty"`
AmountLeft int64 `json:"amount_left"`
@@ -182,7 +186,7 @@ type Torrent struct {
CompletionOn int `json:"completion_on,omitempty"`
ContentPath string `json:"content_path"`
DlLimit int `json:"dl_limit"`
Dlspeed int `json:"dlspeed"`
Dlspeed int64 `json:"dlspeed"`
Downloaded int64 `json:"downloaded"`
DownloadedSession int64 `json:"downloaded_session"`
Eta int `json:"eta"`
@@ -216,7 +220,10 @@ type Torrent struct {
UpLimit int64 `json:"up_limit,omitempty"`
Uploaded int64 `json:"uploaded,omitempty"`
UploadedSession int64 `json:"uploaded_session,omitempty"`
Upspeed int `json:"upspeed,omitempty"`
Upspeed int64 `json:"upspeed,omitempty"`
Source string `json:"source,omitempty"`
Mu sync.Mutex `json:"-"`
}
func (t *Torrent) IsReady() bool {
@@ -230,7 +237,7 @@ type TorrentProperties struct {
CreatedBy string `json:"created_by,omitempty"`
CreationDate int64 `json:"creation_date,omitempty"`
DlLimit int `json:"dl_limit,omitempty"`
DlSpeed int `json:"dl_speed,omitempty"`
DlSpeed int64 `json:"dl_speed,omitempty"`
DlSpeedAvg int `json:"dl_speed_avg,omitempty"`
Eta int `json:"eta,omitempty"`
LastSeen int64 `json:"last_seen,omitempty"`
@@ -255,7 +262,7 @@ type TorrentProperties struct {
TotalUploadedSession int64 `json:"total_uploaded_session,omitempty"`
TotalWasted int64 `json:"total_wasted,omitempty"`
UpLimit int `json:"up_limit,omitempty"`
UpSpeed int `json:"up_speed,omitempty"`
UpSpeed int64 `json:"up_speed,omitempty"`
UpSpeedAvg int `json:"up_speed_avg,omitempty"`
}

View File

@@ -1,12 +1,13 @@
package shared
package qbit
import (
"context"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"time"
)
func (q *QBit) StartWorker(ctx context.Context) {
q.logger.Println("Qbit Worker started")
q.logger.Info().Msg("Qbit Worker started")
q.StartRefreshWorker(ctx)
}
@@ -16,7 +17,7 @@ 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)
@@ -28,7 +29,8 @@ func (q *QBit) StartRefreshWorker(ctx context.Context) {
}
func (q *QBit) RefreshArrs() {
for _, arr := range q.Arrs.GetAll() {
arrs := service.GetService().Arr
for _, arr := range arrs.GetAll() {
err := arr.Refresh()
if err != nil {
return

283
pkg/rclone/rclone.go Normal file
View File

@@ -0,0 +1,283 @@
package rclone
import (
"bufio"
"context"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/pkg/webdav"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
)
type Remote struct {
Type string `json:"type"`
Name string `json:"name"`
Url string `json:"url"`
MountPoint string `json:"mount_point"`
Flags map[string]string `json:"flags"`
}
func (rc *Rclone) Config() string {
var content string
for _, remote := range rc.Remotes {
content += fmt.Sprintf("[%s]\n", remote.Name)
content += fmt.Sprintf("type = %s\n", remote.Type)
content += fmt.Sprintf("url = %s\n", remote.Url)
content += fmt.Sprintf("vendor = other\n")
for key, value := range remote.Flags {
content += fmt.Sprintf("%s = %s\n", key, value)
}
content += "\n\n"
}
return content
}
type Rclone struct {
Remotes map[string]Remote `json:"remotes"`
logger zerolog.Logger
cmd *exec.Cmd
configPath string
}
func New(webdav *webdav.WebDav) (*Rclone, error) {
// Check if rclone is installed
cfg := config.GetConfig()
configPath := fmt.Sprintf("%s/rclone.conf", cfg.Path)
if _, err := exec.LookPath("rclone"); err != nil {
return nil, fmt.Errorf("rclone is not installed: %w", err)
}
remotes := make(map[string]Remote)
for _, handler := range webdav.Handlers {
url := fmt.Sprintf("http://localhost:%s/webdav/%s/", cfg.QBitTorrent.Port, strings.ToLower(handler.Name))
rmt := Remote{
Type: "webdav",
Name: handler.Name,
Url: url,
MountPoint: filepath.Join("/mnt/rclone/", handler.Name),
Flags: map[string]string{},
}
remotes[handler.Name] = rmt
}
rc := &Rclone{
logger: logger.NewLogger("rclone", "info", os.Stdout),
Remotes: remotes,
configPath: configPath,
}
if err := rc.WriteConfig(); err != nil {
return nil, err
}
return rc, nil
}
func (rc *Rclone) WriteConfig() error {
// Create config directory if it doesn't exist
configDir := filepath.Dir(rc.configPath)
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
// Write the config file
if err := os.WriteFile(rc.configPath, []byte(rc.Config()), 0600); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
rc.logger.Info().Msgf("Wrote rclone config with %d remotes to %s", len(rc.Remotes), rc.configPath)
return nil
}
func (rc *Rclone) Start(ctx context.Context) error {
var wg sync.WaitGroup
errChan := make(chan error)
for _, remote := range rc.Remotes {
wg.Add(1)
go func(remote Remote) {
defer wg.Done()
if err := rc.Mount(ctx, &remote); err != nil {
rc.logger.Error().Err(err).Msgf("failed to mount %s", remote.Name)
select {
case errChan <- err:
default:
}
}
}(remote)
}
return <-errChan
}
func (rc *Rclone) testConnection(ctx context.Context, remote *Remote) error {
testArgs := []string{
"ls",
"--config", rc.configPath,
"--log-level", "DEBUG",
remote.Name + ":",
}
cmd := exec.CommandContext(ctx, "rclone", testArgs...)
output, err := cmd.CombinedOutput()
if err != nil {
rc.logger.Error().Err(err).Str("output", string(output)).Msg("Connection test failed")
return fmt.Errorf("connection test failed: %w", err)
}
rc.logger.Info().Msg("Connection test successful")
return nil
}
func (rc *Rclone) Mount(ctx context.Context, remote *Remote) error {
// Ensure the mount point directory exists
if err := os.MkdirAll(remote.MountPoint, 0755); err != nil {
rc.logger.Info().Err(err).Msgf("failed to create mount point directory: %s", remote.MountPoint)
return err
}
//if err := rc.testConnection(ctx, remote); err != nil {
// return err
//}
// Basic arguments
args := []string{
"mount",
remote.Name + ":",
remote.MountPoint,
"--config", rc.configPath,
"--vfs-cache-mode", "full",
"--log-level", "DEBUG", // Keep this, remove -vv
"--allow-other", // Keep this
"--allow-root", // Add this
"--default-permissions", // Add this
"--vfs-cache-max-age", "24h",
"--timeout", "1m",
"--transfers", "4",
"--buffer-size", "32M",
}
// Add any additional flags
for key, value := range remote.Flags {
args = append(args, "--"+key, value)
}
// Create command
rc.cmd = exec.CommandContext(ctx, "rclone", args...)
// Set up pipes for stdout and stderr
stdout, err := rc.cmd.StdoutPipe()
if err != nil {
return err
}
stderr, err := rc.cmd.StderrPipe()
if err != nil {
return err
}
// Start the command
if err := rc.cmd.Start(); err != nil {
return err
}
// Channel to signal mount success
mountReady := make(chan bool)
mountError := make(chan error)
// Monitor stdout
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
text := scanner.Text()
rc.logger.Info().Msg("stdout: " + text)
if strings.Contains(text, "Mount succeeded") {
mountReady <- true
return
}
}
}()
// Monitor stderr
go func() {
scanner := bufio.NewScanner(stderr)
for scanner.Scan() {
text := scanner.Text()
rc.logger.Info().Msg("stderr: " + text)
if strings.Contains(text, "error") {
mountError <- fmt.Errorf("mount error: %s", text)
return
}
}
}()
// Wait for mount with timeout
select {
case <-mountReady:
rc.logger.Info().Msgf("Successfully mounted %s at %s", remote.Name, remote.MountPoint)
return nil
case err := <-mountError:
err = rc.cmd.Process.Kill()
if err != nil {
return err
}
return err
case <-ctx.Done():
err := rc.cmd.Process.Kill()
if err != nil {
return err
}
return ctx.Err()
case <-time.After(30 * time.Second):
err := rc.cmd.Process.Kill()
if err != nil {
return err
}
return fmt.Errorf("mount timeout after 30 seconds")
}
}
func (rc *Rclone) Unmount(ctx context.Context, remote *Remote) error {
if rc.cmd != nil && rc.cmd.Process != nil {
// First try graceful shutdown
if err := rc.cmd.Process.Signal(os.Interrupt); err != nil {
rc.logger.Warn().Err(err).Msg("failed to send interrupt signal")
}
// Wait for a bit to allow graceful shutdown
done := make(chan error)
go func() {
done <- rc.cmd.Wait()
}()
select {
case err := <-done:
if err != nil {
rc.logger.Warn().Err(err).Msg("process exited with error")
}
case <-time.After(5 * time.Second):
// Force kill if it doesn't shut down gracefully
if err := rc.cmd.Process.Kill(); err != nil {
rc.logger.Error().Err(err).Msg("failed to kill process")
return err
}
}
}
// Use fusermount to ensure the mountpoint is unmounted
cmd := exec.CommandContext(ctx, "fusermount", "-u", remote.MountPoint)
if err := cmd.Run(); err != nil {
rc.logger.Warn().Err(err).Msg("fusermount unmount failed")
// Don't return error here as the process might already be dead
}
rc.logger.Info().Msgf("Successfully unmounted %s", remote.MountPoint)
return nil
}

131
pkg/repair/misc.go Normal file
View File

@@ -0,0 +1,131 @@
package repair
import (
"fmt"
"os"
"path/filepath"
"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)
}
}
func fileIsSymlinked(file string) bool {
info, err := os.Lstat(file)
if err != nil {
return false
}
return info.Mode()&os.ModeSymlink != 0
}
func getSymlinkTarget(file string) string {
if fileIsSymlinked(file) {
target, err := os.Readlink(file)
if err != nil {
return ""
}
if !filepath.IsAbs(target) {
dir := filepath.Dir(file)
target = filepath.Join(dir, target)
}
return target
}
return ""
}
func fileIsReadable(filePath string) error {
// First check if file exists and is accessible
info, err := os.Stat(filePath)
if err != nil {
return err
}
// Check if it's a regular file
if !info.Mode().IsRegular() {
return fmt.Errorf("not a regular file")
}
// Try to read the first 1024 bytes
err = checkFileStart(filePath)
if err != nil {
return err
}
return nil
}
func checkFileStart(filePath string) error {
f, err := os.Open(filePath)
if err != nil {
return err
}
defer f.Close()
// Read first 1kb
buffer := make([]byte, 1024)
_, err = f.Read(buffer)
if err != nil {
return err
}
return nil
}

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

@@ -0,0 +1,352 @@
package repair
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
"log"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"
)
type Repair struct {
Jobs []Job `json:"jobs"`
arrs *arr.Storage
deb engine.Service
duration time.Duration
runOnStart bool
ZurgURL string
IsZurg bool
logger zerolog.Logger
}
func New(deb *engine.Engine, arrs *arr.Storage) *Repair {
cfg := config.GetConfig()
duration, err := parseSchedule(cfg.Repair.Interval)
if err != nil {
duration = time.Hour * 24
}
r := &Repair{
arrs: arrs,
deb: deb.Get(),
logger: logger.NewLogger("repair", cfg.LogLevel, os.Stdout),
duration: duration,
runOnStart: cfg.Repair.RunOnStart,
ZurgURL: cfg.Repair.ZurgURL,
}
if r.ZurgURL != "" {
r.IsZurg = true
}
return r
}
type Job struct {
ID string `json:"id"`
Arrs []*arr.Arr `json:"arrs"`
MediaIDs []string `json:"media_ids"`
StartedAt time.Time `json:"created_at"`
CompletedAt time.Time `json:"finished_at"`
FailedAt time.Time `json:"failed_at"`
Error string `json:"error"`
}
func (r *Repair) NewJob(arrs []*arr.Arr, mediaIDs []string) *Job {
return &Job{
ID: uuid.New().String(),
Arrs: arrs,
MediaIDs: mediaIDs,
StartedAt: time.Now(),
}
}
func (r *Repair) PreRunChecks() error {
// Check if zurg url is reachable
if !r.IsZurg {
return nil
}
resp, err := http.Get(fmt.Sprint(r.ZurgURL, "/http/version.txt"))
if err != nil {
r.logger.Debug().Err(err).Msgf("Precheck failed: Failed to reach zurg at %s", r.ZurgURL)
return err
}
if resp.StatusCode != http.StatusOK {
r.logger.Debug().Msgf("Precheck failed: Zurg returned %d", resp.StatusCode)
return err
}
return nil
}
func (r *Repair) Repair(arrs []*arr.Arr, mediaIds []string) error {
j := r.NewJob(arrs, mediaIds)
if err := r.PreRunChecks(); err != nil {
return err
}
var wg sync.WaitGroup
errors := make(chan error)
for _, a := range j.Arrs {
wg.Add(1)
go func(a *arr.Arr) {
defer wg.Done()
if len(j.MediaIDs) == 0 {
if err := r.RepairArr(a, ""); err != nil {
log.Printf("Error repairing %s: %v", a.Name, err)
errors <- err
}
} else {
for _, id := range j.MediaIDs {
if err := r.RepairArr(a, id); err != nil {
log.Printf("Error repairing %s: %v", a.Name, err)
errors <- err
}
}
}
}(a)
}
wg.Wait()
close(errors)
err := <-errors
if err != nil {
j.FailedAt = time.Now()
j.Error = err.Error()
return err
}
j.CompletedAt = time.Now()
return nil
}
func (r *Repair) Start(ctx context.Context) error {
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
cfg := config.GetConfig()
if r.runOnStart {
r.logger.Info().Msgf("Running initial repair")
go func() {
if err := r.Repair(r.arrs.GetAll(), []string{}); err != nil {
r.logger.Info().Msgf("Error during initial repair: %v", err)
}
}()
}
ticker := time.NewTicker(r.duration)
defer ticker.Stop()
r.logger.Info().Msgf("Starting repair worker with %v interval", r.duration)
for {
select {
case <-ctx.Done():
r.logger.Info().Msg("Repair worker stopped")
return nil
case t := <-ticker.C:
r.logger.Info().Msgf("Running repair at %v", t.Format("15:04:05"))
err := r.Repair(r.arrs.GetAll(), []string{})
if err != nil {
r.logger.Info().Msgf("Error during repair: %v", err)
}
// If using time-of-day schedule, reset the ticker for next day
if strings.Contains(cfg.Repair.Interval, ":") {
ticker.Reset(r.duration)
}
r.logger.Info().Msgf("Next scheduled repair at %v", t.Add(r.duration).Format("15:04:05"))
}
}
}
func (r *Repair) RepairArr(a *arr.Arr, tmdbId string) error {
cfg := config.GetConfig()
r.logger.Info().Msgf("Starting repair for %s", a.Name)
media, err := a.GetMedia(tmdbId)
if err != nil {
r.logger.Info().Msgf("Failed to get %s media: %v", a.Type, err)
return err
}
r.logger.Info().Msgf("Found %d %s media", len(media), a.Type)
if len(media) == 0 {
r.logger.Info().Msgf("No %s media found", a.Type)
return nil
}
// Check first media to confirm mounts are accessible
if !r.isMediaAccessible(media[0]) {
r.logger.Info().Msgf("Skipping repair. Parent directory not accessible for. Check your mounts")
return nil
}
semaphore := make(chan struct{}, runtime.NumCPU()*4)
totalBrokenItems := 0
var wg sync.WaitGroup
for _, m := range media {
wg.Add(1)
semaphore <- struct{}{}
go func(m arr.Content) {
defer wg.Done()
defer func() { <-semaphore }()
brokenItems := r.getBrokenFiles(m)
if brokenItems != nil {
r.logger.Debug().Msgf("Found %d broken files for %s", len(brokenItems), m.Title)
if !cfg.Repair.SkipDeletion {
if err := a.DeleteFiles(brokenItems); err != nil {
r.logger.Info().Msgf("Failed to delete broken items for %s: %v", m.Title, err)
}
}
if err := a.SearchMissing(brokenItems); err != nil {
r.logger.Info().Msgf("Failed to search missing items for %s: %v", m.Title, err)
}
totalBrokenItems += len(brokenItems)
}
}(m)
}
wg.Wait()
r.logger.Info().Msgf("Repair completed for %s. %d broken items found", a.Name, totalBrokenItems)
return nil
}
func (r *Repair) isMediaAccessible(m arr.Content) bool {
files := m.Files
if len(files) == 0 {
return false
}
firstFile := files[0]
r.logger.Debug().Msgf("Checking parent directory for %s", firstFile.Path)
if _, err := os.Stat(firstFile.Path); os.IsNotExist(err) {
return false
}
// Check symlink parent directory
symlinkPath := getSymlinkTarget(firstFile.Path)
r.logger.Debug().Msgf("Checking symlink parent directory for %s", symlinkPath)
if symlinkPath != "" {
parentSymlink := filepath.Dir(filepath.Dir(symlinkPath)) // /mnt/zurg/torrents/movie/movie.mkv -> /mnt/zurg/torrents
if _, err := os.Stat(parentSymlink); os.IsNotExist(err) {
return false
}
}
return true
}
func (r *Repair) getBrokenFiles(media arr.Content) []arr.ContentFile {
if r.IsZurg {
return r.getZurgBrokenFiles(media)
} else {
return r.getFileBrokenFiles(media)
}
}
func (r *Repair) getFileBrokenFiles(media arr.Content) []arr.ContentFile {
// This checks symlink target, try to get read a tiny bit of the file
brokenFiles := make([]arr.ContentFile, 0)
uniqueParents := make(map[string][]arr.ContentFile)
files := media.Files
for _, file := range files {
target := getSymlinkTarget(file.Path)
if target != "" {
file.IsSymlink = true
dir, _ := filepath.Split(target)
parent := filepath.Base(filepath.Clean(dir))
uniqueParents[parent] = append(uniqueParents[parent], file)
}
}
for parent, f := range uniqueParents {
// Check stat
// Check file stat first
firstFile := f[0]
// Read a tiny bit of the file
if err := fileIsReadable(firstFile.Path); err != nil {
r.logger.Debug().Msgf("Broken file found at: %s", parent)
brokenFiles = append(brokenFiles, f...)
continue
}
}
if len(brokenFiles) == 0 {
r.logger.Debug().Msgf("No broken files found for %s", media.Title)
return nil
}
r.logger.Debug().Msgf("%d broken files found for %s", len(brokenFiles), media.Title)
return brokenFiles
}
func (r *Repair) getZurgBrokenFiles(media arr.Content) []arr.ContentFile {
// Use zurg setup to check file availability with zurg
// This reduces bandwidth usage significantly
brokenFiles := make([]arr.ContentFile, 0)
uniqueParents := make(map[string][]arr.ContentFile)
files := media.Files
for _, file := range files {
target := getSymlinkTarget(file.Path)
if target != "" {
file.IsSymlink = true
dir, f := filepath.Split(target)
parent := filepath.Base(filepath.Clean(dir))
// Set target path folder/file.mkv
file.TargetPath = f
uniqueParents[parent] = append(uniqueParents[parent], file)
}
}
// Access zurg url + symlink folder + first file(encoded)
for parent, f := range uniqueParents {
r.logger.Debug().Msgf("Checking %s", parent)
encodedParent := url.PathEscape(parent)
encodedFile := url.PathEscape(f[0].TargetPath)
fullURL := fmt.Sprintf("%s/http/__all__/%s/%s", r.ZurgURL, encodedParent, encodedFile)
// Check file stat first
if _, err := os.Stat(f[0].Path); os.IsNotExist(err) {
r.logger.Debug().Msgf("Broken symlink found: %s", fullURL)
brokenFiles = append(brokenFiles, f...)
continue
}
resp, err := http.Get(fullURL)
if err != nil {
r.logger.Debug().Err(err).Msgf("Failed to reach %s", fullURL)
brokenFiles = append(brokenFiles, f...)
continue
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
brokenFiles = append(brokenFiles, f...)
continue
}
downloadUrl := resp.Request.URL.String()
if downloadUrl != "" {
r.logger.Debug().Msgf("Found download url: %s", downloadUrl)
} else {
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
brokenFiles = append(brokenFiles, f...)
continue
}
}
if len(brokenFiles) == 0 {
r.logger.Debug().Msgf("No broken files found for %s", media.Title)
return nil
}
r.logger.Debug().Msgf("%d broken files found for %s", len(brokenFiles), media.Title)
return brokenFiles
}

101
pkg/server/server.go Normal file
View File

@@ -0,0 +1,101 @@
package server
import (
"context"
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"io"
"net/http"
"os"
"os/signal"
"syscall"
)
type Server struct {
router *chi.Mux
logger zerolog.Logger
}
func New() *Server {
cfg := config.GetConfig()
l := logger.NewLogger("http", cfg.QBitTorrent.LogLevel, os.Stdout)
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
return &Server{
router: r,
logger: l,
}
}
func (s *Server) Start(ctx context.Context) error {
cfg := config.GetConfig()
// Register routes
s.router.Get("/logs", s.getLogs)
port := fmt.Sprintf(":%s", cfg.QBitTorrent.Port)
s.logger.Info().Msgf("Starting server on %s", port)
srv := &http.Server{
Addr: port,
Handler: s.router,
}
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) AddRoutes(routes func(r chi.Router) http.Handler) {
routes(s.router)
}
func (s *Server) Mount(pattern string, handler http.Handler) {
s.router.Mount(pattern, handler)
}
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
logFile := logger.GetLogPath()
// Open and read the file
file, err := os.Open(logFile)
if err != nil {
http.Error(w, "Error reading log file", http.StatusInternalServerError)
return
}
defer func(file *os.File) {
err := file.Close()
if err != nil {
s.logger.Debug().Err(err).Msg("Error closing log file")
}
}(file)
// Set headers
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Content-Disposition", "inline; filename=application.log")
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
w.Header().Set("Pragma", "no-cache")
w.Header().Set("Expires", "0")
// Stream the file
_, err = io.Copy(w, file)
if err != nil {
s.logger.Debug().Err(err).Msg("Error streaming log file")
http.Error(w, "Error streaming log file", http.StatusInternalServerError)
return
}
}

56
pkg/service/service.go Normal file
View File

@@ -0,0 +1,56 @@
package service
import (
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
"github.com/sirrobot01/debrid-blackhole/pkg/repair"
"sync"
)
type Service struct {
Repair *repair.Repair
Arr *arr.Storage
Debrid *engine.Engine
}
var (
instance *Service
once sync.Once
)
func New() *Service {
once.Do(func() {
arrs := arr.NewStorage()
deb := debrid.New()
instance = &Service{
Repair: repair.New(deb, arrs),
Arr: arrs,
Debrid: deb,
}
})
return instance
}
// GetService returns the singleton instance
func GetService() *Service {
if instance == nil {
instance = New()
}
return instance
}
func Update() *Service {
arrs := arr.NewStorage()
deb := debrid.New()
instance = &Service{
Repair: repair.New(deb, arrs),
Arr: arrs,
Debrid: deb,
}
return instance
}
func GetDebrid() *engine.Engine {
return GetService().Debrid
}

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

@@ -0,0 +1,24 @@
package version
import "fmt"
type Info struct {
Version string `json:"version"`
Channel string `json:"channel"`
}
func (i Info) String() string {
return fmt.Sprintf("%s-%s", i.Version, i.Channel)
}
var (
Version = ""
Channel = ""
)
func GetInfo() Info {
return Info{
Version: Version,
Channel: Channel,
}
}

35
pkg/web/routes.go Normal file
View File

@@ -0,0 +1,35 @@
package web
import (
"github.com/go-chi/chi/v5"
"net/http"
)
func (ui *Handler) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/login", ui.LoginHandler)
r.Post("/login", ui.LoginHandler)
r.Get("/setup", ui.SetupHandler)
r.Post("/setup", ui.SetupHandler)
r.Group(func(r chi.Router) {
r.Use(ui.authMiddleware)
r.Get("/", ui.IndexHandler)
r.Get("/download", ui.DownloadHandler)
r.Get("/repair", ui.RepairHandler)
r.Get("/config", ui.ConfigHandler)
r.Route("/internal", func(r chi.Router) {
r.Get("/arrs", ui.handleGetArrs)
r.Post("/add", ui.handleAddContent)
r.Post("/repair", ui.handleRepairMedia)
r.Get("/torrents", ui.handleGetTorrents)
r.Delete("/torrents/{category}/{hash}", ui.handleDeleteTorrent)
r.Delete("/torrents/", ui.handleDeleteTorrents)
r.Get("/config", ui.handleGetConfig)
r.Get("/version", ui.handleGetVersion)
})
})
return r
}

443
pkg/web/ui.go Normal file
View File

@@ -0,0 +1,443 @@
package web
import (
"embed"
"encoding/json"
"fmt"
"github.com/gorilla/sessions"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"golang.org/x/crypto/bcrypt"
"html/template"
"net/http"
"os"
"strings"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/version"
)
type AddRequest struct {
Url string `json:"url"`
Arr string `json:"arr"`
File string `json:"file"`
NotSymlink bool `json:"notSymlink"`
Content string `json:"content"`
Seasons []string `json:"seasons"`
Episodes []string `json:"episodes"`
}
type ArrResponse struct {
Name string `json:"name"`
Url string `json:"url"`
}
type ContentResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
ArrID string `json:"arr"`
}
type RepairRequest struct {
ArrName string `json:"arr"`
MediaIds []string `json:"mediaIds"`
Async bool `json:"async"`
}
//go:embed web/*
var content embed.FS
type Handler struct {
qbit *qbit.QBit
logger zerolog.Logger
}
func New(qbit *qbit.QBit) *Handler {
cfg := config.GetConfig()
return &Handler{
qbit: qbit,
logger: logger.NewLogger("ui", cfg.LogLevel, os.Stdout),
}
}
var (
store = sessions.NewCookieStore([]byte("your-secret-key")) // Change this to a secure key
templates *template.Template
)
func init() {
templates = template.Must(template.ParseFS(
content,
"web/layout.html",
"web/index.html",
"web/download.html",
"web/repair.html",
"web/config.html",
"web/login.html",
"web/setup.html",
))
store.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: false,
}
}
func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if setup is needed
cfg := config.GetConfig()
if cfg.NeedsSetup() && r.URL.Path != "/setup" {
http.Redirect(w, r, "/setup", http.StatusSeeOther)
return
}
if !cfg.UseAuth {
next.ServeHTTP(w, r)
return
}
// Skip auth check for setup page
if r.URL.Path == "/setup" {
next.ServeHTTP(w, r)
return
}
session, _ := store.Get(r, "auth-session")
auth, ok := session.Values["authenticated"].(bool)
if !ok || !auth {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func (ui *Handler) verifyAuth(username, password string) bool {
// If you're storing hashed password, use bcrypt to compare
if username == "" {
return false
}
auth := config.GetConfig().GetAuth()
if auth == nil {
return false
}
if username != auth.Username {
return false
}
err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password))
return err == nil
}
func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
if r.Method == "GET" {
data := map[string]interface{}{
"Page": "login",
"Title": "Login",
}
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
var credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if ui.verifyAuth(credentials.Username, credentials.Password) {
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = credentials.Username
if err := session.Save(r, w); err != nil {
http.Error(w, "Error saving session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
}
func (ui *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = false
session.Options.MaxAge = -1
err := session.Save(r, w)
if err != nil {
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (ui *Handler) SetupHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.GetConfig()
authCfg := cfg.GetAuth()
if !cfg.NeedsSetup() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.Method == "GET" {
data := map[string]interface{}{
"Page": "setup",
"Title": "Setup",
}
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
return
}
// Handle POST (setup attempt)
username := r.FormValue("username")
password := r.FormValue("password")
confirmPassword := r.FormValue("confirmPassword")
if password != confirmPassword {
http.Error(w, "Passwords do not match", http.StatusBadRequest)
return
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Error processing password", http.StatusInternalServerError)
return
}
// Set the credentials
authCfg.Username = username
authCfg.Password = string(hashedPassword)
if err := cfg.SaveAuth(authCfg); err != nil {
http.Error(w, "Error saving credentials", http.StatusInternalServerError)
return
}
// Create a session
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = username
if err := session.Save(r, w); err != nil {
http.Error(w, "Error saving session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (ui *Handler) 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 (ui *Handler) 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 (ui *Handler) 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 (ui *Handler) 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 (ui *Handler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
svc := service.GetService()
request.JSONResponse(w, svc.Arr.GetAll(), http.StatusOK)
}
func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
svc := service.GetService()
results := make([]*qbit.ImportRequest, 0)
errs := make([]string, 0)
arrName := r.FormValue("arr")
notSymlink := r.FormValue("notSymlink") == "true"
_arr := svc.Arr.Get(arrName)
if _arr == nil {
_arr = arr.New(arrName, "", "", false)
}
// Handle URLs
if urls := r.FormValue("urls"); urls != "" {
var urlList []string
for _, u := range strings.Split(urls, "\n") {
if trimmed := strings.TrimSpace(u); trimmed != "" {
urlList = append(urlList, trimmed)
}
}
for _, url := range urlList {
importReq := qbit.NewImportRequest(url, _arr, !notSymlink)
err := importReq.Process(ui.qbit)
if err != nil {
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
continue
}
results = append(results, importReq)
}
}
// Handle torrent/magnet files
if files := r.MultipartForm.File["files"]; len(files) > 0 {
for _, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to open file %s: %v", fileHeader.Filename, err))
continue
}
magnet, err := utils.GetMagnetFromFile(file, fileHeader.Filename)
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to parse torrent file %s: %v", fileHeader.Filename, err))
continue
}
importReq := qbit.NewImportRequest(magnet.Link, _arr, !notSymlink)
err = importReq.Process(ui.qbit)
if err != nil {
errs = append(errs, fmt.Sprintf("File %s: %v", fileHeader.Filename, err))
continue
}
results = append(results, importReq)
}
}
request.JSONResponse(w, struct {
Results []*qbit.ImportRequest `json:"results"`
Errors []string `json:"errors,omitempty"`
}{
Results: results,
Errors: errs,
}, http.StatusOK)
}
func (ui *Handler) 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
}
svc := service.GetService()
_arr := svc.Arr.Get(req.ArrName)
if _arr == nil {
http.Error(w, "No Arrs found to repair", http.StatusNotFound)
return
}
if req.Async {
go func() {
if err := svc.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil {
ui.logger.Error().Err(err).Msg("Failed to repair media")
}
}()
request.JSONResponse(w, "Repair process started", http.StatusOK)
return
}
if err := svc.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil {
http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
return
}
request.JSONResponse(w, "Repair completed", http.StatusOK)
}
func (ui *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
v := version.GetInfo()
request.JSONResponse(w, v, http.StatusOK)
}
func (ui *Handler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
request.JSONResponse(w, ui.qbit.Storage.GetAll("", "", nil), http.StatusOK)
}
func (ui *Handler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
category := r.URL.Query().Get("category")
if hash == "" {
http.Error(w, "No hash provided", http.StatusBadRequest)
return
}
ui.qbit.Storage.Delete(hash, category)
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
hashesStr := r.URL.Query().Get("hashes")
if hashesStr == "" {
http.Error(w, "No hashes provided", http.StatusBadRequest)
return
}
hashes := strings.Split(hashesStr, ",")
ui.qbit.Storage.DeleteMultiple(hashes)
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg := config.GetConfig()
arrCfgs := make([]config.Arr, 0)
svc := service.GetService()
for _, a := range svc.Arr.GetAll() {
arrCfgs = append(arrCfgs, config.Arr{Host: a.Host, Name: a.Name, Token: a.Token})
}
cfg.Arrs = arrCfgs
request.JSONResponse(w, cfg, http.StatusOK)
}

393
pkg/web/web/config.html Normal file
View File

@@ -0,0 +1,393 @@
{{ define "config" }}
<div class="container mt-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h4>
</div>
<div class="card-body">
<form id="configForm">
<div class="section mb-5">
<h5 class="border-bottom pb-2">General Configuration</h5>
<div class="row">
<div class="col-md-6">
<div class="form-group">
<label for="qbitDebug">Log Level</label>
<select class="form-select" name="qbit.log_level" id="log-level" disabled>
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="trace">Trace</option>
</select>
</div>
</div>
<!-- Register Magnet Link Button -->
<div class="col-md-6">
<label>
<!-- Empty label to keep the button aligned -->
</label>
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler()" id="registerMagnetLink">
Open Magnet Links in DecyphArr
</div>
</div>
<div class="col-12 mt-3">
<div class="form-group">
<label for="allowedExtensions">Allowed File Extensions</label>
<div class="input-group">
<textarea type="text"
class="form-control"
id="allowedExtensions"
name="allowed_file_types"
disabled
placeholder="mkv, mp4, avi, etc.">
</textarea>
</div>
</div>
</div>
<div class="col-md-6 mt-3">
<div class="form-group">
<label for="minFileSize">Minimum File Size</label>
<input type="text"
class="form-control"
id="minFileSize"
name="min_file_size"
disabled
placeholder="e.g., 10MB, 1GB">
<small class="form-text text-muted">Minimum file size to download (0 for no limit)</small>
</div>
</div>
<div class="col-md-6 mt-3">
<div class="form-group">
<label for="maxFileSize">Maximum File Size</label>
<input type="text"
class="form-control"
id="maxFileSize"
name="max_file_size"
disabled
placeholder="e.g., 50GB, 100MB">
<small class="form-text text-muted">Maximum file size to download (0 for no limit)</small>
</div>
</div>
</div>
</div>
<!-- Debrid Configuration -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">Debrid Configuration</h5>
<div id="debridConfigs"></div>
</div>
<!-- QBitTorrent Configuration -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">QBitTorrent Configuration</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Username</label>
<input type="text" disabled class="form-control" name="qbit.username">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Password</label>
<input type="password" disabled class="form-control" name="qbit.password">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Port</label>
<input type="text" disabled class="form-control" name="qbit.port">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Symlink/Download Folder</label>
<input type="text" disabled class="form-control" name="qbit.download_folder">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Refresh Interval (seconds)</label>
<input type="number" class="form-control" name="qbit.refresh_interval">
</div>
<div class="col-12 mb-3">
<div class="form-group">
<label for="qbitDebug">Log Level</label>
<select class="form-select" name="qbit.log_level" id="qbitDebug" disabled>
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warn">Warning</option>
<option value="error">Error</option>
<option value="trace">Trace</option>
</select>
</div>
</div>
</div>
</div>
<!-- Arr Configurations -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">Arr Configurations</h5>
<div id="arrConfigs"></div>
</div>
<!-- Repair Configuration -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">Repair Configuration</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Interval</label>
<input type="text" disabled class="form-control" name="repair.interval" placeholder="e.g., 24h">
</div>
<div class="col-12">
<div class="form-check mb-2">
<input type="checkbox" disabled class="form-check-input" name="repair.enabled" id="repairEnabled">
<label class="form-check-label" for="repairEnabled">Enable Repair</label>
</div>
<div class="form-check">
<input type="checkbox" disabled class="form-check-input" name="repair.run_on_start" id="repairOnStart">
<label class="form-check-label" for="repairOnStart">Run on Start</label>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
// Templates for dynamic elements
const debridTemplate = (index) => `
<div class="config-item position-relative mb-3 p-3 border rounded">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Name</label>
<input type="text" disabled class="form-control" name="debrid[${index}].name" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Host</label>
<input type="text" disabled class="form-control" name="debrid[${index}].host" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">API Key</label>
<input type="password" disabled class="form-control" name="debrid[${index}].api_key" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Mount Folder</label>
<input type="text" disabled class="form-control" name="debrid[${index}].folder">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Rate Limit</label>
<input type="text" disabled class="form-control" name="debrid[${index}].rate_limit" placeholder="e.g., 200/minute">
</div>
<div class="col-12">
<div class="form-check me-3 d-inline-block">
<input type="checkbox" disabled class="form-check-input" name="debrid[${index}].download_uncached">
<label class="form-check-label">Download Uncached</label>
</div>
<div class="form-check d-inline-block">
<input type="checkbox" disabled class="form-check-input" name="debrid[${index}].check_cached">
<label class="form-check-label">Check Cached</label>
</div>
</div>
</div>
</div>
`;
const arrTemplate = (index) => `
<div class="config-item position-relative mb-3 p-3 border rounded">
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Name</label>
<input type="text" disabled class="form-control" name="arr[${index}].name" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Host</label>
<input type="text" disabled class="form-control" name="arr[${index}].host" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">API Token</label>
<input type="password" disabled class="form-control" name="arr[${index}].token" required>
</div>
</div>
</div>
`;
// Main functionality
document.addEventListener('DOMContentLoaded', function() {
let debridCount = 0;
let arrCount = 0;
// Load existing configuration
fetch('/internal/config')
.then(response => response.json())
.then(config => {
// Load Debrid configs
config.debrids?.forEach(debrid => {
addDebridConfig(debrid);
});
// Load QBitTorrent config
if (config.qbittorrent) {
Object.entries(config.qbittorrent).forEach(([key, value]) => {
const input = document.querySelector(`[name="qbit.${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
});
}
// Load Arr configs
config.arrs?.forEach(arr => {
addArrConfig(arr);
});
// Load Repair config
if (config.repair) {
Object.entries(config.repair).forEach(([key, value]) => {
const input = document.querySelector(`[name="repair.${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
});
}
// Load general config
const logLevel = document.getElementById('log-level');
logLevel.value = config.log_level;
if (config.allowed_file_types && Array.isArray(config.allowed_file_types)) {
document.querySelector('[name="allowed_file_types"]').value = config.allowed_file_types.join(', ');
}
if (config.min_file_size) {
document.querySelector('[name="min_file_size"]').value = config.min_file_size;
}
if (config.max_file_size) {
document.querySelector('[name="max_file_size"]').value = config.max_file_size;
}
});
// Handle form submission
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const config = {
debrids: [],
qbittorrent: {},
arrs: [],
repair: {}
};
// Process form data
for (let [key, value] of formData.entries()) {
if (key.startsWith('debrid[')) {
const match = key.match(/debrid\[(\d+)\]\.(.+)/);
if (match) {
const [_, index, field] = match;
if (!config.debrids[index]) config.debrids[index] = {};
config.debrids[index][field] = value;
}
} else if (key.startsWith('qbit.')) {
config.qbittorrent[key.replace('qbit.', '')] = value;
} else if (key.startsWith('arr[')) {
const match = key.match(/arr\[(\d+)\]\.(.+)/);
if (match) {
const [_, index, field] = match;
if (!config.arrs[index]) config.arrs[index] = {};
config.arrs[index][field] = value;
}
} else if (key.startsWith('repair.')) {
config.repair[key.replace('repair.', '')] = value;
}
}
// Clean up arrays (remove empty entries)
config.debrids = config.debrids.filter(Boolean);
config.arrs = config.arrs.filter(Boolean);
try {
const response = await fetch('/internal/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
});
if (!response.ok) throw new Error(await response.text());
createToast('Configuration saved successfully!');
} catch (error) {
createToast(`Error saving configuration: ${error.message}`, 'error');
}
});
// Helper functions
function addDebridConfig(data = {}) {
const container = document.getElementById('debridConfigs');
container.insertAdjacentHTML('beforeend', debridTemplate(debridCount));
if (data) {
Object.entries(data).forEach(([key, value]) => {
const input = container.querySelector(`[name="debrid[${debridCount}].${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
});
}
debridCount++;
}
function addArrConfig(data = {}) {
const container = document.getElementById('arrConfigs');
container.insertAdjacentHTML('beforeend', arrTemplate(arrCount));
if (data) {
Object.entries(data).forEach(([key, value]) => {
const input = container.querySelector(`[name="arr[${arrCount}].${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
});
}
arrCount++;
}
});
// Register magnet link handler
function registerMagnetLinkHandler() {
if ('registerProtocolHandler' in navigator) {
try {
navigator.registerProtocolHandler(
'magnet',
`${window.location.origin}/download?magnet=%s`,
'DecyphArr'
);
localStorage.setItem('magnetHandler', 'true');
document.getElementById('registerMagnetLink').innerText = '✅ DecyphArr Can Open Magnet Links';
document.getElementById('registerMagnetLink').classList.add('bg-white', 'text-black');
console.log('Registered magnet link handler successfully.');
} catch (error) {
console.error('Failed to register magnet link handler:', error);
}
}
}
var magnetHandler = localStorage.getItem('magnetHandler');
if (magnetHandler === 'true') {
document.getElementById('registerMagnetLink').innerText = '✅ DecyphArr Can Open Magnet Links';
document.getElementById('registerMagnetLink').classList.add('bg-white', 'text-black');
}
</script>
{{ end }}

142
pkg/web/web/download.html Normal file
View File

@@ -0,0 +1,142 @@
{{ define "download" }}
<div class="container mt-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0"><i class="bi bi-cloud-download me-2"></i>Add New Download</h4>
</div>
<div class="card-body">
<form id="downloadForm" enctype="multipart/form-data">
<div class="mb-2">
<label for="magnetURI" class="form-label">Torrent(s)</label>
<textarea class="form-control" id="magnetURI" name="urls" rows="8" placeholder="Paste your magnet links or torrent URLs here, one per line..."></textarea>
</div>
<div class="mb-3">
<input type="file" class="form-control" id="torrentFiles" name="torrents" multiple accept=".torrent,.magnet">
</div>
<hr />
<div class="mb-3">
<label for="category" class="form-label">Enter Category</label>
<input type="text" class="form-control" id="category" name="arr" placeholder="Enter Category (e.g sonarr, radarr, radarr4k)">
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isSymlink" name="notSymlink">
<label class="form-check-label" for="isSymlink">
Download real files instead of symlinks
</label>
</div>
</div>
<button type="submit" class="btn btn-primary" id="submitDownload">
<i class="bi bi-cloud-upload me-2"></i>Add to Download Queue
</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const loadSavedDownloadOptions = () => {
const savedCategory = localStorage.getItem('downloadCategory');
const savedSymlink = localStorage.getItem('downloadSymlink');
document.getElementById('category').value = savedCategory || '';
document.getElementById('isSymlink').checked = savedSymlink === 'true'
};
const saveCurrentDownloadOptions = () => {
const category = document.getElementById('category').value;
const isSymlink = document.getElementById('isSymlink').checked;
localStorage.setItem('downloadCategory', category);
localStorage.setItem('downloadSymlink', isSymlink.toString());
};
// Load the last used download options from local storage
loadSavedDownloadOptions();
// Handle form submission
document.getElementById('downloadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submitDownload');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding...';
try {
const formData = new FormData();
// Add URLs if present
const urls = document.getElementById('magnetURI').value
.split('\n')
.map(url => url.trim())
.filter(url => url.length > 0);
if (urls.length > 0) {
formData.append('urls', urls.join('\n'));
}
// Add torrent files if present
const fileInput = document.getElementById('torrentFiles');
for (let i = 0; i < fileInput.files.length; i++) {
formData.append('files', fileInput.files[i]);
}
if (urls.length + fileInput.files.length === 0) {
createToast('Please submit at least one torrent', 'warning');
return;
}
if (urls.length + fileInput.files.length > 100) {
createToast('Please submit up to 100 torrents at a time', 'warning');
return;
}
formData.append('arr', document.getElementById('category').value);
formData.append('notSymlink', document.getElementById('isSymlink').checked);
const response = await fetch('/internal/add', {
method: 'POST',
body: formData
});
const result = await response.json();
if (!response.ok) throw new Error(result.error || 'Unknown error');
if (result.errors && result.errors.length > 0) {
if (result.results.length > 0) {
createToast(`Added ${result.results.length} torrents with ${result.errors.length} errors:\n${result.errors.join('\n')}`, 'warning');
} else {
createToast(`Failed to add torrents:\n${result.errors.join('\n')}`, 'error');
}
} else {
createToast(`Successfully added ${result.results.length} torrents!`);
}
document.getElementById('magnetURI').value = '';
document.getElementById('torrentFiles').value = '';
} catch (error) {
createToast(`Error adding downloads: ${error.message}`, 'error');
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
// Save the download options to local storage when they change
document.getElementById('category').addEventListener('change', saveCurrentDownloadOptions);
document.getElementById('isSymlink').addEventListener('change', saveCurrentDownloadOptions);
// Read the URL parameters for a magnet link and add it to the download queue if found
const urlParams = new URLSearchParams(window.location.search);
const magnetURI = urlParams.get('magnet');
if (magnetURI) {
document.getElementById('magnetURI').value = magnetURI;
history.replaceState({}, document.title, window.location.pathname);
}
});
</script>
{{ end }}

246
pkg/web/web/index.html Normal file
View File

@@ -0,0 +1,246 @@
{{ define "index" }}
<div class="container mt-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center gap-4">
<h4 class="mb-0 text-nowrap"><i class="bi bi-table me-2"></i>Active Torrents</h4>
<div class="d-flex align-items-center overflow-auto" style="flex-wrap: nowrap; gap: 0.5rem;">
<button class="btn btn-outline-danger btn-sm" id="batchDeleteBtn" style="display: none; flex-shrink: 0;">
<i class="bi bi-trash me-1"></i>Delete Selected
</button>
<button class="btn btn-outline-secondary btn-sm me-2" id="refreshBtn" style="flex-shrink: 0;">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<select class="form-select form-select-sm d-inline-block w-auto me-2" id="stateFilter" style="flex-shrink: 0;">
<option value="">All States</option>
<option value="downloading">Downloading</option>
<option value="pausedup">Paused</option>
<option value="error">Error</option>
</select>
<select class="form-select form-select-sm d-inline-block w-auto" id="categoryFilter">
<option value="">All Categories</option>
</select>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>
<input type="checkbox" class="form-check-input" id="selectAll">
</th>
<th>Name</th>
<th>Size</th>
<th>Progress</th>
<th>Speed</th>
<th>Category</th>
<th>Debrid</th>
<th>State</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="torrentsList">
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
let refs = {
torrentsList: document.getElementById('torrentsList'),
categoryFilter: document.getElementById('categoryFilter'),
stateFilter: document.getElementById('stateFilter'),
selectAll: document.getElementById('selectAll'),
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
refreshBtn: document.getElementById('refreshBtn'),
};
let state = {
torrents: [],
selectedTorrents: new Set(),
categories: new Set(),
states: new Set('downloading', 'pausedup', 'error'),
selectedCategory: refs.categoryFilter?.value || '',
selectedState: refs.stateFilter?.value || '',
};
const torrentRowTemplate = (torrent) => `
<tr data-hash="${torrent.hash}">
<td>
<input type="checkbox" class="form-check-input torrent-select" data-hash="${torrent.hash}" ${state.selectedTorrents.has(torrent.hash) ? 'checked' : ''}>
</td>
<td class="text-nowrap text-truncate overflow-hidden" style="max-width: 350px;" title="${torrent.name}">${torrent.name}</td>
<td class="text-nowrap">${formatBytes(torrent.size)}</td>
<td style="min-width: 150px;">
<div class="progress" style="height: 8px;">
<div class="progress-bar" role="progressbar"
style="width: ${(torrent.progress * 100).toFixed(1)}%"
aria-valuenow="${(torrent.progress * 100).toFixed(1)}"
aria-valuemin="0"
aria-valuemax="100"></div>
</div>
<small class="text-muted">${(torrent.progress * 100).toFixed(1)}%</small>
</td>
<td>${formatSpeed(torrent.dlspeed)}</td>
<td><span class="badge bg-secondary">${torrent.category || 'None'}</span></td>
<td>${torrent.debrid || 'None'}</td>
<td><span class="badge ${getStateColor(torrent.state)}">${torrent.state}</span></td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category}')">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
function formatBytes(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
function formatSpeed(speed) {
return `${formatBytes(speed)}/s`;
}
function getStateColor(state) {
const stateColors = {
'downloading': 'bg-primary',
'pausedup': 'bg-success',
'error': 'bg-danger',
};
return stateColors[state?.toLowerCase()] || 'bg-secondary';
}
function updateUI() {
// Filter torrents by selected category and state
let filteredTorrents = state.torrents;
if (state.selectedCategory) {
filteredTorrents = filteredTorrents.filter(t => t.category === state.selectedCategory);
}
if (state.selectedState) {
filteredTorrents = filteredTorrents.filter(t => t.state === state.selectedState);
}
// Update the torrents list table
refs.torrentsList.innerHTML = filteredTorrents.map(torrent => torrentRowTemplate(torrent)).join('');
// Update the category filter dropdown
const currentCategories = Array.from(state.categories).sort();
const categoryOptions = ['<option value="">All Categories</option>']
.concat(currentCategories.map(cat =>
`<option value="${cat}" ${cat === state.selectedCategory ? 'selected' : ''}>${cat}</option>`
));
refs.categoryFilter.innerHTML = categoryOptions.join('');
// Clean up selected torrents that no longer exist
state.selectedTorrents = new Set(
Array.from(state.selectedTorrents)
.filter(hash => filteredTorrents.some(t => t.hash === hash))
);
// Update batch delete button visibility
refs.batchDeleteBtn.style.display = state.selectedTorrents.size > 0 ? '' : 'none';
// Update the select all checkbox state
refs.selectAll.checked = filteredTorrents.length > 0 && filteredTorrents.every(torrent => state.selectedTorrents.has(torrent.hash));
}
async function loadTorrents() {
try {
const response = await fetch('/internal/torrents');
const torrents = await response.json();
state.torrents = torrents;
state.categories = new Set(torrents.map(t => t.category).filter(Boolean));
updateUI();
} catch (error) {
console.error('Error loading torrents:', error);
}
}
async function deleteTorrent(hash, category) {
if (!confirm('Are you sure you want to delete this torrent?')) return;
try {
await fetch(`/internal/torrents/${category}/${hash}`, {
method: 'DELETE'
});
await loadTorrents();
createToast('Torrent deleted successfully');
} catch (error) {
console.error('Error deleting torrent:', error);
createToast('Failed to delete torrent', 'error');
}
}
async function deleteSelectedTorrents() {
if (!confirm(`Are you sure you want to delete ${state.selectedTorrents.size} selected torrents?`)) return;
try {
// COmma separated list of hashes
const hashes = Array.from(state.selectedTorrents).join(',');
await fetch(`/internal/torrents/?hashes=${encodeURIComponent(hashes)}`, {
method: 'DELETE'
});
await loadTorrents();
createToast('Selected torrents deleted successfully');
} catch (error) {
console.error('Error deleting torrents:', error);
createToast('Failed to delete some torrents' , 'error');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadTorrents();
const refreshInterval = setInterval(loadTorrents, 5000);
refs.refreshBtn.addEventListener('click', loadTorrents);
refs.batchDeleteBtn.addEventListener('click', deleteSelectedTorrents);
refs.selectAll.addEventListener('change', (e) => {
const filteredTorrents = state.torrents.filter(t => {
if (state.selectedCategory && t.category !== state.selectedCategory) return false;
if (state.selectedState && t.state?.toLowerCase() !== state.selectedState.toLowerCase()) return false;
return true;
});
if (e.target.checked) {
filteredTorrents.forEach(torrent => state.selectedTorrents.add(torrent.hash));
} else {
filteredTorrents.forEach(torrent => state.selectedTorrents.delete(torrent.hash));
}
updateUI();
});
refs.torrentsList.addEventListener('change', (e) => {
if (e.target.classList.contains('torrent-select')) {
const hash = e.target.dataset.hash;
if (e.target.checked) {
state.selectedTorrents.add(hash);
} else {
state.selectedTorrents.delete(hash);
}
updateUI();
}
});
refs.categoryFilter.addEventListener('change', (e) => {
state.selectedCategory = e.target.value;
updateUI();
});
refs.stateFilter.addEventListener('change', (e) => {
state.selectedState = e.target.value;
updateUI();
});
window.addEventListener('beforeunload', () => {
clearInterval(refreshInterval);
});
});
</script>
{{ end }}

196
pkg/web/web/layout.html Normal file
View File

@@ -0,0 +1,196 @@
{{ define "layout" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DecyphArr - {{.Title}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css"/>
<style>
:root {
--primary-color: #2563eb;
--secondary-color: #1e40af;
}
body {
background-color: #f8fafc;
}
.navbar {
padding: 1rem 0;
background: #fff !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
color: var(--primary-color) !important;
font-weight: 700;
font-size: 1.5rem;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.nav-link {
padding: 0.5rem 1rem;
color: #4b5563;
}
.nav-link.active {
color: var(--primary-color) !important;
font-weight: 500;
}
.badge#channel-badge {
background-color: #0d6efd;
}
.badge#channel-badge.beta {
background-color: #fd7e14;
}
</style>
</head>
<body>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<!-- Toast messages will be created dynamically here -->
</div>
<nav class="navbar navbar-expand-lg navbar-light mb-4">
<div class="container">
<a class="navbar-brand" href="/">
<i class="bi bi-cloud-download me-2"></i>DecyphArr
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{if eq .Page "index"}}active{{end}}" href="/">
<i class="bi bi-table me-1"></i>Torrents
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "download"}}active{{end}}" href="/download">
<i class="bi bi-cloud-download me-1"></i>Download
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "repair"}}active{{end}}" href="/repair">
<i class="bi bi-tools me-1"></i>Repair
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="/config">
<i class="bi bi-gear me-1"></i>Config
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/logs" target="_blank">
<i class="bi bi-journal me-1"></i>Logs
</a>
</li>
</ul>
<div class="d-flex align-items-center">
<span class="badge me-2" id="channel-badge">Loading...</span>
<span class="badge bg-primary" id="version-badge">Loading...</span>
</div>
</div>
</div>
</nav>
{{ if eq .Page "index" }}
{{ template "index" . }}
{{ else if eq .Page "download" }}
{{ template "download" . }}
{{ else if eq .Page "repair" }}
{{ template "repair" . }}
{{ else if eq .Page "config" }}
{{ template "config" . }}
{{ else if eq .Page "login" }}
{{ template "login" . }}
{{ else if eq .Page "setup" }}
{{ template "setup" . }}
{{ else }}
{{ end }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
/**
* Create a toast message
* @param {string} message - The message to display
* @param {string} [type='success'] - The type of toast (success, warning, error)
*/
const createToast = (message, type = 'success') => {
type = ['success', 'warning', 'error'].includes(type) ? type : 'success';
const toastTimeouts = {
success: 5000,
warning: 10000,
error: 15000
}
const toastContainer = document.querySelector('.toast-container');
const toastId = `toast-${Date.now()}`;
const toastHtml = `
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header ${type === 'error' ? 'bg-danger text-white' : type === 'warning' ? 'bg-warning text-dark' : 'bg-success text-white'}">
<strong class="me-auto">
${type === 'error' ? 'Error' : type === 'warning' ? 'Warning' : 'Success'}
</strong>
<button type="button" class="btn-close ${type === 'warning' ? '' : 'btn-close-white'}" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body">
${message.replace(/\n/g, '<br>')}
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: toastTimeouts[type]
});
toast.show();
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
};
document.addEventListener('DOMContentLoaded', function() {
fetch('/internal/version')
.then(response => response.json())
.then(data => {
const versionBadge = document.getElementById('version-badge');
const channelBadge = document.getElementById('channel-badge');
// Add url to version badge
versionBadge.innerHTML = `<a href="https://github.com/sirrobot01/debrid-blackhole/releases/tag/${data.version}" target="_blank" class="text-white">${data.version}</a>`;
channelBadge.textContent = data.channel.charAt(0).toUpperCase() + data.channel.slice(1);
if (data.channel === 'beta') {
channelBadge.classList.add('beta');
}
})
.catch(error => {
console.error('Error fetching version:', error);
document.getElementById('version-badge').textContent = 'Unknown';
document.getElementById('channel-badge').textContent = 'Unknown';
});
});
</script>
</body>
</html>
{{ end }}

131
pkg/web/web/login.html Normal file
View File

@@ -0,0 +1,131 @@
{{ define "login" }}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0 text-center">Login</h4>
</div>
<div class="card-body">
<form id="loginForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Login</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = {
username: document.getElementById('username').value,
password: document.getElementById('password').value
};
try {
const response = await fetch('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
window.location.href = '/';
} else {
createToast('Invalid credentials', 'error');
}
} catch (error) {
console.error('Login error:', error);
createToast('Login failed', 'error');
}
});
</script>
{{ end }}
{{ define "setup" }}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0 text-center">First Time Setup</h4>
</div>
<div class="card-body">
<form id="setupForm">
<div class="mb-3">
<label for="username" class="form-label">Choose Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Choose Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Set Credentials</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('setupForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
createToast('Passwords do not match', 'error');
return;
}
const formData = {
username: document.getElementById('username').value,
password: password,
confirmPassword: confirmPassword
};
try {
const response = await fetch('/setup', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
window.location.href = '/';
} else {
const error = await response.text();
createToast(error, 'error');
}
} catch (error) {
console.error('Setup error:', error);
createToast('Setup failed', 'error');
}
});
</script>
{{ end }}

94
pkg/web/web/repair.html Normal file
View File

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

32
pkg/web/web/setup.html Normal file
View File

@@ -0,0 +1,32 @@
{{ define "setup" }}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0 text-center">First Time Setup</h4>
</div>
<div class="card-body">
<form id="setupForm" method="POST" action="/setup">
<div class="mb-3">
<label for="username" class="form-label">Choose Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Choose Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Set Credentials</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{{ end }}

Some files were not shown because too many files have changed in this diff Show More