26 Commits

Author SHA1 Message Date
Mukhtar Akere
003f73c456 Merge branch 'beta' of github.com:sirrobot01/debrid-blackhole into beta
Some checks failed
GoReleaser / goreleaser (push) Has been cancelled
Release Docker Build / docker (push) Has been cancelled
2025-04-03 11:26:47 +01:00
Mukhtar Akere
b34935d490 hotfix un-cached downloading 2025-04-03 11:26:27 +01:00
Mukhtar Akere
dc6ee2f020 fix umask for windows
Some checks failed
GoReleaser / goreleaser (push) Has been cancelled
Release Docker Build / docker (push) Has been cancelled
2025-03-23 07:14:46 +01:00
Mukhtar Akere
fce0fc0215 update changelog 2025-03-22 06:11:15 +01:00
David Young
4e2fb9c74f Fix minor doc issues (#47)
Signed-off-by: David Young <davidy@funkypenguin.co.nz>
2025-03-18 21:36:05 -07:00
Mukhtar Akere
26f6f384a3 Fix arr download_uncached settings 2025-03-16 05:43:25 +01:00
Mukhtar Akere
b91aa1db38 Add a precacher to significantly improve importing to arrs/plex 2025-03-15 23:12:37 +01:00
Mukhtar Akere
e2ff3b26de Add Umask support 2025-03-15 21:30:19 +01:00
Mukhtar Akere
b4e4db27fb Hotfix for sonarr search
Some checks failed
GoReleaser / goreleaser (push) Has been cancelled
Release Docker Build / docker (push) Has been cancelled
2025-03-13 09:07:35 +01:00
Mukhtar Akere
c0589d4ad2 fix repair; repair.json, remove arr details from endpoint 2025-03-12 04:53:01 +01:00
Mukhtar Akere
a30861984c Fix saveTofile; Add a global panic, Add a recoverer for everything functions 2025-03-11 18:08:03 +01:00
Mukhtar Akere
4f92b135d4 hotfix repair; handle 206 requests; increases log retention 2025-03-11 04:22:51 +01:00
Mukhtar Akere
2b2a682218 - Fix ARR flaky bug
- Refined download uncached options
- Deprecate qbittorent log level
- Skip Repair for specified arr
2025-03-09 03:56:34 +01:00
Mukhtar Akere
a83f3d72ce Changelog 0.5.0 2025-03-05 20:15:10 +01:00
Mukhtar Akere
1c06407900 Hotfixes
Some checks failed
GoReleaser / goreleaser (push) Has been cancelled
Release Docker Build / docker (push) Has been cancelled
2025-03-02 14:33:58 +01:00
Mukhtar Akere
b1a3d8b762 Minor bug fixes 2025-02-28 21:25:47 +01:00
Mukhtar Akere
0e25de0e3c Hotfix 2025-02-28 20:48:30 +01:00
Mukhtar Akere
e741a0e32b Hotfix 2025-02-28 20:21:45 +01:00
Mukhtar Akere
84bd93805f try to fix memory hogging 2025-02-28 16:05:04 +01:00
Mukhtar Akere
fce2ce28c7 Finalize workflow 2025-02-28 04:06:51 +01:00
Mukhtar Akere
302a461efd Update workflows 2025-02-28 04:03:13 +01:00
Mukhtar Akere
7eb021aac1 - Add ghcr
- Add checks for arr url and token
2025-02-28 03:57:26 +01:00
Mukhtar Akere
7a989ccf2b hotfix v0.4.2 2025-02-28 03:33:11 +01:00
Mukhtar Akere
f04d7ac86e hotfixes 2025-02-28 03:10:14 +01:00
Mukhtar Akere
65fb2d1e7c revamp deployment 2025-02-28 00:54:11 +01:00
Mukhtar Akere
46beac7227 Changelog 0.4.2 2025-02-28 00:38:31 +01:00
55 changed files with 2624 additions and 1102 deletions

View File

@@ -5,7 +5,7 @@ tmp_dir = "tmp"
[build]
args_bin = ["--config", "data/"]
bin = "./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 .'"
cmd = "bash -c 'go build -ldflags \"-X github.com/sirrobot01/debrid-blackhole/pkg/version.Version=0.0.0 -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel=dev\" -o ./tmp/main .'"
delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "data"]
exclude_file = []

85
.github/workflows/beta-docker.yml vendored Normal file
View File

@@ -0,0 +1,85 @@
name: Beta Docker Build
on:
push:
branches:
- beta
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Calculate beta version
id: calculate_version
run: |
LATEST_TAG=$(git tag | grep -v 'beta' | sort -V | tail -n1)
echo "Found latest tag: ${LATEST_TAG}"
IFS='.' read -r -a VERSION_PARTS <<< "$LATEST_TAG"
MAJOR="${VERSION_PARTS[0]}"
MINOR="${VERSION_PARTS[1]}"
PATCH="${VERSION_PARTS[2]}"
NEW_PATCH=$((PATCH + 1))
BETA_VERSION="${MAJOR}.${MINOR}.${NEW_PATCH}"
echo "Calculated beta version: ${BETA_VERSION}"
echo "beta_version=${BETA_VERSION}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push beta Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
cy01/blackhole:beta
ghcr.io/${{ github.repository_owner }}/decypharr:beta
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
build-args: |
VERSION=${{ env.beta_version }}
CHANNEL=beta
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

View File

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

View File

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

77
.github/workflows/release-docker.yml vendored Normal file
View File

@@ -0,0 +1,77 @@
name: Release Docker Build
on:
push:
tags:
- '*'
permissions:
contents: read
packages: write
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Get tag name
id: get_tag
run: |
TAG_NAME=${GITHUB_REF#refs/tags/}
echo "tag_name=${TAG_NAME}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
# Login to Docker Hub
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Login to GitHub Container Registry
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push release Docker image
uses: docker/build-push-action@v5
with:
context: .
platforms: linux/amd64,linux/arm64,linux/arm/v7
push: true
tags: |
cy01/blackhole:latest
cy01/blackhole:${{ env.tag_name }}
ghcr.io/${{ github.repository_owner }}/decypharr:latest
ghcr.io/${{ github.repository_owner }}/decypharr:${{ env.tag_name }}
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new,mode=max
build-args: |
VERSION=${{ env.tag_name }}
CHANNEL=stable
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache

View File

@@ -136,4 +136,37 @@
- Fixes
- Fix Alldebrid struggling to find the correct file
- Minor bug fixes or speed-gains
- A new cleanup worker to clean up ARR queues
- A new cleanup worker to clean up ARR queues
#### 0.4.2
- Hotfixes
- Fix saving torrents error
- Fix bugs with the UI
- Speed improvements
#### 0.5.0
- A more refined repair worker(with more control)
- UI Improvements
- Pagination for torrents
- Dark mode
- Ordered torrents table
- Fix Arr API flaky behavior
- Discord Notifications
- Minor bug fixes
- Add Tautulli support
- playback_failed event triggers a repair
- Miscellaneous improvements
- Add an option to skip the repair worker for a specific arr
- Arr specific uncached downloading option
- Option to download uncached torrents from UI
- Remove QbitTorrent Log level(Use the global log level)
#### 0.5.1
- Faster import by prefetching newly downloaded torrents
- Fix UMASK issue due to the docker container
- Arr-Selective uncached downloading

View File

@@ -1,4 +1,4 @@
### DecyphArr(Qbittorent, but with Debrid Proxy Support)
### DecyphArr(Qbittorent, but with Debrid Support)
![ui](doc/main.png)
@@ -6,23 +6,26 @@ This is an implementation of QbitTorrent with a **Multiple Debrid service suppor
### Table of Contents
- [DecyphArr(Qbittorent, but with Debrid Support)](#decypharrqbittorent-but-with-debrid-support)
- [Table of Contents](#table-of-contents)
- [Features](#features)
- [Supported Debrid Providers](#supported-debrid-providers)
- [Installation](#installation)
- [Docker Compose](#docker-compose)
- [Binary](#binary)
- [Docker](#docker)
- [Registry](#registry)
- [Tags](#tags)
- [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)
- [Connecting to Sonarr/Radarr](#connecting-to-sonarrradarr)
- [Basic Sample Config](#basic-sample-config)
- [Debrid Config](#debrid-config)
- [Repair Config (**BETA**)](#repair-config-beta)
- [Proxy Config](#proxy-config)
- [Qbittorrent Config](#qbittorrent-config)
- [Arrs Config](#arrs-config)
- [Repair Worker](#repair-worker)
- [Proxy](#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.](#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)
- [Changelog](#changelog)
- [TODO](#todo)
@@ -35,7 +38,7 @@ This is an implementation of QbitTorrent with a **Multiple Debrid service suppor
- Torbox Support
- Debrid Link Support
- Multi-Debrid Providers support
- Repair Worker for missing files (**NEW**)
- Repair Worker for missing files (**BETA**)
The proxy is useful for filtering out un-cached Debrid torrents
@@ -48,20 +51,35 @@ The proxy is useful for filtering out un-cached Debrid torrents
### Installation
##### Docker Compose
##### Docker
###### Registry
You can use either hub.docker.com or ghcr.io to pull the image. The image is available on both platforms.
- Docker Hub: `cy01/blackhole:latest`
- GitHub Container Registry: `ghcr.io/sirrobot01/decypharr:latest`
###### Tags
- `latest`: The latest stable release
- `beta`: The latest beta release
- `vX.Y.Z`: A specific version (e.g `v0.1.0`)
- `nightly`: The latest nightly build. This is highly unstable
```yaml
version: '3.7'
services:
blackhole:
decypharr:
image: cy01/blackhole:latest # or cy01/blackhole:beta
container_name: blackhole
container_name: decypharr
ports:
- "8282:8282" # qBittorrent
- "8181:8181" # Proxy
user: "1000:1000"
volumes:
- /mnt/:/mnt
- ~/plex/configs/blackhole/:/app # config.json must be in this directory
- ~/plex/configs/decypharr/:/app # config.json must be in this directory
environment:
- PUID=1000
- PGID=1000
@@ -78,7 +96,7 @@ services:
Download the binary from the releases page and run it with the config file.
```bash
./blackhole --config /app
./decypharr --config /app
```
### Usage
@@ -116,7 +134,7 @@ This is the default config file. You can create a `config.json` file in the root
}
],
"proxy": {
"enabled": true,
"enabled": false,
"port": "8100",
"username": "username",
"password": "password"
@@ -131,7 +149,8 @@ This is the default config file. You can create a `config.json` file in the root
"interval": "12h",
"run_on_start": false
},
"use_auth": false
"use_auth": false,
"log_level": "info"
}
```
@@ -147,6 +166,7 @@ Full config are [here](doc/config.full.json)
- 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`
- The `discord_webhook_url` is used to send notifications to discord
##### Debrid Config
- The `debrids` key is an array of debrid providers
@@ -164,7 +184,7 @@ The `repair` 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
- The `auto_process` is used to automatically process the repair worker. This will delete broken symlinks and re-search for missing files
##### Proxy Config
- The `enabled` key is used to enable the proxy
@@ -191,15 +211,6 @@ This is particularly useful if you want to use the Repair tool without using Qbi
</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.
@@ -211,6 +222,14 @@ The repair worker is a simple worker that checks for missing files in the Arrs(S
- Search for deleted/unreadable files
### 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.
### Changelog
- View the [CHANGELOG.md](CHANGELOG.md) for the latest changes

View File

@@ -2,6 +2,7 @@ package decypharr
import (
"context"
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/pkg/proxy"
@@ -11,20 +12,31 @@ import (
"github.com/sirrobot01/debrid-blackhole/pkg/version"
"github.com/sirrobot01/debrid-blackhole/pkg/web"
"github.com/sirrobot01/debrid-blackhole/pkg/worker"
"log"
"os"
"runtime/debug"
"strconv"
"sync"
)
func Start(ctx context.Context) error {
if umaskStr := os.Getenv("UMASK"); umaskStr != "" {
umask, err := strconv.ParseInt(umaskStr, 8, 32)
if err != nil {
return fmt.Errorf("invalid UMASK value: %s", umaskStr)
}
SetUmask(int(umask))
}
cfg := config.GetConfig()
var wg sync.WaitGroup
errChan := make(chan error)
_log := logger.GetLogger(cfg.LogLevel)
_log := logger.GetDefaultLogger()
_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)
_log.Info().Msgf("Default Log Level: %s", cfg.LogLevel)
svc := service.New()
_qbit := qbit.New()
@@ -36,41 +48,51 @@ func Start(ctx context.Context) error {
srv.Mount("/", webRoutes)
srv.Mount("/api/v2", qbitRoutes)
if cfg.Proxy.Enabled {
safeGo := func(f func() error) {
wg.Add(1)
go func() {
defer wg.Done()
if err := proxy.NewProxy().Start(ctx); err != nil {
defer func() {
if r := recover(); r != nil {
stack := debug.Stack()
_log.Error().
Interface("panic", r).
Str("stack", string(stack)).
Msg("Recovered from panic in goroutine")
// Send error to channel so the main goroutine is aware
errChan <- fmt.Errorf("panic: %v", r)
}
}()
if err := f(); err != nil {
errChan <- err
}
}()
}
wg.Add(1)
go func() {
defer wg.Done()
if err := srv.Start(ctx); err != nil {
errChan <- err
}
if cfg.Proxy.Enabled {
safeGo(func() error {
return proxy.NewProxy().Start(ctx)
})
}
}()
safeGo(func() error {
return srv.Start(ctx)
})
wg.Add(1)
go func() {
defer wg.Done()
if err := worker.Start(ctx); err != nil {
errChan <- err
}
}()
safeGo(func() error {
return worker.Start(ctx)
})
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)
safeGo(func() error {
err := svc.Repair.Start(ctx)
if err != nil {
_log.Error().Err(err).Msg("Error during repair")
}
}()
return nil // Not propagating repair errors to terminate the app
})
}
go func() {

View File

@@ -0,0 +1,9 @@
//go:build !windows
package decypharr
import "syscall"
func SetUmask(umask int) {
syscall.Umask(umask)
}

View File

@@ -0,0 +1,8 @@
//go:build windows
// +build windows
package decypharr
func SetUmask(umask int) {
// No-op on Windows
}

View File

@@ -51,20 +51,31 @@
"download_folder": "/mnt/symlinks/",
"categories": ["sonarr", "radarr"],
"refresh_interval": 5,
"log_level": "info"
"skip_pre_cache": false
},
"arrs": [
{
"name": "sonarr",
"host": "http://host:8989",
"host": "http://sonarr:8989",
"token": "arr_key",
"cleanup": false
"cleanup": true,
"skip_repair": true,
"download_uncached": false
},
{
"name": "radarr",
"host": "http://host:7878",
"host": "http://radarr:7878",
"token": "arr_key",
"cleanup": false
"cleanup": false,
"download_uncached": false
},
{
"name": "lidarr",
"host": "http://lidarr:8686",
"token": "arr_key",
"cleanup": false,
"skip_repair": true,
"download_uncached": false
}
],
"repair": {
@@ -72,11 +83,12 @@
"interval": "12h",
"run_on_start": false,
"zurg_url": "http://zurg:9999",
"skip_deletion": false
"auto_process": false
},
"log_level": "info",
"min_file_size": "",
"max_file_size": "",
"allowed_file_types": [],
"use_auth": false
"use_auth": false,
"discord_webhook_url": "https://discord.com/api/webhooks/...",
}

1
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/valyala/fastjson v1.6.4
golang.org/x/crypto v0.33.0
golang.org/x/net v0.33.0
golang.org/x/sync v0.11.0
golang.org/x/time v0.8.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
)

2
go.sum
View File

@@ -243,6 +243,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

View File

@@ -38,25 +38,27 @@ 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"`
SkipPreCache bool `json:"skip_pre_cache"`
}
type Arr struct {
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
Cleanup bool `json:"cleanup"`
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
Cleanup bool `json:"cleanup"`
SkipRepair bool `json:"skip_repair"`
DownloadUncached *bool `json:"download_uncached"`
}
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"`
Enabled bool `json:"enabled"`
Interval string `json:"interval"`
RunOnStart bool `json:"run_on_start"`
ZurgURL string `json:"zurg_url"`
AutoProcess bool `json:"auto_process"`
}
type Auth struct {
@@ -65,20 +67,21 @@ type Auth struct {
}
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:"-"`
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:"-"`
DiscordWebhook string `json:"discord_webhook_url"`
}
func (c *Config) JsonFile() string {
@@ -115,9 +118,9 @@ func (c *Config) loadConfig() error {
c.Auth = c.GetAuth()
//Validate the config
//if err := validateConfig(c); err != nil {
// return err
//}
if err := validateConfig(c); err != nil {
return err
}
return nil
}
@@ -143,13 +146,13 @@ func validateDebrids(debrids []Debrid) error {
}
// 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)
//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
@@ -207,10 +210,7 @@ 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
}
fmt.Fprintf(os.Stderr, "configuration Error: %v\n", err)
os.Exit(1)
}
})

View File

@@ -32,11 +32,10 @@ func GetLogPath() string {
func NewLogger(prefix string, level string, output *os.File) zerolog.Logger {
rotatingLogFile := &lumberjack.Logger{
Filename: GetLogPath(),
MaxSize: 2,
MaxBackups: 2,
MaxAge: 28,
Compress: true,
Filename: GetLogPath(),
MaxSize: 10,
MaxAge: 15,
Compress: true,
}
consoleWriter := zerolog.ConsoleWriter{
@@ -85,9 +84,10 @@ func NewLogger(prefix string, level string, output *os.File) zerolog.Logger {
return logger
}
func GetLogger(level string) zerolog.Logger {
func GetDefaultLogger() zerolog.Logger {
once.Do(func() {
logger = NewLogger("decypharr", level, os.Stdout)
cfg := config.GetConfig()
logger = NewLogger("decypharr", cfg.LogLevel, os.Stdout)
})
return logger
}

100
internal/request/discord.go Normal file
View File

@@ -0,0 +1,100 @@
package request
import (
"bytes"
"encoding/json"
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"io"
"net/http"
"strings"
)
type DiscordEmbed struct {
Title string `json:"title"`
Description string `json:"description"`
Color int `json:"color"`
}
type DiscordWebhook struct {
Embeds []DiscordEmbed `json:"embeds"`
}
func getDiscordColor(status string) int {
switch status {
case "success":
return 3066993
case "error":
return 15158332
case "warning":
return 15844367
case "pending":
return 3447003
default:
return 0
}
}
func getDiscordHeader(event string) string {
switch event {
case "download_complete":
return "[Decypharr] Download Completed"
case "download_failed":
return "[Decypharr] Download Failed"
case "repair_pending":
return "[Decypharr] Repair Completed, Awaiting action"
case "repair_complete":
return "[Decypharr] Repair Complete"
default:
// split the event string and capitalize the first letter of each word
evs := strings.Split(event, "_")
for i, ev := range evs {
evs[i] = strings.ToTitle(ev)
}
return "[Decypharr] %s" + strings.Join(evs, " ")
}
}
func SendDiscordMessage(event string, status string, message string) error {
cfg := config.GetConfig()
webhookURL := cfg.DiscordWebhook
if webhookURL == "" {
return nil
}
// Create the proper Discord webhook structure
webhook := DiscordWebhook{
Embeds: []DiscordEmbed{
{
Title: getDiscordHeader(event),
Description: message,
Color: getDiscordColor(status),
},
},
}
payload, err := json.Marshal(webhook)
if err != nil {
return fmt.Errorf("failed to marshal discord payload: %v", err)
}
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("failed to create discord request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send discord message: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("discord returned error status code: %s, body: %s", resp.Status, string(bodyBytes))
}
return nil
}

View File

@@ -3,6 +3,7 @@ package request
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"golang.org/x/time/rate"
"io"
@@ -109,7 +110,7 @@ func (c *RLHTTPClient) MakeRequest(req *http.Request) ([]byte, error) {
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 nil, errors.New(string(b))
}
return b, nil
@@ -118,14 +119,17 @@ func (c *RLHTTPClient) MakeRequest(req *http.Request) ([]byte, error) {
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{
Transport: tr,
},
Ratelimiter: rl,
Headers: headers,
}
if rl != nil {
c.Ratelimiter = rl
}
if headers != nil {
c.Headers = headers
}
return c
}
@@ -160,5 +164,8 @@ func ParseRateLimit(rateStr string) *rate.Limiter {
func JSONResponse(w http.ResponseWriter, data interface{}, code int) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
json.NewEncoder(w).Encode(data)
err := json.NewEncoder(w).Encode(data)
if err != nil {
return
}
}

View File

@@ -6,9 +6,16 @@ import (
"github.com/sirrobot01/debrid-blackhole/cmd/decypharr"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"log"
"runtime/debug"
)
func main() {
defer func() {
if r := recover(); r != nil {
log.Printf("FATAL: Recovered from panic in main: %v\n", r)
debug.PrintStack()
}
}()
var configPath string
flag.StringVar(&configPath, "config", "/data", "path to the data folder")
flag.Parse()

View File

@@ -2,12 +2,16 @@ package arr
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"io"
"net/http"
"strings"
"sync"
"time"
)
// Type is a type of arr
@@ -20,51 +24,102 @@ const (
Readarr Type = "readarr"
)
var (
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"`
Cleanup bool `json:"cleanup"`
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
Type Type `json:"type"`
Cleanup bool `json:"cleanup"`
SkipRepair bool `json:"skip_repair"`
DownloadUncached *bool `json:"download_uncached"`
client *http.Client
}
func New(name, host, token string, cleanup bool) *Arr {
func New(name, host, token string, cleanup, skipRepair bool, downloadUncached *bool) *Arr {
return &Arr{
Name: name,
Host: host,
Token: token,
Type: InferType(host, name),
Cleanup: cleanup,
Name: name,
Host: host,
Token: strings.TrimSpace(token),
Type: InferType(host, name),
Cleanup: cleanup,
SkipRepair: skipRepair,
DownloadUncached: downloadUncached,
client: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
},
},
}
}
func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Response, error) {
if a.Token == "" || a.Host == "" {
return nil, nil
return nil, fmt.Errorf("arr not configured")
}
url, err := request.JoinURL(a.Host, endpoint)
if err != nil {
return nil, err
}
var jsonPayload []byte
var body io.Reader
if payload != nil {
jsonPayload, err = json.Marshal(payload)
b, err := json.Marshal(payload)
if err != nil {
return nil, err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonPayload))
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", a.Token)
return client.Do(req)
if a.client == nil {
a.client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
},
}
}
var resp *http.Response
for attempts := 0; attempts < 5; attempts++ {
resp, err = a.client.Do(req)
if err != nil {
return nil, err
}
// If we got a 401, wait briefly and retry
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close() // Don't leak response bodies
if attempts < 4 { // Don't sleep on the last attempt
time.Sleep(time.Duration(attempts+1) * 100 * time.Millisecond)
continue
}
}
return resp, nil
}
return resp, err
}
func (a *Arr) Validate() error {
if a.Token == "" || a.Host == "" {
return nil
}
resp, err := a.Request("GET", "/api/v3/health", nil)
if err != nil {
return err
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("arr test failed: %s", resp.Status)
}
return nil
}
type Storage struct {
@@ -91,7 +146,7 @@ func NewStorage() *Storage {
arrs := make(map[string]*Arr)
for _, a := range config.GetConfig().Arrs {
name := a.Name
arrs[name] = New(name, a.Host, a.Token, a.Cleanup)
arrs[name] = New(name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached)
}
return &Storage{
Arrs: arrs,

View File

@@ -5,27 +5,52 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
)
func (a *Arr) GetMedia(tvId string) ([]Content, error) {
type episode struct {
Id int `json:"id"`
EpisodeFileID int `json:"episodeFileId"`
}
type sonarrSearch struct {
Name string `json:"name"`
SeasonNumber int `json:"seasonNumber"`
SeriesId int `json:"seriesId"`
}
type radarrSearch struct {
Name string `json:"name"`
MovieIds []int `json:"movieIds"`
}
func (a *Arr) GetMedia(mediaId string) ([]Content, error) {
// Get series
resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/series?tvdbId=%s", tvId), nil)
if a.Type == Radarr {
return GetMovies(a, mediaId)
}
// This is likely Sonarr
resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/series?tvdbId=%s", mediaId), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
// This is likely Radarr
return GetMovies(a, tvId)
return GetMovies(a, mediaId)
}
a.Type = Sonarr
defer resp.Body.Close()
type series struct {
Title string `json:"title"`
Id int `json:"id"`
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get series: %s", resp.Status)
}
var data []series
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
return nil, fmt.Errorf("failed to decode series: %v", err)
}
// Get series files
contents := make([]Content, 0)
@@ -43,11 +68,6 @@ func (a *Arr) GetMedia(tvId string) ([]Content, error) {
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
@@ -67,12 +87,22 @@ func (a *Arr) GetMedia(tvId string) ([]Content, error) {
if !ok {
eId = 0
}
if file.Id == 0 || file.Path == "" {
// Skip files without path
continue
}
files = append(files, ContentFile{
FileId: file.Id,
Path: file.Path,
Id: eId,
FileId: file.Id,
Path: file.Path,
Id: d.Id,
EpisodeId: eId,
SeasonNumber: file.SeasonNumber,
})
}
if len(files) == 0 {
// Skip series without files
continue
}
ct.Files = files
contents = append(contents, ct)
}
@@ -92,7 +122,7 @@ func GetMovies(a *Arr, tvId string) ([]Content, error) {
defer resp.Body.Close()
var movies []Movie
if err = json.NewDecoder(resp.Body).Decode(&movies); err != nil {
return nil, err
return nil, fmt.Errorf("failed to decode movies: %v", err)
}
contents := make([]Content, 0)
for _, movie := range movies {
@@ -101,6 +131,10 @@ func GetMovies(a *Arr, tvId string) ([]Content, error) {
Id: movie.Id,
}
files := make([]ContentFile, 0)
if movie.MovieFile.Id == 0 || movie.MovieFile.Path == "" {
// Skip movies without files
continue
}
files = append(files, ContentFile{
FileId: movie.MovieFile.Id,
Id: movie.Id,
@@ -112,29 +146,64 @@ func GetMovies(a *Arr, tvId string) ([]Content, error) {
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)
// searchSonarr searches for missing files in the arr
// map ids are series id and season number
func (a *Arr) searchSonarr(files []ContentFile) error {
ids := make(map[string]any)
for _, f := range files {
// Join series id and season number
id := fmt.Sprintf("%d-%d", f.Id, f.SeasonNumber)
ids[id] = nil
}
errs := make(chan error, len(ids))
for id := range ids {
go func() {
parts := strings.Split(id, "-")
if len(parts) != 2 {
return
}
seriesId, err := strconv.Atoi(parts[0])
if err != nil {
return
}
seasonNumber, err := strconv.Atoi(parts[1])
if err != nil {
return
}
payload := sonarrSearch{
Name: "SeasonSearch",
SeasonNumber: seasonNumber,
SeriesId: seriesId,
}
resp, err := a.Request(http.MethodPost, "api/v3/command", payload)
if err != nil {
errs <- fmt.Errorf("failed to automatic search: %v", err)
return
}
if resp.StatusCode >= 300 || resp.StatusCode < 200 {
errs <- fmt.Errorf("failed to automatic search. Status Code: %s", resp.Status)
return
}
}()
}
for range ids {
err := <-errs
if err != nil {
return err
}
}
return nil
}
func (a *Arr) searchRadarr(files []ContentFile) error {
ids := make([]int, 0)
for _, f := range files {
ids = append(ids, f.Id)
}
payload := radarrSearch{
Name: "MoviesSearch",
MovieIds: ids,
}
resp, err := a.Request(http.MethodPost, "api/v3/command", payload)
if err != nil {
return fmt.Errorf("failed to automatic search: %v", err)
@@ -146,16 +215,14 @@ func (a *Arr) search(ids []int) error {
}
func (a *Arr) SearchMissing(files []ContentFile) error {
ids := make([]int, 0)
for _, f := range files {
ids = append(ids, f.Id)
switch a.Type {
case Sonarr:
return a.searchSonarr(files)
case Radarr:
return a.searchRadarr(files)
default:
return fmt.Errorf("unknown arr type: %s", a.Type)
}
if len(ids) == 0 {
return nil
}
return a.search(ids)
}
func (a *Arr) DeleteFiles(files []ContentFile) error {

View File

@@ -2,8 +2,10 @@ package arr
import (
"encoding/json"
"io"
"net/http"
gourl "net/url"
"strconv"
"strings"
)
@@ -77,24 +79,43 @@ func (a *Arr) GetQueue() []QueueSchema {
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 {
func() {
defer func(Body io.ReadCloser) {
err := Body.Close()
if err != nil {
return
}
}(resp.Body)
var data QueueResponseScheme
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
return
}
results = append(results, data.Records...)
query.Set("page", string(rune(data.Page+1)))
} else {
if len(results) >= data.TotalRecords {
// We've fetched all records
err = io.EOF // Signal to exit the loop
return
}
query.Set("page", strconv.Itoa(data.Page+1))
}()
if err != nil {
break
}
}
return results
}
@@ -133,13 +154,11 @@ func (a *Arr) CleanupQueue() error {
}
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)
}
}

View File

@@ -10,7 +10,11 @@ import (
)
func (a *Arr) Refresh() error {
payload := map[string]string{"name": "RefreshMonitoredDownloads"}
payload := struct {
Name string `json:"name"`
}{
Name: "RefreshMonitoredDownloads",
}
resp, err := a.Request(http.MethodPost, "api/v3/command", payload)
if err == nil && resp != nil {
@@ -19,7 +23,8 @@ func (a *Arr) Refresh() error {
return nil
}
}
return fmt.Errorf("failed to refresh monitored downloads for %s", cmp.Or(a.Name, a.Host))
return fmt.Errorf("failed to refresh: %v", err)
}
func (a *Arr) Blacklist(infoHash string) error {

View File

@@ -8,20 +8,21 @@ type Movie 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"`
Name string `json:"name"`
Path string `json:"path"`
Id int `json:"id"`
EpisodeId int `json:"showId"`
FileId int `json:"fileId"`
TargetPath string `json:"targetPath"`
IsSymlink bool `json:"isSymlink"`
IsBroken bool `json:"isBroken"`
SeasonNumber int `json:"seasonNumber"`
}
type Content struct {

View File

@@ -10,6 +10,7 @@ import (
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"slices"
"net/http"
gourl "net/url"
@@ -134,9 +135,8 @@ func flattenFiles(files []MagnetFile, parentPath string, index *int) []torrent.F
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)
func (ad *AllDebrid) GetTorrent(t *torrent.Torrent) (*torrent.Torrent, error) {
url := fmt.Sprintf("%s/magnet/status?id=%s", ad.Host, t.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := ad.client.MakeRequest(req)
if err != nil {
@@ -151,7 +151,6 @@ func (ad *AllDebrid) GetTorrent(id string) (*torrent.Torrent, error) {
data := res.Data.Magnets
status := getAlldebridStatus(data.StatusCode)
name := data.Filename
t.Id = id
t.Name = name
t.Status = status
t.Filename = name
@@ -175,7 +174,7 @@ func (ad *AllDebrid) GetTorrent(id string) (*torrent.Torrent, error) {
func (ad *AllDebrid) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
for {
tb, err := ad.GetTorrent(torrent.Id)
tb, err := ad.GetTorrent(torrent)
torrent = tb
@@ -192,9 +191,8 @@ func (ad *AllDebrid) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*tor
}
}
break
} else if status == "downloading" {
if !ad.DownloadUncached {
go ad.DeleteTorrent(torrent)
} else if slices.Contains(ad.GetDownloadingStatus(), status) {
if !torrent.DownloadUncached {
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
}
// Break out of the loop if the torrent is downloading.
@@ -278,6 +276,14 @@ func (ad *AllDebrid) GetTorrents() ([]*torrent.Torrent, error) {
return nil, fmt.Errorf("not implemented")
}
func (ad *AllDebrid) GetDownloadingStatus() []string {
return []string{"downloading"}
}
func (ad *AllDebrid) GetDownloadUncached() bool {
return ad.DownloadUncached
}
func New(dc config.Debrid, cache *cache.Cache) *AllDebrid {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{

View File

@@ -47,7 +47,8 @@ func createDebrid(dc config.Debrid, cache *cache.Cache) engine.Service {
}
}
func ProcessTorrent(d *engine.Engine, magnet *utils.Magnet, a *arr.Arr, isSymlink bool) (*torrent.Torrent, error) {
func ProcessTorrent(d *engine.Engine, magnet *utils.Magnet, a *arr.Arr, isSymlink, overrideDownloadUncached bool) (*torrent.Torrent, error) {
debridTorrent := &torrent.Torrent{
InfoHash: magnet.InfoHash,
Magnet: magnet,
@@ -62,6 +63,17 @@ func ProcessTorrent(d *engine.Engine, magnet *utils.Magnet, a *arr.Arr, isSymlin
logger := db.GetLogger()
logger.Info().Msgf("Processing debrid: %s", db.GetName())
// Override first, arr second, debrid third
if overrideDownloadUncached {
debridTorrent.DownloadUncached = true
} else if a.DownloadUncached != nil {
// Arr cached is set
debridTorrent.DownloadUncached = *a.DownloadUncached
} else {
debridTorrent.DownloadUncached = db.GetDownloadUncached()
}
logger.Info().Msgf("Torrent Hash: %s", debridTorrent.InfoHash)
if db.GetCheckCached() {
hash, exists := db.IsAvailable([]string{debridTorrent.InfoHash})[debridTorrent.InfoHash]

View File

@@ -11,6 +11,7 @@ import (
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"slices"
"net/http"
"os"
@@ -96,9 +97,8 @@ func (dl *DebridLink) IsAvailable(infohashes []string) map[string]bool {
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)
func (dl *DebridLink) GetTorrent(t *torrent.Torrent) (*torrent.Torrent, error) {
url := fmt.Sprintf("%s/seedbox/list?ids=%s", dl.Host, t.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := dl.client.MakeRequest(req)
if err != nil {
@@ -109,7 +109,7 @@ func (dl *DebridLink) GetTorrent(id string) (*torrent.Torrent, error) {
if err != nil {
return t, err
}
if res.Success == false {
if !res.Success {
return t, fmt.Errorf("error getting torrent")
}
if res.Value == nil {
@@ -167,7 +167,7 @@ func (dl *DebridLink) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error)
if err != nil {
return nil, err
}
if res.Success == false || res.Value == nil {
if !res.Success || res.Value == nil {
return nil, fmt.Errorf("error adding torrent")
}
data := *res.Value
@@ -203,7 +203,7 @@ func (dl *DebridLink) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error)
func (dl *DebridLink) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
for {
t, err := dl.GetTorrent(torrent.Id)
t, err := dl.GetTorrent(torrent)
torrent = t
if err != nil || torrent == nil {
return torrent, err
@@ -216,9 +216,8 @@ func (dl *DebridLink) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*to
return torrent, err
}
break
} else if status == "downloading" {
if !dl.DownloadUncached {
go dl.DeleteTorrent(torrent)
} else if slices.Contains(dl.GetDownloadingStatus(), status) {
if !torrent.DownloadUncached {
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
}
// Break out of the loop if the torrent is downloading.
@@ -264,10 +263,18 @@ func (dl *DebridLink) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *t
return &dlLink
}
func (dl *DebridLink) GetDownloadingStatus() []string {
return []string{"downloading"}
}
func (dl *DebridLink) GetCheckCached() bool {
return dl.CheckCached
}
func (dl *DebridLink) GetDownloadUncached() bool {
return dl.DownloadUncached
}
func New(dc config.Debrid, cache *cache.Cache) *DebridLink {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{

View File

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

View File

@@ -13,8 +13,10 @@ type Service interface {
DeleteTorrent(tr *torrent.Torrent)
IsAvailable(infohashes []string) map[string]bool
GetCheckCached() bool
GetTorrent(id string) (*torrent.Torrent, error)
GetDownloadUncached() bool
GetTorrent(torrent *torrent.Torrent) (*torrent.Torrent, error)
GetTorrents() ([]*torrent.Torrent, error)
GetName() string
GetLogger() zerolog.Logger
GetDownloadingStatus() []string
}

View File

@@ -151,16 +151,17 @@ func (r *RealDebrid) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error)
if err != nil {
return nil, err
}
err = json.Unmarshal(resp, &data)
if err = json.Unmarshal(resp, &data); err != nil {
return nil, err
}
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)
func (r *RealDebrid) GetTorrent(t *torrent.Torrent) (*torrent.Torrent, error) {
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, t.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := r.client.MakeRequest(req)
if err != nil {
@@ -172,7 +173,6 @@ func (r *RealDebrid) GetTorrent(id string) (*torrent.Torrent, error) {
return t, err
}
name := utils.RemoveInvalidChars(data.OriginalFilename)
t.Id = id
t.Name = name
t.Bytes = data.Bytes
t.Folder = name
@@ -201,7 +201,9 @@ func (r *RealDebrid) CheckStatus(t *torrent.Torrent, isSymlink bool) (*torrent.T
return t, err
}
var data TorrentInfo
err = json.Unmarshal(resp, &data)
if err = json.Unmarshal(resp, &data); err != nil {
return t, err
}
status := data.Status
name := utils.RemoveInvalidChars(data.OriginalFilename)
t.Name = name // Important because some magnet changes the name
@@ -216,7 +218,6 @@ func (r *RealDebrid) CheckStatus(t *torrent.Torrent, isSymlink bool) (*torrent.T
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
@@ -247,13 +248,10 @@ func (r *RealDebrid) CheckStatus(t *torrent.Torrent, isSymlink bool) (*torrent.T
}
}
break
} else if slices.Contains(downloadingStatus, status) {
if !r.DownloadUncached {
go r.DeleteTorrent(t)
} else if slices.Contains(r.GetDownloadingStatus(), status) {
if !t.DownloadUncached {
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)
@@ -380,6 +378,14 @@ func (r *RealDebrid) GetTorrents() ([]*torrent.Torrent, error) {
}
func (r *RealDebrid) GetDownloadingStatus() []string {
return []string{"downloading", "magnet_conversion", "queued", "compressing", "uploading"}
}
func (r *RealDebrid) GetDownloadUncached() bool {
return r.DownloadUncached
}
func New(dc config.Debrid, cache *cache.Cache) *RealDebrid {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{

View File

@@ -149,9 +149,8 @@ func getTorboxStatus(status string, finished bool) string {
}
}
func (tb *Torbox) GetTorrent(id string) (*torrent.Torrent, error) {
t := &torrent.Torrent{}
url := fmt.Sprintf("%s/api/torrents/mylist/?id=%s", tb.Host, id)
func (tb *Torbox) GetTorrent(t *torrent.Torrent) (*torrent.Torrent, error) {
url := fmt.Sprintf("%s/api/torrents/mylist/?id=%s", tb.Host, t.Id)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := tb.client.MakeRequest(req)
if err != nil {
@@ -164,7 +163,6 @@ func (tb *Torbox) GetTorrent(id string) (*torrent.Torrent, error) {
}
data := res.Data
name := data.Name
t.Id = id
t.Name = name
t.Bytes = data.Size
t.Folder = name
@@ -215,7 +213,7 @@ func (tb *Torbox) GetTorrent(id string) (*torrent.Torrent, error) {
func (tb *Torbox) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
for {
t, err := tb.GetTorrent(torrent.Id)
t, err := tb.GetTorrent(torrent)
torrent = t
@@ -232,9 +230,8 @@ func (tb *Torbox) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torren
}
}
break
} else if status == "downloading" {
if !tb.DownloadUncached {
go tb.DeleteTorrent(torrent)
} else if slices.Contains(tb.GetDownloadingStatus(), status) {
if !torrent.DownloadUncached {
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
}
// Break out of the loop if the torrent is downloading.
@@ -323,6 +320,10 @@ func (tb *Torbox) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torre
}
}
func (tb *Torbox) GetDownloadingStatus() []string {
return []string{"downloading"}
}
func (tb *Torbox) GetCheckCached() bool {
return tb.CheckCached
}
@@ -331,6 +332,10 @@ func (tb *Torbox) GetTorrents() ([]*torrent.Torrent, error) {
return nil, fmt.Errorf("not implemented")
}
func (tb *Torbox) GetDownloadUncached() bool {
return tb.DownloadUncached
}
func New(dc config.Debrid, cache *cache.Cache) *Torbox {
rl := request.ParseRateLimit(dc.RateLimit)
headers := map[string]string{

View File

@@ -3,6 +3,7 @@ package torrent
import (
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/cache"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"os"
@@ -10,24 +11,6 @@ import (
"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"`
@@ -50,9 +33,10 @@ type Torrent struct {
Debrid string `json:"debrid"`
Arr *arr.Arr `json:"arr"`
Mu sync.Mutex `json:"-"`
SizeDownloaded int64 `json:"-"` // This is used for local download
Arr *arr.Arr `json:"arr"`
Mu sync.Mutex `json:"-"`
SizeDownloaded int64 `json:"-"` // This is used for local download
DownloadUncached bool `json:"-"`
}
type DownloadLinks struct {
@@ -66,6 +50,7 @@ func (t *Torrent) GetSymlinkFolder(parent string) string {
}
func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
_log := logger.GetDefaultLogger()
possiblePaths := []string{
t.OriginalFilename,
t.Filename,
@@ -73,7 +58,9 @@ func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
}
for _, path := range possiblePaths {
_, err := os.Stat(filepath.Join(rClonePath, path))
_p := filepath.Join(rClonePath, path)
_log.Trace().Msgf("Checking path: %s", _p)
_, err := os.Stat(_p)
if !os.IsNotExist(err) {
return path, nil
}

View File

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

View File

@@ -6,6 +6,7 @@ import (
"github.com/cavaliergopher/grab/v3"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
debrid "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"io"
"net/http"
"os"
"path/filepath"
@@ -202,4 +203,56 @@ func (q *QBit) createSymLink(path string, torrentMountPath string, file debrid.F
// It's okay if the symlink already exists
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fullPath, err)
}
if q.SkipPreCache {
return
}
go func() {
err := q.preCacheFile(torrentFilePath)
if err != nil {
q.logger.Debug().Msgf("Failed to pre-cache file: %s: %v", torrentFilePath, err)
}
}()
}
func (q *QBit) preCacheFile(filePath string) error {
q.logger.Trace().Msgf("Pre-caching file: %s", filePath)
file, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("error opening file: %v", err)
}
defer file.Close()
// Pre-cache the file header (first 256KB) using 16KB chunks.
q.readSmallChunks(file, 0, 256*1024, 16*1024)
q.readSmallChunks(file, 1024*1024, 64*1024, 16*1024)
return nil
}
func (q *QBit) readSmallChunks(file *os.File, startPos int64, totalToRead int, chunkSize int) {
_, err := file.Seek(startPos, 0)
if err != nil {
return
}
buf := make([]byte, chunkSize)
bytesRemaining := totalToRead
for bytesRemaining > 0 {
toRead := chunkSize
if bytesRemaining < chunkSize {
toRead = bytesRemaining
}
n, err := file.Read(buf[:toRead])
if err != nil {
if err == io.EOF {
break
}
return
}
bytesRemaining -= n
}
return
}

View File

@@ -46,8 +46,7 @@ func (q *QBit) CategoryContext(next http.Handler) http.Handler {
category = r.FormValue("category")
}
}
ctx := r.Context()
ctx = context.WithValue(r.Context(), "category", strings.TrimSpace(category))
ctx := context.WithValue(r.Context(), "category", strings.TrimSpace(category))
next.ServeHTTP(w, r.WithContext(ctx))
})
}
@@ -60,11 +59,18 @@ func (q *QBit) authContext(next http.Handler) http.Handler {
// Check if arr exists
a := svc.Arr.Get(category)
if a == nil {
a = arr.New(category, "", "", false)
downloadUncached := false
a = arr.New(category, "", "", false, false, &downloadUncached)
}
if err == nil {
a.Host = strings.TrimSpace(host)
a.Token = strings.TrimSpace(token)
host = strings.TrimSpace(host)
if host != "" {
a.Host = host
}
token = strings.TrimSpace(token)
if token != "" {
a.Token = token
}
}
svc.Arr.AddOrUpdate(a)
@@ -94,6 +100,16 @@ func HashesCtx(next http.Handler) http.Handler {
}
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_arr := ctx.Value("arr").(*arr.Arr)
if _arr == nil {
// No arr
_, _ = w.Write([]byte("Ok."))
return
}
if err := _arr.Validate(); err != nil {
q.logger.Info().Msgf("Error validating arr: %v", err)
}
_, _ = w.Write([]byte("Ok."))
}
@@ -137,7 +153,7 @@ func (q *QBit) handleTorrentsInfo(w http.ResponseWriter, r *http.Request) {
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)
torrents := q.Storage.GetAllSorted(category, filter, hashes, "added_on", false)
request.JSONResponse(w, torrents, http.StatusOK)
}

View File

@@ -12,14 +12,15 @@ import (
)
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"`
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"`
DownloadUncached bool `json:"downloadUncached"`
Failed bool `json:"failed"`
FailedAt time.Time `json:"failedAt"`
@@ -40,15 +41,16 @@ type ManualImportResponseSchema struct {
Id int `json:"id"`
}
func NewImportRequest(uri string, arr *arr.Arr, isSymlink bool) *ImportRequest {
func NewImportRequest(uri string, arr *arr.Arr, isSymlink, downloadUncached bool) *ImportRequest {
return &ImportRequest{
ID: uuid.NewString(),
URI: uri,
Arr: arr,
Failed: false,
Completed: false,
Async: false,
IsSymlink: isSymlink,
ID: uuid.NewString(),
URI: uri,
Arr: arr,
Failed: false,
Completed: false,
Async: false,
IsSymlink: isSymlink,
DownloadUncached: downloadUncached,
}
}
@@ -72,9 +74,8 @@ func (i *ImportRequest) Process(q *QBit) (err error) {
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)
debridTorrent, err := debrid.ProcessTorrent(svc.Debrid, magnet, i.Arr, i.IsSymlink, i.DownloadUncached)
if err != nil || debridTorrent == nil {
fmt.Println("Error deleting torrent: ", err)
if debridTorrent != nil {
dbClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
go dbClient.DeleteTorrent(debridTorrent)

View File

@@ -3,31 +3,9 @@ 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(),

View File

@@ -16,10 +16,10 @@ type QBit struct {
DownloadFolder string `json:"download_folder"`
Categories []string `json:"categories"`
Storage *TorrentStorage
debug bool
logger zerolog.Logger
Tags []string
RefreshInterval int
SkipPreCache bool
}
func New() *QBit {
@@ -34,7 +34,8 @@ func New() *QBit {
DownloadFolder: cfg.DownloadFolder,
Categories: cfg.Categories,
Storage: NewTorrentStorage(filepath.Join(_cfg.Path, "torrents.json")),
logger: logger.NewLogger("qbit", cfg.LogLevel, os.Stdout),
logger: logger.NewLogger("qbit", _cfg.LogLevel, os.Stdout),
RefreshInterval: refreshInterval,
SkipPreCache: cfg.SkipPreCache,
}
}

View File

@@ -8,10 +8,9 @@ import (
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.Post("/auth/login", q.handleLogin)
r.Route("/torrents", func(r chi.Router) {
r.Use(HashesCtx)
r.Get("/info", q.handleTorrentsInfo)

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"sync"
)
@@ -51,14 +52,24 @@ func (ts *TorrentStorage) Add(torrent *Torrent) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
_ = ts.saveToFile()
go func() {
err := ts.saveToFile()
if err != nil {
fmt.Println(err)
}
}()
}
func (ts *TorrentStorage) AddOrUpdate(torrent *Torrent) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
_ = ts.saveToFile()
go func() {
err := ts.saveToFile()
if err != nil {
fmt.Println(err)
}
}()
}
func (ts *TorrentStorage) Get(hash, category string) *Torrent {
@@ -99,7 +110,46 @@ func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string
}
}
}
return filtered
torrents = filtered
}
return torrents
}
func (ts *TorrentStorage) GetAllSorted(category string, filter string, hashes []string, sortBy string, ascending bool) []*Torrent {
torrents := ts.GetAll(category, filter, hashes)
if sortBy != "" {
sort.Slice(torrents, func(i, j int) bool {
// If ascending is false, swap i and j to get descending order
if !ascending {
i, j = j, i
}
switch sortBy {
case "name":
return torrents[i].Name < torrents[j].Name
case "size":
return torrents[i].Size < torrents[j].Size
case "added_on":
return torrents[i].AddedOn < torrents[j].AddedOn
case "completed":
return torrents[i].Completed < torrents[j].Completed
case "progress":
return torrents[i].Progress < torrents[j].Progress
case "state":
return torrents[i].State < torrents[j].State
case "category":
return torrents[i].Category < torrents[j].Category
case "dlspeed":
return torrents[i].Dlspeed < torrents[j].Dlspeed
case "upspeed":
return torrents[i].Upspeed < torrents[j].Upspeed
case "ratio":
return torrents[i].Ratio < torrents[j].Ratio
default:
// Default sort by added_on
return torrents[i].AddedOn < torrents[j].AddedOn
}
})
}
return torrents
}
@@ -108,7 +158,12 @@ func (ts *TorrentStorage) Update(torrent *Torrent) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
_ = ts.saveToFile()
go func() {
err := ts.saveToFile()
if err != nil {
fmt.Println(err)
}
}()
}
func (ts *TorrentStorage) Delete(hash, category string) {
@@ -127,6 +182,9 @@ func (ts *TorrentStorage) Delete(hash, category string) {
}
}
delete(ts.torrents, key)
if torrent == nil {
return
}
// Delete the torrent folder
if torrent.ContentPath != "" {
err := os.RemoveAll(torrent.ContentPath)
@@ -134,7 +192,12 @@ func (ts *TorrentStorage) Delete(hash, category string) {
return
}
}
_ = ts.saveToFile()
go func() {
err := ts.saveToFile()
if err != nil {
fmt.Println(err)
}
}()
}
func (ts *TorrentStorage) DeleteMultiple(hashes []string) {
@@ -147,18 +210,23 @@ func (ts *TorrentStorage) DeleteMultiple(hashes []string) {
}
}
}
_ = ts.saveToFile()
go func() {
err := ts.saveToFile()
if err != nil {
fmt.Println(err)
}
}()
}
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 {
ts.mu.RLock()
data, err := json.MarshalIndent(ts.torrents, "", " ")
ts.mu.RUnlock()
if err != nil {
return err
}

View File

@@ -4,6 +4,7 @@ import (
"cmp"
"context"
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
db "github.com/sirrobot01/debrid-blackhole/pkg/debrid"
@@ -55,7 +56,7 @@ func (q *QBit) Process(ctx context.Context, magnet *utils.Magnet, category strin
return fmt.Errorf("arr not found in context")
}
isSymlink := ctx.Value("isSymlink").(bool)
debridTorrent, err := db.ProcessTorrent(svc.Debrid, magnet, a, isSymlink)
debridTorrent, err := db.ProcessTorrent(svc.Debrid, magnet, a, isSymlink, false)
if err != nil || debridTorrent == nil {
if debridTorrent != nil {
dbClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
@@ -75,19 +76,26 @@ func (q *QBit) Process(ctx context.Context, magnet *utils.Magnet, category strin
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.Debug().Msgf("%s -> (%s) Download Progress: %.2f%%", debridTorrent.Debrid, debridTorrent.Name, progress)
time.Sleep(10 * time.Second)
q.logger.Debug().Msgf("%s <- (%s) Download Progress: %.2f%%", debridTorrent.Debrid, debridTorrent.Name, debridTorrent.Progress)
dbT, err := debridClient.CheckStatus(debridTorrent, isSymlink)
if err != nil {
q.logger.Error().Msgf("Error checking status: %v", err)
go debridClient.DeleteTorrent(debridTorrent)
q.MarkAsFailed(torrent)
_ = arr.Refresh()
if err := arr.Refresh(); err != nil {
q.logger.Error().Msgf("Error refreshing arr: %v", err)
}
return
}
debridTorrent = dbT
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
// Exit the loop for downloading statuses to prevent memory buildup
if !slices.Contains(debridClient.GetDownloadingStatus(), debridTorrent.Status) {
break
}
time.Sleep(time.Duration(q.RefreshInterval) * time.Second)
}
var (
torrentSymlinkPath string
@@ -107,12 +115,24 @@ func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr
}
torrent.TorrentPath = torrentSymlinkPath
q.UpdateTorrent(torrent, debridTorrent)
_ = arr.Refresh()
go func() {
if err := request.SendDiscordMessage("download_complete", "success", torrent.discordContext()); err != nil {
q.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
if err := arr.Refresh(); err != nil {
q.logger.Error().Msgf("Error refreshing arr: %v", err)
}
}
func (q *QBit) MarkAsFailed(t *Torrent) *Torrent {
t.State = "error"
q.Storage.AddOrUpdate(t)
go func() {
if err := request.SendDiscordMessage("download_failed", "error", t.discordContext()); err != nil {
q.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
return t
}
@@ -160,16 +180,12 @@ func (q *QBit) UpdateTorrentMin(t *Torrent, debridTorrent *debrid.Torrent) *Torr
}
func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent {
_db := service.GetDebrid().GetByName(debridTorrent.Debrid)
if debridTorrent == nil && t.ID != "" {
debridTorrent, _ = _db.GetTorrent(t.ID)
}
if debridTorrent == nil {
q.logger.Info().Msgf("Torrent with ID %s not found in %s", t.ID, _db.GetName())
return t
}
_db := service.GetDebrid().GetByName(debridTorrent.Debrid)
if debridTorrent.Status != "downloaded" {
debridTorrent, _ = _db.GetTorrent(t.ID)
debridTorrent, _ = _db.GetTorrent(debridTorrent)
}
t = q.UpdateTorrentMin(t, debridTorrent)
t.ContentPath = t.TorrentPath + string(os.PathSeparator)
@@ -180,7 +196,7 @@ func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent
return t
}
ticker := time.NewTicker(2 * time.Second)
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {

View File

@@ -1,6 +1,7 @@
package qbit
import (
"fmt"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"sync"
)
@@ -230,6 +231,17 @@ func (t *Torrent) IsReady() bool {
return t.AmountLeft <= 0 && t.TorrentPath != ""
}
func (t *Torrent) discordContext() string {
format := `
**Name:** %s
**Arr:** %s
**Hash:** %s
**MagnetURI:** %s
**Debrid:** %s
`
return fmt.Sprintf(format, t.Name, t.Category, t.Hash, t.MagnetUri, t.Debrid)
}
type TorrentProperties struct {
AdditionDate int64 `json:"addition_date,omitempty"`
Comment string `json:"comment,omitempty"`

View File

@@ -1,39 +0,0 @@
package qbit
import (
"context"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"time"
)
func (q *QBit) StartWorker(ctx context.Context) {
q.logger.Info().Msg("Qbit Worker started")
q.StartRefreshWorker(ctx)
}
func (q *QBit) StartRefreshWorker(ctx context.Context) {
refreshCtx := context.WithValue(ctx, "worker", "refresh")
refreshTicker := time.NewTicker(time.Duration(q.RefreshInterval) * time.Second)
for {
select {
case <-refreshCtx.Done():
q.logger.Info().Msg("Qbit Refresh Worker stopped")
return
case <-refreshTicker.C:
torrents := q.Storage.GetAll("", "", nil)
if len(torrents) > 0 {
q.RefreshArrs()
}
}
}
}
func (q *QBit) RefreshArrs() {
arrs := service.GetService().Arr
for _, arr := range arrs.GetAll() {
err := arr.Refresh()
if err != nil {
return
}
}
}

View File

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

View File

@@ -2,20 +2,24 @@ package repair
import (
"context"
"encoding/json"
"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/internal/request"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
"log"
"golang.org/x/sync/errgroup"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"syscall"
@@ -23,57 +27,134 @@ import (
)
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
Jobs map[string]*Job
arrs *arr.Storage
deb engine.Service
duration time.Duration
runOnStart bool
ZurgURL string
IsZurg bool
autoProcess bool
logger zerolog.Logger
filename string
}
func New(deb *engine.Engine, arrs *arr.Storage) *Repair {
func New(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,
arrs: arrs,
logger: logger.NewLogger("repair", cfg.LogLevel, os.Stdout),
duration: duration,
runOnStart: cfg.Repair.RunOnStart,
ZurgURL: cfg.Repair.ZurgURL,
autoProcess: cfg.Repair.AutoProcess,
filename: filepath.Join(cfg.Path, "repair.json"),
}
if r.ZurgURL != "" {
r.IsZurg = true
}
// Load jobs from file
r.loadFromFile()
return r
}
type JobStatus string
const (
JobStarted JobStatus = "started"
JobPending JobStatus = "pending"
JobFailed JobStatus = "failed"
JobCompleted JobStatus = "completed"
)
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"`
ID string `json:"id"`
Arrs []string `json:"arrs"`
MediaIDs []string `json:"media_ids"`
StartedAt time.Time `json:"created_at"`
BrokenItems map[string][]arr.ContentFile `json:"broken_items"`
Status JobStatus `json:"status"`
CompletedAt time.Time `json:"finished_at"`
FailedAt time.Time `json:"failed_at"`
AutoProcess bool `json:"auto_process"`
Recurrent bool `json:"recurrent"`
Error string `json:"error"`
}
func (r *Repair) NewJob(arrs []*arr.Arr, mediaIDs []string) *Job {
func (j *Job) discordContext() string {
format := `
**ID**: %s
**Arrs**: %s
**Media IDs**: %s
**Status**: %s
**Started At**: %s
**Completed At**: %s
`
dateFmt := "2006-01-02 15:04:05"
return fmt.Sprintf(format, j.ID, strings.Join(j.Arrs, ","), strings.Join(j.MediaIDs, ", "), j.Status, j.StartedAt.Format(dateFmt), j.CompletedAt.Format(dateFmt))
}
func (r *Repair) getArrs(arrNames []string) []string {
arrs := make([]string, 0)
if len(arrNames) == 0 {
// No specific arrs, get all
// Also check if any arrs are set to skip repair
_arrs := r.arrs.GetAll()
for _, a := range _arrs {
if a.SkipRepair {
continue
}
arrs = append(arrs, a.Name)
}
} else {
for _, name := range arrNames {
a := r.arrs.Get(name)
if a == nil || a.Host == "" || a.Token == "" {
continue
}
arrs = append(arrs, a.Name)
}
}
return arrs
}
func jobKey(arrNames []string, mediaIDs []string) string {
return fmt.Sprintf("%s-%s", strings.Join(arrNames, ","), strings.Join(mediaIDs, ","))
}
func (r *Repair) reset(j *Job) {
// Update job for rerun
j.Status = JobStarted
j.StartedAt = time.Now()
j.CompletedAt = time.Time{}
j.FailedAt = time.Time{}
j.BrokenItems = nil
j.Error = ""
if j.Recurrent || j.Arrs == nil {
j.Arrs = r.getArrs([]string{}) // Get new arrs
}
}
func (r *Repair) newJob(arrsNames []string, mediaIDs []string) *Job {
arrs := r.getArrs(arrsNames)
return &Job{
ID: uuid.New().String(),
Arrs: arrs,
MediaIDs: mediaIDs,
StartedAt: time.Now(),
Status: JobStarted,
}
}
func (r *Repair) PreRunChecks() error {
func (r *Repair) preRunChecks() error {
// Check if zurg url is reachable
if !r.IsZurg {
return nil
@@ -90,43 +171,123 @@ func (r *Repair) PreRunChecks() error {
return nil
}
func (r *Repair) Repair(arrs []*arr.Arr, mediaIds []string) error {
func (r *Repair) AddJob(arrsNames []string, mediaIDs []string, autoProcess, recurrent bool) error {
key := jobKey(arrsNames, mediaIDs)
job, ok := r.Jobs[key]
if job != nil && job.Status == JobStarted {
return fmt.Errorf("job already running")
}
if !ok {
job = r.newJob(arrsNames, mediaIDs)
}
job.AutoProcess = autoProcess
job.Recurrent = recurrent
r.reset(job)
r.Jobs[key] = job
go r.saveToFile()
err := r.repair(job)
go r.saveToFile()
return err
}
j := r.NewJob(arrs, mediaIds)
if err := r.PreRunChecks(); err != nil {
func (r *Repair) repair(job *Job) error {
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
// Create a new error group with context
g, ctx := errgroup.WithContext(context.Background())
// Use a mutex to protect concurrent access to brokenItems
var mu sync.Mutex
brokenItems := map[string][]arr.ContentFile{}
for _, a := range job.Arrs {
a := a // Capture range variable
g.Go(func() error {
var items []arr.ContentFile
var err error
if len(job.MediaIDs) == 0 {
items, err = r.repairArr(job, a, "")
if err != nil {
r.logger.Error().Err(err).Msgf("Error repairing %s", a)
return 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
for _, id := range job.MediaIDs {
// Check if any other goroutine has failed
select {
case <-ctx.Done():
return ctx.Err()
default:
}
someItems, err := r.repairArr(job, a, id)
if err != nil {
r.logger.Error().Err(err).Msgf("Error repairing %s with ID %s", a, id)
return err
}
items = append(items, someItems...)
}
}
}(a)
// Safely append the found items to the shared slice
if len(items) > 0 {
mu.Lock()
brokenItems[a] = items
mu.Unlock()
}
return nil
})
}
wg.Wait()
close(errors)
err := <-errors
if err != nil {
j.FailedAt = time.Now()
j.Error = err.Error()
// Wait for all goroutines to complete and check for errors
if err := g.Wait(); err != nil {
job.FailedAt = time.Now()
job.Error = err.Error()
job.Status = JobFailed
job.CompletedAt = time.Now()
go func() {
if err := request.SendDiscordMessage("repair_failed", "error", job.discordContext()); err != nil {
r.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
return err
}
j.CompletedAt = time.Now()
if len(brokenItems) == 0 {
job.CompletedAt = time.Now()
job.Status = JobCompleted
go func() {
if err := request.SendDiscordMessage("repair_complete", "success", job.discordContext()); err != nil {
r.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
return nil
}
job.BrokenItems = brokenItems
if job.AutoProcess {
// Job is already processed
job.CompletedAt = time.Now() // Mark as completed
job.Status = JobCompleted
go func() {
if err := request.SendDiscordMessage("repair_complete", "success", job.discordContext()); err != nil {
r.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
} else {
job.Status = JobPending
go func() {
if err := request.SendDiscordMessage("repair_pending", "pending", job.discordContext()); err != nil {
r.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
}
return nil
}
@@ -138,8 +299,8 @@ func (r *Repair) Start(ctx context.Context) error {
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)
if err := r.AddJob([]string{}, []string{}, r.autoProcess, true); err != nil {
r.logger.Error().Err(err).Msg("Error running initial repair")
}
}()
}
@@ -156,9 +317,8 @@ func (r *Repair) Start(ctx context.Context) error {
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 err := r.AddJob([]string{}, []string{}, r.autoProcess, true); err != nil {
r.logger.Error().Err(err).Msg("Error running repair")
}
// If using time-of-day schedule, reset the ticker for next day
@@ -171,55 +331,79 @@ func (r *Repair) Start(ctx context.Context) error {
}
}
func (r *Repair) RepairArr(a *arr.Arr, tmdbId string) error {
cfg := config.GetConfig()
func (r *Repair) repairArr(j *Job, _arr string, tmdbId string) ([]arr.ContentFile, error) {
brokenItems := make([]arr.ContentFile, 0)
a := r.arrs.Get(_arr)
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("Failed to get %s media: %v", a.Name, err)
return brokenItems, err
}
r.logger.Info().Msgf("Found %d %s media", len(media), a.Type)
r.logger.Info().Msgf("Found %d %s media", len(media), a.Name)
if len(media) == 0 {
r.logger.Info().Msgf("No %s media found", a.Type)
return nil
r.logger.Info().Msgf("No %s media found", a.Name)
return brokenItems, 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
return brokenItems, nil
}
semaphore := make(chan struct{}, runtime.NumCPU()*4)
totalBrokenItems := 0
var wg sync.WaitGroup
// Create a new error group
g, ctx := errgroup.WithContext(context.Background())
// Limit concurrent goroutines
g.SetLimit(runtime.NumCPU() * 4)
// Mutex for brokenItems
var mu sync.Mutex
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)
m := m // Create a new variable scoped to the loop iteration
g.Go(func() error {
// Check if context was canceled
select {
case <-ctx.Done():
return ctx.Err()
default:
}
items := r.getBrokenFiles(m)
if items != nil {
r.logger.Debug().Msgf("Found %d broken files for %s", len(items), m.Title)
if j.AutoProcess {
r.logger.Info().Msgf("Auto processing %d broken items for %s", len(items), m.Title)
// Delete broken items
if err := a.DeleteFiles(items); err != nil {
r.logger.Debug().Msgf("Failed to delete broken items for %s: %v", m.Title, err)
}
// Search for missing items
if err := a.SearchMissing(items); err != nil {
r.logger.Debug().Msgf("Failed to search missing 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)
mu.Lock()
brokenItems = append(brokenItems, items...)
mu.Unlock()
}
}(m)
return nil
})
}
wg.Wait()
r.logger.Info().Msgf("Repair completed for %s. %d broken items found", a.Name, totalBrokenItems)
return nil
if err := g.Wait(); err != nil {
return brokenItems, err
}
r.logger.Info().Msgf("Repair completed for %s. %d broken items found", a.Name, len(brokenItems))
return brokenItems, nil
}
func (r *Repair) isMediaAccessible(m arr.Content) bool {
@@ -309,6 +493,16 @@ func (r *Repair) getZurgBrokenFiles(media arr.Content) []arr.ContentFile {
uniqueParents[parent] = append(uniqueParents[parent], file)
}
}
client := &http.Client{
Timeout: 0,
Transport: &http.Transport{
TLSHandshakeTimeout: 60 * time.Second,
DialContext: (&net.Dialer{
Timeout: 20 * time.Second,
KeepAlive: 30 * time.Second,
}).DialContext,
},
}
// Access zurg url + symlink folder + first file(encoded)
for parent, f := range uniqueParents {
r.logger.Debug().Msgf("Checking %s", parent)
@@ -322,21 +516,25 @@ func (r *Repair) getZurgBrokenFiles(media arr.Content) []arr.ContentFile {
continue
}
resp, err := http.Get(fullURL)
resp, err := client.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 {
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
resp.Body.Close()
brokenFiles = append(brokenFiles, f...)
continue
}
downloadUrl := resp.Request.URL.String()
resp.Body.Close()
if downloadUrl != "" {
r.logger.Debug().Msgf("Found download url: %s", downloadUrl)
r.logger.Trace().Msgf("Found download url: %s", downloadUrl)
} else {
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
brokenFiles = append(brokenFiles, f...)
@@ -350,3 +548,131 @@ func (r *Repair) getZurgBrokenFiles(media arr.Content) []arr.ContentFile {
r.logger.Debug().Msgf("%d broken files found for %s", len(brokenFiles), media.Title)
return brokenFiles
}
func (r *Repair) GetJob(id string) *Job {
for _, job := range r.Jobs {
if job.ID == id {
return job
}
}
return nil
}
func (r *Repair) GetJobs() []*Job {
jobs := make([]*Job, 0)
for _, job := range r.Jobs {
jobs = append(jobs, job)
}
sort.Slice(jobs, func(i, j int) bool {
return jobs[i].StartedAt.After(jobs[j].StartedAt)
})
return jobs
}
func (r *Repair) ProcessJob(id string) error {
job := r.GetJob(id)
if job == nil {
return fmt.Errorf("job %s not found", id)
}
if job.Status != JobPending {
return fmt.Errorf("job %s not pending", id)
}
if job.StartedAt.IsZero() {
return fmt.Errorf("job %s not started", id)
}
if !job.CompletedAt.IsZero() {
return fmt.Errorf("job %s already completed", id)
}
if !job.FailedAt.IsZero() {
return fmt.Errorf("job %s already failed", id)
}
brokenItems := job.BrokenItems
if len(brokenItems) == 0 {
r.logger.Info().Msgf("No broken items found for job %s", id)
job.CompletedAt = time.Now()
job.Status = JobCompleted
return nil
}
// Create a new error group
g := new(errgroup.Group)
for arrName, items := range brokenItems {
items := items
arrName := arrName
g.Go(func() error {
a := r.arrs.Get(arrName)
if a == nil {
r.logger.Error().Msgf("Arr %s not found", arrName)
return nil
}
if err := a.DeleteFiles(items); err != nil {
r.logger.Error().Err(err).Msgf("Failed to delete broken items for %s", arrName)
return nil
}
// Search for missing items
if err := a.SearchMissing(items); err != nil {
r.logger.Error().Err(err).Msgf("Failed to search missing items for %s", arrName)
return nil
}
return nil
})
}
if err := g.Wait(); err != nil {
job.FailedAt = time.Now()
job.Error = err.Error()
job.CompletedAt = time.Now()
job.Status = JobFailed
return err
}
job.CompletedAt = time.Now()
job.Status = JobCompleted
return nil
}
func (r *Repair) saveToFile() {
// Save jobs to file
data, err := json.Marshal(r.Jobs)
if err != nil {
r.logger.Debug().Err(err).Msg("Failed to marshal jobs")
}
err = os.WriteFile(r.filename, data, 0644)
}
func (r *Repair) loadFromFile() {
data, err := os.ReadFile(r.filename)
if err != nil && os.IsNotExist(err) {
r.Jobs = make(map[string]*Job)
return
}
jobs := make(map[string]*Job)
err = json.Unmarshal(data, &jobs)
if err != nil {
r.logger.Trace().Err(err).Msg("Failed to unmarshal jobs; resetting")
r.Jobs = make(map[string]*Job)
return
}
r.Jobs = jobs
}
func (r *Repair) DeleteJobs(ids []string) {
for _, id := range ids {
if id == "" {
continue
}
for k, job := range r.Jobs {
if job.ID == id {
delete(r.Jobs, k)
}
}
}
go r.saveToFile()
}

View File

@@ -23,7 +23,7 @@ type Server struct {
func New() *Server {
cfg := config.GetConfig()
l := logger.NewLogger("http", cfg.QBitTorrent.LogLevel, os.Stdout)
l := logger.NewLogger("http", cfg.LogLevel, os.Stdout)
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
@@ -37,6 +37,10 @@ func New() *Server {
func (s *Server) Start(ctx context.Context) error {
cfg := config.GetConfig()
// Register routes
// Register webhooks
s.router.Post("/webhooks/tautulli", s.handleTautulli)
// Register logs
s.router.Get("/logs", s.getLogs)
port := fmt.Sprintf(":%s", cfg.QBitTorrent.Port)
s.logger.Info().Msgf("Starting server on %s", port)

54
pkg/server/webhook.go Normal file
View File

@@ -0,0 +1,54 @@
package server
import (
"cmp"
"encoding/json"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"net/http"
)
func (s *Server) handleTautulli(w http.ResponseWriter, r *http.Request) {
// Verify it's a POST request
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse the JSON body from Tautulli
var payload struct {
Type string `json:"type"`
TvdbID string `json:"tvdb_id"`
TmdbID string `json:"tmdb_id"`
Topic string `json:"topic"`
AutoProcess bool `json:"autoProcess"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
s.logger.Error().Err(err).Msg("Failed to parse webhook body")
http.Error(w, "Failed to parse webhook body: "+err.Error(), http.StatusBadRequest)
return
}
if payload.Topic != "tautulli" {
http.Error(w, "Invalid topic", http.StatusBadRequest)
return
}
if payload.TmdbID == "" && payload.TvdbID == "" {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
svc := service.GetService()
repair := svc.Repair
mediaId := cmp.Or(payload.TmdbID, payload.TvdbID)
if repair == nil {
http.Error(w, "Repair service is not enabled", http.StatusInternalServerError)
return
}
if err := repair.AddJob([]string{}, []string{mediaId}, payload.AutoProcess, false); err != nil {
http.Error(w, "Failed to add job: "+err.Error(), http.StatusInternalServerError)
return
}
}

View File

@@ -24,7 +24,7 @@ func New() *Service {
arrs := arr.NewStorage()
deb := debrid.New()
instance = &Service{
Repair: repair.New(deb, arrs),
Repair: repair.New(arrs),
Arr: arrs,
Debrid: deb,
}
@@ -44,7 +44,7 @@ func Update() *Service {
arrs := arr.NewStorage()
deb := debrid.New()
instance = &Service{
Repair: repair.New(deb, arrs),
Repair: repair.New(arrs),
Arr: arrs,
Debrid: deb,
}

View File

@@ -23,6 +23,9 @@ func (ui *Handler) Routes() http.Handler {
r.Get("/arrs", ui.handleGetArrs)
r.Post("/add", ui.handleAddContent)
r.Post("/repair", ui.handleRepairMedia)
r.Get("/repair/jobs", ui.handleGetRepairJobs)
r.Post("/repair/jobs/{id}/process", ui.handleProcessRepairJob)
r.Delete("/repair/jobs", ui.handleDeleteRepairJob)
r.Get("/torrents", ui.handleGetTorrents)
r.Delete("/torrents/{category}/{hash}", ui.handleDeleteTorrent)
r.Delete("/torrents/", ui.handleDeleteTorrents)

View File

@@ -46,9 +46,10 @@ type ContentResponse struct {
}
type RepairRequest struct {
ArrName string `json:"arr"`
MediaIds []string `json:"mediaIds"`
Async bool `json:"async"`
ArrName string `json:"arr"`
MediaIds []string `json:"mediaIds"`
Async bool `json:"async"`
AutoProcess bool `json:"autoProcess"`
}
//go:embed web/*
@@ -306,10 +307,11 @@ func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
arrName := r.FormValue("arr")
notSymlink := r.FormValue("notSymlink") == "true"
downloadUncached := r.FormValue("downloadUncached") == "true"
_arr := svc.Arr.Get(arrName)
if _arr == nil {
_arr = arr.New(arrName, "", "", false)
_arr = arr.New(arrName, "", "", false, false, &downloadUncached)
}
// Handle URLs
@@ -322,7 +324,7 @@ func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
}
for _, url := range urlList {
importReq := qbit.NewImportRequest(url, _arr, !notSymlink)
importReq := qbit.NewImportRequest(url, _arr, !notSymlink, downloadUncached)
err := importReq.Process(ui.qbit)
if err != nil {
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
@@ -347,7 +349,7 @@ func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
continue
}
importReq := qbit.NewImportRequest(magnet.Link, _arr, !notSymlink)
importReq := qbit.NewImportRequest(magnet.Link, _arr, !notSymlink, downloadUncached)
err = importReq.Process(ui.qbit)
if err != nil {
errs = append(errs, fmt.Sprintf("File %s: %v", fileHeader.Filename, err))
@@ -383,7 +385,7 @@ func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
if req.Async {
go func() {
if err := svc.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil {
if err := svc.Repair.AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess, false); err != nil {
ui.logger.Error().Err(err).Msg("Failed to repair media")
}
}()
@@ -391,10 +393,9 @@ func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
return
}
if err := svc.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil {
if err := svc.Repair.AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess, false); err != nil {
http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
return
}
request.JSONResponse(w, "Repair completed", http.StatusOK)
@@ -436,8 +437,54 @@ func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
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})
arrCfgs = append(arrCfgs, config.Arr{
Host: a.Host,
Name: a.Name,
Token: a.Token,
Cleanup: a.Cleanup,
SkipRepair: a.SkipRepair,
DownloadUncached: a.DownloadUncached,
})
}
cfg.Arrs = arrCfgs
request.JSONResponse(w, cfg, http.StatusOK)
}
func (ui *Handler) handleGetRepairJobs(w http.ResponseWriter, r *http.Request) {
svc := service.GetService()
request.JSONResponse(w, svc.Repair.GetJobs(), http.StatusOK)
}
func (ui *Handler) handleProcessRepairJob(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "No job ID provided", http.StatusBadRequest)
return
}
go func() {
svc := service.GetService()
if err := svc.Repair.ProcessJob(id); err != nil {
ui.logger.Error().Err(err).Msg("Failed to process repair job")
}
}()
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request) {
// Read ids from body
var req struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
http.Error(w, "No job IDs provided", http.StatusBadRequest)
return
}
svc := service.GetService()
svc.Repair.DeleteJobs(req.IDs)
w.WriteHeader(http.StatusOK)
}

View File

@@ -12,7 +12,7 @@
<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>
<select class="form-select" name="log_level" id="log-level" disabled>
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="warn">Warning</option>
@@ -27,14 +27,27 @@
<!-- Empty label to keep the button aligned -->
</label>
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler()" id="registerMagnetLink">
Open Magnet Links in DecyphArr
Open Magnet Links in Decypharr
</div>
</div>
<div class="col-12 mt-3">
<div class="col-md-6 mt-3">
<div class="form-group">
<label for="discordWebhookUrl">Discord Webhook URL</label>
<div class="input-group">
<textarea type="text"
class="form-control"
id="discordWebhookUrl"
name="discord_webhook_url"
disabled
placeholder="https://discord..."></textarea>
</div>
</div>
</div>
<div class="col-md-6 mt-3">
<div class="form-group">
<label for="allowedExtensions">Allowed File Extensions</label>
<div class="input-group">
<textarea type="text"
<textarea
class="form-control"
id="allowedExtensions"
name="allowed_file_types"
@@ -101,18 +114,6 @@
<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>
@@ -126,19 +127,27 @@
<div class="section mb-5">
<h5 class="border-bottom pb-2">Repair Configuration</h5>
<div class="row">
<div class="col-md-6 mb-3">
<div class="col-md-3 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 class="col-md-4 mb-3">
<label class="form-label">Zurg URL</label>
<input type="text" disabled class="form-control" name="repair.zurg_url" placeholder="http://zurg:9999">
</div>
</div>
<div class="col-12">
<div class="form-check me-3 d-inline-block">
<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 me-3 d-inline-block">
<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 class="form-check d-inline-block">
<input type="checkbox" disabled class="form-check-input" name="repair.auto_process" id="autoProcess">
<label class="form-check-label" for="autoProcess">Auto Process(Scheduled jobs will be processed automatically)</label>
</div>
</div>
</div>
@@ -201,6 +210,26 @@
<input type="password" disabled class="form-control" name="arr[${index}].token" required>
</div>
</div>
<div class="row">
<div class="col-md-2 mb-3">
<div class="form-check">
<label class="form-check-label">Cleanup Queue</label>
<input type="checkbox" disabled class="form-check-input" name="arr[${index}].cleanup">
</div>
</div>
<div class="col-md-2 mb-3">
<div class="form-check">
<label class="form-check-label">Skip Repair</label>
<input type="checkbox" disabled class="form-check-input" name="arr[${index}].skip_repair">
</div>
</div>
<div class="col-md-2 mb-3">
<div class="form-check">
<label class="form-check-label">Download Uncached</label>
<input type="checkbox" disabled class="form-check-input" name="arr[${index}].download_uncached">
</div>
</div>
</div>
</div>
`;
@@ -264,6 +293,9 @@
if (config.max_file_size) {
document.querySelector('[name="max_file_size"]').value = config.max_file_size;
}
if (config.discord_webhook_url) {
document.querySelector('[name="discord_webhook_url"]').value = config.discord_webhook_url;
}
});

View File

@@ -22,13 +22,21 @@
<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 class="row mb-3">
<div class="col-md-2 mb-3">
<div class="form-check d-inline-block me-3">
<input type="checkbox" class="form-check-input" id="isSymlink" name="notSymlink">
<label class="form-check-label" for="isSymlink">No Symlinks</label>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="form-check d-inline-block">
<input type="checkbox" class="form-check-input" name="downloadUncached" id="downloadUncached">
<label class="form-check-label" for="downloadUncached">Download Uncached</label>
</div>
</div>
</div>
<button type="submit" class="btn btn-primary" id="submitDownload">
@@ -44,15 +52,19 @@
const loadSavedDownloadOptions = () => {
const savedCategory = localStorage.getItem('downloadCategory');
const savedSymlink = localStorage.getItem('downloadSymlink');
const savedDownloadUncached = localStorage.getItem('downloadUncached');
document.getElementById('category').value = savedCategory || '';
document.getElementById('isSymlink').checked = savedSymlink === 'true'
document.getElementById('isSymlink').checked = savedSymlink === 'true';
document.getElementById('downloadUncached').checked = savedDownloadUncached === 'true';
};
const saveCurrentDownloadOptions = () => {
const category = document.getElementById('category').value;
const isSymlink = document.getElementById('isSymlink').checked;
const downloadUncached = document.getElementById('downloadUncached').checked;
localStorage.setItem('downloadCategory', category);
localStorage.setItem('downloadSymlink', isSymlink.toString());
localStorage.setItem('downloadUncached', downloadUncached.toString());
};
// Load the last used download options from local storage
@@ -98,6 +110,7 @@
formData.append('arr', document.getElementById('category').value);
formData.append('notSymlink', document.getElementById('isSymlink').checked);
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
const response = await fetch('/internal/add', {
method: 'POST',
@@ -114,10 +127,9 @@
}
} else {
createToast(`Successfully added ${result.results.length} torrents!`);
document.getElementById('magnetURI').value = '';
document.getElementById('torrentFiles').value = '';
}
document.getElementById('magnetURI').value = '';
document.getElementById('torrentFiles').value = '';
} catch (error) {
createToast(`Error adding downloads: ${error.message}`, 'error');
} finally {

View File

@@ -12,13 +12,23 @@
</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="pausedup">Completed</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>
<select class="form-select form-select-sm d-inline-block w-auto" id="sortSelector" style="flex-shrink: 0;">
<option value="added_on" selected>Date Added (Newest First)</option>
<option value="added_on_asc">Date Added (Oldest First)</option>
<option value="name_asc">Name (A-Z)</option>
<option value="name_desc">Name (Z-A)</option>
<option value="size_desc">Size (Largest First)</option>
<option value="size_asc">Size (Smallest First)</option>
<option value="progress_desc">Progress (Most First)</option>
<option value="progress_asc">Progress (Least First)</option>
</select>
</div>
</div>
<div class="card-body p-0">
@@ -43,6 +53,14 @@
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center p-3 border-top">
<div class="pagination-info">
<span id="paginationInfo">Showing 0-0 of 0 torrents</span>
</div>
<nav aria-label="Torrents pagination">
<ul class="pagination pagination-sm m-0" id="paginationControls"></ul>
</nav>
</div>
</div>
</div>
</div>
@@ -51,9 +69,12 @@
torrentsList: document.getElementById('torrentsList'),
categoryFilter: document.getElementById('categoryFilter'),
stateFilter: document.getElementById('stateFilter'),
sortSelector: document.getElementById('sortSelector'),
selectAll: document.getElementById('selectAll'),
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
refreshBtn: document.getElementById('refreshBtn'),
paginationControls: document.getElementById('paginationControls'),
paginationInfo: document.getElementById('paginationInfo')
};
let state = {
torrents: [],
@@ -62,6 +83,9 @@
states: new Set('downloading', 'pausedup', 'error'),
selectedCategory: refs.categoryFilter?.value || '',
selectedState: refs.stateFilter?.value || '',
sortBy: refs.sortSelector?.value || 'added_on',
itemsPerPage: 20,
currentPage: 1
};
const torrentRowTemplate = (torrent) => `
@@ -124,8 +148,19 @@
filteredTorrents = filteredTorrents.filter(t => t.state === state.selectedState);
}
// Sort the filtered torrents
filteredTorrents = sortTorrents(filteredTorrents, state.sortBy);
const totalPages = Math.ceil(filteredTorrents.length / state.itemsPerPage);
if (state.currentPage > totalPages && totalPages > 0) {
state.currentPage = totalPages;
}
const paginatedTorrents = paginateTorrents(filteredTorrents);
// Update the torrents list table
refs.torrentsList.innerHTML = filteredTorrents.map(torrent => torrentRowTemplate(torrent)).join('');
refs.torrentsList.innerHTML = paginatedTorrents.map(torrent => torrentRowTemplate(torrent)).join('');
// Update the category filter dropdown
const currentCategories = Array.from(state.categories).sort();
@@ -162,6 +197,56 @@
}
}
function sortTorrents(torrents, sortBy) {
// Create a copy of the array to avoid mutating the original
const result = [...torrents];
// Parse the sort value to determine field and direction
const [field, direction] = sortBy.includes('_asc') || sortBy.includes('_desc')
? [sortBy.split('_').slice(0, -1).join('_'), sortBy.endsWith('_asc') ? 'asc' : 'desc']
: [sortBy, 'desc']; // Default to descending if not specified
result.sort((a, b) => {
let valueA, valueB;
// Get values based on field
switch (field) {
case 'name':
valueA = a.name?.toLowerCase() || '';
valueB = b.name?.toLowerCase() || '';
break;
case 'size':
valueA = a.size || 0;
valueB = b.size || 0;
break;
case 'progress':
valueA = a.progress || 0;
valueB = b.progress || 0;
break;
case 'added_on':
valueA = a.added_on || 0;
valueB = b.added_on || 0;
break;
default:
valueA = a[field] || 0;
valueB = b[field] || 0;
}
// Compare based on type
if (typeof valueA === 'string') {
return direction === 'asc'
? valueA.localeCompare(valueB)
: valueB.localeCompare(valueA);
} else {
return direction === 'asc'
? valueA - valueB
: valueB - valueA;
}
});
return result;
}
async function deleteTorrent(hash, category) {
if (!confirm('Are you sure you want to delete this torrent?')) return;
@@ -194,6 +279,83 @@
}
}
function paginateTorrents(torrents) {
const totalItems = torrents.length;
const totalPages = Math.ceil(totalItems / state.itemsPerPage);
const startIndex = (state.currentPage - 1) * state.itemsPerPage;
const endIndex = Math.min(startIndex + state.itemsPerPage, totalItems);
// Update pagination info text
refs.paginationInfo.textContent =
`Showing ${totalItems > 0 ? startIndex + 1 : 0}-${endIndex} of ${totalItems} torrents`;
// Generate pagination controls
refs.paginationControls.innerHTML = '';
if (totalPages <= 1) {
return torrents.slice(startIndex, endIndex);
}
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${state.currentPage === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `
<a class="page-link" href="#" aria-label="Previous" ${state.currentPage === 1 ? 'tabindex="-1" aria-disabled="true"' : ''}>
<span aria-hidden="true">&laquo;</span>
</a>
`;
if (state.currentPage > 1) {
prevLi.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
state.currentPage--;
updateUI();
});
}
refs.paginationControls.appendChild(prevLi);
// Page numbers
const maxPageButtons = 5;
let startPage = Math.max(1, state.currentPage - Math.floor(maxPageButtons / 2));
let endPage = Math.min(totalPages, startPage + maxPageButtons - 1);
if (endPage - startPage + 1 < maxPageButtons) {
startPage = Math.max(1, endPage - maxPageButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
const pageLi = document.createElement('li');
pageLi.className = `page-item ${i === state.currentPage ? 'active' : ''}`;
pageLi.innerHTML = `<a class="page-link" href="#">${i}</a>`;
pageLi.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
state.currentPage = i;
updateUI();
});
refs.paginationControls.appendChild(pageLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${state.currentPage === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `
<a class="page-link" href="#" aria-label="Next" ${state.currentPage === totalPages ? 'tabindex="-1" aria-disabled="true"' : ''}>
<span aria-hidden="true">&raquo;</span>
</a>
`;
if (state.currentPage < totalPages) {
nextLi.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
state.currentPage++;
updateUI();
});
}
refs.paginationControls.appendChild(nextLi);
return torrents.slice(startIndex, endIndex);
}
document.addEventListener('DOMContentLoaded', () => {
loadTorrents();
const refreshInterval = setInterval(loadTorrents, 5000);
@@ -230,11 +392,19 @@
refs.categoryFilter.addEventListener('change', (e) => {
state.selectedCategory = e.target.value;
state.currentPage = 1; // Reset to first page
updateUI();
});
refs.stateFilter.addEventListener('change', (e) => {
state.selectedState = e.target.value;
state.currentPage = 1; // Reset to first page
updateUI();
});
refs.sortSelector.addEventListener('change', (e) => {
state.sortBy = e.target.value;
state.currentPage = 1; // Reset to first page
updateUI();
});

View File

@@ -1,146 +1,212 @@
{{ 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;
}
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<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;
--bg-color: #f8fafc;
--card-bg: #ffffff;
--text-color: #333333;
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--nav-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--border-color: #e5e7eb;
}
body {
background-color: #f8fafc;
}
[data-bs-theme="dark"] {
--primary-color: #3b82f6;
--secondary-color: #60a5fa;
--bg-color: #1e293b;
--card-bg: #283548;
--text-color: #e5e7eb;
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--nav-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
--border-color: #4b5563;
}
.navbar {
padding: 1rem 0;
background: #fff !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
.navbar-brand {
color: var(--primary-color) !important;
font-weight: 700;
font-size: 1.5rem;
}
.navbar {
padding: 1rem 0;
background: var(--card-bg) !important;
box-shadow: var(--nav-shadow);
border-bottom: 1px solid var(--border-color);
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
color: var(--primary-color) !important;
font-weight: 700;
font-size: 1.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
color: #4b5563;
}
.card {
border: none;
border-radius: 10px;
box-shadow: var(--card-shadow);
background-color: var(--card-bg);
}
.nav-link.active {
color: var(--primary-color) !important;
font-weight: 500;
}
.nav-link {
padding: 0.5rem 1rem;
color: var(--text-color);
}
.badge#channel-badge {
background-color: #0d6efd;
}
.nav-link.active {
color: var(--primary-color) !important;
font-weight: 500;
}
.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">
.badge#channel-badge {
background-color: #0d6efd;
}
.badge#channel-badge.beta {
background-color: #fd7e14;
}
.badge#channel-badge.nightly {
background-color: #6c757d;
}
.table {
color: var(--text-color);
}
/* Dark mode specific overrides */
[data-bs-theme="dark"] .navbar-light .navbar-toggler-icon {
filter: invert(1);
}
[data-bs-theme="dark"] .form-control,
[data-bs-theme="dark"] .form-select {
background-color: #374151;
color: #e5e7eb;
border-color: #4b5563;
}
[data-bs-theme="dark"] .form-control:focus,
[data-bs-theme="dark"] .form-select:focus {
border-color: var(--primary-color);
}
/* Theme toggle button styles */
.theme-toggle {
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s;
}
.theme-toggle:hover {
background-color: rgba(128, 128, 128, 0.2);
}
</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>
</a>
</li>
</ul>
<div class="d-flex align-items-center">
<div class="theme-toggle me-3" id="themeToggle" title="Toggle dark mode">
<i class="bi bi-sun-fill" id="lightIcon"></i>
<i class="bi bi-moon-fill d-none" id="darkIcon"></i>
</div>
<span class="badge me-2" id="channel-badge">Loading...</span>
<span class="badge bg-primary" id="version-badge">Loading...</span>
</div>
</div>
</nav>
</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 }}
{{ 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';
<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 toastTimeouts = {
success: 5000,
warning: 10000,
error: 15000
};
const toastContainer = document.querySelector('.toast-container');
const toastId = `toast-${Date.now()}`;
const toastHtml = `
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">
@@ -153,44 +219,95 @@
</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');
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
// 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';
});
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: toastTimeouts[type]
});
</script>
</body>
</html>
toast.show();
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
};
// Theme management
const themeToggle = document.getElementById('themeToggle');
const lightIcon = document.getElementById('lightIcon');
const darkIcon = document.getElementById('darkIcon');
const htmlElement = document.documentElement;
// Function to set the theme
function setTheme(theme) {
htmlElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('theme', theme);
if (theme === 'dark') {
lightIcon.classList.add('d-none');
darkIcon.classList.remove('d-none');
} else {
lightIcon.classList.remove('d-none');
darkIcon.classList.add('d-none');
}
}
// Check for saved theme preference or use system preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
setTheme(savedTheme);
} else {
// Check for system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark');
} else {
setTheme('light');
}
}
// Toggle theme when button is clicked
themeToggle.addEventListener('click', () => {
const currentTheme = htmlElement.getAttribute('data-bs-theme');
setTheme(currentTheme === 'dark' ? 'light' : 'dark');
});
// Listen for system theme changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
}
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');
} else if (data.channel === 'nightly') {
channelBadge.classList.add('nightly');
}
})
.catch(error => {
console.error('Error fetching version:', error);
document.getElementById('version-badge').textContent = 'Unknown';
document.getElementById('channel-badge').textContent = 'Unknown';
});
});
</script>
</body>
</html>
{{ end }}

View File

@@ -20,11 +20,20 @@
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
</div>
<div class="mb-3">
<div class="mb-2">
<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
Run in background
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="autoProcess">
<label class="form-check-label" for="autoProcess">
Auto Process(this will delete and re-search broken media)
</label>
</div>
</div>
@@ -35,7 +44,111 @@
</form>
</div>
</div>
<!-- Jobs Table Section -->
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-list-task me-2"></i>Repair Jobs</h4>
<div>
<button id="deleteSelectedJobs" class="btn btn-sm btn-danger me-2" disabled>
<i class="bi bi-trash me-1"></i>Delete Selected
</button>
<button id="refreshJobs" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="jobsTable">
<thead>
<tr>
<th>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllJobs">
</div>
</th>
<th>ID</th>
<th>Arr Instances</th>
<th>Started</th>
<th>Status</th>
<th>Broken Items</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="jobsTableBody">
<!-- Jobs will be loaded here -->
</tbody>
</table>
</div>
<!-- Pagination -->
<nav aria-label="Jobs pagination" class="mt-3">
<ul class="pagination justify-content-center" id="jobsPagination">
<!-- Pagination will be generated here -->
</ul>
</nav>
<div id="noJobsMessage" class="text-center py-3 d-none">
<p class="text-muted">No repair jobs found</p>
</div>
</div>
</div>
<!-- Job Details Modal -->
<div class="modal fade" id="jobDetailsModal" tabindex="-1" aria-labelledby="jobDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jobDetailsModalLabel">Job Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<p><strong>Job ID:</strong> <span id="modalJobId"></span></p>
<p><strong>Status:</strong> <span id="modalJobStatus"></span></p>
<p><strong>Started:</strong> <span id="modalJobStarted"></span></p>
<p><strong>Completed:</strong> <span id="modalJobCompleted"></span></p>
</div>
<div class="col-md-6">
<p><strong>Arrs:</strong> <span id="modalJobArrs"></span></p>
<p><strong>Media IDs:</strong> <span id="modalJobMediaIds"></span></p>
<p><strong>Auto Process:</strong> <span id="modalJobAutoProcess"></span></p>
</div>
</div>
<div id="errorContainer" class="alert alert-danger mb-3 d-none">
<strong>Error:</strong> <span id="modalJobError"></span>
</div>
<h6>Broken Items</h6>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Arr</th>
<th>Path</th>
</tr>
</thead>
<tbody id="brokenItemsTableBody">
<!-- Broken items will be loaded here -->
</tbody>
</table>
</div>
<div id="noBrokenItemsMessage" class="text-center py-2 d-none">
<p class="text-muted">No broken items found</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="processJobBtn">Process Items</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Load Arr instances
@@ -76,12 +189,14 @@
body: JSON.stringify({
arr: document.getElementById('arrSelect').value,
mediaIds: mediaIds,
async: document.getElementById('isAsync').checked
async: document.getElementById('isAsync').checked,
autoProcess: document.getElementById('autoProcess').checked,
})
});
if (!response.ok) throw new Error(await response.text());
createToast('Repair process initiated successfully!');
loadJobs(1); // Refresh jobs after submission
} catch (error) {
createToast(`Error starting repair: ${error.message}`, 'error');
} finally {
@@ -89,6 +204,374 @@
submitBtn.innerHTML = originalText;
}
});
// Jobs table pagination variables
let currentPage = 1;
const itemsPerPage = 10;
let allJobs = [];
// Load jobs function
async function loadJobs(page) {
try {
const response = await fetch('/internal/repair/jobs');
if (!response.ok) throw new Error('Failed to fetch jobs');
allJobs = await response.json();
renderJobsTable(page);
} catch (error) {
console.error('Error loading jobs:', error);
createToast(`Error loading jobs: ${error.message}`, 'error');
}
}
// Render jobs table with pagination
function renderJobsTable(page) {
const tableBody = document.getElementById('jobsTableBody');
const paginationElement = document.getElementById('jobsPagination');
const noJobsMessage = document.getElementById('noJobsMessage');
const deleteSelectedBtn = document.getElementById('deleteSelectedJobs');
// Clear previous content
tableBody.innerHTML = '';
paginationElement.innerHTML = '';
document.getElementById('selectAllJobs').checked = false;
deleteSelectedBtn.disabled = true;
if (allJobs.length === 0) {
noJobsMessage.classList.remove('d-none');
return;
}
noJobsMessage.classList.add('d-none');
// Calculate pagination
const totalPages = Math.ceil(allJobs.length / itemsPerPage);
const startIndex = (page - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, allJobs.length);
// Display jobs for current page
for (let i = startIndex; i < endIndex; i++) {
const job = allJobs[i];
const row = document.createElement('tr');
// Format date
const startedDate = new Date(job.created_at);
const formattedDate = startedDate.toLocaleString();
// Determine status
let status = 'In Progress';
let statusClass = 'text-primary';
let canDelete = false;
let totalItems = job.broken_items ? Object.values(job.broken_items).reduce((sum, arr) => sum + arr.length, 0) : 0;
if (job.status === 'failed') {
status = 'Failed';
statusClass = 'text-danger';
canDelete = true;
} else if (job.status === 'completed') {
status = 'Completed';
statusClass = 'text-success';
canDelete = true;
} else if (job.status === 'pending') {
status = 'Pending';
statusClass = 'text-warning';
}
row.innerHTML = `
<td>
<div class="form-check">
<input class="form-check-input job-checkbox" type="checkbox" value="${job.id}"
${canDelete ? '' : 'disabled'} data-can-delete="${canDelete}">
</div>
</td>
<td><a href="#" class="text-link view-job" data-id="${job.id}"><small>${job.id.substring(0, 8)}</small></a></td>
<td>${job.arrs.join(', ')}</td>
<td><small>${formattedDate}</small></td>
<td><span class="${statusClass}">${status}</span></td>
<td>${totalItems}</td>
<td>
${job.status === "pending" ?
`<button class="btn btn-sm btn-primary process-job" data-id="${job.id}">
<i class="bi bi-play-fill"></i> Process
</button>` :
`<button class="btn btn-sm btn-primary" disabled>
<i class="bi bi-eye"></i> Process
</button>`
}
${canDelete ?
`<button class="btn btn-sm btn-danger delete-job" data-id="${job.id}">
<i class="bi bi-trash"></i>
</button>` :
`<button class="btn btn-sm btn-danger" disabled>
<i class="bi bi-trash"></i>
</button>`
}
</td>
`;
tableBody.appendChild(row);
}
// Create pagination
if (totalPages > 1) {
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${page === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" aria-label="Previous" ${page !== 1 ? `data-page="${page - 1}"` : ''}>
<span aria-hidden="true">&laquo;</span>
</a>`;
paginationElement.appendChild(prevLi);
// Page numbers
for (let i = 1; i <= totalPages; i++) {
const pageLi = document.createElement('li');
pageLi.className = `page-item ${i === page ? 'active' : ''}`;
pageLi.innerHTML = `<a class="page-link" href="#" data-page="${i}">${i}</a>`;
paginationElement.appendChild(pageLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${page === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" aria-label="Next" ${page !== totalPages ? `data-page="${page + 1}"` : ''}>
<span aria-hidden="true">&raquo;</span>
</a>`;
paginationElement.appendChild(nextLi);
}
// Add event listeners to pagination
document.querySelectorAll('#jobsPagination a[data-page]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const newPage = parseInt(e.currentTarget.dataset.page);
currentPage = newPage;
renderJobsTable(newPage);
});
});
document.querySelectorAll('.job-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', updateDeleteButtonState);
});
document.querySelectorAll('.delete-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
deleteJob(jobId);
});
});
// Add event listeners to action buttons
document.querySelectorAll('.process-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
processJob(jobId);
});
});
document.querySelectorAll('.view-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
viewJobDetails(jobId);
});
});
}
document.getElementById('selectAllJobs').addEventListener('change', function() {
const isChecked = this.checked;
document.querySelectorAll('.job-checkbox:not(:disabled)').forEach(checkbox => {
checkbox.checked = isChecked;
});
updateDeleteButtonState();
});
// Function to update delete button state
function updateDeleteButtonState() {
const deleteBtn = document.getElementById('deleteSelectedJobs');
const selectedCheckboxes = document.querySelectorAll('.job-checkbox:checked');
deleteBtn.disabled = selectedCheckboxes.length === 0;
}
// Delete selected jobs
document.getElementById('deleteSelectedJobs').addEventListener('click', async () => {
const selectedIds = Array.from(
document.querySelectorAll('.job-checkbox:checked')
).map(checkbox => checkbox.value);
if (!selectedIds.length) return;
if (confirm(`Are you sure you want to delete ${selectedIds.length} job(s)?`)) {
await deleteMultipleJobs(selectedIds);
}
});
async function deleteJob(jobId) {
if (confirm('Are you sure you want to delete this job?')) {
try {
const response = await fetch(`/internal/repair/jobs`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: [jobId] })
});
if (!response.ok) throw new Error(await response.text());
createToast('Job deleted successfully');
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error deleting job: ${error.message}`, 'error');
}
}
}
async function deleteMultipleJobs(jobIds) {
try {
const response = await fetch(`/internal/repair/jobs`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: jobIds })
});
if (!response.ok) throw new Error(await response.text());
createToast(`${jobIds.length} job(s) deleted successfully`);
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error deleting jobs: ${error.message}`, 'error');
}
}
// Process job function
async function processJob(jobId) {
try {
const response = await fetch(`/internal/repair/jobs/${jobId}/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
});
if (!response.ok) throw new Error(await response.text());
createToast('Job processing started successfully');
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error processing job: ${error.message}`, 'error');
}
}
// View job details function
function viewJobDetails(jobId) {
// Find the job
const job = allJobs.find(j => j.id === jobId);
if (!job) return;
// Prepare modal data
document.getElementById('modalJobId').textContent = job.id.substring(0, 8);
// Format dates
const startedDate = new Date(job.created_at);
document.getElementById('modalJobStarted').textContent = startedDate.toLocaleString();
if (job.finished_at) {
const completedDate = new Date(job.finished_at);
document.getElementById('modalJobCompleted').textContent = completedDate.toLocaleString();
} else {
document.getElementById('modalJobCompleted').textContent = 'N/A';
}
// Set status with color
let status = 'In Progress';
let statusClass = 'text-primary';
if (job.status === 'failed') {
status = 'Failed';
statusClass = 'text-danger';
} else if (job.status === 'completed') {
status = 'Completed';
statusClass = 'text-success';
} else if (job.status === 'pending') {
status = 'Pending';
statusClass = 'text-warning';
}
document.getElementById('modalJobStatus').innerHTML = `<span class="${statusClass}">${status}</span>`;
// Set other job details
document.getElementById('modalJobArrs').textContent = job.arrs.join(', ');
document.getElementById('modalJobMediaIds').textContent = job.media_ids && job.media_ids.length > 0 ?
job.media_ids.join(', ') : 'All';
document.getElementById('modalJobAutoProcess').textContent = job.auto_process ? 'Yes' : 'No';
// Show/hide error message
const errorContainer = document.getElementById('errorContainer');
if (job.error) {
document.getElementById('modalJobError').textContent = job.error;
errorContainer.classList.remove('d-none');
} else {
errorContainer.classList.add('d-none');
}
// Process button visibility
const processBtn = document.getElementById('processJobBtn');
if (job.status === 'pending') {
processBtn.classList.remove('d-none');
processBtn.onclick = () => {
processJob(job.id);
const modal = bootstrap.Modal.getInstance(document.getElementById('jobDetailsModal'));
modal.hide();
};
} else {
processBtn.classList.add('d-none');
}
// Populate broken items table
const brokenItemsTableBody = document.getElementById('brokenItemsTableBody');
const noBrokenItemsMessage = document.getElementById('noBrokenItemsMessage');
brokenItemsTableBody.innerHTML = '';
let hasBrokenItems = false;
// Check if broken_items exists and has entries
if (job.broken_items && Object.entries(job.broken_items).length > 0) {
hasBrokenItems = true;
// Loop through each Arr's broken items
for (const [arrName, items] of Object.entries(job.broken_items)) {
if (items && items.length > 0) {
// Add each item to the table
items.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${arrName}</td>
<td><small class="text-muted">${item.path}</small></td>
`;
brokenItemsTableBody.appendChild(row);
});
}
}
}
// Show/hide no items message
if (hasBrokenItems) {
noBrokenItemsMessage.classList.add('d-none');
} else {
noBrokenItemsMessage.classList.remove('d-none');
}
// Show the modal
const modal = new bootstrap.Modal(document.getElementById('jobDetailsModal'));
modal.show();
}
// Add event listener for refresh button
document.getElementById('refreshJobs').addEventListener('click', () => {
loadJobs(currentPage);
});
// Load jobs on page load
loadJobs(1);
});
</script>
{{ end }}

View File

@@ -5,7 +5,6 @@ import (
"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/service"
"os"
"sync"
@@ -31,13 +30,6 @@ func Start(ctx context.Context) error {
// Start Arr Refresh Worker
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
arrRefreshWorker(ctx, cfg)
}()
wg.Add(1)
go func() {
defer wg.Done()
@@ -47,46 +39,27 @@ func Start(ctx context.Context) error {
return nil
}
func arrRefreshWorker(ctx context.Context, cfg *config.Config) {
// Start Arr Refresh Worker
_logger := getLogger()
_logger.Debug().Msg("Refresh Worker started")
refreshCtx := context.WithValue(ctx, "worker", "refresh")
refreshTicker := time.NewTicker(time.Duration(cfg.QBitTorrent.RefreshInterval) * time.Second)
var refreshMutex sync.Mutex
for {
select {
case <-refreshCtx.Done():
_logger.Debug().Msg("Refresh Worker stopped")
return
case <-refreshTicker.C:
if refreshMutex.TryLock() {
go func() {
defer refreshMutex.Unlock()
refreshArrs()
}()
} else {
_logger.Debug().Msg("Previous refresh still running, skipping this cycle")
}
}
}
}
//func arrRefreshWorker(ctx context.Context, cfg *config.Config) {
// // Start Arr Refresh Worker
// _logger := getLogger()
// _logger.Debug().Msg("Refresh Worker started")
// refreshCtx := context.WithValue(ctx, "worker", "refresh")
// refreshTicker := time.NewTicker(time.Duration(cfg.QBitTorrent.RefreshInterval) * time.Second)
//
// for {
// select {
// case <-refreshCtx.Done():
// _logger.Debug().Msg("Refresh Worker stopped")
// return
// case <-refreshTicker.C:
// refreshArrs()
// }
// }
//}
func cleanUpQueuesWorker(ctx context.Context, cfg *config.Config) {
// Start Clean up Queues Worker
_logger := getLogger()
_arrs := service.GetService().Arr
filtered := make([]*arr.Arr, 0)
for _, a := range _arrs.GetAll() {
if a.Cleanup {
filtered = append(filtered, a)
}
}
if len(filtered) == 0 {
_logger.Debug().Msg("No ARR instances configured for cleanup")
return
}
_logger.Debug().Msg("Clean up Queues Worker started")
cleanupCtx := context.WithValue(ctx, "worker", "cleanup")
cleanupTicker := time.NewTicker(time.Duration(10) * time.Second)
@@ -102,28 +75,32 @@ func cleanUpQueuesWorker(ctx context.Context, cfg *config.Config) {
if cleanupMutex.TryLock() {
go func() {
defer cleanupMutex.Unlock()
cleanUpQueues(filtered)
cleanUpQueues()
}()
}
}
}
}
func refreshArrs() {
arrs := service.GetService().Arr
for _, arr := range arrs.GetAll() {
err := arr.Refresh()
if err != nil {
return
}
}
}
//func refreshArrs() {
// for _, a := range service.GetService().Arr.GetAll() {
// err := a.Refresh()
// if err != nil {
// _logger := getLogger()
// _logger.Debug().Err(err).Msg("Error refreshing arr")
// return
// }
// }
//}
func cleanUpQueues(arrs []*arr.Arr) {
func cleanUpQueues() {
// Clean up queues
_logger := getLogger()
for _, a := range arrs {
_logger.Debug().Msgf("Cleaning up queue for %s", a.Name)
for _, a := range service.GetService().Arr.GetAll() {
if !a.Cleanup {
continue
}
_logger.Trace().Msgf("Cleaning up queue for %s", a.Name)
if err := a.CleanupQueue(); err != nil {
_logger.Debug().Err(err).Msg("Error cleaning up queue")
}