Compare commits
101 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc6ee2f020 | ||
|
|
fce0fc0215 | ||
|
|
26f6f384a3 | ||
|
|
b91aa1db38 | ||
|
|
e2ff3b26de | ||
|
|
b4e4db27fb | ||
|
|
c0589d4ad2 | ||
|
|
a30861984c | ||
|
|
4f92b135d4 | ||
|
|
2b2a682218 | ||
|
|
a83f3d72ce | ||
|
|
1c06407900 | ||
|
|
b1a3d8b762 | ||
|
|
0e25de0e3c | ||
|
|
e741a0e32b | ||
|
|
84bd93805f | ||
|
|
fce2ce28c7 | ||
|
|
302a461efd | ||
|
|
7eb021aac1 | ||
|
|
7a989ccf2b | ||
|
|
f04d7ac86e | ||
|
|
65fb2d1e7c | ||
|
|
46beac7227 | ||
|
|
e0e71b0f7e | ||
|
|
3b463adf09 | ||
|
|
7af0de76cc | ||
|
|
108da305b3 | ||
|
|
9a7bff04ef | ||
|
|
325e6c912c | ||
|
|
6a24c372f5 | ||
|
|
32ecc08a72 | ||
|
|
4b2f601df2 | ||
|
|
bfd2596367 | ||
|
|
6f4f72d781 | ||
|
|
14341d30bc | ||
|
|
878f78468f | ||
|
|
c386495d3d | ||
|
|
1614e29f8f | ||
|
|
186a24cc4a | ||
|
|
16c825d5ba | ||
|
|
8ca3cb32f3 | ||
|
|
c5b975a721 | ||
|
|
2fa6737f31 | ||
|
|
f40cd9ba56 | ||
|
|
bca96dd858 | ||
|
|
1b9b7e203e | ||
|
|
99b4a3152d | ||
|
|
297715bf6e | ||
|
|
64995d0bf3 | ||
|
|
92504cc8e0 | ||
|
|
12f89b3047 | ||
|
|
5d7ddbd208 | ||
|
|
84b70464da | ||
|
|
092a028ad9 | ||
|
|
530de20276 | ||
|
|
e2eb11056d | ||
|
|
1a2504ff6c | ||
|
|
07d632309a | ||
|
|
d9b06fb518 | ||
|
|
d58b327957 | ||
|
|
bba90cb89a | ||
|
|
fc5c6e2869 | ||
|
|
66f4965ec8 | ||
|
|
dc16f0d8a1 | ||
|
|
0741ddf999 | ||
|
|
2ae4bd571e | ||
|
|
0b1c1af8b8 | ||
|
|
74a55149fc | ||
|
|
cfb0051b04 | ||
|
|
a986c4b5d0 | ||
|
|
3841b7751e | ||
|
|
ea73572557 | ||
|
|
7cb41a0e8b | ||
|
|
451c17cdf7 | ||
|
|
c39eebea0d | ||
|
|
03c9657945 | ||
|
|
28e5342c66 | ||
|
|
eeb3a31b05 | ||
|
|
e9d3e120f3 | ||
|
|
104df3c33c | ||
|
|
810c9d705e | ||
|
|
4ff00859a3 | ||
|
|
b77dbcc4f4 | ||
|
|
58c0aafab1 | ||
|
|
357da54083 | ||
|
|
88a7196eaf | ||
|
|
abc86a0460 | ||
|
|
dd0b7efdff | ||
|
|
7359f280b0 | ||
|
|
4eb3539347 | ||
|
|
9fb1118475 | ||
|
|
07491b43fe | ||
|
|
8f7c9a19c5 | ||
|
|
a51364d150 | ||
|
|
df2aa4e361 | ||
|
|
b51cb954f8 | ||
|
|
8bdb2e3547 | ||
|
|
2c9a076cd2 | ||
|
|
d2a77620bc | ||
|
|
4b8f1ccfb6 | ||
|
|
f118c5b794 |
52
.air.toml
Normal file
52
.air.toml
Normal file
@@ -0,0 +1,52 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
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.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 = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html", ".json"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
post_cmd = []
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
build = "yellow"
|
||||
main = "magenta"
|
||||
runner = "green"
|
||||
watcher = "cyan"
|
||||
|
||||
[log]
|
||||
main_only = false
|
||||
silent = false
|
||||
time = false
|
||||
|
||||
[misc]
|
||||
clean_on_exit = false
|
||||
|
||||
[proxy]
|
||||
app_port = 0
|
||||
enabled = false
|
||||
proxy_port = 0
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = false
|
||||
keep_scroll = true
|
||||
@@ -5,4 +5,7 @@ docker-compose.yml
|
||||
.DS_Store
|
||||
**/.idea/
|
||||
*.magnet
|
||||
**.torrent
|
||||
**.torrent
|
||||
torrents.json
|
||||
**/dist/
|
||||
*.json
|
||||
|
||||
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
github: sirrobot01
|
||||
buy_me_a_coffee: sirrobot01
|
||||
85
.github/workflows/beta-docker.yml
vendored
Normal file
85
.github/workflows/beta-docker.yml
vendored
Normal 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
|
||||
33
.github/workflows/goreleaser.yml
vendored
Normal file
33
.github/workflows/goreleaser.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: GoReleaser
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
goreleaser:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
with:
|
||||
go-version: '1.22'
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_CHANNEL: stable
|
||||
77
.github/workflows/release-docker.yml
vendored
Normal file
77
.github/workflows/release-docker.yml
vendored
Normal 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
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -9,3 +9,7 @@ docker-compose.yml
|
||||
*.log
|
||||
*.log.*
|
||||
dist/
|
||||
tmp/**
|
||||
torrents.json
|
||||
logs/**
|
||||
auth.json
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
version: 2
|
||||
version: 1
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
@@ -16,6 +15,10 @@ builds:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/sirrobot01/debrid-blackhole/pkg/version.Version={{.Version}}
|
||||
- -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel={{.Env.RELEASE_CHANNEL}}
|
||||
|
||||
|
||||
archives:
|
||||
|
||||
106
CHANGELOG.md
106
CHANGELOG.md
@@ -39,7 +39,7 @@
|
||||
- Rewrote the whole codebase
|
||||
|
||||
|
||||
#### 0.2.0
|
||||
### 0.2.0
|
||||
- Implement 0.2.0-beta changes
|
||||
- Removed Blackhole
|
||||
- Added QbitTorrent API
|
||||
@@ -67,4 +67,106 @@
|
||||
- Add file download support(Sequential Download)
|
||||
- Fix http handler error
|
||||
- Fix *arrs map failing concurrently
|
||||
- Fix cache not being updated
|
||||
- Fix cache not being updated
|
||||
|
||||
#### 0.2.5
|
||||
- Fix ContentPath not being set prior
|
||||
- Rewrote Readme
|
||||
- Cleaned up the code
|
||||
|
||||
#### 0.2.6
|
||||
- Delete torrent for empty matched files
|
||||
- Update Readme
|
||||
|
||||
#### 0.2.7
|
||||
|
||||
- Add support for multiple debrid providers
|
||||
- Add Torbox support
|
||||
- Add support for configurable debrid cache checks
|
||||
- Add support for configurable debrid download uncached torrents
|
||||
|
||||
#### 0.3.0
|
||||
|
||||
- Add UI for adding torrents
|
||||
- Refraction of the code
|
||||
- -Fix Torbox bug
|
||||
- Update CI/CD
|
||||
- Update Readme
|
||||
|
||||
#### 0.3.1
|
||||
|
||||
- Add DebridLink Support
|
||||
- Refactor error handling
|
||||
|
||||
#### 0.3.2
|
||||
|
||||
- Fix DebridLink not downloading
|
||||
- Fix Torbox with uncached torrents
|
||||
- Add new /internal/cached endpoint to check if an hash is cached
|
||||
- implement per-debrid local cache
|
||||
- Fix file check for torbox
|
||||
- Other minor bug fixes
|
||||
|
||||
#### 0.3.3
|
||||
|
||||
- Add AllDebrid Support
|
||||
- Fix Torbox not downloading uncached torrents
|
||||
- Fix Rar files being downloaded
|
||||
|
||||
#### 0.4.0
|
||||
|
||||
- Add support for multiple debrid providers
|
||||
- A full-fledged UI for adding torrents, repairing files, viewing config and managing torrents
|
||||
- Fix issues with Alldebrid
|
||||
- Fix file transversal bug
|
||||
- Fix files with no parent directory
|
||||
- Logging
|
||||
- Add a more robust logging system
|
||||
- Add logging to a file
|
||||
- Add logging to the UI
|
||||
- Qbittorrent
|
||||
- Add support for tags(creating, deleting, listing)
|
||||
- Add support for categories(creating, deleting, listing)
|
||||
- Fix issues with arr sending torrents using a different content type.
|
||||
|
||||
#### 0.4.1
|
||||
|
||||
- Adds optional UI authentication
|
||||
- Downloaded Torrents persist on restart
|
||||
- Fixes
|
||||
- Fix Alldebrid struggling to find the correct file
|
||||
- Minor bug fixes or speed-gains
|
||||
- A new cleanup worker to clean up ARR queues
|
||||
|
||||
|
||||
#### 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
|
||||
72
Dockerfile
72
Dockerfile
@@ -1,27 +1,65 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:1.22 as builder
|
||||
# Stage 1: Build binaries
|
||||
FROM --platform=$BUILDPLATFORM golang:1.23-alpine as builder
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG BUILDPLATFORM
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=0.0.0
|
||||
ARG CHANNEL=dev
|
||||
|
||||
# Set destination for COPY
|
||||
WORKDIR /app
|
||||
|
||||
# Download Go modules
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go mod download -x
|
||||
|
||||
# Copy the source code. Note the slash at the end, as explained in
|
||||
# https://docs.docker.com/reference/dockerfile/#copy
|
||||
ADD . .
|
||||
COPY . .
|
||||
|
||||
# Build
|
||||
RUN CGO_ENABLED=0 GOOS=$(echo $TARGETPLATFORM | cut -d '/' -f1) GOARCH=$(echo $TARGETPLATFORM | cut -d '/' -f2) go build -o /blackhole
|
||||
# Build main binary
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||
go build -trimpath \
|
||||
-ldflags="-w -s -X github.com/sirrobot01/debrid-blackhole/pkg/version.Version=${VERSION} -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel=${CHANNEL}" \
|
||||
-o /blackhole
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /blackhole /blackhole
|
||||
# Build healthcheck (optimized)
|
||||
RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
--mount=type=cache,target=/root/.cache/go-build \
|
||||
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
|
||||
go build -trimpath -ldflags="-w -s" \
|
||||
-o /healthcheck cmd/healthcheck/main.go
|
||||
|
||||
EXPOSE 8181
|
||||
# Stage 2: Create directory structure
|
||||
FROM alpine:3.19 as dirsetup
|
||||
RUN mkdir -p /app/logs && \
|
||||
chmod 777 /app/logs && \
|
||||
touch /app/logs/decypharr.log && \
|
||||
chmod 666 /app/logs/decypharr.log
|
||||
|
||||
# Run
|
||||
CMD ["/blackhole", "--config", "/app/config.json"]
|
||||
# Stage 3: Final image
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
LABEL version = "${VERSION}-${CHANNEL}"
|
||||
|
||||
LABEL org.opencontainers.image.source = "https://github.com/sirrobot01/debrid-blackhole"
|
||||
LABEL org.opencontainers.image.title = "debrid-blackhole"
|
||||
LABEL org.opencontainers.image.authors = "sirrobot01"
|
||||
LABEL org.opencontainers.image.documentation = "https://github.com/sirrobot01/debrid-blackhole/blob/main/README.md"
|
||||
|
||||
# Copy binaries
|
||||
COPY --from=builder --chown=nonroot:nonroot /blackhole /usr/bin/blackhole
|
||||
COPY --from=builder --chown=nonroot:nonroot /healthcheck /usr/bin/healthcheck
|
||||
|
||||
# Copy pre-made directory structure
|
||||
COPY --from=dirsetup --chown=nonroot:nonroot /app /app
|
||||
|
||||
|
||||
# Metadata
|
||||
ENV LOG_PATH=/app/logs
|
||||
EXPOSE 8181 8282
|
||||
VOLUME ["/app"]
|
||||
USER nonroot:nonroot
|
||||
|
||||
HEALTHCHECK CMD ["/usr/bin/healthcheck"]
|
||||
|
||||
CMD ["/usr/bin/blackhole", "--config", "/app"]
|
||||
291
README.md
291
README.md
@@ -1,35 +1,82 @@
|
||||
### GoBlackHole(with Debrid Proxy Support)
|
||||
### DecyphArr(Qbittorent, but with Debrid Support)
|
||||
|
||||
This is a Golang implementation go Torrent QbitTorrent with a **Real Debrid Proxy Support**.
|
||||

|
||||
|
||||
This is an implementation of QbitTorrent with a **Multiple Debrid service support**. Written in Go.
|
||||
|
||||
### Table of Contents
|
||||
|
||||
- [Features](#features)
|
||||
- [Supported Debrid Providers](#supported-debrid-providers)
|
||||
- [Installation](#installation)
|
||||
- [Docker Compose](#docker-compose)
|
||||
- [Binary](#binary)
|
||||
- [Usage](#usage)
|
||||
- [Connecting to Sonarr/Radarr](#connecting-to-sonarrradarr)
|
||||
- [Sample Config](#sample-config)
|
||||
- [Config Notes](#config-notes)
|
||||
- [Log Level](#log-level)
|
||||
- [Max Cache Size](#max-cache-size)
|
||||
- [Debrid Config](#debrid-config)
|
||||
- [Proxy Config](#proxy-config)
|
||||
- [Qbittorrent Config](#qbittorrent-config)
|
||||
- [Arrs Config](#arrs-config)
|
||||
- [Proxy](#proxy)
|
||||
- [Repair Worker](#repair-worker)
|
||||
- [Changelog](#changelog)
|
||||
- [TODO](#todo)
|
||||
|
||||
### Features
|
||||
|
||||
#### Uses
|
||||
- Mock Qbittorent API that supports the Arrs(Sonarr, Radarr, etc)
|
||||
- A Full-fledged UI for managing torrents
|
||||
- Proxy support for the Arrs
|
||||
- Real Debrid Support
|
||||
- Torbox Support
|
||||
- Debrid Link Support
|
||||
- Multi-Debrid Providers support
|
||||
- Repair Worker for missing files (**BETA**)
|
||||
|
||||
The proxy is useful in filtering out un-cached Real Debrid torrents
|
||||
The proxy is useful for filtering out un-cached Debrid torrents
|
||||
|
||||
### Changelog
|
||||
|
||||
- View the [CHANGELOG.md](CHANGELOG.md) for the latest changes
|
||||
### Supported Debrid Providers
|
||||
- [Real Debrid](https://real-debrid.com)
|
||||
- [Torbox](https://torbox.app)
|
||||
- [Debrid Link](https://debrid-link.com)
|
||||
- [All Debrid](https://alldebrid.com)
|
||||
|
||||
|
||||
### Installation
|
||||
|
||||
##### 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
|
||||
|
||||
|
||||
#### Installation
|
||||
##### Docker Compose
|
||||
```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:
|
||||
- ./logs:/app/logs
|
||||
- ~/plex/media:/media
|
||||
- ~/plex/media/symlinks/:/media/symlinks/
|
||||
- ~/plex/configs/blackhole/config.json:/app/config.json # Config file, see below
|
||||
- /mnt/:/mnt
|
||||
- ~/plex/configs/decypharr/:/app # config.json must be in this directory
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
@@ -37,6 +84,8 @@ services:
|
||||
- QBIT_PORT=8282 # qBittorrent Port. This is optional. You can set this in the config file
|
||||
- PORT=8181 # Proxy Port. This is optional. You can set this in the config file
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- rclone # If you are using rclone with docker
|
||||
|
||||
```
|
||||
|
||||
@@ -44,77 +93,17 @@ services:
|
||||
Download the binary from the releases page and run it with the config file.
|
||||
|
||||
```bash
|
||||
./blackhole --config /path/to/config.json
|
||||
./decypharr --config /app
|
||||
```
|
||||
|
||||
#### Config
|
||||
```json
|
||||
{
|
||||
"debrid": {
|
||||
"name": "realdebrid",
|
||||
"host": "https://api.real-debrid.com/rest/1.0",
|
||||
"api_key": "realdebrid_api_key",
|
||||
"folder": "data/realdebrid/torrents/",
|
||||
"rate_limit": "250/minute"
|
||||
},
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"port": "8181",
|
||||
"debug": false,
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"cached_only": true
|
||||
},
|
||||
"max_cache_size": 1000,
|
||||
"qbittorrent": {
|
||||
"port": "8282",
|
||||
"username": "admin", // deprecated
|
||||
"password": "admin", // deprecated
|
||||
"download_folder": "/media/symlinks/",
|
||||
"categories": ["sonarr", "radarr"],
|
||||
"refresh_interval": 5 // in seconds
|
||||
}
|
||||
}
|
||||
```
|
||||
### Usage
|
||||
- The UI is available at `http://localhost:8282`
|
||||
- Setup the config.json file. Scroll down for the sample config file
|
||||
- Setup docker compose/ binary with the config file
|
||||
- Start the service
|
||||
- Connect to Sonarr/Radarr/Lidarr
|
||||
|
||||
#### Config Notes
|
||||
##### Debrid Config
|
||||
- This config key is important as it's used for both Blackhole and Proxy
|
||||
|
||||
##### Proxy Config
|
||||
- The `enabled` key is used to enable the proxy
|
||||
- The `port` key is the port the proxy will listen on
|
||||
- The `debug` key is used to enable debug logs
|
||||
- The `username` and `password` keys are used for basic authentication
|
||||
- The `cached_only` means only cached torrents will be returned
|
||||
|
||||
|
||||
##### Qbittorrent Config
|
||||
- The `port` key is the port the qBittorrent will listen on
|
||||
- The `download_folder` is the folder where the torrents will be downloaded. e.g `/media/symlinks/`
|
||||
- The `categories` key is used to filter out torrents based on the category. e.g `sonarr`, `radarr`
|
||||
|
||||
### Proxy
|
||||
|
||||
The proxy is useful in filtering out un-cached Real Debrid torrents.
|
||||
The proxy is a simple HTTP proxy that requires basic authentication. The proxy can be enabled by setting the `proxy.enabled` to `true` in the config file.
|
||||
The proxy listens on the port `8181` by default. The username and password can be set in the config file.
|
||||
|
||||
Setting Up Proxy in Arr
|
||||
|
||||
- Sonarr/Radarr
|
||||
- Settings -> General -> Use Proxy
|
||||
- Hostname: `localhost` # or the IP of the server
|
||||
- Port: `8181` # or the port set in the config file
|
||||
- Username: `username` # or the username set in the config file
|
||||
- Password: `password` # or the password set in the config file
|
||||
- Bypass Proxy for Local Addresses -> `No`
|
||||
|
||||
### Qbittorrent
|
||||
|
||||
The qBittorrent is a mock qBittorrent API that supports the Arrs(Sonarr, Radarr, etc).
|
||||
|
||||
Setting Up Qbittorrent in Arr
|
||||
#### Connecting to Sonarr/Radarr
|
||||
|
||||
- Sonarr/Radarr
|
||||
- Settings -> Download Client -> Add Client -> qBittorrent
|
||||
@@ -125,18 +114,130 @@ Setting Up Qbittorrent in Arr
|
||||
- Category: e.g `sonarr`, `radarr`
|
||||
- Use SSL -> `No`
|
||||
- Sequential Download -> `No`|`Yes` (If you want to download the torrents locally instead of symlink)
|
||||
- Test
|
||||
- Save
|
||||
- Click Test
|
||||
- Click Save
|
||||
|
||||
#### Basic Sample Config
|
||||
|
||||
This is the default config file. You can create a `config.json` file in the root directory of the project or mount it to /app in the docker-compose file.
|
||||
```json
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"host": "https://api.real-debrid.com/rest/1.0",
|
||||
"api_key": "realdebrid_key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/"
|
||||
}
|
||||
],
|
||||
"proxy": {
|
||||
"enabled": false,
|
||||
"port": "8100",
|
||||
"username": "username",
|
||||
"password": "password"
|
||||
},
|
||||
"qbittorrent": {
|
||||
"port": "8282",
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr"],
|
||||
},
|
||||
"repair": {
|
||||
"enabled": false,
|
||||
"interval": "12h",
|
||||
"run_on_start": false
|
||||
},
|
||||
"use_auth": false,
|
||||
"log_level": "info"
|
||||
}
|
||||
```
|
||||
|
||||
Full config are [here](doc/config.full.json)
|
||||
|
||||
<details>
|
||||
|
||||
<summary>
|
||||
Click Here for the full config notes
|
||||
</summary>
|
||||
|
||||
- The `log_level` key is used to set the log level of the application. The default value is `info`. log level can be set to `debug`, `info`, `warn`, `error`
|
||||
- The `max_cache_size` key is used to set the maximum number of infohashes that can be stored in the availability cache. This is used to prevent round trip to the debrid provider when using the proxy/Qbittorrent. The default value is `1000`
|
||||
- The `allowed_file_types` key is an array of allowed file types that can be downloaded. By default, all movie, tv show and music file types are allowed
|
||||
- The `use_auth` is used to enable basic authentication for the UI. The default value is `false`
|
||||
- The `discord_webhook_url` is used to send notifications to discord
|
||||
|
||||
##### Debrid Config
|
||||
- The `debrids` key is an array of debrid providers
|
||||
- The `name` key is the name of the debrid provider
|
||||
- The `host` key is the API endpoint of the debrid provider
|
||||
- The `api_key` key is the API key of the debrid provider
|
||||
- The `folder` key is the folder where your debrid folder is mounted(webdav, rclone, zurg etc). e.g `data/realdebrid/torrents/`, `/media/remote/alldebrid/magnets/`
|
||||
- The `rate_limit` key is the rate limit of the debrid provider(null by default)
|
||||
- The `download_uncached` bool key is used to download uncached torrents(disabled by default)
|
||||
- The `check_cached` bool key is used to check if the torrent is cached(disabled by default)
|
||||
|
||||
##### Repair Config (**BETA**)
|
||||
The `repair` key is used to enable the repair worker
|
||||
- The `enabled` key is used to enable the repair worker
|
||||
- The `interval` key is the interval in either minutes, seconds, hours, days. Use any of this format, e.g 12:00, 5:00, 1h, 1d, 1m, 1s.
|
||||
- The `run_on_start` key is used to run the repair worker on start
|
||||
- The `zurg_url` is the url of the zurg server. Typically `http://localhost:9999` or `http://zurg:9999`
|
||||
- The `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
|
||||
- The `port` key is the port the proxy will listen on
|
||||
- The `log_level` key is used to set the log level of the proxy. The default value is `info`
|
||||
- The `username` and `password` keys are used for basic authentication
|
||||
- The `cached_only` means only cached torrents will be returned
|
||||
|
||||
|
||||
##### Qbittorrent Config
|
||||
- The `port` key is the port the qBittorrent will listen on
|
||||
- The `download_folder` is the folder where the torrents will be downloaded. e.g `/media/symlinks/`
|
||||
- The `categories` key is used to filter out torrents based on the category. e.g `sonarr`, `radarr`
|
||||
- The `refresh_interval` key is used to set the interval in minutes to refresh the Arrs Monitored Downloads(it's in seconds). The default value is `5` seconds
|
||||
|
||||
|
||||
##### Arrs Config
|
||||
This is an array of Arrs(Sonarr, Radarr, etc) that will be used to download the torrents. This is not required if you already set up the Qbittorrent in the Arrs with the host, token.
|
||||
This is particularly useful if you want to use the Repair tool without using Qbittorent
|
||||
- The `name` key is the name of the Arr/ Category
|
||||
- The `host` key is the host of the Arr
|
||||
- The `token` key is the API token of the Arr
|
||||
- THe `cleanup` key is used to cleanup your arr queues. This is usually for removing dangling queues(downloads that all the files have been import, sometimes, some incomplete season packs)
|
||||
|
||||
</details>
|
||||
|
||||
### Repair Worker
|
||||
|
||||
The repair worker is a simple worker that checks for missing files in the Arrs(Sonarr, Radarr, etc). It's particularly useful for files either deleted by the Debrid provider or files with bad symlinks.
|
||||
|
||||
**Note**: If you're using zurg, set the `zurg_url` under repair config. This will speed up the repair process, exponentially.
|
||||
|
||||
- Search for broken symlinks/files
|
||||
- Search for missing files
|
||||
- Search for deleted/unreadable files
|
||||
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
### TODO
|
||||
- [ ] A proper name!!!!
|
||||
- [ ] Debrid
|
||||
- [ ] Add more Debrid Providers
|
||||
- [x] A proper name!!!!
|
||||
- [x] Debrid
|
||||
- [x] Add more Debrid Providers
|
||||
|
||||
- [ ] Proxy
|
||||
- [ ] Add more Proxy features
|
||||
|
||||
- [ ] Qbittorrent
|
||||
- [ ] Add more Qbittorrent features
|
||||
- [ ] Persist torrents on restart/server crash
|
||||
- [ ] Add tests
|
||||
- [x] Qbittorrent
|
||||
- [x] Add more Qbittorrent features
|
||||
- [x] Persist torrents on restart/server crash
|
||||
- [ ] Add tests
|
||||
|
||||
110
cmd/decypharr/main.go
Normal file
110
cmd/decypharr/main.go
Normal file
@@ -0,0 +1,110 @@
|
||||
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"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/qbit"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/server"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/version"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/web"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/worker"
|
||||
"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.GetDefaultLogger()
|
||||
|
||||
_log.Info().Msgf("Version: %s", version.GetInfo().String())
|
||||
_log.Debug().Msgf("Config Loaded: %s", cfg.JsonFile())
|
||||
_log.Info().Msgf("Default Log Level: %s", cfg.LogLevel)
|
||||
|
||||
svc := service.New()
|
||||
_qbit := qbit.New()
|
||||
srv := server.New()
|
||||
webRoutes := web.New(_qbit).Routes()
|
||||
qbitRoutes := _qbit.Routes()
|
||||
|
||||
// Register routes
|
||||
srv.Mount("/", webRoutes)
|
||||
srv.Mount("/api/v2", qbitRoutes)
|
||||
|
||||
safeGo := func(f func() error) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
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
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
if cfg.Proxy.Enabled {
|
||||
safeGo(func() error {
|
||||
return proxy.NewProxy().Start(ctx)
|
||||
})
|
||||
}
|
||||
|
||||
safeGo(func() error {
|
||||
return srv.Start(ctx)
|
||||
})
|
||||
|
||||
safeGo(func() error {
|
||||
return worker.Start(ctx)
|
||||
})
|
||||
|
||||
if cfg.Repair.Enabled {
|
||||
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() {
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
}()
|
||||
|
||||
// Wait for context cancellation or completion or error
|
||||
select {
|
||||
case err := <-errChan:
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
9
cmd/decypharr/umask_unix.go
Normal file
9
cmd/decypharr/umask_unix.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package decypharr
|
||||
|
||||
import "syscall"
|
||||
|
||||
func SetUmask(umask int) {
|
||||
syscall.Umask(umask)
|
||||
}
|
||||
8
cmd/decypharr/umask_win.go
Normal file
8
cmd/decypharr/umask_win.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build windows
|
||||
// +build windows
|
||||
|
||||
package decypharr
|
||||
|
||||
func SetUmask(umask int) {
|
||||
// No-op on Windows
|
||||
}
|
||||
22
cmd/healthcheck/main.go
Normal file
22
cmd/healthcheck/main.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := cmp.Or(os.Getenv("QBIT_PORT"), "8282")
|
||||
resp, err := http.Get("http://localhost:" + port + "/api/v2/app/version")
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
os.Exit(0)
|
||||
}
|
||||
40
cmd/main.go
40
cmd/main.go
@@ -1,40 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"goBlack/pkg/proxy"
|
||||
"goBlack/pkg/qbit"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func Start(config *common.Config) {
|
||||
maxCacheSize := cmp.Or(config.MaxCacheSize, 1000)
|
||||
cache := common.NewCache(maxCacheSize)
|
||||
|
||||
deb := debrid.NewDebrid(config.Debrid, cache)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
if config.Proxy.Enabled {
|
||||
p := proxy.NewProxy(*config, deb, cache)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
p.Start()
|
||||
}()
|
||||
}
|
||||
if config.QBitTorrent.Port != "" {
|
||||
qb := qbit.NewQBit(config, deb, cache)
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
qb.Start()
|
||||
}()
|
||||
}
|
||||
|
||||
// Wait indefinitely
|
||||
wg.Wait()
|
||||
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type DebridConfig struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
APIKey string `json:"api_key"`
|
||||
Folder string `json:"folder"`
|
||||
DownloadUncached bool `json:"download_uncached"`
|
||||
RateLimit string `json:"rate_limit"` // 200/minute or 10/second
|
||||
}
|
||||
|
||||
type ProxyConfig struct {
|
||||
Port string `json:"port"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Debug bool `json:"debug"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CachedOnly *bool `json:"cached_only"`
|
||||
}
|
||||
|
||||
type QBitTorrentConfig struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Port string `json:"port"`
|
||||
Debug bool `json:"debug"`
|
||||
DownloadFolder string `json:"download_folder"`
|
||||
Categories []string `json:"categories"`
|
||||
RefreshInterval int `json:"refresh_interval"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Debrid DebridConfig `json:"debrid"`
|
||||
Proxy ProxyConfig `json:"proxy"`
|
||||
MaxCacheSize int `json:"max_cache_size"`
|
||||
QBitTorrent QBitTorrentConfig `json:"qbittorrent"`
|
||||
}
|
||||
|
||||
func LoadConfig(path string) (*Config, error) {
|
||||
// Load the config file
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}(file)
|
||||
|
||||
decoder := json.NewDecoder(file)
|
||||
config := &Config{}
|
||||
err = decoder.Decode(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.Proxy.CachedOnly == nil {
|
||||
config.Proxy.CachedOnly = new(bool)
|
||||
*config.Proxy.CachedOnly = true
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
VIDEOMATCH = "(?i)(\\.)(YUV|WMV|WEBM|VOB|VIV|SVI|ROQ|RMVB|RM|OGV|OGG|NSV|MXF|MPG|MPEG|M2V|MP2|MPE|MPV|MP4|M4P|M4V|MOV|QT|MNG|MKV|FLV|DRC|AVI|ASF|AMV|MKA|F4V|3GP|3G2|DIVX|X264|X265)$"
|
||||
MUSICMATCH = "(?i)(\\.)(?:MP3|WAV|FLAC|AAC|OGG|WMA|AIFF|ALAC|M4A|APE|AC3|DTS|M4P|MID|MIDI|MKA|MP2|MPA|RA|VOC|WV|AMR)$"
|
||||
SUBMATCH = "(?i)(\\.)(SRT|SUB|SBV|ASS|VTT|TTML|DFXP|STL|SCC|CAP|SMI|TTXT|TDS|USF|JSS|SSA|PSB|RT|LRC|SSB)$"
|
||||
SAMPLEMATCH = `(?i)(^|[\\/]|[._-])(sample|trailer|thumb)s?([._-]|$)`
|
||||
)
|
||||
|
||||
func RegexMatch(regex string, value string) bool {
|
||||
re := regexp.MustCompile(regex)
|
||||
return re.MatchString(value)
|
||||
}
|
||||
|
||||
func RemoveInvalidChars(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == filepath.Separator || r == ':' {
|
||||
return r
|
||||
}
|
||||
if filepath.IsAbs(string(r)) {
|
||||
return r
|
||||
}
|
||||
if strings.ContainsRune(filepath.VolumeName("C:"+string(r)), r) {
|
||||
return r
|
||||
}
|
||||
if r < 32 || strings.ContainsRune(`<>:"/\|?*`, r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
func RemoveExtension(value string) string {
|
||||
re := regexp.MustCompile(VIDEOMATCH + "|" + SUBMATCH + "|" + SAMPLEMATCH + "|" + MUSICMATCH)
|
||||
|
||||
// Find the last index of the matched extension
|
||||
loc := re.FindStringIndex(value)
|
||||
if loc != nil {
|
||||
return value[:loc[0]]
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func RegexFind(regex string, value string) string {
|
||||
re := regexp.MustCompile(regex)
|
||||
match := re.FindStringSubmatch(value)
|
||||
if len(match) > 0 {
|
||||
return match[0]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
94
doc/config.full.json
Normal file
94
doc/config.full.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "torbox",
|
||||
"host": "https://api.torbox.app/v1",
|
||||
"api_key": "torbox_api_key",
|
||||
"folder": "/mnt/remote/torbox/torrents/",
|
||||
"rate_limit": "250/minute",
|
||||
"download_uncached": false,
|
||||
"check_cached": true
|
||||
},
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"host": "https://api.real-debrid.com/rest/1.0",
|
||||
"api_key": "realdebrid_key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"rate_limit": "250/minute",
|
||||
"download_uncached": false,
|
||||
"check_cached": false
|
||||
},
|
||||
{
|
||||
"name": "debridlink",
|
||||
"host": "https://debrid-link.com/api/v2",
|
||||
"api_key": "debridlink_key",
|
||||
"folder": "/mnt/remote/debridlink/torrents/",
|
||||
"rate_limit": "250/minute",
|
||||
"download_uncached": false,
|
||||
"check_cached": false
|
||||
},
|
||||
{
|
||||
"name": "alldebrid",
|
||||
"host": "http://api.alldebrid.com/v4.1",
|
||||
"api_key": "alldebrid_key",
|
||||
"folder": "/mnt/remote/alldebrid/magnet/",
|
||||
"rate_limit": "600/minute",
|
||||
"download_uncached": false,
|
||||
"check_cached": false
|
||||
}
|
||||
],
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"port": "8100",
|
||||
"log_level": "info",
|
||||
"username": "username",
|
||||
"password": "password",
|
||||
"cached_only": true
|
||||
},
|
||||
"max_cache_size": 1000,
|
||||
"qbittorrent": {
|
||||
"port": "8282",
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr"],
|
||||
"refresh_interval": 5,
|
||||
"skip_pre_cache": false
|
||||
},
|
||||
"arrs": [
|
||||
{
|
||||
"name": "sonarr",
|
||||
"host": "http://radarr:8989",
|
||||
"token": "arr_key",
|
||||
"cleanup": true,
|
||||
"skip_repair": true,
|
||||
"download_uncached": false
|
||||
},
|
||||
{
|
||||
"name": "radarr",
|
||||
"host": "http://radarr:7878",
|
||||
"token": "arr_key",
|
||||
"cleanup": false,
|
||||
"download_uncached": false
|
||||
},
|
||||
{
|
||||
"name": "lidarr",
|
||||
"host": "http://lidarr:7878",
|
||||
"token": "arr_key",
|
||||
"cleanup": false,
|
||||
"skip_repair": true,
|
||||
"download_uncached": false
|
||||
}
|
||||
],
|
||||
"repair": {
|
||||
"enabled": false,
|
||||
"interval": "12h",
|
||||
"run_on_start": false,
|
||||
"zurg_url": "http://zurg:9999",
|
||||
"auto_process": false
|
||||
},
|
||||
"log_level": "info",
|
||||
"min_file_size": "",
|
||||
"max_file_size": "",
|
||||
"allowed_file_types": [],
|
||||
"use_auth": false,
|
||||
"discord_webhook_url": "https://discord.com/api/webhooks/...",
|
||||
}
|
||||
BIN
doc/download.png
Normal file
BIN
doc/download.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 185 KiB |
BIN
doc/main.png
Normal file
BIN
doc/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
29
go.mod
29
go.mod
@@ -1,6 +1,8 @@
|
||||
module goBlack
|
||||
module github.com/sirrobot01/debrid-blackhole
|
||||
|
||||
go 1.22
|
||||
go 1.23
|
||||
|
||||
toolchain go1.23.2
|
||||
|
||||
require (
|
||||
github.com/anacrolix/torrent v1.55.0
|
||||
@@ -9,20 +11,29 @@ require (
|
||||
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/valyala/fasthttp v1.55.0
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/valyala/fastjson v1.6.4
|
||||
golang.org/x/time v0.6.0
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net v0.33.0
|
||||
golang.org/x/sync v0.11.0
|
||||
golang.org/x/time v0.8.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/anacrolix/missinggo v1.3.0 // indirect
|
||||
github.com/anacrolix/missinggo/v2 v2.7.3 // indirect
|
||||
github.com/andybalholm/brotli v1.1.0 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/huandu/xstrings v1.3.2 // indirect
|
||||
github.com/klauspost/compress v1.17.9 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.12.0 // indirect
|
||||
github.com/stretchr/testify v1.10.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
)
|
||||
|
||||
62
go.sum
62
go.sum
@@ -35,8 +35,6 @@ github.com/anacrolix/tagflag v1.0.0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pm
|
||||
github.com/anacrolix/tagflag v1.1.0/go.mod h1:Scxs9CV10NQatSmbyjqmqmeQNwGzlNe0CMUMIxqHIG8=
|
||||
github.com/anacrolix/torrent v1.55.0 h1:s9yh/YGdPmbN9dTa+0Inh2dLdrLQRvEAj1jdFW/Hdd8=
|
||||
github.com/anacrolix/torrent v1.55.0/go.mod h1:sBdZHBSZNj4de0m+EbYg7vvs/G/STubxu/GzzNbojsE=
|
||||
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
|
||||
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
|
||||
github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ=
|
||||
github.com/benbjohnson/immutable v0.2.0/go.mod h1:uc6OHo6PN2++n98KHLxW8ef4W42ylHiQSENghE1ezxI=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
@@ -50,9 +48,11 @@ github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIH
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
|
||||
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
@@ -79,6 +79,7 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
@@ -104,6 +105,8 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
@@ -112,6 +115,10 @@ github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORR
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190910122728-9d188e94fb99/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
|
||||
github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
|
||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
|
||||
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
|
||||
@@ -125,8 +132,6 @@ github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVY
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
|
||||
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
@@ -136,6 +141,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
@@ -153,8 +164,9 @@ github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi
|
||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v0.9.3-0.20190127221311-3c4408c8b829/go.mod h1:p2iRAGwDERtqlqzRXnrOVns+ignqQo//hLXqYxZYVNs=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
@@ -173,8 +185,11 @@ github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+Gx
|
||||
github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
github.com/rogpeppe/go-charset v0.0.0-20180617210344-2471d30d28b4/go.mod h1:qgYeAmZ5ZIpBWTGllZSQnw97Dj+woV0toclVaRGI8pc=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
@@ -188,15 +203,11 @@ github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tinylib/msgp v1.0.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tinylib/msgp v1.1.0/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/tinylib/msgp v1.1.2/go.mod h1:+d+yLhGm8mzTaHzB+wgMYrodPfmZrzkirds8fDWklFE=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8=
|
||||
github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
@@ -206,6 +217,8 @@ go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -221,8 +234,8 @@ golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73r
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -230,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=
|
||||
@@ -241,12 +256,17 @@ golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U=
|
||||
golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
@@ -273,6 +293,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
|
||||
20
common/cache.go → internal/cache/cache.go
vendored
20
common/cache.go → internal/cache/cache.go
vendored
@@ -1,4 +1,4 @@
|
||||
package common
|
||||
package cache
|
||||
|
||||
import (
|
||||
"sync"
|
||||
@@ -11,7 +11,7 @@ type Cache struct {
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewCache(maxItems int) *Cache {
|
||||
func New(maxItems int) *Cache {
|
||||
if maxItems <= 0 {
|
||||
maxItems = 1000
|
||||
}
|
||||
@@ -40,14 +40,16 @@ func (c *Cache) AddMultiple(values map[string]bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
for value := range values {
|
||||
if _, exists := c.data[value]; !exists {
|
||||
if len(c.order) >= c.maxItems {
|
||||
delete(c.data, c.order[0])
|
||||
c.order = c.order[1:]
|
||||
for value, exists := range values {
|
||||
if !exists {
|
||||
if _, exists := c.data[value]; !exists {
|
||||
if len(c.order) >= c.maxItems {
|
||||
delete(c.data, c.order[0])
|
||||
c.order = c.order[1:]
|
||||
}
|
||||
c.data[value] = struct{}{}
|
||||
c.order = append(c.order, value)
|
||||
}
|
||||
c.data[value] = struct{}{}
|
||||
c.order = append(c.order, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
287
internal/config/config.go
Normal file
287
internal/config/config.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
instance *Config
|
||||
once sync.Once
|
||||
configPath string
|
||||
)
|
||||
|
||||
type Debrid struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
APIKey string `json:"api_key"`
|
||||
Folder string `json:"folder"`
|
||||
DownloadUncached bool `json:"download_uncached"`
|
||||
CheckCached bool `json:"check_cached"`
|
||||
RateLimit string `json:"rate_limit"` // 200/minute or 10/second
|
||||
}
|
||||
|
||||
type Proxy struct {
|
||||
Port string `json:"port"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LogLevel string `json:"log_level"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
CachedOnly bool `json:"cached_only"`
|
||||
}
|
||||
|
||||
type QBitTorrent struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Port string `json:"port"`
|
||||
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"`
|
||||
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"`
|
||||
AutoProcess bool `json:"auto_process"`
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
LogLevel string `json:"log_level"`
|
||||
Debrid Debrid `json:"debrid"`
|
||||
Debrids []Debrid `json:"debrids"`
|
||||
Proxy Proxy `json:"proxy"`
|
||||
MaxCacheSize int `json:"max_cache_size"`
|
||||
QBitTorrent QBitTorrent `json:"qbittorrent"`
|
||||
Arrs []Arr `json:"arrs"`
|
||||
Repair Repair `json:"repair"`
|
||||
AllowedExt []string `json:"allowed_file_types"`
|
||||
MinFileSize string `json:"min_file_size"` // Minimum file size to download, 10MB, 1GB, etc
|
||||
MaxFileSize string `json:"max_file_size"` // Maximum file size to download (0 means no limit)
|
||||
Path string `json:"-"` // Path to save the config file
|
||||
UseAuth bool `json:"use_auth"`
|
||||
Auth *Auth `json:"-"`
|
||||
DiscordWebhook string `json:"discord_webhook_url"`
|
||||
}
|
||||
|
||||
func (c *Config) JsonFile() string {
|
||||
return filepath.Join(c.Path, "config.json")
|
||||
}
|
||||
func (c *Config) AuthFile() string {
|
||||
return filepath.Join(c.Path, "auth.json")
|
||||
}
|
||||
|
||||
func (c *Config) loadConfig() error {
|
||||
// Load the config file
|
||||
if configPath == "" {
|
||||
return fmt.Errorf("config path not set")
|
||||
}
|
||||
c.Path = configPath
|
||||
file, err := os.ReadFile(c.JsonFile())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(file, &c); err != nil {
|
||||
return fmt.Errorf("error unmarshaling config: %w", err)
|
||||
}
|
||||
|
||||
if c.Debrid.Name != "" {
|
||||
c.Debrids = append(c.Debrids, c.Debrid)
|
||||
}
|
||||
|
||||
if len(c.AllowedExt) == 0 {
|
||||
c.AllowedExt = getDefaultExtensions()
|
||||
}
|
||||
|
||||
// Load the auth file
|
||||
c.Auth = c.GetAuth()
|
||||
|
||||
//Validate the config
|
||||
if err := validateConfig(c); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDebrids(debrids []Debrid) error {
|
||||
if len(debrids) == 0 {
|
||||
return errors.New("no debrids configured")
|
||||
}
|
||||
|
||||
errChan := make(chan error, len(debrids))
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for _, debrid := range debrids {
|
||||
// Basic field validation
|
||||
if debrid.Host == "" {
|
||||
return errors.New("debrid host is required")
|
||||
}
|
||||
if debrid.APIKey == "" {
|
||||
return errors.New("debrid api key is required")
|
||||
}
|
||||
if debrid.Folder == "" {
|
||||
return errors.New("debrid folder is required")
|
||||
}
|
||||
|
||||
// Check folder existence concurrently
|
||||
//wg.Add(1)
|
||||
//go func(folder string) {
|
||||
// defer wg.Done()
|
||||
// if _, err := os.Stat(folder); os.IsNotExist(err) {
|
||||
// errChan <- fmt.Errorf("debrid folder does not exist: %s", folder)
|
||||
// }
|
||||
//}(debrid.Folder)
|
||||
}
|
||||
|
||||
// Wait for all checks to complete
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
}()
|
||||
|
||||
// Return first error if any
|
||||
if err := <-errChan; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateQbitTorrent(config *QBitTorrent) error {
|
||||
if config.DownloadFolder == "" {
|
||||
return errors.New("qbittorent download folder is required")
|
||||
}
|
||||
if _, err := os.Stat(config.DownloadFolder); os.IsNotExist(err) {
|
||||
return errors.New("qbittorent download folder does not exist")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConfig(config *Config) error {
|
||||
// Run validations concurrently
|
||||
errChan := make(chan error, 2)
|
||||
|
||||
go func() {
|
||||
errChan <- validateDebrids(config.Debrids)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
errChan <- validateQbitTorrent(&config.QBitTorrent)
|
||||
}()
|
||||
|
||||
// Check for errors
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := <-errChan; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetConfigPath(path string) error {
|
||||
configPath = path
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetConfig() *Config {
|
||||
once.Do(func() {
|
||||
instance = &Config{} // Initialize instance first
|
||||
if err := instance.loadConfig(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "configuration Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
func (c *Config) GetMinFileSize() int64 {
|
||||
// 0 means no limit
|
||||
if c.MinFileSize == "" {
|
||||
return 0
|
||||
}
|
||||
s, err := parseSize(c.MinFileSize)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *Config) GetMaxFileSize() int64 {
|
||||
// 0 means no limit
|
||||
if c.MaxFileSize == "" {
|
||||
return 0
|
||||
}
|
||||
s, err := parseSize(c.MaxFileSize)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (c *Config) IsSizeAllowed(size int64) bool {
|
||||
if size == 0 {
|
||||
return true // Maybe the debrid hasn't reported the size yet
|
||||
}
|
||||
if c.GetMinFileSize() > 0 && size < c.GetMinFileSize() {
|
||||
return false
|
||||
}
|
||||
if c.GetMaxFileSize() > 0 && size > c.GetMaxFileSize() {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Config) GetAuth() *Auth {
|
||||
if !c.UseAuth {
|
||||
return nil
|
||||
}
|
||||
if c.Auth == nil {
|
||||
c.Auth = &Auth{}
|
||||
if _, err := os.Stat(c.AuthFile()); err == nil {
|
||||
file, err := os.ReadFile(c.AuthFile())
|
||||
if err == nil {
|
||||
_ = json.Unmarshal(file, c.Auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.Auth
|
||||
}
|
||||
|
||||
func (c *Config) SaveAuth(auth *Auth) error {
|
||||
c.Auth = auth
|
||||
data, err := json.Marshal(auth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(c.AuthFile(), data, 0644)
|
||||
}
|
||||
|
||||
func (c *Config) NeedsSetup() bool {
|
||||
if c.UseAuth {
|
||||
return c.GetAuth().Username == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
75
internal/config/misc.go
Normal file
75
internal/config/misc.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (c *Config) IsAllowedFile(filename string) bool {
|
||||
ext := strings.ToLower(filepath.Ext(filename))
|
||||
if ext == "" {
|
||||
return false
|
||||
}
|
||||
// Remove the leading dot
|
||||
ext = ext[1:]
|
||||
|
||||
for _, allowed := range c.AllowedExt {
|
||||
if ext == allowed {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func getDefaultExtensions() []string {
|
||||
videoExts := strings.Split("YUV,WMV,WEBM,VOB,VIV,SVI,ROQ,RMVB,RM,OGV,OGG,NSV,MXF,MPG,MPEG,M2V,MP2,MPE,MPV,MP4,M4P,M4V,MOV,QT,MNG,MKV,FLV,DRC,AVI,ASF,AMV,MKA,F4V,3GP,3G2,DIVX,X264,X265", ",")
|
||||
musicExts := strings.Split("MP3,WAV,FLAC,AAC,OGG,WMA,AIFF,ALAC,M4A,APE,AC3,DTS,M4P,MID,MIDI,MKA,MP2,MPA,RA,VOC,WV,AMR", ",")
|
||||
|
||||
// Combine both slices
|
||||
allExts := append(videoExts, musicExts...)
|
||||
|
||||
// Convert to lowercase
|
||||
for i, ext := range allExts {
|
||||
allExts[i] = strings.ToLower(ext)
|
||||
}
|
||||
|
||||
// Remove duplicates
|
||||
seen := make(map[string]bool)
|
||||
var unique []string
|
||||
|
||||
for _, ext := range allExts {
|
||||
if !seen[ext] {
|
||||
seen[ext] = true
|
||||
unique = append(unique, ext)
|
||||
}
|
||||
}
|
||||
|
||||
sort.Strings(unique)
|
||||
return unique
|
||||
}
|
||||
|
||||
func parseSize(sizeStr string) (int64, error) {
|
||||
sizeStr = strings.ToUpper(strings.TrimSpace(sizeStr))
|
||||
|
||||
// Absolute size-based cache
|
||||
multiplier := 1.0
|
||||
if strings.HasSuffix(sizeStr, "GB") {
|
||||
multiplier = 1024 * 1024 * 1024
|
||||
sizeStr = strings.TrimSuffix(sizeStr, "GB")
|
||||
} else if strings.HasSuffix(sizeStr, "MB") {
|
||||
multiplier = 1024 * 1024
|
||||
sizeStr = strings.TrimSuffix(sizeStr, "MB")
|
||||
} else if strings.HasSuffix(sizeStr, "KB") {
|
||||
multiplier = 1024
|
||||
sizeStr = strings.TrimSuffix(sizeStr, "KB")
|
||||
}
|
||||
|
||||
size, err := strconv.ParseFloat(sizeStr, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return int64(size * multiplier), nil
|
||||
}
|
||||
93
internal/logger/logger.go
Normal file
93
internal/logger/logger.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
logger zerolog.Logger
|
||||
)
|
||||
|
||||
func GetLogPath() string {
|
||||
cfg := config.GetConfig()
|
||||
logsDir := filepath.Join(cfg.Path, "logs")
|
||||
|
||||
if _, err := os.Stat(logsDir); os.IsNotExist(err) {
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
panic(fmt.Sprintf("Failed to create logs directory: %v", err))
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(logsDir, "decypharr.log")
|
||||
}
|
||||
|
||||
func NewLogger(prefix string, level string, output *os.File) zerolog.Logger {
|
||||
|
||||
rotatingLogFile := &lumberjack.Logger{
|
||||
Filename: GetLogPath(),
|
||||
MaxSize: 10,
|
||||
MaxAge: 15,
|
||||
Compress: true,
|
||||
}
|
||||
|
||||
consoleWriter := zerolog.ConsoleWriter{
|
||||
Out: output,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
NoColor: false, // Set to true if you don't want colors
|
||||
FormatLevel: func(i interface{}) string {
|
||||
return strings.ToUpper(fmt.Sprintf("| %-6s|", i))
|
||||
},
|
||||
FormatMessage: func(i interface{}) string {
|
||||
return fmt.Sprintf("[%s] %v", prefix, i)
|
||||
},
|
||||
}
|
||||
|
||||
fileWriter := zerolog.ConsoleWriter{
|
||||
Out: rotatingLogFile,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
NoColor: true, // No colors in file output
|
||||
FormatLevel: func(i interface{}) string {
|
||||
return strings.ToUpper(fmt.Sprintf("| %-6s|", i))
|
||||
},
|
||||
FormatMessage: func(i interface{}) string {
|
||||
return fmt.Sprintf("[%s] %v", prefix, i)
|
||||
},
|
||||
}
|
||||
|
||||
multi := zerolog.MultiLevelWriter(consoleWriter, fileWriter)
|
||||
|
||||
logger := zerolog.New(multi).
|
||||
With().
|
||||
Timestamp().
|
||||
Logger().
|
||||
Level(zerolog.InfoLevel)
|
||||
|
||||
// Set the log level
|
||||
switch level {
|
||||
case "debug":
|
||||
logger = logger.Level(zerolog.DebugLevel)
|
||||
case "info":
|
||||
logger = logger.Level(zerolog.InfoLevel)
|
||||
case "warn":
|
||||
logger = logger.Level(zerolog.WarnLevel)
|
||||
case "error":
|
||||
logger = logger.Level(zerolog.ErrorLevel)
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
func GetDefaultLogger() zerolog.Logger {
|
||||
once.Do(func() {
|
||||
cfg := config.GetConfig()
|
||||
logger = NewLogger("decypharr", cfg.LogLevel, os.Stdout)
|
||||
})
|
||||
return logger
|
||||
}
|
||||
100
internal/request/discord.go
Normal file
100
internal/request/discord.go
Normal 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
|
||||
}
|
||||
@@ -1,17 +1,40 @@
|
||||
package common
|
||||
package request
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"golang.org/x/time/rate"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func JoinURL(base string, paths ...string) (string, error) {
|
||||
// Split the last path component to separate query parameters
|
||||
lastPath := paths[len(paths)-1]
|
||||
parts := strings.Split(lastPath, "?")
|
||||
paths[len(paths)-1] = parts[0]
|
||||
|
||||
joined, err := url.JoinPath(base, paths...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Add back query parameters if they exist
|
||||
if len(parts) > 1 {
|
||||
return joined + "?" + parts[1], nil
|
||||
}
|
||||
|
||||
return joined, nil
|
||||
}
|
||||
|
||||
type RLHTTPClient struct {
|
||||
client *http.Client
|
||||
Ratelimiter *rate.Limiter
|
||||
@@ -60,11 +83,7 @@ func (c *RLHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
return resp, fmt.Errorf("max retries exceeded")
|
||||
}
|
||||
|
||||
func (c *RLHTTPClient) MakeRequest(method string, url string, body io.Reader) ([]byte, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func (c *RLHTTPClient) MakeRequest(req *http.Request) ([]byte, error) {
|
||||
if c.Headers != nil {
|
||||
for key, value := range c.Headers {
|
||||
req.Header.Set(key, value)
|
||||
@@ -75,17 +94,26 @@ func (c *RLHTTPClient) MakeRequest(method string, url string, body io.Reader) ([
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statusOk := strconv.Itoa(res.StatusCode)[0] == '2'
|
||||
if !statusOk {
|
||||
return nil, fmt.Errorf("unexpected status code: %d", res.StatusCode)
|
||||
}
|
||||
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
}
|
||||
}(res.Body)
|
||||
return io.ReadAll(res.Body)
|
||||
|
||||
b, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
statusOk := res.StatusCode >= 200 && res.StatusCode < 300
|
||||
if !statusOk {
|
||||
// Add status code error to the body
|
||||
b = append(b, []byte(fmt.Sprintf("\nstatus code: %d", res.StatusCode))...)
|
||||
return nil, errors.New(string(b))
|
||||
}
|
||||
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func NewRLHTTPClient(rl *rate.Limiter, headers map[string]string) *RLHTTPClient {
|
||||
@@ -96,8 +124,12 @@ func NewRLHTTPClient(rl *rate.Limiter, headers map[string]string) *RLHTTPClient
|
||||
client: &http.Client{
|
||||
Transport: tr,
|
||||
},
|
||||
Ratelimiter: rl,
|
||||
Headers: headers,
|
||||
}
|
||||
if rl != nil {
|
||||
c.Ratelimiter = rl
|
||||
}
|
||||
if headers != nil {
|
||||
c.Headers = headers
|
||||
}
|
||||
return c
|
||||
}
|
||||
@@ -128,3 +160,12 @@ func ParseRateLimit(rateStr string) *rate.Limiter {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func JSONResponse(w http.ResponseWriter, data interface{}, code int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
err := json.NewEncoder(w).Encode(data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
2
internal/utils/file.go
Normal file
2
internal/utils/file.go
Normal file
@@ -0,0 +1,2 @@
|
||||
package utils
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
package common
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
@@ -9,11 +10,9 @@ import (
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"io"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -29,23 +28,11 @@ type Magnet struct {
|
||||
|
||||
func GetMagnetFromFile(file io.Reader, filePath string) (*Magnet, error) {
|
||||
if filepath.Ext(filePath) == ".torrent" {
|
||||
mi, err := metainfo.Load(file)
|
||||
torrentData, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash := mi.HashInfoBytes()
|
||||
infoHash := hash.HexString()
|
||||
info, err := mi.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
magnet := &Magnet{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Length,
|
||||
Link: mi.Magnet(&hash, &info).String(),
|
||||
}
|
||||
return magnet, nil
|
||||
return GetMagnetFromBytes(torrentData)
|
||||
} else {
|
||||
// .magnet file
|
||||
magnetLink := ReadMagnetFile(file)
|
||||
@@ -62,6 +49,28 @@ func GetMagnetFromUrl(url string) (*Magnet, error) {
|
||||
return nil, fmt.Errorf("invalid url")
|
||||
}
|
||||
|
||||
func GetMagnetFromBytes(torrentData []byte) (*Magnet, error) {
|
||||
// Create a scanner to read the file line by line
|
||||
mi, err := metainfo.Load(bytes.NewReader(torrentData))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash := mi.HashInfoBytes()
|
||||
infoHash := hash.HexString()
|
||||
info, err := mi.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Println("InfoHash: ", infoHash)
|
||||
magnet := &Magnet{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Length,
|
||||
Link: mi.Magnet(&hash, &info).String(),
|
||||
}
|
||||
return magnet, nil
|
||||
}
|
||||
|
||||
func OpenMagnetFile(filePath string) string {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
@@ -104,27 +113,11 @@ func OpenMagnetHttpURL(magnetLink string) (*Magnet, error) {
|
||||
return
|
||||
}
|
||||
}(resp) // Ensure the response is closed after the function ends
|
||||
|
||||
// Create a scanner to read the file line by line
|
||||
|
||||
mi, err := metainfo.Load(resp.Body)
|
||||
torrentData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("error reading response body: %v", err)
|
||||
}
|
||||
hash := mi.HashInfoBytes()
|
||||
infoHash := hash.HexString()
|
||||
info, err := mi.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Println("InfoHash: ", infoHash)
|
||||
magnet := &Magnet{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Length,
|
||||
Link: mi.Magnet(&hash, &info).String(),
|
||||
}
|
||||
return magnet, nil
|
||||
return GetMagnetFromBytes(torrentData)
|
||||
}
|
||||
|
||||
func GetMagnetInfo(magnetLink string) (*Magnet, error) {
|
||||
@@ -156,15 +149,6 @@ func GetMagnetInfo(magnetLink string) (*Magnet, error) {
|
||||
return magnet, nil
|
||||
}
|
||||
|
||||
func RandomString(length int) string {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = charset[rand.Intn(len(charset))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func ExtractInfoHash(magnetDesc string) string {
|
||||
const prefix = "xt=urn:btih:"
|
||||
start := strings.Index(magnetDesc, prefix)
|
||||
@@ -209,11 +193,6 @@ func processInfoHash(input string) (string, error) {
|
||||
return "", fmt.Errorf("invalid infohash: %s", input)
|
||||
}
|
||||
|
||||
func NewLogger(prefix string, output *os.File) *log.Logger {
|
||||
f := fmt.Sprintf("[%s] ", prefix)
|
||||
return log.New(output, f, log.LstdFlags)
|
||||
}
|
||||
|
||||
func GetInfohashFromURL(url string) (string, error) {
|
||||
// Download the torrent file
|
||||
var magnetLink string
|
||||
@@ -254,22 +233,3 @@ func GetInfohashFromURL(url string) (string, error) {
|
||||
infoHash := hash.HexString()
|
||||
return infoHash, nil
|
||||
}
|
||||
|
||||
func JoinURL(base string, paths ...string) (string, error) {
|
||||
// Parse the base URL
|
||||
u, err := url.Parse(base)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Join the path components
|
||||
u.Path = path.Join(u.Path, path.Join(paths...))
|
||||
|
||||
// Return the resulting URL as a string
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
func FileReady(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err) // Returns true if the file exists
|
||||
}
|
||||
15
internal/utils/misc.go
Normal file
15
internal/utils/misc.go
Normal file
@@ -0,0 +1,15 @@
|
||||
package utils
|
||||
|
||||
func RemoveItem[S ~[]E, E comparable](s S, values ...E) S {
|
||||
result := make(S, 0, len(s))
|
||||
outer:
|
||||
for _, item := range s {
|
||||
for _, v := range values {
|
||||
if item == v {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
result = append(result, item)
|
||||
}
|
||||
return result
|
||||
}
|
||||
58
internal/utils/regex.go
Normal file
58
internal/utils/regex.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
VIDEOMATCH = "(?i)(\\.)(YUV|WMV|WEBM|VOB|VIV|SVI|ROQ|RMVB|RM|OGV|OGG|NSV|MXF|MPG|MPEG|M2V|MP2|MPE|MPV|MP4|M4P|M4V|MOV|QT|MNG|MKV|FLV|DRC|AVI|ASF|AMV|MKA|F4V|3GP|3G2|DIVX|X264|X265)$"
|
||||
MUSICMATCH = "(?i)(\\.)(?:MP3|WAV|FLAC|AAC|OGG|WMA|AIFF|ALAC|M4A|APE|AC3|DTS|M4P|MID|MIDI|MKA|MP2|MPA|RA|VOC|WV|AMR)$"
|
||||
)
|
||||
|
||||
var SAMPLEMATCH = `(?i)(^|[\\/]|[._-])(sample|trailer|thumb)s?([._-]|$)`
|
||||
|
||||
func RegexMatch(regex string, value string) bool {
|
||||
re := regexp.MustCompile(regex)
|
||||
return re.MatchString(value)
|
||||
}
|
||||
|
||||
func RemoveInvalidChars(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == filepath.Separator || r == ':' {
|
||||
return r
|
||||
}
|
||||
if filepath.IsAbs(string(r)) {
|
||||
return r
|
||||
}
|
||||
if strings.ContainsRune(filepath.VolumeName("C:"+string(r)), r) {
|
||||
return r
|
||||
}
|
||||
if r < 32 || strings.ContainsRune(`<>:"/\|?*`, r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
func RemoveExtension(value string) string {
|
||||
re := regexp.MustCompile(VIDEOMATCH + "|" + SAMPLEMATCH + "|" + MUSICMATCH)
|
||||
|
||||
// Find the last index of the matched extension
|
||||
loc := re.FindStringIndex(value)
|
||||
if loc != nil {
|
||||
return value[:loc[0]]
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func IsMediaFile(path string) bool {
|
||||
mediaPattern := VIDEOMATCH + "|" + MUSICMATCH
|
||||
return RegexMatch(mediaPattern, path)
|
||||
}
|
||||
|
||||
func IsSampleFile(path string) bool {
|
||||
return RegexMatch(SAMPLEMATCH, path)
|
||||
}
|
||||
24
main.go
24
main.go
@@ -1,22 +1,32 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"goBlack/cmd"
|
||||
"goBlack/common"
|
||||
"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", "config.json", "path to the config file")
|
||||
flag.StringVar(&configPath, "config", "/data", "path to the data folder")
|
||||
flag.Parse()
|
||||
|
||||
// Load the config file
|
||||
conf, err := common.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
if err := config.SetConfigPath(configPath); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
config.GetConfig()
|
||||
ctx := context.Background()
|
||||
if err := decypharr.Start(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cmd.Start(conf)
|
||||
|
||||
}
|
||||
|
||||
181
pkg/arr/arr.go
Normal file
181
pkg/arr/arr.go
Normal file
@@ -0,0 +1,181 @@
|
||||
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
|
||||
type Type string
|
||||
|
||||
const (
|
||||
Sonarr Type = "sonarr"
|
||||
Radarr Type = "radarr"
|
||||
Lidarr Type = "lidarr"
|
||||
Readarr Type = "readarr"
|
||||
)
|
||||
|
||||
type Arr struct {
|
||||
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, skipRepair bool, downloadUncached *bool) *Arr {
|
||||
return &Arr{
|
||||
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, fmt.Errorf("arr not configured")
|
||||
}
|
||||
url, err := request.JoinURL(a.Host, endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
b, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
body = bytes.NewReader(b)
|
||||
}
|
||||
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)
|
||||
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 {
|
||||
Arrs map[string]*Arr // name -> arr
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func InferType(host, name string) Type {
|
||||
switch {
|
||||
case strings.Contains(host, "sonarr") || strings.Contains(name, "sonarr"):
|
||||
return Sonarr
|
||||
case strings.Contains(host, "radarr") || strings.Contains(name, "radarr"):
|
||||
return Radarr
|
||||
case strings.Contains(host, "lidarr") || strings.Contains(name, "lidarr"):
|
||||
return Lidarr
|
||||
case strings.Contains(host, "readarr") || strings.Contains(name, "readarr"):
|
||||
return Readarr
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func NewStorage() *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, a.SkipRepair, a.DownloadUncached)
|
||||
}
|
||||
return &Storage{
|
||||
Arrs: arrs,
|
||||
}
|
||||
}
|
||||
|
||||
func (as *Storage) AddOrUpdate(arr *Arr) {
|
||||
as.mu.Lock()
|
||||
defer as.mu.Unlock()
|
||||
if arr.Name == "" {
|
||||
return
|
||||
}
|
||||
as.Arrs[arr.Name] = arr
|
||||
}
|
||||
|
||||
func (as *Storage) Get(name string) *Arr {
|
||||
as.mu.RLock()
|
||||
defer as.mu.RUnlock()
|
||||
return as.Arrs[name]
|
||||
}
|
||||
|
||||
func (as *Storage) GetAll() []*Arr {
|
||||
as.mu.RLock()
|
||||
defer as.mu.RUnlock()
|
||||
arrs := make([]*Arr, 0, len(as.Arrs))
|
||||
for _, arr := range as.Arrs {
|
||||
if arr.Host != "" && arr.Token != "" {
|
||||
arrs = append(arrs, arr)
|
||||
}
|
||||
}
|
||||
return arrs
|
||||
}
|
||||
259
pkg/arr/content.go
Normal file
259
pkg/arr/content.go
Normal file
@@ -0,0 +1,259 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
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
|
||||
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, mediaId)
|
||||
}
|
||||
a.Type = Sonarr
|
||||
|
||||
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, fmt.Errorf("failed to decode series: %v", err)
|
||||
}
|
||||
// Get series files
|
||||
contents := make([]Content, 0)
|
||||
for _, d := range data {
|
||||
resp, err = a.Request(http.MethodGet, fmt.Sprintf("api/v3/episodefile?seriesId=%d", d.Id), nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var seriesFiles []seriesFile
|
||||
if err = json.NewDecoder(resp.Body).Decode(&seriesFiles); err != nil {
|
||||
continue
|
||||
}
|
||||
ct := Content{
|
||||
Title: d.Title,
|
||||
Id: d.Id,
|
||||
}
|
||||
resp, err = a.Request(http.MethodGet, fmt.Sprintf("api/v3/episode?seriesId=%d", d.Id), nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var episodes []episode
|
||||
if err = json.NewDecoder(resp.Body).Decode(&episodes); err != nil {
|
||||
continue
|
||||
}
|
||||
episodeFileIDMap := make(map[int]int)
|
||||
for _, e := range episodes {
|
||||
episodeFileIDMap[e.EpisodeFileID] = e.Id
|
||||
}
|
||||
files := make([]ContentFile, 0)
|
||||
for _, file := range seriesFiles {
|
||||
eId, ok := episodeFileIDMap[file.Id]
|
||||
if !ok {
|
||||
eId = 0
|
||||
}
|
||||
if file.Id == 0 || file.Path == "" {
|
||||
// Skip files without path
|
||||
continue
|
||||
}
|
||||
files = append(files, ContentFile{
|
||||
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)
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func GetMovies(a *Arr, tvId string) ([]Content, error) {
|
||||
resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/movie?tmdbId=%s", tvId), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// This is likely Lidarr or Readarr
|
||||
return nil, fmt.Errorf("failed to get movies: %s", resp.Status)
|
||||
}
|
||||
a.Type = Radarr
|
||||
defer resp.Body.Close()
|
||||
var movies []Movie
|
||||
if err = json.NewDecoder(resp.Body).Decode(&movies); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode movies: %v", err)
|
||||
}
|
||||
contents := make([]Content, 0)
|
||||
for _, movie := range movies {
|
||||
ct := Content{
|
||||
Title: movie.Title,
|
||||
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,
|
||||
Path: movie.MovieFile.Path,
|
||||
})
|
||||
ct.Files = files
|
||||
contents = append(contents, ct)
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
if statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'; !statusOk {
|
||||
return fmt.Errorf("failed to automatic search. Status Code: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Arr) SearchMissing(files []ContentFile) error {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Arr) DeleteFiles(files []ContentFile) error {
|
||||
ids := make([]int, 0)
|
||||
for _, f := range files {
|
||||
ids = append(ids, f.FileId)
|
||||
}
|
||||
var payload interface{}
|
||||
switch a.Type {
|
||||
case Sonarr:
|
||||
payload = struct {
|
||||
EpisodeFileIds []int `json:"episodeFileIds"`
|
||||
}{
|
||||
EpisodeFileIds: ids,
|
||||
}
|
||||
_, err := a.Request(http.MethodDelete, "api/v3/episodefile/bulk", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case Radarr:
|
||||
payload = struct {
|
||||
MovieFileIds []int `json:"movieFileIds"`
|
||||
}{
|
||||
MovieFileIds: ids,
|
||||
}
|
||||
_, err := a.Request(http.MethodDelete, "api/v3/moviefile/bulk", payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown arr type: %s", a.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
187
pkg/arr/history.go
Normal file
187
pkg/arr/history.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type HistorySchema struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
SortKey string `json:"sortKey"`
|
||||
SortDirection string `json:"sortDirection"`
|
||||
TotalRecords int `json:"totalRecords"`
|
||||
Records []struct {
|
||||
ID int `json:"id"`
|
||||
DownloadID string `json:"downloadId"`
|
||||
} `json:"records"`
|
||||
}
|
||||
|
||||
type QueueResponseScheme struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
SortKey string `json:"sortKey"`
|
||||
SortDirection string `json:"sortDirection"`
|
||||
TotalRecords int `json:"totalRecords"`
|
||||
Records []QueueSchema `json:"records"`
|
||||
}
|
||||
|
||||
type QueueSchema struct {
|
||||
SeriesId int `json:"seriesId"`
|
||||
EpisodeId int `json:"episodeId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Title string `json:"title"`
|
||||
Status string `json:"status"`
|
||||
TrackedDownloadStatus string `json:"trackedDownloadStatus"`
|
||||
TrackedDownloadState string `json:"trackedDownloadState"`
|
||||
StatusMessages []struct {
|
||||
Title string `json:"title"`
|
||||
Messages []string `json:"messages"`
|
||||
} `json:"statusMessages"`
|
||||
DownloadId string `json:"downloadId"`
|
||||
Protocol string `json:"protocol"`
|
||||
DownloadClient string `json:"downloadClient"`
|
||||
DownloadClientHasPostImportCategory bool `json:"downloadClientHasPostImportCategory"`
|
||||
Indexer string `json:"indexer"`
|
||||
OutputPath string `json:"outputPath"`
|
||||
EpisodeHasFile bool `json:"episodeHasFile"`
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
func (a *Arr) GetHistory(downloadId, eventType string) *HistorySchema {
|
||||
query := gourl.Values{}
|
||||
if downloadId != "" {
|
||||
query.Add("downloadId", downloadId)
|
||||
}
|
||||
query.Add("eventType", eventType)
|
||||
query.Add("pageSize", "100")
|
||||
url := "api/v3/history" + "?" + query.Encode()
|
||||
resp, err := a.Request(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var data *HistorySchema
|
||||
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
|
||||
}
|
||||
|
||||
func (a *Arr) GetQueue() []QueueSchema {
|
||||
query := gourl.Values{}
|
||||
query.Add("page", "1")
|
||||
query.Add("pageSize", "200")
|
||||
results := make([]QueueSchema, 0)
|
||||
|
||||
for {
|
||||
url := "api/v3/queue" + "?" + query.Encode()
|
||||
resp, err := a.Request(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
|
||||
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...)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (a *Arr) CleanupQueue() error {
|
||||
queue := a.GetQueue()
|
||||
type messedUp struct {
|
||||
id int
|
||||
episodeId int
|
||||
seasonNum int
|
||||
}
|
||||
cleanups := make(map[int][]messedUp)
|
||||
for _, q := range queue {
|
||||
isMessedUp := false
|
||||
if q.Protocol == "torrent" && q.Status == "completed" && q.TrackedDownloadStatus == "warning" && q.TrackedDownloadState == "importPending" {
|
||||
messages := q.StatusMessages
|
||||
if len(messages) > 0 {
|
||||
for _, m := range messages {
|
||||
if strings.Contains(strings.Join(m.Messages, " "), "No files found are eligible for import in") {
|
||||
isMessedUp = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isMessedUp {
|
||||
cleanups[q.SeriesId] = append(cleanups[q.SeriesId], messedUp{
|
||||
id: q.Id,
|
||||
episodeId: q.EpisodeId,
|
||||
seasonNum: q.SeasonNumber,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(cleanups) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
queueIds := make([]int, 0)
|
||||
|
||||
for _, c := range cleanups {
|
||||
// Delete the messed up episodes from queue
|
||||
for _, m := range c {
|
||||
queueIds = append(queueIds, m.id)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the messed up episodes from queue
|
||||
|
||||
payload := struct {
|
||||
Ids []int `json:"ids"`
|
||||
}{
|
||||
Ids: queueIds,
|
||||
}
|
||||
|
||||
// Blocklist that hash(it's typically not complete, then research the episode)
|
||||
|
||||
query := gourl.Values{}
|
||||
query.Add("removeFromClient", "true")
|
||||
query.Add("blocklist", "true")
|
||||
query.Add("skipRedownload", "false")
|
||||
query.Add("changeCategory", "false")
|
||||
url := "api/v3/queue/bulk" + "?" + query.Encode()
|
||||
|
||||
_, err := a.Request(http.MethodDelete, url, payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
209
pkg/arr/import.go
Normal file
209
pkg/arr/import.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ImportResponseSchema struct {
|
||||
Path string `json:"path"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
FolderName string `json:"folderName"`
|
||||
Name string `json:"name"`
|
||||
Size int `json:"size"`
|
||||
Series struct {
|
||||
Title string `json:"title"`
|
||||
SortTitle string `json:"sortTitle"`
|
||||
Status string `json:"status"`
|
||||
Ended bool `json:"ended"`
|
||||
Overview string `json:"overview"`
|
||||
Network string `json:"network"`
|
||||
AirTime string `json:"airTime"`
|
||||
Images []struct {
|
||||
CoverType string `json:"coverType"`
|
||||
RemoteUrl string `json:"remoteUrl"`
|
||||
} `json:"images"`
|
||||
OriginalLanguage struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"originalLanguage"`
|
||||
Seasons []struct {
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Monitored bool `json:"monitored"`
|
||||
} `json:"seasons"`
|
||||
Year int `json:"year"`
|
||||
Path string `json:"path"`
|
||||
QualityProfileId int `json:"qualityProfileId"`
|
||||
SeasonFolder bool `json:"seasonFolder"`
|
||||
Monitored bool `json:"monitored"`
|
||||
MonitorNewItems string `json:"monitorNewItems"`
|
||||
UseSceneNumbering bool `json:"useSceneNumbering"`
|
||||
Runtime int `json:"runtime"`
|
||||
TvdbId int `json:"tvdbId"`
|
||||
TvRageId int `json:"tvRageId"`
|
||||
TvMazeId int `json:"tvMazeId"`
|
||||
TmdbId int `json:"tmdbId"`
|
||||
FirstAired time.Time `json:"firstAired"`
|
||||
LastAired time.Time `json:"lastAired"`
|
||||
SeriesType string `json:"seriesType"`
|
||||
CleanTitle string `json:"cleanTitle"`
|
||||
ImdbId string `json:"imdbId"`
|
||||
TitleSlug string `json:"titleSlug"`
|
||||
Certification string `json:"certification"`
|
||||
Genres []string `json:"genres"`
|
||||
Tags []interface{} `json:"tags"`
|
||||
Added time.Time `json:"added"`
|
||||
Ratings struct {
|
||||
Votes int `json:"votes"`
|
||||
Value float64 `json:"value"`
|
||||
} `json:"ratings"`
|
||||
LanguageProfileId int `json:"languageProfileId"`
|
||||
Id int `json:"id"`
|
||||
} `json:"series"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Episodes []struct {
|
||||
SeriesId int `json:"seriesId"`
|
||||
TvdbId int `json:"tvdbId"`
|
||||
EpisodeFileId int `json:"episodeFileId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
Title string `json:"title"`
|
||||
AirDate string `json:"airDate"`
|
||||
AirDateUtc time.Time `json:"airDateUtc"`
|
||||
Runtime int `json:"runtime"`
|
||||
Overview string `json:"overview"`
|
||||
HasFile bool `json:"hasFile"`
|
||||
Monitored bool `json:"monitored"`
|
||||
AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"`
|
||||
UnverifiedSceneNumbering bool `json:"unverifiedSceneNumbering"`
|
||||
Id int `json:"id"`
|
||||
FinaleType string `json:"finaleType,omitempty"`
|
||||
} `json:"episodes"`
|
||||
ReleaseGroup string `json:"releaseGroup"`
|
||||
Quality struct {
|
||||
Quality struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Resolution int `json:"resolution"`
|
||||
} `json:"quality"`
|
||||
Revision struct {
|
||||
Version int `json:"version"`
|
||||
Real int `json:"real"`
|
||||
IsRepack bool `json:"isRepack"`
|
||||
} `json:"revision"`
|
||||
} `json:"quality"`
|
||||
Languages []struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"languages"`
|
||||
QualityWeight int `json:"qualityWeight"`
|
||||
CustomFormats []interface{} `json:"customFormats"`
|
||||
CustomFormatScore int `json:"customFormatScore"`
|
||||
IndexerFlags int `json:"indexerFlags"`
|
||||
ReleaseType string `json:"releaseType"`
|
||||
Rejections []struct {
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"`
|
||||
} `json:"rejections"`
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
type ManualImportRequestFile struct {
|
||||
Path string `json:"path"`
|
||||
SeriesId int `json:"seriesId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
EpisodeIds []int `json:"episodeIds"`
|
||||
Quality struct {
|
||||
Quality struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
Resolution int `json:"resolution"`
|
||||
} `json:"quality"`
|
||||
Revision struct {
|
||||
Version int `json:"version"`
|
||||
Real int `json:"real"`
|
||||
IsRepack bool `json:"isRepack"`
|
||||
} `json:"revision"`
|
||||
} `json:"quality"`
|
||||
Languages []struct {
|
||||
Id int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
} `json:"languages"`
|
||||
ReleaseGroup string `json:"releaseGroup"`
|
||||
CustomFormats []interface{} `json:"customFormats"`
|
||||
CustomFormatScore int `json:"customFormatScore"`
|
||||
IndexerFlags int `json:"indexerFlags"`
|
||||
ReleaseType string `json:"releaseType"`
|
||||
Rejections []struct {
|
||||
Reason string `json:"reason"`
|
||||
Type string `json:"type"`
|
||||
} `json:"rejections"`
|
||||
}
|
||||
|
||||
type ManualImportRequestSchema struct {
|
||||
Name string `json:"name"`
|
||||
Files []ManualImportRequestFile `json:"files"`
|
||||
ImportMode string `json:"importMode"`
|
||||
}
|
||||
|
||||
func (a *Arr) Import(path string, seriesId int, seasons []int) (io.ReadCloser, error) {
|
||||
query := gourl.Values{}
|
||||
query.Add("folder", path)
|
||||
if seriesId != 0 {
|
||||
query.Add("seriesId", strconv.Itoa(seriesId))
|
||||
}
|
||||
url := "api/v3/manualimport" + "?" + query.Encode()
|
||||
resp, err := a.Request(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to import, invalid file: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var data []ImportResponseSchema
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
var files []ManualImportRequestFile
|
||||
for _, d := range data {
|
||||
episodesIds := []int{}
|
||||
for _, e := range d.Episodes {
|
||||
episodesIds = append(episodesIds, e.Id)
|
||||
}
|
||||
file := ManualImportRequestFile{
|
||||
Path: d.Path,
|
||||
SeriesId: d.Series.Id,
|
||||
SeasonNumber: d.SeasonNumber,
|
||||
EpisodeIds: episodesIds,
|
||||
Quality: d.Quality,
|
||||
Languages: d.Languages,
|
||||
ReleaseGroup: d.ReleaseGroup,
|
||||
CustomFormats: d.CustomFormats,
|
||||
CustomFormatScore: d.CustomFormatScore,
|
||||
IndexerFlags: d.IndexerFlags,
|
||||
ReleaseType: d.ReleaseType,
|
||||
Rejections: d.Rejections,
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
request := ManualImportRequestSchema{
|
||||
Name: "ManualImport",
|
||||
Files: files,
|
||||
ImportMode: "copy",
|
||||
}
|
||||
|
||||
url = "api/v3/command"
|
||||
resp, err = a.Request(http.MethodPost, url, request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to import: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
return resp.Body, nil
|
||||
|
||||
}
|
||||
59
pkg/arr/refresh.go
Normal file
59
pkg/arr/refresh.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (a *Arr) Refresh() error {
|
||||
payload := struct {
|
||||
Name string `json:"name"`
|
||||
}{
|
||||
Name: "RefreshMonitoredDownloads",
|
||||
}
|
||||
|
||||
resp, err := a.Request(http.MethodPost, "api/v3/command", payload)
|
||||
if err == nil && resp != nil {
|
||||
statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'
|
||||
if statusOk {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to refresh: %v", err)
|
||||
}
|
||||
|
||||
func (a *Arr) Blacklist(infoHash string) error {
|
||||
downloadId := strings.ToUpper(infoHash)
|
||||
history := a.GetHistory(downloadId, "grabbed")
|
||||
if history == nil {
|
||||
return nil
|
||||
}
|
||||
torrentId := 0
|
||||
for _, record := range history.Records {
|
||||
if strings.EqualFold(record.DownloadID, downloadId) {
|
||||
torrentId = record.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if torrentId != 0 {
|
||||
url, err := request.JoinURL(a.Host, "history/failed/", strconv.Itoa(torrentId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{}
|
||||
_, err = client.Do(req)
|
||||
if err == nil {
|
||||
return fmt.Errorf("failed to mark %s as failed: %v", cmp.Or(a.Name, a.Host), err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
pkg/arr/tmdb.go
Normal file
31
pkg/arr/tmdb.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
url2 "net/url"
|
||||
)
|
||||
|
||||
type TMDBResponse struct {
|
||||
Page int `json:"page"`
|
||||
Results []struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
MediaType string `json:"media_type"`
|
||||
PosterPath string `json:"poster_path"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func SearchTMDB(term string) (*TMDBResponse, error) {
|
||||
resp, err := http.Get("https://api.themoviedb.org/3/search/multi?api_key=key&query=" + url2.QueryEscape(term))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data *TMDBResponse
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return data, nil
|
||||
}
|
||||
39
pkg/arr/types.go
Normal file
39
pkg/arr/types.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package arr
|
||||
|
||||
type Movie struct {
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"originalTitle"`
|
||||
Path string `json:"path"`
|
||||
MovieFile struct {
|
||||
MovieId int `json:"movieId"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
Path string `json:"path"`
|
||||
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"`
|
||||
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 {
|
||||
Title string `json:"title"`
|
||||
Id int `json:"id"`
|
||||
Files []ContentFile `json:"files"`
|
||||
}
|
||||
|
||||
type seriesFile struct {
|
||||
SeriesId int `json:"seriesId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Path string `json:"path"`
|
||||
Id int `json:"id"`
|
||||
}
|
||||
5
pkg/arr/utils.go
Normal file
5
pkg/arr/utils.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package arr
|
||||
|
||||
func Readfile(path string) error {
|
||||
return nil
|
||||
}
|
||||
304
pkg/debrid/alldebrid/alldebrid.go
Normal file
304
pkg/debrid/alldebrid/alldebrid.go
Normal file
@@ -0,0 +1,304 @@
|
||||
package alldebrid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/cache"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
"slices"
|
||||
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type AllDebrid struct {
|
||||
Name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *request.RLHTTPClient
|
||||
cache *cache.Cache
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
CheckCached bool
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetName() string {
|
||||
return ad.Name
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetLogger() zerolog.Logger {
|
||||
return ad.logger
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) IsAvailable(infohashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
hashes, result := torrent.GetLocalCache(infohashes, ad.cache)
|
||||
|
||||
if len(hashes) == 0 {
|
||||
// Either all the infohashes are locally cached or none are
|
||||
ad.cache.AddMultiple(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Divide hashes into groups of 100
|
||||
// AllDebrid does not support checking cached infohashes
|
||||
return result
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) SubmitMagnet(torrent *torrent.Torrent) (*torrent.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/magnet/upload", ad.Host)
|
||||
query := gourl.Values{}
|
||||
query.Add("magnets[]", torrent.Magnet.Link)
|
||||
url += "?" + query.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := ad.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data UploadMagnetResponse
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
magnets := data.Data.Magnets
|
||||
if len(magnets) == 0 {
|
||||
return nil, fmt.Errorf("error adding torrent")
|
||||
}
|
||||
magnet := magnets[0]
|
||||
torrentId := strconv.Itoa(magnet.ID)
|
||||
torrent.Id = torrentId
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func getAlldebridStatus(statusCode int) string {
|
||||
switch {
|
||||
case statusCode == 4:
|
||||
return "downloaded"
|
||||
case statusCode >= 0 && statusCode <= 3:
|
||||
return "downloading"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
func flattenFiles(files []MagnetFile, parentPath string, index *int) []torrent.File {
|
||||
result := make([]torrent.File, 0)
|
||||
|
||||
cfg := config.GetConfig()
|
||||
|
||||
for _, f := range files {
|
||||
currentPath := f.Name
|
||||
if parentPath != "" {
|
||||
currentPath = filepath.Join(parentPath, f.Name)
|
||||
}
|
||||
|
||||
if f.Elements != nil {
|
||||
// This is a folder, recurse into it
|
||||
result = append(result, flattenFiles(f.Elements, currentPath, index)...)
|
||||
} else {
|
||||
// This is a file
|
||||
fileName := filepath.Base(f.Name)
|
||||
|
||||
// Skip sample files
|
||||
if utils.IsSampleFile(fileName) {
|
||||
continue
|
||||
}
|
||||
if !cfg.IsAllowedFile(fileName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
|
||||
*index++
|
||||
file := torrent.File{
|
||||
Id: strconv.Itoa(*index),
|
||||
Name: fileName,
|
||||
Size: f.Size,
|
||||
Path: currentPath,
|
||||
}
|
||||
result = append(result, file)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetTorrent(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 {
|
||||
return t, err
|
||||
}
|
||||
var res TorrentInfoResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
|
||||
return t, err
|
||||
}
|
||||
data := res.Data.Magnets
|
||||
status := getAlldebridStatus(data.StatusCode)
|
||||
name := data.Filename
|
||||
t.Name = name
|
||||
t.Status = status
|
||||
t.Filename = name
|
||||
t.OriginalFilename = name
|
||||
t.Folder = name
|
||||
t.MountPath = ad.MountPath
|
||||
t.Debrid = ad.Name
|
||||
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
|
||||
if status == "downloaded" {
|
||||
t.Bytes = data.Size
|
||||
|
||||
t.Progress = float64((data.Downloaded / data.Size) * 100)
|
||||
t.Speed = data.DownloadSpeed
|
||||
t.Seeders = data.Seeders
|
||||
index := -1
|
||||
files := flattenFiles(data.Files, "", &index)
|
||||
t.Files = files
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
|
||||
for {
|
||||
tb, err := ad.GetTorrent(torrent)
|
||||
|
||||
torrent = tb
|
||||
|
||||
if err != nil || tb == nil {
|
||||
return tb, err
|
||||
}
|
||||
status := torrent.Status
|
||||
if status == "downloaded" {
|
||||
ad.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||
if !isSymlink {
|
||||
err = ad.GetDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if 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.
|
||||
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
|
||||
break
|
||||
} else {
|
||||
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||
}
|
||||
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) DeleteTorrent(torrent *torrent.Torrent) {
|
||||
url := fmt.Sprintf("%s/magnet/delete?id=%s", ad.Host, torrent.Id)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
_, err := ad.client.MakeRequest(req)
|
||||
if err == nil {
|
||||
ad.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
|
||||
} else {
|
||||
ad.logger.Info().Msgf("Error deleting torrent: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloadLinks(t *torrent.Torrent) error {
|
||||
downloadLinks := make(map[string]torrent.DownloadLinks)
|
||||
for _, file := range t.Files {
|
||||
url := fmt.Sprintf("%s/link/unlock", ad.Host)
|
||||
query := gourl.Values{}
|
||||
query.Add("link", file.Link)
|
||||
url += "?" + query.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := ad.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data DownloadLink
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
link := data.Data.Link
|
||||
|
||||
dl := torrent.DownloadLinks{
|
||||
Link: file.Link,
|
||||
Filename: data.Data.Filename,
|
||||
DownloadLink: link,
|
||||
}
|
||||
downloadLinks[file.Id] = dl
|
||||
}
|
||||
t.DownloadLinks = downloadLinks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
|
||||
url := fmt.Sprintf("%s/link/unlock", ad.Host)
|
||||
query := gourl.Values{}
|
||||
query.Add("link", file.Link)
|
||||
url += "?" + query.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := ad.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var data DownloadLink
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
link := data.Data.Link
|
||||
return &torrent.DownloadLinks{
|
||||
DownloadLink: link,
|
||||
Link: file.Link,
|
||||
Filename: data.Data.Filename,
|
||||
}
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetCheckCached() bool {
|
||||
return ad.CheckCached
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetTorrents() ([]*torrent.Torrent, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (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{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
}
|
||||
client := request.NewRLHTTPClient(rl, headers)
|
||||
return &AllDebrid{
|
||||
Name: "alldebrid",
|
||||
Host: dc.Host,
|
||||
APIKey: dc.APIKey,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
cache: cache,
|
||||
MountPath: dc.Folder,
|
||||
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
|
||||
CheckCached: dc.CheckCached,
|
||||
}
|
||||
}
|
||||
75
pkg/debrid/alldebrid/types.go
Normal file
75
pkg/debrid/alldebrid/types.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package alldebrid
|
||||
|
||||
type errorResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
type MagnetFile struct {
|
||||
Name string `json:"n"`
|
||||
Size int64 `json:"s"`
|
||||
Link string `json:"l"`
|
||||
Elements []MagnetFile `json:"e"`
|
||||
}
|
||||
type magnetInfo struct {
|
||||
Id int `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
Status string `json:"status"`
|
||||
StatusCode int `json:"statusCode"`
|
||||
UploadDate int `json:"uploadDate"`
|
||||
Downloaded int64 `json:"downloaded"`
|
||||
Uploaded int64 `json:"uploaded"`
|
||||
DownloadSpeed int64 `json:"downloadSpeed"`
|
||||
UploadSpeed int64 `json:"uploadSpeed"`
|
||||
Seeders int `json:"seeders"`
|
||||
CompletionDate int `json:"completionDate"`
|
||||
Type string `json:"type"`
|
||||
Notified bool `json:"notified"`
|
||||
Version int `json:"version"`
|
||||
NbLinks int `json:"nbLinks"`
|
||||
Files []MagnetFile `json:"files"`
|
||||
}
|
||||
|
||||
type TorrentInfoResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Magnets magnetInfo `json:"magnets"`
|
||||
} `json:"data"`
|
||||
Error *errorResponse `json:"error"`
|
||||
}
|
||||
|
||||
type UploadMagnetResponse struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Magnets []struct {
|
||||
Magnet string `json:"magnet"`
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
FilenameOriginal string `json:"filename_original"`
|
||||
Size int64 `json:"size"`
|
||||
Ready bool `json:"ready"`
|
||||
ID int `json:"id"`
|
||||
} `json:"magnets"`
|
||||
}
|
||||
Error *errorResponse `json:"error"`
|
||||
}
|
||||
|
||||
type DownloadLink struct {
|
||||
Status string `json:"status"`
|
||||
Data struct {
|
||||
Link string `json:"link"`
|
||||
Host string `json:"host"`
|
||||
Filename string `json:"filename"`
|
||||
Streaming []interface{} `json:"streaming"`
|
||||
Paws bool `json:"paws"`
|
||||
Filesize int `json:"filesize"`
|
||||
Id string `json:"id"`
|
||||
Path []struct {
|
||||
Name string `json:"n"`
|
||||
Size int `json:"s"`
|
||||
} `json:"path"`
|
||||
} `json:"data"`
|
||||
Error *errorResponse `json:"error"`
|
||||
}
|
||||
360
pkg/debrid/cache/cache.go
vendored
Normal file
360
pkg/debrid/cache/cache.go
vendored
Normal file
@@ -0,0 +1,360 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
)
|
||||
|
||||
type DownloadLinkCache struct {
|
||||
Link string `json:"download_link"`
|
||||
}
|
||||
|
||||
type CachedTorrent struct {
|
||||
*torrent.Torrent
|
||||
LastRead time.Time `json:"last_read"`
|
||||
IsComplete bool `json:"is_complete"`
|
||||
DownloadLinks map[string]DownloadLinkCache `json:"download_links"`
|
||||
}
|
||||
|
||||
var (
|
||||
_logInstance zerolog.Logger
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func getLogger() zerolog.Logger {
|
||||
once.Do(func() {
|
||||
_logInstance = logger.NewLogger("cache", "info", os.Stdout)
|
||||
})
|
||||
return _logInstance
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
dir string
|
||||
client engine.Service
|
||||
torrents *sync.Map // key: torrent.Id, value: *CachedTorrent
|
||||
torrentsNames *sync.Map // key: torrent.Name, value: torrent.Id
|
||||
LastUpdated time.Time `json:"last_updated"`
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
caches map[string]*Cache
|
||||
}
|
||||
|
||||
func NewManager(debridService *engine.Engine) *Manager {
|
||||
cfg := config.GetConfig()
|
||||
cm := &Manager{
|
||||
caches: make(map[string]*Cache),
|
||||
}
|
||||
for _, debrid := range debridService.GetDebrids() {
|
||||
c := New(debrid, cfg.Path)
|
||||
cm.caches[debrid.GetName()] = c
|
||||
}
|
||||
return cm
|
||||
}
|
||||
|
||||
func (m *Manager) GetCaches() map[string]*Cache {
|
||||
return m.caches
|
||||
}
|
||||
|
||||
func (m *Manager) GetCache(debridName string) *Cache {
|
||||
return m.caches[debridName]
|
||||
}
|
||||
|
||||
func New(debridService engine.Service, basePath string) *Cache {
|
||||
return &Cache{
|
||||
dir: filepath.Join(basePath, "cache", debridService.GetName(), "torrents"),
|
||||
torrents: &sync.Map{},
|
||||
torrentsNames: &sync.Map{},
|
||||
client: debridService,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) Start() error {
|
||||
_logger := getLogger()
|
||||
_logger.Info().Msg("Starting cache for: " + c.client.GetName())
|
||||
if err := c.Load(); err != nil {
|
||||
return fmt.Errorf("failed to load cache: %v", err)
|
||||
}
|
||||
if err := c.Sync(); err != nil {
|
||||
return fmt.Errorf("failed to sync cache: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) Load() error {
|
||||
_logger := getLogger()
|
||||
|
||||
if err := os.MkdirAll(c.dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(c.dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read cache directory: %w", err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() || filepath.Ext(file.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(c.dir, file.Name())
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
_logger.Debug().Err(err).Msgf("Failed to read file: %s", filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
var ct CachedTorrent
|
||||
if err := json.Unmarshal(data, &ct); err != nil {
|
||||
_logger.Debug().Err(err).Msgf("Failed to unmarshal file: %s", filePath)
|
||||
continue
|
||||
}
|
||||
if len(ct.Files) > 0 {
|
||||
c.torrents.Store(ct.Torrent.Id, &ct)
|
||||
c.torrentsNames.Store(ct.Torrent.Name, ct.Torrent.Id)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) GetTorrent(id string) *CachedTorrent {
|
||||
if value, ok := c.torrents.Load(id); ok {
|
||||
return value.(*CachedTorrent)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) GetTorrentByName(name string) *CachedTorrent {
|
||||
if id, ok := c.torrentsNames.Load(name); ok {
|
||||
return c.GetTorrent(id.(string))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) SaveTorrent(ct *CachedTorrent) error {
|
||||
data, err := json.MarshalIndent(ct, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal torrent: %w", err)
|
||||
}
|
||||
|
||||
fileName := ct.Torrent.Id + ".json"
|
||||
filePath := filepath.Join(c.dir, fileName)
|
||||
tmpFile := filePath + ".tmp"
|
||||
|
||||
f, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
w := bufio.NewWriter(f)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
return fmt.Errorf("failed to write data: %w", err)
|
||||
}
|
||||
|
||||
if err := w.Flush(); err != nil {
|
||||
return fmt.Errorf("failed to flush data: %w", err)
|
||||
}
|
||||
|
||||
return os.Rename(tmpFile, filePath)
|
||||
}
|
||||
|
||||
func (c *Cache) SaveAll() error {
|
||||
const batchSize = 100
|
||||
var wg sync.WaitGroup
|
||||
_logger := getLogger()
|
||||
|
||||
tasks := make(chan *CachedTorrent, batchSize)
|
||||
|
||||
for i := 0; i < runtime.NumCPU(); i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for ct := range tasks {
|
||||
if err := c.SaveTorrent(ct); err != nil {
|
||||
_logger.Error().Err(err).Msg("failed to save torrent")
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
c.torrents.Range(func(_, value interface{}) bool {
|
||||
tasks <- value.(*CachedTorrent)
|
||||
return true
|
||||
})
|
||||
|
||||
close(tasks)
|
||||
wg.Wait()
|
||||
c.LastUpdated = time.Now()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) Sync() error {
|
||||
_logger := getLogger()
|
||||
torrents, err := c.client.GetTorrents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync torrents: %v", err)
|
||||
}
|
||||
|
||||
workers := runtime.NumCPU() * 200
|
||||
workChan := make(chan *torrent.Torrent, len(torrents))
|
||||
errChan := make(chan error, len(torrents))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for t := range workChan {
|
||||
if err := c.processTorrent(t); err != nil {
|
||||
errChan <- err
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, t := range torrents {
|
||||
workChan <- t
|
||||
}
|
||||
close(workChan)
|
||||
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
|
||||
for err := range errChan {
|
||||
_logger.Error().Err(err).Msg("sync error")
|
||||
}
|
||||
|
||||
_logger.Info().Msgf("Synced %d torrents", len(torrents))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) processTorrent(t *torrent.Torrent) error {
|
||||
if existing, ok := c.torrents.Load(t.Id); ok {
|
||||
ct := existing.(*CachedTorrent)
|
||||
if ct.IsComplete {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.AddTorrent(t)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) AddTorrent(t *torrent.Torrent) {
|
||||
_logger := getLogger()
|
||||
|
||||
if len(t.Files) == 0 {
|
||||
tNew, err := c.client.GetTorrent(t.Id)
|
||||
_logger.Debug().Msgf("Getting torrent files for %s", t.Id)
|
||||
if err != nil {
|
||||
_logger.Debug().Msgf("Failed to get torrent files for %s: %v", t.Id, err)
|
||||
return
|
||||
}
|
||||
t = tNew
|
||||
}
|
||||
|
||||
if len(t.Files) == 0 {
|
||||
_logger.Debug().Msgf("No files found for %s", t.Id)
|
||||
return
|
||||
}
|
||||
|
||||
ct := &CachedTorrent{
|
||||
Torrent: t,
|
||||
LastRead: time.Now(),
|
||||
IsComplete: len(t.Files) > 0,
|
||||
DownloadLinks: make(map[string]DownloadLinkCache),
|
||||
}
|
||||
|
||||
c.torrents.Store(t.Id, ct)
|
||||
c.torrentsNames.Store(t.Name, t.Id)
|
||||
|
||||
go func() {
|
||||
if err := c.SaveTorrent(ct); err != nil {
|
||||
_logger.Debug().Err(err).Msgf("Failed to save torrent %s", t.Id)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (c *Cache) RefreshTorrent(torrentId string) *CachedTorrent {
|
||||
_logger := getLogger()
|
||||
|
||||
t, err := c.client.GetTorrent(torrentId)
|
||||
if err != nil {
|
||||
_logger.Debug().Msgf("Failed to get torrent files for %s: %v", torrentId, err)
|
||||
return nil
|
||||
}
|
||||
if len(t.Files) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ct := &CachedTorrent{
|
||||
Torrent: t,
|
||||
LastRead: time.Now(),
|
||||
IsComplete: len(t.Files) > 0,
|
||||
DownloadLinks: make(map[string]DownloadLinkCache),
|
||||
}
|
||||
|
||||
c.torrents.Store(t.Id, ct)
|
||||
c.torrentsNames.Store(t.Name, t.Id)
|
||||
|
||||
go func() {
|
||||
if err := c.SaveTorrent(ct); err != nil {
|
||||
_logger.Debug().Err(err).Msgf("Failed to save torrent %s", t.Id)
|
||||
}
|
||||
}()
|
||||
|
||||
return ct
|
||||
}
|
||||
|
||||
func (c *Cache) GetFileDownloadLink(t *CachedTorrent, file *torrent.File) (string, error) {
|
||||
_logger := getLogger()
|
||||
|
||||
if linkCache, ok := t.DownloadLinks[file.Id]; ok {
|
||||
return linkCache.Link, nil
|
||||
}
|
||||
|
||||
if file.Link == "" {
|
||||
t = c.RefreshTorrent(t.Id)
|
||||
if t == nil {
|
||||
return "", fmt.Errorf("torrent not found")
|
||||
}
|
||||
file = t.Torrent.GetFile(file.Id)
|
||||
}
|
||||
|
||||
_logger.Debug().Msgf("Getting download link for %s", t.Name)
|
||||
link := c.client.GetDownloadLink(t.Torrent, file)
|
||||
if link == nil {
|
||||
return "", fmt.Errorf("download link not found")
|
||||
}
|
||||
|
||||
t.DownloadLinks[file.Id] = DownloadLinkCache{
|
||||
Link: link.DownloadLink,
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := c.SaveTorrent(t); err != nil {
|
||||
_logger.Debug().Err(err).Msgf("Failed to save torrent %s", t.Id)
|
||||
}
|
||||
}()
|
||||
|
||||
return link.DownloadLink, nil
|
||||
}
|
||||
|
||||
func (c *Cache) GetTorrents() *sync.Map {
|
||||
return c.torrents
|
||||
}
|
||||
@@ -1,148 +1,105 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"goBlack/common"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/cache"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/alldebrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/debrid_link"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/realdebrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torbox"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
SubmitMagnet(torrent *Torrent) (*Torrent, error)
|
||||
CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error)
|
||||
GetDownloadLinks(torrent *Torrent) error
|
||||
DeleteTorrent(torrent *Torrent)
|
||||
IsAvailable(infohashes []string) map[string]bool
|
||||
GetMountPath() string
|
||||
GetDownloadUncached() bool
|
||||
GetTorrent(id string) (*Torrent, error)
|
||||
GetName() string
|
||||
GetLogger() *log.Logger
|
||||
func New() *engine.Engine {
|
||||
cfg := config.GetConfig()
|
||||
maxCachedSize := cmp.Or(cfg.MaxCacheSize, 1000)
|
||||
debrids := make([]engine.Service, 0)
|
||||
// Divide the cache size by the number of debrids
|
||||
maxCacheSize := maxCachedSize / len(cfg.Debrids)
|
||||
|
||||
for _, dc := range cfg.Debrids {
|
||||
d := createDebrid(dc, cache.New(maxCacheSize))
|
||||
logger := d.GetLogger()
|
||||
logger.Info().Msg("Debrid Service started")
|
||||
debrids = append(debrids, d)
|
||||
}
|
||||
d := &engine.Engine{Debrids: debrids, LastUsed: 0}
|
||||
return d
|
||||
}
|
||||
|
||||
type Debrid struct {
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *common.RLHTTPClient
|
||||
cache *common.Cache
|
||||
MountPath string
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func NewDebrid(dc common.DebridConfig, cache *common.Cache) Service {
|
||||
func createDebrid(dc config.Debrid, cache *cache.Cache) engine.Service {
|
||||
switch dc.Name {
|
||||
case "realdebrid":
|
||||
return NewRealDebrid(dc, cache)
|
||||
return realdebrid.New(dc, cache)
|
||||
case "torbox":
|
||||
return torbox.New(dc, cache)
|
||||
case "debridlink":
|
||||
return debrid_link.New(dc, cache)
|
||||
case "alldebrid":
|
||||
return alldebrid.New(dc, cache)
|
||||
default:
|
||||
return NewRealDebrid(dc, cache)
|
||||
return realdebrid.New(dc, cache)
|
||||
}
|
||||
}
|
||||
|
||||
func GetTorrentInfo(filePath string) (*Torrent, error) {
|
||||
// Open and read the .torrent file
|
||||
if filepath.Ext(filePath) == ".torrent" {
|
||||
return getTorrentInfo(filePath)
|
||||
} else {
|
||||
return torrentFromMagnetFile(filePath)
|
||||
}
|
||||
func ProcessTorrent(d *engine.Engine, magnet *utils.Magnet, a *arr.Arr, isSymlink, overrideDownloadUncached bool) (*torrent.Torrent, error) {
|
||||
|
||||
}
|
||||
|
||||
func torrentFromMagnetFile(filePath string) (*Torrent, error) {
|
||||
magnetLink := common.OpenMagnetFile(filePath)
|
||||
magnet, err := common.GetMagnetInfo(magnetLink)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
torrent := &Torrent{
|
||||
InfoHash: magnet.InfoHash,
|
||||
Name: magnet.Name,
|
||||
Size: magnet.Size,
|
||||
Magnet: magnet,
|
||||
Filename: filePath,
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func getTorrentInfo(filePath string) (*Torrent, error) {
|
||||
mi, err := metainfo.LoadFromFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash := mi.HashInfoBytes()
|
||||
infoHash := hash.HexString()
|
||||
info, err := mi.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
magnet := &common.Magnet{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Length,
|
||||
Link: mi.Magnet(&hash, &info).String(),
|
||||
}
|
||||
torrent := &Torrent{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Length,
|
||||
Magnet: magnet,
|
||||
Filename: filePath,
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func GetLocalCache(infohashes []string, cache *common.Cache) ([]string, map[string]bool) {
|
||||
result := make(map[string]bool)
|
||||
hashes := make([]string, len(infohashes))
|
||||
|
||||
if len(infohashes) == 0 {
|
||||
return hashes, result
|
||||
}
|
||||
if len(infohashes) == 1 {
|
||||
if cache.Exists(infohashes[0]) {
|
||||
return hashes, map[string]bool{infohashes[0]: true}
|
||||
}
|
||||
return infohashes, result
|
||||
}
|
||||
|
||||
cachedHashes := cache.GetMultiple(infohashes)
|
||||
for _, h := range infohashes {
|
||||
_, exists := cachedHashes[h]
|
||||
if !exists {
|
||||
hashes = append(hashes, h)
|
||||
} else {
|
||||
result[h] = true
|
||||
}
|
||||
}
|
||||
|
||||
return hashes, result
|
||||
}
|
||||
|
||||
func ProcessQBitTorrent(d Service, magnet *common.Magnet, arr *Arr, isSymlink bool) (*Torrent, error) {
|
||||
debridTorrent := &Torrent{
|
||||
debridTorrent := &torrent.Torrent{
|
||||
InfoHash: magnet.InfoHash,
|
||||
Magnet: magnet,
|
||||
Name: magnet.Name,
|
||||
Arr: arr,
|
||||
Arr: a,
|
||||
Size: magnet.Size,
|
||||
}
|
||||
logger := d.GetLogger()
|
||||
logger.Printf("Torrent Hash: %s", debridTorrent.InfoHash)
|
||||
if !d.GetDownloadUncached() {
|
||||
hash, exists := d.IsAvailable([]string{debridTorrent.InfoHash})[debridTorrent.InfoHash]
|
||||
if !exists || !hash {
|
||||
return debridTorrent, fmt.Errorf("torrent: %s is not cached", debridTorrent.Name)
|
||||
} else {
|
||||
logger.Printf("Torrent: %s is cached(or downloading)", debridTorrent.Name)
|
||||
}
|
||||
}
|
||||
|
||||
debridTorrent, err := d.SubmitMagnet(debridTorrent)
|
||||
if err != nil || debridTorrent.Id == "" {
|
||||
logger.Printf("Error submitting magnet: %s", err)
|
||||
return nil, err
|
||||
errs := make([]error, 0)
|
||||
|
||||
for index, db := range d.Debrids {
|
||||
logger := db.GetLogger()
|
||||
logger.Info().Msgf("Processing debrid: %s", db.GetName())
|
||||
|
||||
// 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]
|
||||
if !exists || !hash {
|
||||
logger.Info().Msgf("Torrent: %s is not cached", debridTorrent.Name)
|
||||
continue
|
||||
} else {
|
||||
logger.Info().Msgf("Torrent: %s is cached(or downloading)", debridTorrent.Name)
|
||||
}
|
||||
}
|
||||
|
||||
dbt, err := db.SubmitMagnet(debridTorrent)
|
||||
if dbt != nil {
|
||||
dbt.Arr = a
|
||||
}
|
||||
if err != nil || dbt == nil || dbt.Id == "" {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
logger.Info().Msgf("Torrent: %s(id=%s) submitted to %s", dbt.Name, dbt.Id, db.GetName())
|
||||
d.LastUsed = index
|
||||
return db.CheckStatus(dbt, isSymlink)
|
||||
}
|
||||
return d.CheckStatus(debridTorrent, isSymlink)
|
||||
err := fmt.Errorf("failed to process torrent")
|
||||
for _, e := range errs {
|
||||
err = fmt.Errorf("%w\n%w", err, e)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
300
pkg/debrid/debrid_link/debrid_link.go
Normal file
300
pkg/debrid/debrid_link/debrid_link.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package debrid_link
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/cache"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
"slices"
|
||||
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DebridLink struct {
|
||||
Name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *request.RLHTTPClient
|
||||
cache *cache.Cache
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
CheckCached bool
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetName() string {
|
||||
return dl.Name
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetLogger() zerolog.Logger {
|
||||
return dl.logger
|
||||
}
|
||||
|
||||
func (dl *DebridLink) IsAvailable(infohashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
hashes, result := torrent.GetLocalCache(infohashes, dl.cache)
|
||||
|
||||
if len(hashes) == 0 {
|
||||
// Either all the infohashes are locally cached or none are
|
||||
dl.cache.AddMultiple(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Divide hashes into groups of 100
|
||||
for i := 0; i < len(hashes); i += 100 {
|
||||
end := i + 100
|
||||
if end > len(hashes) {
|
||||
end = len(hashes)
|
||||
}
|
||||
|
||||
// Filter out empty strings
|
||||
validHashes := make([]string, 0, end-i)
|
||||
for _, hash := range hashes[i:end] {
|
||||
if hash != "" {
|
||||
validHashes = append(validHashes, hash)
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid hashes in this batch, continue to the next batch
|
||||
if len(validHashes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hashStr := strings.Join(validHashes, ",")
|
||||
url := fmt.Sprintf("%s/seedbox/cached/%s", dl.Host, hashStr)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := dl.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
dl.logger.Info().Msgf("Error checking availability: %v", err)
|
||||
return result
|
||||
}
|
||||
var data AvailableResponse
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
dl.logger.Info().Msgf("Error marshalling availability: %v", err)
|
||||
return result
|
||||
}
|
||||
if data.Value == nil {
|
||||
return result
|
||||
}
|
||||
value := *data.Value
|
||||
for _, h := range hashes[i:end] {
|
||||
_, exists := value[h]
|
||||
if exists {
|
||||
result[h] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
dl.cache.AddMultiple(result) // Add the results to the cache
|
||||
return result
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetTorrent(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 {
|
||||
return t, err
|
||||
}
|
||||
var res TorrentInfo
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
if !res.Success {
|
||||
return t, fmt.Errorf("error getting torrent")
|
||||
}
|
||||
if res.Value == nil {
|
||||
return t, fmt.Errorf("torrent not found")
|
||||
}
|
||||
dt := *res.Value
|
||||
|
||||
if len(dt) == 0 {
|
||||
return t, fmt.Errorf("torrent not found")
|
||||
}
|
||||
data := dt[0]
|
||||
status := "downloading"
|
||||
if data.Status == 100 {
|
||||
status = "downloaded"
|
||||
}
|
||||
name := utils.RemoveInvalidChars(data.Name)
|
||||
t.Id = data.ID
|
||||
t.Name = name
|
||||
t.Bytes = data.TotalSize
|
||||
t.Folder = name
|
||||
t.Progress = data.DownloadPercent
|
||||
t.Status = status
|
||||
t.Speed = data.DownloadSpeed
|
||||
t.Seeders = data.PeersConnected
|
||||
t.Filename = name
|
||||
t.OriginalFilename = name
|
||||
files := make([]torrent.File, len(data.Files))
|
||||
cfg := config.GetConfig()
|
||||
for i, f := range data.Files {
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
files[i] = torrent.File{
|
||||
Id: f.ID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
Path: f.Name,
|
||||
}
|
||||
}
|
||||
t.Files = files
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/seedbox/add", dl.Host)
|
||||
payload := map[string]string{"url": t.Magnet.Link}
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest(http.MethodPost, url, bytes.NewBuffer(jsonPayload))
|
||||
resp, err := dl.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res SubmitTorrentInfo
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.Success || res.Value == nil {
|
||||
return nil, fmt.Errorf("error adding torrent")
|
||||
}
|
||||
data := *res.Value
|
||||
status := "downloading"
|
||||
name := utils.RemoveInvalidChars(data.Name)
|
||||
t.Id = data.ID
|
||||
t.Name = name
|
||||
t.Bytes = data.TotalSize
|
||||
t.Folder = name
|
||||
t.Progress = data.DownloadPercent
|
||||
t.Status = status
|
||||
t.Speed = data.DownloadSpeed
|
||||
t.Seeders = data.PeersConnected
|
||||
t.Filename = name
|
||||
t.OriginalFilename = name
|
||||
t.MountPath = dl.MountPath
|
||||
t.Debrid = dl.Name
|
||||
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
|
||||
files := make([]torrent.File, len(data.Files))
|
||||
for i, f := range data.Files {
|
||||
files[i] = torrent.File{
|
||||
Id: f.ID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
Path: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
}
|
||||
}
|
||||
t.Files = files
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
|
||||
for {
|
||||
t, err := dl.GetTorrent(torrent)
|
||||
torrent = t
|
||||
if err != nil || torrent == nil {
|
||||
return torrent, err
|
||||
}
|
||||
status := torrent.Status
|
||||
if status == "downloaded" {
|
||||
dl.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||
err = dl.GetDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
break
|
||||
} else if 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.
|
||||
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
|
||||
break
|
||||
} else {
|
||||
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||
}
|
||||
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) DeleteTorrent(torrent *torrent.Torrent) {
|
||||
url := fmt.Sprintf("%s/seedbox/%s/remove", dl.Host, torrent.Id)
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
_, err := dl.client.MakeRequest(req)
|
||||
if err == nil {
|
||||
dl.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
|
||||
} else {
|
||||
dl.logger.Info().Msgf("Error deleting torrent: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetDownloadLinks(t *torrent.Torrent) error {
|
||||
downloadLinks := make(map[string]torrent.DownloadLinks)
|
||||
for _, f := range t.Files {
|
||||
dl := torrent.DownloadLinks{
|
||||
Link: f.Link,
|
||||
Filename: f.Name,
|
||||
}
|
||||
downloadLinks[f.Id] = dl
|
||||
}
|
||||
t.DownloadLinks = downloadLinks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
|
||||
dlLink, ok := t.DownloadLinks[file.Id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return &dlLink
|
||||
}
|
||||
|
||||
func (dl *DebridLink) 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{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
client := request.NewRLHTTPClient(rl, headers)
|
||||
return &DebridLink{
|
||||
Name: "debridlink",
|
||||
Host: dc.Host,
|
||||
APIKey: dc.APIKey,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
cache: cache,
|
||||
MountPath: dc.Folder,
|
||||
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
|
||||
CheckCached: dc.CheckCached,
|
||||
}
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetTorrents() ([]*torrent.Torrent, error) {
|
||||
return nil, fmt.Errorf("not implemented")
|
||||
}
|
||||
45
pkg/debrid/debrid_link/types.go
Normal file
45
pkg/debrid/debrid_link/types.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package debrid_link
|
||||
|
||||
type APIResponse[T any] struct {
|
||||
Success bool `json:"success"`
|
||||
Value *T `json:"value"` // Use pointer to allow nil
|
||||
}
|
||||
|
||||
type AvailableResponse APIResponse[map[string]map[string]struct {
|
||||
Name string `json:"name"`
|
||||
HashString string `json:"hashString"`
|
||||
Files []struct {
|
||||
Name string `json:"name"`
|
||||
Size int `json:"size"`
|
||||
} `json:"files"`
|
||||
}]
|
||||
|
||||
type debridLinkTorrentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HashString string `json:"hashString"`
|
||||
UploadRatio float64 `json:"uploadRatio"`
|
||||
ServerID string `json:"serverId"`
|
||||
Wait bool `json:"wait"`
|
||||
PeersConnected int `json:"peersConnected"`
|
||||
Status int `json:"status"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
Files []struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
DownloadURL string `json:"downloadUrl"`
|
||||
Size int64 `json:"size"`
|
||||
DownloadPercent int `json:"downloadPercent"`
|
||||
} `json:"files"`
|
||||
Trackers []struct {
|
||||
Announce string `json:"announce"`
|
||||
} `json:"trackers"`
|
||||
Created int64 `json:"created"`
|
||||
DownloadPercent float64 `json:"downloadPercent"`
|
||||
DownloadSpeed int64 `json:"downloadSpeed"`
|
||||
UploadSpeed int64 `json:"uploadSpeed"`
|
||||
}
|
||||
|
||||
type TorrentInfo APIResponse[[]debridLinkTorrentInfo]
|
||||
|
||||
type SubmitTorrentInfo APIResponse[debridLinkTorrentInfo]
|
||||
26
pkg/debrid/engine/engine.go
Normal file
26
pkg/debrid/engine/engine.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package engine
|
||||
|
||||
type Engine struct {
|
||||
Debrids []Service
|
||||
LastUsed int
|
||||
}
|
||||
|
||||
func (d *Engine) Get() Service {
|
||||
if d.LastUsed == 0 {
|
||||
return d.Debrids[0]
|
||||
}
|
||||
return d.Debrids[d.LastUsed]
|
||||
}
|
||||
|
||||
func (d *Engine) GetByName(name string) Service {
|
||||
for _, deb := range d.Debrids {
|
||||
if deb.GetName() == name {
|
||||
return deb
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Engine) GetDebrids() []Service {
|
||||
return d.Debrids
|
||||
}
|
||||
22
pkg/debrid/engine/service.go
Normal file
22
pkg/debrid/engine/service.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
SubmitMagnet(tr *torrent.Torrent) (*torrent.Torrent, error)
|
||||
CheckStatus(tr *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error)
|
||||
GetDownloadLinks(tr *torrent.Torrent) error
|
||||
GetDownloadLink(tr *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks
|
||||
DeleteTorrent(tr *torrent.Torrent)
|
||||
IsAvailable(infohashes []string) map[string]bool
|
||||
GetCheckCached() bool
|
||||
GetDownloadUncached() bool
|
||||
GetTorrent(torrent *torrent.Torrent) (*torrent.Torrent, error)
|
||||
GetTorrents() ([]*torrent.Torrent, error)
|
||||
GetName() string
|
||||
GetLogger() zerolog.Logger
|
||||
GetDownloadingStatus() []string
|
||||
}
|
||||
@@ -1,287 +0,0 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid/structs"
|
||||
"log"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RealDebrid struct {
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *common.RLHTTPClient
|
||||
cache *common.Cache
|
||||
MountPath string
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetMountPath() string {
|
||||
return r.MountPath
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetName() string {
|
||||
return "realdebrid"
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetLogger() *log.Logger {
|
||||
return r.logger
|
||||
}
|
||||
|
||||
func GetTorrentFiles(data structs.RealDebridTorrentInfo) []TorrentFile {
|
||||
files := make([]TorrentFile, 0)
|
||||
for _, f := range data.Files {
|
||||
name := filepath.Base(f.Path)
|
||||
if (!common.RegexMatch(common.VIDEOMATCH, name) &&
|
||||
!common.RegexMatch(common.SUBMATCH, name) &&
|
||||
!common.RegexMatch(common.MUSICMATCH, name)) || common.RegexMatch(common.SAMPLEMATCH, name) {
|
||||
continue
|
||||
}
|
||||
fileId := f.ID
|
||||
file := &TorrentFile{
|
||||
Name: name,
|
||||
Path: name,
|
||||
Size: int64(f.Bytes),
|
||||
Id: strconv.Itoa(fileId),
|
||||
}
|
||||
files = append(files, *file)
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
hashes, result := GetLocalCache(infohashes, r.cache)
|
||||
|
||||
if len(hashes) == 0 {
|
||||
// Either all the infohashes are locally cached or none are
|
||||
r.cache.AddMultiple(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Divide hashes into groups of 100
|
||||
for i := 0; i < len(hashes); i += 200 {
|
||||
end := i + 200
|
||||
if end > len(hashes) {
|
||||
end = len(hashes)
|
||||
}
|
||||
|
||||
// Filter out empty strings
|
||||
validHashes := make([]string, 0, end-i)
|
||||
for _, hash := range hashes[i:end] {
|
||||
if hash != "" {
|
||||
validHashes = append(validHashes, hash)
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid hashes in this batch, continue to the next batch
|
||||
if len(validHashes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hashStr := strings.Join(validHashes, "/")
|
||||
url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, hashStr)
|
||||
resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
log.Println("Error checking availability:", err)
|
||||
return result
|
||||
}
|
||||
var data structs.RealDebridAvailabilityResponse
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
log.Println("Error marshalling availability:", err)
|
||||
return result
|
||||
}
|
||||
for _, h := range hashes[i:end] {
|
||||
hosters, exists := data[strings.ToLower(h)]
|
||||
if exists && len(hosters.Rd) > 0 {
|
||||
result[h] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
r.cache.AddMultiple(result) // Add the results to the cache
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *RealDebrid) SubmitMagnet(torrent *Torrent) (*Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/addMagnet", r.Host)
|
||||
payload := gourl.Values{
|
||||
"magnet": {torrent.Magnet.Link},
|
||||
}
|
||||
var data structs.RealDebridAddMagnetSchema
|
||||
resp, err := r.client.MakeRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(resp, &data)
|
||||
log.Printf("Torrent: %s added with id: %s\n", torrent.Name, data.Id)
|
||||
torrent.Id = data.Id
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetTorrent(id string) (*Torrent, error) {
|
||||
torrent := &Torrent{}
|
||||
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, id)
|
||||
resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
var data structs.RealDebridTorrentInfo
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
name := common.RemoveInvalidChars(data.OriginalFilename)
|
||||
torrent.Id = id
|
||||
torrent.Name = name
|
||||
torrent.Bytes = data.Bytes
|
||||
torrent.Folder = name
|
||||
torrent.Progress = data.Progress
|
||||
torrent.Status = data.Status
|
||||
torrent.Speed = data.Speed
|
||||
torrent.Seeders = data.Seeders
|
||||
torrent.Filename = data.Filename
|
||||
torrent.OriginalFilename = data.OriginalFilename
|
||||
torrent.Links = data.Links
|
||||
files := GetTorrentFiles(data)
|
||||
torrent.Files = files
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) CheckStatus(torrent *Torrent, isSymlink bool) (*Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrent.Id)
|
||||
for {
|
||||
resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
log.Println("ERROR Checking file: ", err)
|
||||
return torrent, err
|
||||
}
|
||||
var data structs.RealDebridTorrentInfo
|
||||
err = json.Unmarshal(resp, &data)
|
||||
status := data.Status
|
||||
name := common.RemoveInvalidChars(data.OriginalFilename)
|
||||
torrent.Name = name // Important because some magnet changes the name
|
||||
torrent.Folder = name
|
||||
torrent.Filename = data.Filename
|
||||
torrent.OriginalFilename = data.OriginalFilename
|
||||
torrent.Bytes = data.Bytes
|
||||
torrent.Progress = data.Progress
|
||||
torrent.Speed = data.Speed
|
||||
torrent.Seeders = data.Seeders
|
||||
torrent.Links = data.Links
|
||||
torrent.Status = status
|
||||
if status == "error" || status == "dead" || status == "magnet_error" {
|
||||
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||
} else if status == "waiting_files_selection" {
|
||||
files := GetTorrentFiles(data)
|
||||
torrent.Files = files
|
||||
if len(files) == 0 {
|
||||
return torrent, fmt.Errorf("no video files found")
|
||||
}
|
||||
filesId := make([]string, 0)
|
||||
for _, f := range files {
|
||||
filesId = append(filesId, f.Id)
|
||||
}
|
||||
p := gourl.Values{
|
||||
"files": {strings.Join(filesId, ",")},
|
||||
}
|
||||
payload := strings.NewReader(p.Encode())
|
||||
_, err = r.client.MakeRequest(http.MethodPost, fmt.Sprintf("%s/torrents/selectFiles/%s", r.Host, torrent.Id), payload)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
} else if status == "downloaded" {
|
||||
files := GetTorrentFiles(data)
|
||||
torrent.Files = files
|
||||
log.Printf("Torrent: %s downloaded to RD\n", torrent.Name)
|
||||
if !isSymlink {
|
||||
err = r.GetDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
} else if status == "downloading" {
|
||||
if !r.DownloadUncached {
|
||||
go r.DeleteTorrent(torrent)
|
||||
return torrent, fmt.Errorf("torrent: %s not cached", torrent.Name)
|
||||
}
|
||||
// Break out of the loop if the torrent is downloading.
|
||||
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
|
||||
break
|
||||
}
|
||||
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) DeleteTorrent(torrent *Torrent) {
|
||||
url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrent.Id)
|
||||
_, err := r.client.MakeRequest(http.MethodDelete, url, nil)
|
||||
if err == nil {
|
||||
r.logger.Printf("Torrent: %s deleted\n", torrent.Name)
|
||||
} else {
|
||||
r.logger.Printf("Error deleting torrent: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadLinks(torrent *Torrent) error {
|
||||
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
|
||||
downloadLinks := make([]TorrentDownloadLinks, 0)
|
||||
for _, link := range torrent.Links {
|
||||
if link == "" {
|
||||
continue
|
||||
}
|
||||
payload := gourl.Values{
|
||||
"link": {link},
|
||||
}
|
||||
resp, err := r.client.MakeRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data structs.RealDebridUnrestrictResponse
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
download := TorrentDownloadLinks{
|
||||
Link: data.Link,
|
||||
Filename: data.Filename,
|
||||
DownloadLink: data.Download,
|
||||
}
|
||||
downloadLinks = append(downloadLinks, download)
|
||||
}
|
||||
torrent.DownloadLinks = downloadLinks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadUncached() bool {
|
||||
return r.DownloadUncached
|
||||
}
|
||||
|
||||
func NewRealDebrid(dc common.DebridConfig, cache *common.Cache) *RealDebrid {
|
||||
rl := common.ParseRateLimit(dc.RateLimit)
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
}
|
||||
client := common.NewRLHTTPClient(rl, headers)
|
||||
logger := common.NewLogger(dc.Name, os.Stdout)
|
||||
return &RealDebrid{
|
||||
Host: dc.Host,
|
||||
APIKey: dc.APIKey,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
cache: cache,
|
||||
MountPath: dc.Folder,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
405
pkg/debrid/realdebrid/realdebrid.go
Normal file
405
pkg/debrid/realdebrid/realdebrid.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package realdebrid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/cache"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RealDebrid struct {
|
||||
Name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *request.RLHTTPClient
|
||||
cache *cache.Cache
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
CheckCached bool
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetName() string {
|
||||
return r.Name
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetLogger() zerolog.Logger {
|
||||
return r.logger
|
||||
}
|
||||
|
||||
// GetTorrentFiles returns a list of torrent files from the torrent info
|
||||
// validate is used to determine if the files should be validated
|
||||
// if validate is false, selected files will be returned
|
||||
func GetTorrentFiles(data TorrentInfo, validate bool) []torrent.File {
|
||||
files := make([]torrent.File, 0)
|
||||
cfg := config.GetConfig()
|
||||
idx := 0
|
||||
for _, f := range data.Files {
|
||||
|
||||
name := filepath.Base(f.Path)
|
||||
|
||||
if validate {
|
||||
if utils.RegexMatch(utils.SAMPLEMATCH, name) {
|
||||
// Skip sample files
|
||||
continue
|
||||
}
|
||||
if !cfg.IsAllowedFile(name) {
|
||||
continue
|
||||
}
|
||||
if !cfg.IsSizeAllowed(f.Bytes) {
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
if f.Selected == 0 {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
fileId := f.ID
|
||||
_link := ""
|
||||
if len(data.Links) > idx {
|
||||
_link = data.Links[idx]
|
||||
}
|
||||
file := torrent.File{
|
||||
Name: name,
|
||||
Path: name,
|
||||
Size: f.Bytes,
|
||||
Id: strconv.Itoa(fileId),
|
||||
Link: _link,
|
||||
}
|
||||
files = append(files, file)
|
||||
idx++
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (r *RealDebrid) IsAvailable(infohashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
hashes, result := torrent.GetLocalCache(infohashes, r.cache)
|
||||
|
||||
if len(hashes) == 0 {
|
||||
// Either all the infohashes are locally cached or none are
|
||||
r.cache.AddMultiple(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Divide hashes into groups of 100
|
||||
for i := 0; i < len(hashes); i += 200 {
|
||||
end := i + 200
|
||||
if end > len(hashes) {
|
||||
end = len(hashes)
|
||||
}
|
||||
|
||||
// Filter out empty strings
|
||||
validHashes := make([]string, 0, end-i)
|
||||
for _, hash := range hashes[i:end] {
|
||||
if hash != "" {
|
||||
validHashes = append(validHashes, hash)
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid hashes in this batch, continue to the next batch
|
||||
if len(validHashes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hashStr := strings.Join(validHashes, "/")
|
||||
url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, hashStr)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
r.logger.Info().Msgf("Error checking availability: %v", err)
|
||||
return result
|
||||
}
|
||||
var data AvailabilityResponse
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
r.logger.Info().Msgf("Error marshalling availability: %v", err)
|
||||
return result
|
||||
}
|
||||
for _, h := range hashes[i:end] {
|
||||
hosters, exists := data[strings.ToLower(h)]
|
||||
if exists && len(hosters.Rd) > 0 {
|
||||
result[h] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
r.cache.AddMultiple(result) // Add the results to the cache
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *RealDebrid) SubmitMagnet(t *torrent.Torrent) (*torrent.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/addMagnet", r.Host)
|
||||
payload := gourl.Values{
|
||||
"magnet": {t.Magnet.Link},
|
||||
}
|
||||
var data AddMagnetSchema
|
||||
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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(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 {
|
||||
return t, err
|
||||
}
|
||||
var data TorrentInfo
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
name := utils.RemoveInvalidChars(data.OriginalFilename)
|
||||
t.Name = name
|
||||
t.Bytes = data.Bytes
|
||||
t.Folder = name
|
||||
t.Progress = data.Progress
|
||||
t.Status = data.Status
|
||||
t.Speed = data.Speed
|
||||
t.Seeders = data.Seeders
|
||||
t.Filename = data.Filename
|
||||
t.OriginalFilename = data.OriginalFilename
|
||||
t.Links = data.Links
|
||||
t.MountPath = r.MountPath
|
||||
t.Debrid = r.Name
|
||||
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
|
||||
files := GetTorrentFiles(data, false) // Get selected files
|
||||
t.Files = files
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) CheckStatus(t *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, t.Id)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
for {
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
r.logger.Info().Msgf("ERROR Checking file: %v", err)
|
||||
return t, err
|
||||
}
|
||||
var data TorrentInfo
|
||||
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
|
||||
t.Folder = name
|
||||
t.Filename = data.Filename
|
||||
t.OriginalFilename = data.OriginalFilename
|
||||
t.Bytes = data.Bytes
|
||||
t.Progress = data.Progress
|
||||
t.Speed = data.Speed
|
||||
t.Seeders = data.Seeders
|
||||
t.Links = data.Links
|
||||
t.Status = status
|
||||
t.Debrid = r.Name
|
||||
t.MountPath = r.MountPath
|
||||
if status == "waiting_files_selection" {
|
||||
files := GetTorrentFiles(data, true) // Validate files to be selected
|
||||
t.Files = files
|
||||
if len(files) == 0 {
|
||||
return t, fmt.Errorf("no video files found")
|
||||
}
|
||||
filesId := make([]string, 0)
|
||||
for _, f := range files {
|
||||
filesId = append(filesId, f.Id)
|
||||
}
|
||||
p := gourl.Values{
|
||||
"files": {strings.Join(filesId, ",")},
|
||||
}
|
||||
payload := strings.NewReader(p.Encode())
|
||||
req, _ := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/torrents/selectFiles/%s", r.Host, t.Id), payload)
|
||||
_, err = r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
} else if status == "downloaded" {
|
||||
files := GetTorrentFiles(data, false) // Get selected files
|
||||
t.Files = files
|
||||
r.logger.Info().Msgf("Torrent: %s downloaded to RD", t.Name)
|
||||
if !isSymlink {
|
||||
err = r.GetDownloadLinks(t)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if slices.Contains(r.GetDownloadingStatus(), status) {
|
||||
if !t.DownloadUncached {
|
||||
return t, fmt.Errorf("torrent: %s not cached", t.Name)
|
||||
}
|
||||
} else {
|
||||
return t, fmt.Errorf("torrent: %s has error: %s", t.Name, status)
|
||||
}
|
||||
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) DeleteTorrent(torrent *torrent.Torrent) {
|
||||
url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrent.Id)
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
_, err := r.client.MakeRequest(req)
|
||||
if err == nil {
|
||||
r.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
|
||||
} else {
|
||||
r.logger.Info().Msgf("Error deleting torrent: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadLinks(t *torrent.Torrent) error {
|
||||
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
|
||||
downloadLinks := make(map[string]torrent.DownloadLinks)
|
||||
for _, f := range t.Files {
|
||||
dlLink := t.DownloadLinks[f.Id]
|
||||
if f.Link == "" || dlLink.DownloadLink != "" {
|
||||
continue
|
||||
}
|
||||
payload := gourl.Values{
|
||||
"link": {f.Link},
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data UnrestrictResponse
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
download := torrent.DownloadLinks{
|
||||
Link: data.Link,
|
||||
Filename: data.Filename,
|
||||
DownloadLink: data.Download,
|
||||
}
|
||||
downloadLinks[f.Id] = download
|
||||
}
|
||||
t.DownloadLinks = downloadLinks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
|
||||
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
|
||||
payload := gourl.Values{
|
||||
"link": {file.Link},
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var data UnrestrictResponse
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &torrent.DownloadLinks{
|
||||
Link: data.Link,
|
||||
Filename: data.Filename,
|
||||
DownloadLink: data.Download,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetCheckCached() bool {
|
||||
return r.CheckCached
|
||||
}
|
||||
|
||||
func (r *RealDebrid) getTorrents(offset int, limit int) ([]*torrent.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents?limit=%d", r.Host, limit)
|
||||
if offset > 0 {
|
||||
url = fmt.Sprintf("%s&offset=%d", url, offset)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data []TorrentsResponse
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
torrents := make([]*torrent.Torrent, 0)
|
||||
for _, t := range data {
|
||||
|
||||
torrents = append(torrents, &torrent.Torrent{
|
||||
Id: t.Id,
|
||||
Name: t.Filename,
|
||||
Bytes: t.Bytes,
|
||||
Progress: t.Progress,
|
||||
Status: t.Status,
|
||||
Filename: t.Filename,
|
||||
OriginalFilename: t.Filename,
|
||||
Links: t.Links,
|
||||
})
|
||||
}
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetTorrents() ([]*torrent.Torrent, error) {
|
||||
torrents := make([]*torrent.Torrent, 0)
|
||||
offset := 0
|
||||
limit := 5000
|
||||
for {
|
||||
ts, err := r.getTorrents(offset, limit)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if len(ts) == 0 {
|
||||
break
|
||||
}
|
||||
torrents = append(torrents, ts...)
|
||||
offset = len(torrents)
|
||||
}
|
||||
return torrents, nil
|
||||
|
||||
}
|
||||
|
||||
func (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{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
}
|
||||
client := request.NewRLHTTPClient(rl, headers)
|
||||
return &RealDebrid{
|
||||
Name: "realdebrid",
|
||||
Host: dc.Host,
|
||||
APIKey: dc.APIKey,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
cache: cache,
|
||||
MountPath: dc.Folder,
|
||||
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
|
||||
CheckCached: dc.CheckCached,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
package structs
|
||||
package realdebrid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RealDebridAvailabilityResponse map[string]Hoster
|
||||
type AvailabilityResponse map[string]Hoster
|
||||
|
||||
func (r *RealDebridAvailabilityResponse) UnmarshalJSON(data []byte) error {
|
||||
func (r *AvailabilityResponse) UnmarshalJSON(data []byte) error {
|
||||
// First, try to unmarshal as an object
|
||||
var objectData map[string]Hoster
|
||||
err := json.Unmarshal(data, &objectData)
|
||||
@@ -64,18 +65,18 @@ type FileVariant struct {
|
||||
Filesize int `json:"filesize"`
|
||||
}
|
||||
|
||||
type RealDebridAddMagnetSchema struct {
|
||||
type AddMagnetSchema struct {
|
||||
Id string `json:"id"`
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
type RealDebridTorrentInfo struct {
|
||||
type TorrentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Hash string `json:"hash"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
OriginalBytes int `json:"original_bytes"`
|
||||
OriginalBytes int64 `json:"original_bytes"`
|
||||
Host string `json:"host"`
|
||||
Split int `json:"split"`
|
||||
Progress float64 `json:"progress"`
|
||||
@@ -84,7 +85,7 @@ type RealDebridTorrentInfo struct {
|
||||
Files []struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Bytes int `json:"bytes"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Selected int `json:"selected"`
|
||||
} `json:"files"`
|
||||
Links []string `json:"links"`
|
||||
@@ -93,15 +94,29 @@ type RealDebridTorrentInfo struct {
|
||||
Seeders int `json:"seeders,omitempty"`
|
||||
}
|
||||
|
||||
type RealDebridUnrestrictResponse struct {
|
||||
type UnrestrictResponse struct {
|
||||
Id string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
Filesize int `json:"filesize"`
|
||||
Link string `json:"link"`
|
||||
Host string `json:"host"`
|
||||
Chunks int64 `json:"chunks"`
|
||||
Crc int64 `json:"crc"`
|
||||
Chunks int `json:"chunks"`
|
||||
Crc int `json:"crc"`
|
||||
Download string `json:"download"`
|
||||
Streamable int `json:"streamable"`
|
||||
}
|
||||
|
||||
type TorrentsResponse struct {
|
||||
Id string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
Hash string `json:"hash"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Host string `json:"host"`
|
||||
Split int64 `json:"split"`
|
||||
Progress float64 `json:"progress"`
|
||||
Status string `json:"status"`
|
||||
Added time.Time `json:"added"`
|
||||
Links []string `json:"links"`
|
||||
Ended time.Time `json:"ended"`
|
||||
}
|
||||
356
pkg/debrid/torbox/torbox.go
Normal file
356
pkg/debrid/torbox/torbox.go
Normal file
@@ -0,0 +1,356 @@
|
||||
package torbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/cache"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Torbox struct {
|
||||
Name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *request.RLHTTPClient
|
||||
cache *cache.Cache
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
CheckCached bool
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetName() string {
|
||||
return tb.Name
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetLogger() zerolog.Logger {
|
||||
return tb.logger
|
||||
}
|
||||
|
||||
func (tb *Torbox) IsAvailable(infohashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
hashes, result := torrent.GetLocalCache(infohashes, tb.cache)
|
||||
|
||||
if len(hashes) == 0 {
|
||||
// Either all the infohashes are locally cached or none are
|
||||
tb.cache.AddMultiple(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// Divide hashes into groups of 100
|
||||
for i := 0; i < len(hashes); i += 100 {
|
||||
end := i + 100
|
||||
if end > len(hashes) {
|
||||
end = len(hashes)
|
||||
}
|
||||
|
||||
// Filter out empty strings
|
||||
validHashes := make([]string, 0, end-i)
|
||||
for _, hash := range hashes[i:end] {
|
||||
if hash != "" {
|
||||
validHashes = append(validHashes, hash)
|
||||
}
|
||||
}
|
||||
|
||||
// If no valid hashes in this batch, continue to the next batch
|
||||
if len(validHashes) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
hashStr := strings.Join(validHashes, ",")
|
||||
url := fmt.Sprintf("%s/api/torrents/checkcached?hash=%s", tb.Host, hashStr)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := tb.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
tb.logger.Info().Msgf("Error checking availability: %v", err)
|
||||
return result
|
||||
}
|
||||
var res AvailableResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
tb.logger.Info().Msgf("Error marshalling availability: %v", err)
|
||||
return result
|
||||
}
|
||||
if res.Data == nil {
|
||||
return result
|
||||
}
|
||||
|
||||
for h, cache := range *res.Data {
|
||||
if cache.Size > 0 {
|
||||
result[strings.ToUpper(h)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
tb.cache.AddMultiple(result) // Add the results to the cache
|
||||
return result
|
||||
}
|
||||
|
||||
func (tb *Torbox) SubmitMagnet(torrent *torrent.Torrent) (*torrent.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/api/torrents/createtorrent", tb.Host)
|
||||
payload := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(payload)
|
||||
_ = writer.WriteField("magnet", torrent.Magnet.Link)
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, url, payload)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
resp, err := tb.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data AddMagnetResponse
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data.Data == nil {
|
||||
return nil, fmt.Errorf("error adding torrent")
|
||||
}
|
||||
dt := *data.Data
|
||||
torrentId := strconv.Itoa(dt.Id)
|
||||
torrent.Id = torrentId
|
||||
torrent.MountPath = tb.MountPath
|
||||
torrent.Debrid = tb.Name
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func getTorboxStatus(status string, finished bool) string {
|
||||
if finished {
|
||||
return "downloaded"
|
||||
}
|
||||
downloading := []string{"completed", "cached", "paused", "downloading", "uploading",
|
||||
"checkingResumeData", "metaDL", "pausedUP", "queuedUP", "checkingUP",
|
||||
"forcedUP", "allocating", "downloading", "metaDL", "pausedDL",
|
||||
"queuedDL", "checkingDL", "forcedDL", "checkingResumeData", "moving"}
|
||||
switch {
|
||||
case slices.Contains(downloading, status):
|
||||
return "downloading"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetTorrent(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 {
|
||||
return t, err
|
||||
}
|
||||
var res InfoResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
data := res.Data
|
||||
name := data.Name
|
||||
t.Name = name
|
||||
t.Bytes = data.Size
|
||||
t.Folder = name
|
||||
t.Progress = data.Progress * 100
|
||||
t.Status = getTorboxStatus(data.DownloadState, data.DownloadFinished)
|
||||
t.Speed = data.DownloadSpeed
|
||||
t.Seeders = data.Seeds
|
||||
t.Filename = name
|
||||
t.OriginalFilename = name
|
||||
t.MountPath = tb.MountPath
|
||||
t.Debrid = tb.Name
|
||||
t.DownloadLinks = make(map[string]torrent.DownloadLinks)
|
||||
files := make([]torrent.File, 0)
|
||||
cfg := config.GetConfig()
|
||||
for _, f := range data.Files {
|
||||
fileName := filepath.Base(f.Name)
|
||||
if utils.IsSampleFile(fileName) {
|
||||
// Skip sample files
|
||||
continue
|
||||
}
|
||||
if !cfg.IsAllowedFile(fileName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
file := torrent.File{
|
||||
Id: strconv.Itoa(f.Id),
|
||||
Name: fileName,
|
||||
Size: f.Size,
|
||||
Path: fileName,
|
||||
}
|
||||
files = append(files, file)
|
||||
}
|
||||
var cleanPath string
|
||||
if len(files) > 0 {
|
||||
cleanPath = path.Clean(data.Files[0].Name)
|
||||
} else {
|
||||
cleanPath = path.Clean(data.Name)
|
||||
}
|
||||
|
||||
t.OriginalFilename = strings.Split(cleanPath, "/")[0]
|
||||
t.Files = files
|
||||
//t.Debrid = tb
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) CheckStatus(torrent *torrent.Torrent, isSymlink bool) (*torrent.Torrent, error) {
|
||||
for {
|
||||
t, err := tb.GetTorrent(torrent)
|
||||
|
||||
torrent = t
|
||||
|
||||
if err != nil || t == nil {
|
||||
return t, err
|
||||
}
|
||||
status := torrent.Status
|
||||
if status == "downloaded" {
|
||||
tb.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||
if !isSymlink {
|
||||
err = tb.GetDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if 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.
|
||||
// This is necessary to prevent infinite loop since we moved to sync downloading and async processing
|
||||
break
|
||||
} else {
|
||||
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||
}
|
||||
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) DeleteTorrent(torrent *torrent.Torrent) {
|
||||
url := fmt.Sprintf("%s/api/torrents/controltorrent/%s", tb.Host, torrent.Id)
|
||||
payload := map[string]string{"torrent_id": torrent.Id, "action": "Delete"}
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(jsonPayload))
|
||||
_, err := tb.client.MakeRequest(req)
|
||||
if err == nil {
|
||||
tb.logger.Info().Msgf("Torrent: %s deleted", torrent.Name)
|
||||
} else {
|
||||
tb.logger.Info().Msgf("Error deleting torrent: %s", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadLinks(t *torrent.Torrent) error {
|
||||
downloadLinks := make(map[string]torrent.DownloadLinks)
|
||||
for _, file := range t.Files {
|
||||
url := fmt.Sprintf("%s/api/torrents/requestdl/", tb.Host)
|
||||
query := gourl.Values{}
|
||||
query.Add("torrent_id", t.Id)
|
||||
query.Add("token", tb.APIKey)
|
||||
query.Add("file_id", file.Id)
|
||||
url += "?" + query.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := tb.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var data DownloadLinksResponse
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return err
|
||||
}
|
||||
if data.Data == nil {
|
||||
return fmt.Errorf("error getting download links")
|
||||
}
|
||||
idx := 0
|
||||
link := *data.Data
|
||||
|
||||
dl := torrent.DownloadLinks{
|
||||
Link: link,
|
||||
Filename: t.Files[idx].Name,
|
||||
DownloadLink: link,
|
||||
}
|
||||
downloadLinks[file.Id] = dl
|
||||
}
|
||||
t.DownloadLinks = downloadLinks
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadLink(t *torrent.Torrent, file *torrent.File) *torrent.DownloadLinks {
|
||||
url := fmt.Sprintf("%s/api/torrents/requestdl/", tb.Host)
|
||||
query := gourl.Values{}
|
||||
query.Add("torrent_id", t.Id)
|
||||
query.Add("token", tb.APIKey)
|
||||
query.Add("file_id", file.Id)
|
||||
url += "?" + query.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := tb.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var data DownloadLinksResponse
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return nil
|
||||
}
|
||||
if data.Data == nil {
|
||||
return nil
|
||||
}
|
||||
link := *data.Data
|
||||
return &torrent.DownloadLinks{
|
||||
Link: file.Link,
|
||||
Filename: file.Name,
|
||||
DownloadLink: link,
|
||||
}
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadingStatus() []string {
|
||||
return []string{"downloading"}
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetCheckCached() bool {
|
||||
return tb.CheckCached
|
||||
}
|
||||
|
||||
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{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
}
|
||||
client := request.NewRLHTTPClient(rl, headers)
|
||||
return &Torbox{
|
||||
Name: "torbox",
|
||||
Host: dc.Host,
|
||||
APIKey: dc.APIKey,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
cache: cache,
|
||||
MountPath: dc.Folder,
|
||||
logger: logger.NewLogger(dc.Name, config.GetConfig().LogLevel, os.Stdout),
|
||||
CheckCached: dc.CheckCached,
|
||||
}
|
||||
}
|
||||
75
pkg/debrid/torbox/types.go
Normal file
75
pkg/debrid/torbox/types.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package torbox
|
||||
|
||||
import "time"
|
||||
|
||||
type APIResponse[T any] struct {
|
||||
Success bool `json:"success"`
|
||||
Error any `json:"error"`
|
||||
Detail string `json:"detail"`
|
||||
Data *T `json:"data"` // Use pointer to allow nil
|
||||
}
|
||||
|
||||
type AvailableResponse APIResponse[map[string]struct {
|
||||
Name string `json:"name"`
|
||||
Size int `json:"size"`
|
||||
Hash string `json:"hash"`
|
||||
}]
|
||||
|
||||
type AddMagnetResponse APIResponse[struct {
|
||||
Id int `json:"torrent_id"`
|
||||
Hash string `json:"hash"`
|
||||
}]
|
||||
|
||||
type torboxInfo struct {
|
||||
Id int `json:"id"`
|
||||
AuthId string `json:"auth_id"`
|
||||
Server int `json:"server"`
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
Magnet interface{} `json:"magnet"`
|
||||
Size int64 `json:"size"`
|
||||
Active bool `json:"active"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DownloadState string `json:"download_state"`
|
||||
Seeds int `json:"seeds"`
|
||||
Peers int `json:"peers"`
|
||||
Ratio float64 `json:"ratio"`
|
||||
Progress float64 `json:"progress"`
|
||||
DownloadSpeed int64 `json:"download_speed"`
|
||||
UploadSpeed int `json:"upload_speed"`
|
||||
Eta int `json:"eta"`
|
||||
TorrentFile bool `json:"torrent_file"`
|
||||
ExpiresAt interface{} `json:"expires_at"`
|
||||
DownloadPresent bool `json:"download_present"`
|
||||
Files []struct {
|
||||
Id int `json:"id"`
|
||||
Md5 interface{} `json:"md5"`
|
||||
Hash string `json:"hash"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Zipped bool `json:"zipped"`
|
||||
S3Path string `json:"s3_path"`
|
||||
Infected bool `json:"infected"`
|
||||
Mimetype string `json:"mimetype"`
|
||||
ShortName string `json:"short_name"`
|
||||
AbsolutePath string `json:"absolute_path"`
|
||||
} `json:"files"`
|
||||
DownloadPath string `json:"download_path"`
|
||||
InactiveCheck int `json:"inactive_check"`
|
||||
Availability int `json:"availability"`
|
||||
DownloadFinished bool `json:"download_finished"`
|
||||
Tracker interface{} `json:"tracker"`
|
||||
TotalUploaded int `json:"total_uploaded"`
|
||||
TotalDownloaded int `json:"total_downloaded"`
|
||||
Cached bool `json:"cached"`
|
||||
Owner string `json:"owner"`
|
||||
SeedTorrent bool `json:"seed_torrent"`
|
||||
AllowZipped bool `json:"allow_zipped"`
|
||||
LongTermSeeding bool `json:"long_term_seeding"`
|
||||
TrackerMessage interface{} `json:"tracker_message"`
|
||||
}
|
||||
|
||||
type InfoResponse APIResponse[torboxInfo]
|
||||
|
||||
type DownloadLinksResponse APIResponse[string]
|
||||
@@ -1,103 +0,0 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"goBlack/common"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Arr struct {
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
Host string `json:"host"`
|
||||
}
|
||||
|
||||
type ArrHistorySchema struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
SortKey string `json:"sortKey"`
|
||||
SortDirection string `json:"sortDirection"`
|
||||
TotalRecords int `json:"totalRecords"`
|
||||
Records []struct {
|
||||
ID int `json:"id"`
|
||||
DownloadID string `json:"downloadId"`
|
||||
} `json:"records"`
|
||||
}
|
||||
|
||||
type Torrent struct {
|
||||
Id string `json:"id"`
|
||||
InfoHash string `json:"info_hash"`
|
||||
Name string `json:"name"`
|
||||
Folder string `json:"folder"`
|
||||
Filename string `json:"filename"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Size int64 `json:"size"`
|
||||
Bytes int64 `json:"bytes"` // Size of only the files that are downloaded
|
||||
Magnet *common.Magnet `json:"magnet"`
|
||||
Files []TorrentFile `json:"files"`
|
||||
Status string `json:"status"`
|
||||
Progress float64 `json:"progress"`
|
||||
Speed int64 `json:"speed"`
|
||||
Seeders int `json:"seeders"`
|
||||
Links []string `json:"links"`
|
||||
DownloadLinks []TorrentDownloadLinks `json:"download_links"`
|
||||
|
||||
Debrid *Debrid
|
||||
Arr *Arr
|
||||
}
|
||||
|
||||
type TorrentDownloadLinks struct {
|
||||
Filename string `json:"filename"`
|
||||
Link string `json:"link"`
|
||||
DownloadLink string `json:"download_link"`
|
||||
}
|
||||
|
||||
func (t *Torrent) GetSymlinkFolder(parent string) string {
|
||||
return filepath.Join(parent, t.Arr.Name, t.Folder)
|
||||
}
|
||||
|
||||
func (t *Torrent) GetMountFolder(rClonePath string) string {
|
||||
pathWithNoExt := common.RemoveExtension(t.OriginalFilename)
|
||||
if common.FileReady(filepath.Join(rClonePath, t.OriginalFilename)) {
|
||||
return t.OriginalFilename
|
||||
} else if common.FileReady(filepath.Join(rClonePath, t.Filename)) {
|
||||
return t.Filename
|
||||
} else if common.FileReady(filepath.Join(rClonePath, pathWithNoExt)) {
|
||||
return pathWithNoExt
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
type TorrentFile struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func getEventId(eventType string) int {
|
||||
switch eventType {
|
||||
case "grabbed":
|
||||
return 1
|
||||
case "seriesFolderDownloaded":
|
||||
return 2
|
||||
case "DownloadFolderImported":
|
||||
return 3
|
||||
case "DownloadFailed":
|
||||
return 4
|
||||
case "DownloadIgnored":
|
||||
return 7
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Torrent) Cleanup(remove bool) {
|
||||
if remove {
|
||||
err := os.Remove(t.Filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
122
pkg/debrid/torrent/torrent.go
Normal file
122
pkg/debrid/torrent/torrent.go
Normal file
@@ -0,0 +1,122 @@
|
||||
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"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Torrent struct {
|
||||
Id string `json:"id"`
|
||||
InfoHash string `json:"info_hash"`
|
||||
Name string `json:"name"`
|
||||
Folder string `json:"folder"`
|
||||
Filename string `json:"filename"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Size int64 `json:"size"`
|
||||
Bytes int64 `json:"bytes"` // Size of only the files that are downloaded
|
||||
Magnet *utils.Magnet `json:"magnet"`
|
||||
Files []File `json:"files"`
|
||||
Status string `json:"status"`
|
||||
Added string `json:"added"`
|
||||
Progress float64 `json:"progress"`
|
||||
Speed int64 `json:"speed"`
|
||||
Seeders int `json:"seeders"`
|
||||
Links []string `json:"links"`
|
||||
DownloadLinks map[string]DownloadLinks `json:"download_links"`
|
||||
MountPath string `json:"mount_path"`
|
||||
|
||||
Debrid string `json:"debrid"`
|
||||
|
||||
Arr *arr.Arr `json:"arr"`
|
||||
Mu sync.Mutex `json:"-"`
|
||||
SizeDownloaded int64 `json:"-"` // This is used for local download
|
||||
DownloadUncached bool `json:"-"`
|
||||
}
|
||||
|
||||
type DownloadLinks struct {
|
||||
Filename string `json:"filename"`
|
||||
Link string `json:"link"`
|
||||
DownloadLink string `json:"download_link"`
|
||||
}
|
||||
|
||||
func (t *Torrent) GetSymlinkFolder(parent string) string {
|
||||
return filepath.Join(parent, t.Arr.Name, t.Folder)
|
||||
}
|
||||
|
||||
func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
|
||||
_log := logger.GetDefaultLogger()
|
||||
possiblePaths := []string{
|
||||
t.OriginalFilename,
|
||||
t.Filename,
|
||||
utils.RemoveExtension(t.OriginalFilename),
|
||||
}
|
||||
|
||||
for _, path := range possiblePaths {
|
||||
_p := filepath.Join(rClonePath, path)
|
||||
_log.Trace().Msgf("Checking path: %s", _p)
|
||||
_, err := os.Stat(_p)
|
||||
if !os.IsNotExist(err) {
|
||||
return path, nil
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("no path found")
|
||||
}
|
||||
|
||||
type File struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Path string `json:"path"`
|
||||
Link string `json:"link"`
|
||||
}
|
||||
|
||||
func (t *Torrent) Cleanup(remove bool) {
|
||||
if remove {
|
||||
err := os.Remove(t.Filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Torrent) GetFile(id string) *File {
|
||||
for _, f := range t.Files {
|
||||
if f.Id == id {
|
||||
return &f
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func GetLocalCache(infohashes []string, cache *cache.Cache) ([]string, map[string]bool) {
|
||||
result := make(map[string]bool)
|
||||
hashes := make([]string, 0)
|
||||
|
||||
if len(infohashes) == 0 {
|
||||
return hashes, result
|
||||
}
|
||||
if len(infohashes) == 1 {
|
||||
if cache.Exists(infohashes[0]) {
|
||||
return hashes, map[string]bool{infohashes[0]: true}
|
||||
}
|
||||
return infohashes, result
|
||||
}
|
||||
|
||||
cachedHashes := cache.GetMultiple(infohashes)
|
||||
for _, h := range infohashes {
|
||||
_, exists := cachedHashes[h]
|
||||
if !exists {
|
||||
hashes = append(hashes, h)
|
||||
} else {
|
||||
result[h] = true
|
||||
}
|
||||
}
|
||||
|
||||
return infohashes, result
|
||||
}
|
||||
@@ -3,15 +3,19 @@ package proxy
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/elazarl/goproxy"
|
||||
"github.com/elazarl/goproxy/ext/auth"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||
"github.com/valyala/fastjson"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
@@ -72,24 +76,19 @@ type Proxy struct {
|
||||
username string
|
||||
password string
|
||||
cachedOnly bool
|
||||
debrid debrid.Service
|
||||
cache *common.Cache
|
||||
logger *log.Logger
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func NewProxy(config common.Config, deb debrid.Service, cache *common.Cache) *Proxy {
|
||||
cfg := config.Proxy
|
||||
func NewProxy() *Proxy {
|
||||
cfg := config.GetConfig().Proxy
|
||||
port := cmp.Or(os.Getenv("PORT"), cfg.Port, "8181")
|
||||
return &Proxy{
|
||||
port: port,
|
||||
enabled: cfg.Enabled,
|
||||
debug: cfg.Debug,
|
||||
username: cfg.Username,
|
||||
password: cfg.Password,
|
||||
cachedOnly: *cfg.CachedOnly,
|
||||
debrid: deb,
|
||||
cache: cache,
|
||||
logger: common.NewLogger("Proxy", os.Stdout),
|
||||
cachedOnly: cfg.CachedOnly,
|
||||
logger: logger.NewLogger("proxy", cfg.LogLevel, os.Stdout),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +182,7 @@ func (item Item) getHash() string {
|
||||
}
|
||||
|
||||
if strings.Contains(item.GUID, "magnet:?") {
|
||||
magnet, err := common.GetMagnetInfo(item.GUID)
|
||||
magnet, err := utils.GetMagnetInfo(item.GUID)
|
||||
if err == nil && magnet != nil && magnet.InfoHash != "" {
|
||||
return magnet.InfoHash
|
||||
}
|
||||
@@ -197,22 +196,22 @@ func (item Item) getHash() string {
|
||||
}
|
||||
|
||||
if strings.Contains(magnetLink, "magnet:?") {
|
||||
magnet, err := common.GetMagnetInfo(magnetLink)
|
||||
magnet, err := utils.GetMagnetInfo(magnetLink)
|
||||
if err == nil && magnet != nil && magnet.InfoHash != "" {
|
||||
return magnet.InfoHash
|
||||
}
|
||||
}
|
||||
|
||||
//Check Description for infohash
|
||||
hash := common.ExtractInfoHash(item.Description)
|
||||
hash := utils.ExtractInfoHash(item.Description)
|
||||
if hash == "" {
|
||||
// Check Title for infohash
|
||||
hash = common.ExtractInfoHash(item.Comments)
|
||||
hash = utils.ExtractInfoHash(item.Comments)
|
||||
}
|
||||
infohash = hash
|
||||
if infohash == "" {
|
||||
if strings.Contains(magnetLink, "http") {
|
||||
h, _ := common.GetInfohashFromURL(magnetLink)
|
||||
h, _ := utils.GetInfohashFromURL(magnetLink)
|
||||
if h != "" {
|
||||
infohash = h
|
||||
}
|
||||
@@ -227,9 +226,11 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
return resp
|
||||
}
|
||||
|
||||
svc := service.GetService()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
p.logger.Println("Error reading response body:", err)
|
||||
p.logger.Info().Msgf("Error reading response body: %v", err)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
@@ -241,7 +242,7 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
var rss RSS
|
||||
err = xml.Unmarshal(body, &rss)
|
||||
if err != nil {
|
||||
p.logger.Printf("Error unmarshalling XML: %v", err)
|
||||
p.logger.Info().Msgf("Error unmarshalling XML: %v", err)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
@@ -261,7 +262,7 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
hashes = append(hashes, hash)
|
||||
}
|
||||
}
|
||||
availableHashesMap := p.debrid.IsAvailable(hashes)
|
||||
availableHashesMap := svc.Debrid.Get().IsAvailable(hashes)
|
||||
newItems := make([]Item, 0, len(rss.Channel.Items))
|
||||
|
||||
if len(hashes) > 0 {
|
||||
@@ -279,10 +280,10 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
}
|
||||
|
||||
if len(newItems) > 0 {
|
||||
p.logger.Printf("[%s Report]: %d/%d items are cached || Found %d infohash", indexer, len(newItems), len(rss.Channel.Items), len(hashes))
|
||||
p.logger.Info().Msgf("[%s Report]: %d/%d items are cached || Found %d infohash", indexer, len(newItems), len(rss.Channel.Items), len(hashes))
|
||||
} else {
|
||||
// This will prevent the indexer from being disabled by the arr
|
||||
p.logger.Printf("[%s Report]: No Items are cached; Return only first item with [UnCached]", indexer)
|
||||
p.logger.Info().Msgf("[%s Report]: No Items are cached; Return only first item with [UnCached]", indexer)
|
||||
item := rss.Channel.Items[0]
|
||||
item.Title = fmt.Sprintf("%s [UnCached]", item.Title)
|
||||
newItems = append(newItems, item)
|
||||
@@ -291,7 +292,7 @@ func (p *Proxy) ProcessXMLResponse(resp *http.Response) *http.Response {
|
||||
rss.Channel.Items = newItems
|
||||
modifiedBody, err := xml.MarshalIndent(rss, "", " ")
|
||||
if err != nil {
|
||||
p.logger.Printf("Error marshalling XML: %v", err)
|
||||
p.logger.Info().Msgf("Error marshalling XML: %v", err)
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
@@ -308,7 +309,7 @@ func UrlMatches(re *regexp.Regexp) goproxy.ReqConditionFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) Start() {
|
||||
func (p *Proxy) Start(ctx context.Context) error {
|
||||
username, password := p.username, p.password
|
||||
proxy := goproxy.NewProxyHttpServer()
|
||||
if username != "" || password != "" {
|
||||
@@ -328,6 +329,17 @@ func (p *Proxy) Start() {
|
||||
|
||||
proxy.Verbose = p.debug
|
||||
portFmt := fmt.Sprintf(":%s", p.port)
|
||||
p.logger.Printf("[*] Starting proxy server on %s\n", portFmt)
|
||||
p.logger.Fatal(http.ListenAndServe(fmt.Sprintf("%s", portFmt), proxy))
|
||||
srv := &http.Server{
|
||||
Addr: portFmt,
|
||||
Handler: proxy,
|
||||
}
|
||||
p.logger.Info().Msgf("Starting proxy server on %s", portFmt)
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
p.logger.Info().Msgf("Error starting proxy server: %v", err)
|
||||
}
|
||||
}()
|
||||
<-ctx.Done()
|
||||
p.logger.Info().Msg("Shutting down gracefully...")
|
||||
return srv.Shutdown(context.Background())
|
||||
}
|
||||
|
||||
103
pkg/qbit/arr.go
103
pkg/qbit/arr.go
@@ -1,103 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (q *QBit) RefreshArr(arr *debrid.Arr) {
|
||||
if arr.Token == "" || arr.Host == "" {
|
||||
return
|
||||
}
|
||||
url, err := common.JoinURL(arr.Host, "api/v3/command")
|
||||
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
payload := map[string]string{"name": "RefreshMonitoredDownloads"}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonPayload))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Api-Key", arr.Token)
|
||||
|
||||
resp, reqErr := client.Do(req)
|
||||
if reqErr == nil {
|
||||
statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'
|
||||
if statusOk {
|
||||
if q.debug {
|
||||
q.logger.Printf("Refreshed monitored downloads for %s", cmp.Or(arr.Name, arr.Host))
|
||||
}
|
||||
}
|
||||
}
|
||||
if reqErr != nil {
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) GetArrHistory(arr *debrid.Arr, downloadId, eventType string) *debrid.ArrHistorySchema {
|
||||
query := gourl.Values{}
|
||||
if downloadId != "" {
|
||||
query.Add("downloadId", downloadId)
|
||||
}
|
||||
query.Add("eventType", eventType)
|
||||
query.Add("pageSize", "100")
|
||||
url, _ := common.JoinURL(arr.Host, "history")
|
||||
url += "?" + query.Encode()
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var data *debrid.ArrHistorySchema
|
||||
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
|
||||
}
|
||||
|
||||
func (q *QBit) MarkArrAsFailed(torrent *Torrent, arr *debrid.Arr) error {
|
||||
downloadId := strings.ToUpper(torrent.Hash)
|
||||
history := q.GetArrHistory(arr, downloadId, "grabbed")
|
||||
if history == nil {
|
||||
return nil
|
||||
}
|
||||
torrentId := 0
|
||||
for _, record := range history.Records {
|
||||
if strings.EqualFold(record.DownloadID, downloadId) {
|
||||
torrentId = record.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if torrentId != 0 {
|
||||
url, err := common.JoinURL(arr.Host, "history/failed/", strconv.Itoa(torrentId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := &http.Client{}
|
||||
_, err = client.Do(req)
|
||||
if err == nil {
|
||||
q.logger.Printf("Marked torrent: %s as failed", torrent.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,134 +1,258 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"goBlack/pkg/qbit/downloaders"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"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"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (q *QBit) processManualFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr *debrid.Arr) {
|
||||
q.logger.Printf("Downloading %d files...", len(debridTorrent.DownloadLinks))
|
||||
torrentPath := common.RemoveExtension(debridTorrent.OriginalFilename)
|
||||
parent := common.RemoveInvalidChars(filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentPath))
|
||||
err := os.MkdirAll(parent, os.ModePerm)
|
||||
func Download(client *grab.Client, url, filename string, progressCallback func(int64, int64)) error {
|
||||
req, err := grab.NewRequest(filename, url)
|
||||
if err != nil {
|
||||
q.logger.Printf("Failed to create directory: %s\n", parent)
|
||||
q.MarkAsFailed(torrent)
|
||||
return
|
||||
return err
|
||||
}
|
||||
torrent.TorrentPath = torrentPath
|
||||
q.downloadFiles(debridTorrent, parent)
|
||||
q.UpdateTorrent(torrent, debridTorrent)
|
||||
q.RefreshArr(arr)
|
||||
resp := client.Do(req)
|
||||
|
||||
t := time.NewTicker(time.Second)
|
||||
defer t.Stop()
|
||||
|
||||
var lastReported int64
|
||||
Loop:
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
current := resp.BytesComplete()
|
||||
speed := int64(resp.BytesPerSecond())
|
||||
if current != lastReported {
|
||||
if progressCallback != nil {
|
||||
progressCallback(current-lastReported, speed)
|
||||
}
|
||||
lastReported = current
|
||||
}
|
||||
case <-resp.Done:
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
|
||||
// Report final bytes
|
||||
if progressCallback != nil {
|
||||
progressCallback(resp.BytesComplete()-lastReported, 0)
|
||||
}
|
||||
|
||||
return resp.Err()
|
||||
}
|
||||
|
||||
func (q *QBit) downloadFiles(debridTorrent *debrid.Torrent, parent string) {
|
||||
func (q *QBit) ProcessManualFile(torrent *Torrent) (string, error) {
|
||||
debridTorrent := torrent.DebridTorrent
|
||||
q.logger.Info().Msgf("Downloading %d files...", len(debridTorrent.DownloadLinks))
|
||||
torrentPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, utils.RemoveExtension(debridTorrent.OriginalFilename))
|
||||
torrentPath = utils.RemoveInvalidChars(torrentPath)
|
||||
err := os.MkdirAll(torrentPath, os.ModePerm)
|
||||
if err != nil {
|
||||
// add previous error to the error and return
|
||||
return "", fmt.Errorf("failed to create directory: %s: %v", torrentPath, err)
|
||||
}
|
||||
q.downloadFiles(torrent, torrentPath)
|
||||
return torrentPath, nil
|
||||
}
|
||||
|
||||
func (q *QBit) downloadFiles(torrent *Torrent, parent string) {
|
||||
debridTorrent := torrent.DebridTorrent
|
||||
var wg sync.WaitGroup
|
||||
semaphore := make(chan struct{}, 5)
|
||||
client := downloaders.GetHTTPClient()
|
||||
totalSize := int64(0)
|
||||
for _, file := range debridTorrent.Files {
|
||||
totalSize += file.Size
|
||||
}
|
||||
debridTorrent.Mu.Lock()
|
||||
debridTorrent.SizeDownloaded = 0 // Reset downloaded bytes
|
||||
debridTorrent.Progress = 0 // Reset progress
|
||||
debridTorrent.Mu.Unlock()
|
||||
progressCallback := func(downloaded int64, speed int64) {
|
||||
debridTorrent.Mu.Lock()
|
||||
defer debridTorrent.Mu.Unlock()
|
||||
torrent.Mu.Lock()
|
||||
defer torrent.Mu.Unlock()
|
||||
|
||||
// Update total downloaded bytes
|
||||
debridTorrent.SizeDownloaded += downloaded
|
||||
debridTorrent.Speed = speed
|
||||
|
||||
// Calculate overall progress
|
||||
if totalSize > 0 {
|
||||
debridTorrent.Progress = float64(debridTorrent.SizeDownloaded) / float64(totalSize) * 100
|
||||
}
|
||||
q.UpdateTorrentMin(torrent, debridTorrent)
|
||||
}
|
||||
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
client := &grab.Client{
|
||||
UserAgent: "qBitTorrent",
|
||||
HTTPClient: &http.Client{
|
||||
Transport: tr,
|
||||
},
|
||||
}
|
||||
for _, link := range debridTorrent.DownloadLinks {
|
||||
if link.DownloadLink == "" {
|
||||
q.logger.Printf("No download link found for %s\n", link.Filename)
|
||||
q.logger.Info().Msgf("No download link found for %s", link.Filename)
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
semaphore <- struct{}{}
|
||||
go func(link debrid.TorrentDownloadLinks) {
|
||||
go func(link debrid.DownloadLinks) {
|
||||
defer wg.Done()
|
||||
defer func() { <-semaphore }()
|
||||
err := downloaders.NormalHTTP(client, link.DownloadLink, filepath.Join(parent, link.Filename))
|
||||
filename := link.Filename
|
||||
|
||||
err := Download(
|
||||
client,
|
||||
link.DownloadLink,
|
||||
filepath.Join(parent, filename),
|
||||
progressCallback,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
q.logger.Printf("Error downloading %s: %v\n", link.DownloadLink, err)
|
||||
q.logger.Error().Msgf("Failed to download %s: %v", filename, err)
|
||||
} else {
|
||||
q.logger.Printf("Downloaded %s successfully\n", link.DownloadLink)
|
||||
q.logger.Info().Msgf("Downloaded %s", filename)
|
||||
}
|
||||
}(link)
|
||||
}
|
||||
wg.Wait()
|
||||
q.logger.Printf("Downloaded all files for %s\n", debridTorrent.Name)
|
||||
q.logger.Info().Msgf("Downloaded all files for %s", debridTorrent.Name)
|
||||
}
|
||||
|
||||
func (q *QBit) processSymlink(torrent *Torrent, debridTorrent *debrid.Torrent, arr *debrid.Arr) {
|
||||
var wg sync.WaitGroup
|
||||
func (q *QBit) ProcessSymlink(torrent *Torrent) (string, error) {
|
||||
debridTorrent := torrent.DebridTorrent
|
||||
files := debridTorrent.Files
|
||||
ready := make(chan debrid.TorrentFile, len(files))
|
||||
|
||||
q.logger.Printf("Checking %d files...", len(files))
|
||||
rCloneBase := q.debrid.GetMountPath()
|
||||
torrentPath, err := q.getTorrentPath(rCloneBase, debridTorrent) // /MyTVShow/
|
||||
if err != nil {
|
||||
q.MarkAsFailed(torrent)
|
||||
q.logger.Printf("Error: %v", err)
|
||||
return
|
||||
if len(files) == 0 {
|
||||
return "", fmt.Errorf("no video files found")
|
||||
}
|
||||
|
||||
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentPath) // /mnt/symlinks/{category}/MyTVShow/
|
||||
q.logger.Info().Msgf("Checking symlinks for %d files...", len(files))
|
||||
rCloneBase := debridTorrent.MountPath
|
||||
torrentPath, err := q.getTorrentPath(rCloneBase, debridTorrent) // /MyTVShow/
|
||||
// This returns filename.ext for alldebrid instead of the parent folder filename/
|
||||
torrentFolder := torrentPath
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get torrent path: %v", err)
|
||||
}
|
||||
// Check if the torrent path is a file
|
||||
torrentRclonePath := filepath.Join(rCloneBase, torrentPath) // leave it as is
|
||||
if debridTorrent.Debrid == "alldebrid" && utils.IsMediaFile(torrentPath) {
|
||||
// Alldebrid hotfix for single file torrents
|
||||
torrentFolder = utils.RemoveExtension(torrentFolder)
|
||||
torrentRclonePath = rCloneBase // /mnt/rclone/magnets/ // Remove the filename since it's in the root folder
|
||||
}
|
||||
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentFolder) // /mnt/symlinks/{category}/MyTVShow/
|
||||
err = os.MkdirAll(torrentSymlinkPath, os.ModePerm)
|
||||
if err != nil {
|
||||
q.logger.Printf("Failed to create directory: %s\n", torrentSymlinkPath)
|
||||
q.MarkAsFailed(torrent)
|
||||
return
|
||||
return "", fmt.Errorf("failed to create directory: %s: %v", torrentSymlinkPath, err)
|
||||
}
|
||||
torrentRclonePath := filepath.Join(rCloneBase, torrentPath)
|
||||
|
||||
pending := make(map[string]debrid.File)
|
||||
for _, file := range files {
|
||||
wg.Add(1)
|
||||
go checkFileLoop(&wg, torrentRclonePath, file, ready)
|
||||
pending[file.Path] = file
|
||||
}
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(ready)
|
||||
}()
|
||||
|
||||
for f := range ready {
|
||||
q.logger.Println("File is ready:", f.Path)
|
||||
q.createSymLink(torrentSymlinkPath, torrentRclonePath, f)
|
||||
for len(pending) > 0 {
|
||||
<-ticker.C
|
||||
for path, file := range pending {
|
||||
fullFilePath := filepath.Join(torrentRclonePath, file.Path)
|
||||
if _, err := os.Stat(fullFilePath); !os.IsNotExist(err) {
|
||||
q.logger.Info().Msgf("File is ready: %s", file.Path)
|
||||
q.createSymLink(torrentSymlinkPath, torrentRclonePath, file)
|
||||
delete(pending, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Update the torrent when all files are ready
|
||||
torrent.TorrentPath = filepath.Base(torrentPath) // Quite important
|
||||
q.UpdateTorrent(torrent, debridTorrent)
|
||||
q.RefreshArr(arr)
|
||||
return torrentSymlinkPath, nil
|
||||
}
|
||||
|
||||
func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debrid.Torrent) (string, error) {
|
||||
pathChan := make(chan string)
|
||||
errChan := make(chan error)
|
||||
|
||||
go func() {
|
||||
for {
|
||||
torrentPath := debridTorrent.GetMountFolder(rclonePath)
|
||||
if torrentPath != "" {
|
||||
pathChan <- torrentPath
|
||||
return
|
||||
}
|
||||
time.Sleep(time.Second)
|
||||
for {
|
||||
torrentPath, err := debridTorrent.GetMountFolder(rclonePath)
|
||||
if err == nil {
|
||||
q.logger.Debug().Msgf("Found torrent path: %s", torrentPath)
|
||||
return torrentPath, err
|
||||
}
|
||||
}()
|
||||
|
||||
select {
|
||||
case path := <-pathChan:
|
||||
return path, nil
|
||||
case err := <-errChan:
|
||||
return "", err
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) createSymLink(path string, torrentMountPath string, file debrid.TorrentFile) {
|
||||
func (q *QBit) createSymLink(path string, torrentMountPath string, file debrid.File) {
|
||||
|
||||
// Combine the directory and filename to form a full path
|
||||
fullPath := filepath.Join(path, file.Name) // /mnt/symlinks/{category}/MyTVShow/MyTVShow.S01E01.720p.mkv
|
||||
// Create a symbolic link if file doesn't exist
|
||||
torrentFilePath := filepath.Join(torrentMountPath, file.Name) // debridFolder/MyTVShow/MyTVShow.S01E01.720p.mkv
|
||||
torrentFilePath := filepath.Join(torrentMountPath, file.Path) // debridFolder/MyTVShow/MyTVShow.S01E01.720p.mkv
|
||||
err := os.Symlink(torrentFilePath, fullPath)
|
||||
if err != nil {
|
||||
q.logger.Printf("Failed to create symlink: %s\n", fullPath)
|
||||
// It's okay if the symlink already exists
|
||||
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fullPath, err)
|
||||
}
|
||||
// Check if the file exists
|
||||
if !common.FileReady(fullPath) {
|
||||
q.logger.Printf("Symlink not ready: %s\n", fullPath)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
package downloaders
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"github.com/valyala/fasthttp"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func GetFastHTTPClient() *fasthttp.Client {
|
||||
return &fasthttp.Client{
|
||||
TLSConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
StreamResponseBody: true,
|
||||
}
|
||||
}
|
||||
|
||||
func NormalFastHTTP(client *fasthttp.Client, url, filename string) error {
|
||||
req := fasthttp.AcquireRequest()
|
||||
resp := fasthttp.AcquireResponse()
|
||||
defer fasthttp.ReleaseRequest(req)
|
||||
defer fasthttp.ReleaseResponse(resp)
|
||||
|
||||
req.SetRequestURI(url)
|
||||
req.Header.SetMethod(fasthttp.MethodGet)
|
||||
|
||||
if err := client.Do(req, resp); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check the response status code
|
||||
if resp.StatusCode() != fasthttp.StatusOK {
|
||||
return fmt.Errorf("unexpected status code: %d", resp.StatusCode())
|
||||
}
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
fmt.Println("Error closing file:", err)
|
||||
return
|
||||
}
|
||||
}(file)
|
||||
bodyStream := resp.BodyStream()
|
||||
if bodyStream == nil {
|
||||
// Write to memory and then to file
|
||||
_, err := file.Write(resp.Body())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := io.Copy(file, bodyStream); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
package downloaders
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"github.com/cavaliergopher/grab/v3"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
func GetGrabClient() *grab.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
}
|
||||
return &grab.Client{
|
||||
UserAgent: "qBitTorrent",
|
||||
HTTPClient: &http.Client{
|
||||
Transport: tr,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func NormalGrab(client *grab.Client, url, filename string) error {
|
||||
req, err := grab.NewRequest(filename, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp := client.Do(req)
|
||||
if err := resp.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
t := time.NewTicker(2 * time.Second)
|
||||
defer t.Stop()
|
||||
Loop:
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
fmt.Printf(" %s: transferred %d / %d bytes (%.2f%%)\n",
|
||||
resp.Filename,
|
||||
resp.BytesComplete(),
|
||||
resp.Size,
|
||||
100*resp.Progress())
|
||||
|
||||
case <-resp.Done:
|
||||
// download is complete
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
if err := resp.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package downloaders
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
func GetHTTPClient() *http.Client {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
return &http.Client{Transport: tr}
|
||||
}
|
||||
|
||||
func NormalHTTP(client *http.Client, url, filename string) error {
|
||||
file, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Send the HTTP GET request
|
||||
resp, err := client.Get(url)
|
||||
if err != nil {
|
||||
fmt.Println("Error downloading file:", err)
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Check server response
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("server returned non-200 status: %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
// Write the response body to file
|
||||
_, err = io.Copy(file, resp.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (q *QBit) AddRoutes(r chi.Router) http.Handler {
|
||||
r.Route("/api/v2", func(r chi.Router) {
|
||||
r.Post("/auth/login", q.handleLogin)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
//r.Use(q.authMiddleware)
|
||||
r.Use(q.authContext)
|
||||
r.Route("/torrents", func(r chi.Router) {
|
||||
r.Use(HashesCtx)
|
||||
r.Get("/info", q.handleTorrentsInfo)
|
||||
r.Post("/add", q.handleTorrentsAdd)
|
||||
r.Post("/delete", q.handleTorrentsDelete)
|
||||
r.Get("/categories", q.handleCategories)
|
||||
r.Post("/createCategory", q.handleCreateCategory)
|
||||
|
||||
r.Get("/pause", q.handleTorrentsPause)
|
||||
r.Get("/resume", q.handleTorrentsResume)
|
||||
r.Get("/recheck", q.handleTorrentRecheck)
|
||||
r.Get("/properties", q.handleTorrentProperties)
|
||||
})
|
||||
|
||||
r.Route("/app", func(r chi.Router) {
|
||||
r.Get("/version", q.handleVersion)
|
||||
r.Get("/webapiVersion", q.handleWebAPIVersion)
|
||||
r.Get("/preferences", q.handlePreferences)
|
||||
r.Get("/buildInfo", q.handleBuildInfo)
|
||||
r.Get("/shutdown", q.shutdown)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (q *QBit) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("v4.3.2"))
|
||||
}
|
||||
|
||||
func (q *QBit) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("2.7"))
|
||||
}
|
||||
|
||||
func (q *QBit) handlePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
preferences := NewAppPreferences()
|
||||
|
||||
preferences.WebUiUsername = q.Username
|
||||
preferences.SavePath = q.DownloadFolder
|
||||
preferences.TempPath = filepath.Join(q.DownloadFolder, "temp")
|
||||
|
||||
JSONResponse(w, preferences, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleBuildInfo(w http.ResponseWriter, r *http.Request) {
|
||||
res := BuildInfo{
|
||||
Bitness: 64,
|
||||
Boost: "1.75.0",
|
||||
Libtorrent: "1.2.11.0",
|
||||
Openssl: "1.1.1i",
|
||||
Qt: "5.15.2",
|
||||
Zlib: "1.2.11",
|
||||
}
|
||||
JSONResponse(w, res, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) shutdown(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("Ok."))
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (q *QBit) handleTorrentsInfo(w http.ResponseWriter, r *http.Request) {
|
||||
//log all url params
|
||||
ctx := r.Context()
|
||||
category := strings.Trim(r.URL.Query().Get("category"), "")
|
||||
filter := strings.Trim(r.URL.Query().Get("filter"), "")
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
torrents := q.storage.GetAll(category, filter, hashes)
|
||||
JSONResponse(w, torrents, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0]
|
||||
switch contentType {
|
||||
case "multipart/form-data":
|
||||
err := r.ParseMultipartForm(32 << 20) // 32MB max memory
|
||||
if err != nil {
|
||||
q.logger.Printf("Error parsing form: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
case "application/x-www-form-urlencoded":
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
q.logger.Printf("Error parsing form: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
|
||||
q.logger.Printf("isSymlink: %v\n", isSymlink)
|
||||
urls := r.FormValue("urls")
|
||||
category := r.FormValue("category")
|
||||
|
||||
var urlList []string
|
||||
if urls != "" {
|
||||
urlList = strings.Split(urls, "\n")
|
||||
}
|
||||
|
||||
ctx = context.WithValue(ctx, "isSymlink", isSymlink)
|
||||
|
||||
for _, url := range urlList {
|
||||
if err := q.AddMagnet(ctx, url, category); err != nil {
|
||||
q.logger.Printf("Error adding magnet: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if contentType == "multipart/form-data" {
|
||||
files := r.MultipartForm.File["torrents"]
|
||||
for _, fileHeader := range files {
|
||||
if err := q.AddTorrent(ctx, fileHeader, category); err != nil {
|
||||
q.logger.Printf("Error adding torrent: %v\n", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
if len(hashes) == 0 {
|
||||
http.Error(w, "No hashes provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
for _, hash := range hashes {
|
||||
q.storage.Delete(hash)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsPause(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.storage.Get(hash)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.PauseTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsResume(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.storage.Get(hash)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.ResumeTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.storage.Get(hash)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.RefreshTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||
var categories = map[string]TorrentCategory{}
|
||||
for _, cat := range q.Categories {
|
||||
path := filepath.Join(q.DownloadFolder, cat)
|
||||
categories[cat] = TorrentCategory{
|
||||
Name: cat,
|
||||
SavePath: path,
|
||||
}
|
||||
}
|
||||
JSONResponse(w, categories, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
name := r.Form.Get("category")
|
||||
if name == "" {
|
||||
http.Error(w, "No name provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
q.Categories = append(q.Categories, name)
|
||||
|
||||
JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
|
||||
hash := r.URL.Query().Get("hash")
|
||||
torrent := q.storage.Get(hash)
|
||||
properties := q.GetTorrentProperties(torrent)
|
||||
JSONResponse(w, properties, http.StatusOK)
|
||||
}
|
||||
402
pkg/qbit/http.go
Normal file
402
pkg/qbit/http.go
Normal file
@@ -0,0 +1,402 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func decodeAuthHeader(header string) (string, string, error) {
|
||||
encodedTokens := strings.Split(header, " ")
|
||||
if len(encodedTokens) != 2 {
|
||||
return "", "", nil
|
||||
}
|
||||
encodedToken := encodedTokens[1]
|
||||
|
||||
bytes, err := base64.StdEncoding.DecodeString(encodedToken)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
bearer := string(bytes)
|
||||
|
||||
colonIndex := strings.LastIndex(bearer, ":")
|
||||
host := bearer[:colonIndex]
|
||||
token := bearer[colonIndex+1:]
|
||||
|
||||
return host, token, nil
|
||||
}
|
||||
|
||||
func (q *QBit) CategoryContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
category := strings.Trim(r.URL.Query().Get("category"), "")
|
||||
if category == "" {
|
||||
// Get from form
|
||||
_ = r.ParseForm()
|
||||
category = r.Form.Get("category")
|
||||
if category == "" {
|
||||
// Get from multipart form
|
||||
_ = r.ParseMultipartForm(32 << 20)
|
||||
category = r.FormValue("category")
|
||||
}
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "category", strings.TrimSpace(category))
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, token, err := decodeAuthHeader(r.Header.Get("Authorization"))
|
||||
category := r.Context().Value("category").(string)
|
||||
svc := service.GetService()
|
||||
// Check if arr exists
|
||||
a := svc.Arr.Get(category)
|
||||
if a == nil {
|
||||
downloadUncached := false
|
||||
a = arr.New(category, "", "", false, false, &downloadUncached)
|
||||
}
|
||||
if err == nil {
|
||||
host = strings.TrimSpace(host)
|
||||
if host != "" {
|
||||
a.Host = host
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token != "" {
|
||||
a.Token = token
|
||||
}
|
||||
}
|
||||
|
||||
svc.Arr.AddOrUpdate(a)
|
||||
ctx := context.WithValue(r.Context(), "arr", a)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func HashesCtx(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_hashes := chi.URLParam(r, "hashes")
|
||||
var hashes []string
|
||||
if _hashes != "" {
|
||||
hashes = strings.Split(_hashes, "|")
|
||||
}
|
||||
if hashes == nil {
|
||||
// Get hashes from form
|
||||
_ = r.ParseForm()
|
||||
hashes = r.Form["hashes"]
|
||||
}
|
||||
for i, hash := range hashes {
|
||||
hashes[i] = strings.TrimSpace(hash)
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "hashes", hashes)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
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."))
|
||||
}
|
||||
|
||||
func (q *QBit) handleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("v4.3.2"))
|
||||
}
|
||||
|
||||
func (q *QBit) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("2.7"))
|
||||
}
|
||||
|
||||
func (q *QBit) handlePreferences(w http.ResponseWriter, r *http.Request) {
|
||||
preferences := NewAppPreferences()
|
||||
|
||||
preferences.WebUiUsername = q.Username
|
||||
preferences.SavePath = q.DownloadFolder
|
||||
preferences.TempPath = filepath.Join(q.DownloadFolder, "temp")
|
||||
|
||||
request.JSONResponse(w, preferences, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleBuildInfo(w http.ResponseWriter, r *http.Request) {
|
||||
res := BuildInfo{
|
||||
Bitness: 64,
|
||||
Boost: "1.75.0",
|
||||
Libtorrent: "1.2.11.0",
|
||||
Openssl: "1.1.1i",
|
||||
Qt: "5.15.2",
|
||||
Zlib: "1.2.11",
|
||||
}
|
||||
request.JSONResponse(w, res, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleShutdown(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsInfo(w http.ResponseWriter, r *http.Request) {
|
||||
//log all url params
|
||||
ctx := r.Context()
|
||||
category := ctx.Value("category").(string)
|
||||
filter := strings.Trim(r.URL.Query().Get("filter"), "")
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
torrents := q.Storage.GetAllSorted(category, filter, hashes, "added_on", false)
|
||||
request.JSONResponse(w, torrents, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
|
||||
// Parse form based on content type
|
||||
contentType := r.Header.Get("Content-Type")
|
||||
if strings.Contains(contentType, "multipart/form-data") {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
q.logger.Info().Msgf("Error parsing multipart form: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
q.logger.Info().Msgf("Error parsing form: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
http.Error(w, "Invalid content type", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
|
||||
category := r.FormValue("category")
|
||||
atleastOne := false
|
||||
ctx = context.WithValue(ctx, "isSymlink", isSymlink)
|
||||
|
||||
// Handle magnet URLs
|
||||
if urls := r.FormValue("urls"); urls != "" {
|
||||
var urlList []string
|
||||
for _, u := range strings.Split(urls, "\n") {
|
||||
urlList = append(urlList, strings.TrimSpace(u))
|
||||
}
|
||||
for _, url := range urlList {
|
||||
if err := q.AddMagnet(ctx, url, category); err != nil {
|
||||
q.logger.Info().Msgf("Error adding magnet: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
atleastOne = true
|
||||
}
|
||||
}
|
||||
|
||||
// Handle torrent files
|
||||
if r.MultipartForm != nil && r.MultipartForm.File != nil {
|
||||
if files := r.MultipartForm.File["torrents"]; len(files) > 0 {
|
||||
for _, fileHeader := range files {
|
||||
if err := q.AddTorrent(ctx, fileHeader, category); err != nil {
|
||||
q.logger.Info().Msgf("Error adding torrent: %v", err)
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
atleastOne = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !atleastOne {
|
||||
http.Error(w, "No valid URLs or torrents provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsDelete(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
if len(hashes) == 0 {
|
||||
http.Error(w, "No hashes provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
category := ctx.Value("category").(string)
|
||||
for _, hash := range hashes {
|
||||
q.Storage.Delete(hash, category)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsPause(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
category := ctx.Value("category").(string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.Storage.Get(hash, category)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.PauseTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentsResume(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
category := ctx.Value("category").(string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.Storage.Get(hash, category)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.ResumeTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentRecheck(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
category := ctx.Value("category").(string)
|
||||
for _, hash := range hashes {
|
||||
torrent := q.Storage.Get(hash, category)
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
go q.RefreshTorrent(torrent)
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleCategories(w http.ResponseWriter, r *http.Request) {
|
||||
var categories = map[string]TorrentCategory{}
|
||||
for _, cat := range q.Categories {
|
||||
path := filepath.Join(q.DownloadFolder, cat)
|
||||
categories[cat] = TorrentCategory{
|
||||
Name: cat,
|
||||
SavePath: path,
|
||||
}
|
||||
}
|
||||
request.JSONResponse(w, categories, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
name := r.Form.Get("category")
|
||||
if name == "" {
|
||||
http.Error(w, "No name provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
q.Categories = append(q.Categories, name)
|
||||
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hash := r.URL.Query().Get("hash")
|
||||
torrent := q.Storage.Get(hash, ctx.Value("category").(string))
|
||||
|
||||
properties := q.GetTorrentProperties(torrent)
|
||||
request.JSONResponse(w, properties, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleTorrentFiles(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
hash := r.URL.Query().Get("hash")
|
||||
torrent := q.Storage.Get(hash, ctx.Value("category").(string))
|
||||
if torrent == nil {
|
||||
return
|
||||
}
|
||||
files := q.GetTorrentFiles(torrent)
|
||||
request.JSONResponse(w, files, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleSetCategory(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
category := ctx.Value("category").(string)
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
torrents := q.Storage.GetAll("", "", hashes)
|
||||
for _, torrent := range torrents {
|
||||
torrent.Category = category
|
||||
q.Storage.AddOrUpdate(torrent)
|
||||
}
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleAddTorrentTags(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
tags := strings.Split(r.FormValue("tags"), ",")
|
||||
for i, tag := range tags {
|
||||
tags[i] = strings.TrimSpace(tag)
|
||||
}
|
||||
torrents := q.Storage.GetAll("", "", hashes)
|
||||
for _, t := range torrents {
|
||||
q.SetTorrentTags(t, tags)
|
||||
}
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleRemoveTorrentTags(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ctx := r.Context()
|
||||
hashes, _ := ctx.Value("hashes").([]string)
|
||||
tags := strings.Split(r.FormValue("tags"), ",")
|
||||
for i, tag := range tags {
|
||||
tags[i] = strings.TrimSpace(tag)
|
||||
}
|
||||
torrents := q.Storage.GetAll("", "", hashes)
|
||||
for _, torrent := range torrents {
|
||||
q.RemoveTorrentTags(torrent, tags)
|
||||
|
||||
}
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleGetTags(w http.ResponseWriter, r *http.Request) {
|
||||
request.JSONResponse(w, q.Tags, http.StatusOK)
|
||||
}
|
||||
|
||||
func (q *QBit) handleCreateTags(w http.ResponseWriter, r *http.Request) {
|
||||
err := r.ParseForm()
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tags := strings.Split(r.FormValue("tags"), ",")
|
||||
for i, tag := range tags {
|
||||
tags[i] = strings.TrimSpace(tag)
|
||||
}
|
||||
q.AddTags(tags)
|
||||
request.JSONResponse(w, nil, http.StatusOK)
|
||||
}
|
||||
92
pkg/qbit/import.go
Normal file
92
pkg/qbit/import.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
)
|
||||
|
||||
type ImportRequest struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
URI string `json:"uri"`
|
||||
Arr *arr.Arr `json:"arr"`
|
||||
IsSymlink bool `json:"isSymlink"`
|
||||
SeriesId int `json:"series"`
|
||||
Seasons []int `json:"seasons"`
|
||||
Episodes []string `json:"episodes"`
|
||||
DownloadUncached bool `json:"downloadUncached"`
|
||||
|
||||
Failed bool `json:"failed"`
|
||||
FailedAt time.Time `json:"failedAt"`
|
||||
Reason string `json:"reason"`
|
||||
Completed bool `json:"completed"`
|
||||
CompletedAt time.Time `json:"completedAt"`
|
||||
Async bool `json:"async"`
|
||||
}
|
||||
|
||||
type ManualImportResponseSchema struct {
|
||||
Priority string `json:"priority"`
|
||||
Status string `json:"status"`
|
||||
Result string `json:"result"`
|
||||
Queued time.Time `json:"queued"`
|
||||
Trigger string `json:"trigger"`
|
||||
SendUpdatesToClient bool `json:"sendUpdatesToClient"`
|
||||
UpdateScheduledTask bool `json:"updateScheduledTask"`
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
func NewImportRequest(uri string, arr *arr.Arr, isSymlink, downloadUncached bool) *ImportRequest {
|
||||
return &ImportRequest{
|
||||
ID: uuid.NewString(),
|
||||
URI: uri,
|
||||
Arr: arr,
|
||||
Failed: false,
|
||||
Completed: false,
|
||||
Async: false,
|
||||
IsSymlink: isSymlink,
|
||||
DownloadUncached: downloadUncached,
|
||||
}
|
||||
}
|
||||
|
||||
func (i *ImportRequest) Fail(reason string) {
|
||||
i.Failed = true
|
||||
i.FailedAt = time.Now()
|
||||
i.Reason = reason
|
||||
}
|
||||
|
||||
func (i *ImportRequest) Complete() {
|
||||
i.Completed = true
|
||||
i.CompletedAt = time.Now()
|
||||
}
|
||||
|
||||
func (i *ImportRequest) Process(q *QBit) (err error) {
|
||||
// Use this for now.
|
||||
// This sends the torrent to the arr
|
||||
svc := service.GetService()
|
||||
magnet, err := utils.GetMagnetFromUrl(i.URI)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing magnet link: %w", err)
|
||||
}
|
||||
torrent := CreateTorrentFromMagnet(magnet, i.Arr.Name, "manual")
|
||||
debridTorrent, err := debrid.ProcessTorrent(svc.Debrid, magnet, i.Arr, i.IsSymlink, i.DownloadUncached)
|
||||
if err != nil || debridTorrent == nil {
|
||||
if debridTorrent != nil {
|
||||
dbClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
|
||||
go dbClient.DeleteTorrent(debridTorrent)
|
||||
}
|
||||
if err == nil {
|
||||
err = fmt.Errorf("failed to process torrent")
|
||||
}
|
||||
return err
|
||||
}
|
||||
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||
q.Storage.AddOrUpdate(torrent)
|
||||
go q.ProcessFiles(torrent, debridTorrent, i.Arr, i.IsSymlink)
|
||||
return nil
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type WorkerType struct {
|
||||
ticker *time.Ticker
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
type Worker struct {
|
||||
types map[string]WorkerType
|
||||
}
|
||||
|
||||
type QBit struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Port string `json:"port"`
|
||||
DownloadFolder string `json:"download_folder"`
|
||||
Categories []string `json:"categories"`
|
||||
debrid debrid.Service
|
||||
cache *common.Cache
|
||||
storage *TorrentStorage
|
||||
debug bool
|
||||
logger *log.Logger
|
||||
arrs sync.Map // host:token (Used for refreshing in worker)
|
||||
RefreshInterval int
|
||||
}
|
||||
|
||||
func NewQBit(config *common.Config, deb debrid.Service, cache *common.Cache) *QBit {
|
||||
cfg := config.QBitTorrent
|
||||
storage := NewTorrentStorage("torrents.json")
|
||||
port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8182")
|
||||
refreshInterval := cmp.Or(cfg.RefreshInterval, 10)
|
||||
return &QBit{
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
Port: port,
|
||||
DownloadFolder: cfg.DownloadFolder,
|
||||
Categories: cfg.Categories,
|
||||
debrid: deb,
|
||||
cache: cache,
|
||||
debug: cfg.Debug,
|
||||
storage: storage,
|
||||
logger: common.NewLogger("QBit", os.Stdout),
|
||||
arrs: sync.Map{},
|
||||
RefreshInterval: refreshInterval,
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) Start() {
|
||||
|
||||
r := chi.NewRouter()
|
||||
if q.debug {
|
||||
r.Use(middleware.Logger)
|
||||
}
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
q.AddRoutes(r)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
go q.StartWorker(ctx)
|
||||
|
||||
q.logger.Printf("Starting QBit server on :%s", q.Port)
|
||||
port := fmt.Sprintf(":%s", q.Port)
|
||||
q.logger.Fatal(http.ListenAndServe(port, r))
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (q *QBit) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(user), []byte(q.Username)) != 1 || subtle.ConstantTimeCompare([]byte(pass), []byte(q.Password)) != 1 {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func DecodeAuthHeader(header string) (string, string, error) {
|
||||
encodedTokens := strings.Split(header, " ")
|
||||
if len(encodedTokens) != 2 {
|
||||
return "", "", nil
|
||||
}
|
||||
encodedToken := encodedTokens[1]
|
||||
|
||||
bytes, err := base64.StdEncoding.DecodeString(encodedToken)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
bearer := string(bytes)
|
||||
|
||||
colonIndex := strings.LastIndex(bearer, ":")
|
||||
host := bearer[:colonIndex]
|
||||
token := bearer[colonIndex+1:]
|
||||
|
||||
return host, token, nil
|
||||
}
|
||||
|
||||
func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host, token, err := DecodeAuthHeader(r.Header.Get("Authorization"))
|
||||
ctx := r.Context()
|
||||
if err == nil {
|
||||
ctx = context.WithValue(r.Context(), "host", host)
|
||||
ctx = context.WithValue(ctx, "token", token)
|
||||
q.arrs.Store(host, token)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func HashesCtx(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_hashes := chi.URLParam(r, "hashes")
|
||||
var hashes []string
|
||||
if _hashes != "" {
|
||||
hashes = strings.Split(_hashes, "|")
|
||||
}
|
||||
if hashes == nil {
|
||||
// Get hashes from form
|
||||
_ = r.ParseForm()
|
||||
hashes = r.Form["hashes"]
|
||||
}
|
||||
ctx := context.WithValue(r.Context(), "hashes", hashes)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
28
pkg/qbit/misc.go
Normal file
28
pkg/qbit/misc.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func CreateTorrentFromMagnet(magnet *utils.Magnet, category, source string) *Torrent {
|
||||
torrent := &Torrent{
|
||||
ID: uuid.NewString(),
|
||||
Hash: strings.ToLower(magnet.InfoHash),
|
||||
Name: magnet.Name,
|
||||
Size: magnet.Size,
|
||||
Category: category,
|
||||
Source: source,
|
||||
State: "downloading",
|
||||
MagnetUri: magnet.Link,
|
||||
|
||||
Tracker: "udp://tracker.opentrackr.org:1337",
|
||||
UpLimit: -1,
|
||||
DlLimit: -1,
|
||||
AutoTmm: false,
|
||||
Ratio: 1,
|
||||
RatioLimit: 1,
|
||||
}
|
||||
return torrent
|
||||
}
|
||||
139
pkg/qbit/qbit.go
139
pkg/qbit/qbit.go
@@ -1,116 +1,41 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
"cmp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
func (q *QBit) AddMagnet(ctx context.Context, url, category string) error {
|
||||
magnet, err := common.GetMagnetFromUrl(url)
|
||||
if err != nil {
|
||||
q.logger.Printf("Error parsing magnet link: %v\n", err)
|
||||
return err
|
||||
}
|
||||
err = q.Process(ctx, magnet, category)
|
||||
if err != nil {
|
||||
q.logger.Println("Failed to process magnet:", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
type QBit struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Port string `json:"port"`
|
||||
DownloadFolder string `json:"download_folder"`
|
||||
Categories []string `json:"categories"`
|
||||
Storage *TorrentStorage
|
||||
logger zerolog.Logger
|
||||
Tags []string
|
||||
RefreshInterval int
|
||||
SkipPreCache bool
|
||||
}
|
||||
|
||||
func (q *QBit) AddTorrent(ctx context.Context, fileHeader *multipart.FileHeader, category string) error {
|
||||
file, _ := fileHeader.Open()
|
||||
defer file.Close()
|
||||
var reader io.Reader = file
|
||||
magnet, err := common.GetMagnetFromFile(reader, fileHeader.Filename)
|
||||
if err != nil {
|
||||
q.logger.Printf("Error reading file: %s", fileHeader.Filename)
|
||||
return err
|
||||
}
|
||||
err = q.Process(ctx, magnet, category)
|
||||
if err != nil {
|
||||
q.logger.Println("Failed to process torrent:", err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) Process(ctx context.Context, magnet *common.Magnet, category string) error {
|
||||
torrent := q.CreateTorrentFromMagnet(magnet, category)
|
||||
arr := &debrid.Arr{
|
||||
Name: category,
|
||||
Token: ctx.Value("token").(string),
|
||||
Host: ctx.Value("host").(string),
|
||||
}
|
||||
isSymlink := ctx.Value("isSymlink").(bool)
|
||||
debridTorrent, err := debrid.ProcessQBitTorrent(q.debrid, magnet, arr, isSymlink)
|
||||
if err != nil || debridTorrent == nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("failed to process torrent")
|
||||
}
|
||||
return err
|
||||
}
|
||||
torrent.ID = debridTorrent.Id
|
||||
torrent.DebridTorrent = debridTorrent
|
||||
torrent.Name = debridTorrent.Name
|
||||
q.storage.AddOrUpdate(torrent)
|
||||
go q.processFiles(torrent, debridTorrent, arr, isSymlink) // We can send async for file processing not to delay the response
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) CreateTorrentFromMagnet(magnet *common.Magnet, category string) *Torrent {
|
||||
torrent := &Torrent{
|
||||
ID: uuid.NewString(),
|
||||
Hash: strings.ToLower(magnet.InfoHash),
|
||||
Name: magnet.Name,
|
||||
Size: magnet.Size,
|
||||
Category: category,
|
||||
State: "downloading",
|
||||
AddedOn: time.Now().Unix(),
|
||||
MagnetUri: magnet.Link,
|
||||
|
||||
Tracker: "udp://tracker.opentrackr.org:1337",
|
||||
UpLimit: -1,
|
||||
DlLimit: -1,
|
||||
FlPiecePrio: false,
|
||||
ForceStart: false,
|
||||
AutoTmm: false,
|
||||
Availability: 2,
|
||||
MaxRatio: -1,
|
||||
MaxSeedingTime: -1,
|
||||
NumComplete: 10,
|
||||
NumIncomplete: 0,
|
||||
NumLeechs: 1,
|
||||
Ratio: 1,
|
||||
RatioLimit: 1,
|
||||
}
|
||||
return torrent
|
||||
}
|
||||
|
||||
func (q *QBit) processFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr *debrid.Arr, isSymlink bool) {
|
||||
for debridTorrent.Status != "downloaded" {
|
||||
progress := debridTorrent.Progress
|
||||
q.logger.Printf("Progress: %.2f%%", progress)
|
||||
time.Sleep(5 * time.Second)
|
||||
dbT, err := q.debrid.CheckStatus(debridTorrent, isSymlink)
|
||||
if err != nil {
|
||||
q.logger.Printf("Error checking status: %v", err)
|
||||
q.MarkAsFailed(torrent)
|
||||
return
|
||||
}
|
||||
debridTorrent = dbT
|
||||
}
|
||||
if isSymlink {
|
||||
q.processSymlink(torrent, debridTorrent, arr)
|
||||
} else {
|
||||
q.processManualFiles(torrent, debridTorrent, arr)
|
||||
func New() *QBit {
|
||||
_cfg := config.GetConfig()
|
||||
cfg := _cfg.QBitTorrent
|
||||
port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8282")
|
||||
refreshInterval := cmp.Or(cfg.RefreshInterval, 10)
|
||||
return &QBit{
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
Port: port,
|
||||
DownloadFolder: cfg.DownloadFolder,
|
||||
Categories: cfg.Categories,
|
||||
Storage: NewTorrentStorage(filepath.Join(_cfg.Path, "torrents.json")),
|
||||
logger: logger.NewLogger("qbit", _cfg.LogLevel, os.Stdout),
|
||||
RefreshInterval: refreshInterval,
|
||||
SkipPreCache: cfg.SkipPreCache,
|
||||
}
|
||||
}
|
||||
|
||||
42
pkg/qbit/routes.go
Normal file
42
pkg/qbit/routes.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (q *QBit) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(q.CategoryContext)
|
||||
r.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)
|
||||
r.Post("/add", q.handleTorrentsAdd)
|
||||
r.Post("/delete", q.handleTorrentsDelete)
|
||||
r.Get("/categories", q.handleCategories)
|
||||
r.Post("/createCategory", q.handleCreateCategory)
|
||||
r.Post("/setCategory", q.handleSetCategory)
|
||||
r.Post("/addTags", q.handleAddTorrentTags)
|
||||
r.Post("/removeTags", q.handleRemoveTorrentTags)
|
||||
r.Post("/createTags", q.handleCreateTags)
|
||||
r.Get("/tags", q.handleGetTags)
|
||||
r.Get("/pause", q.handleTorrentsPause)
|
||||
r.Get("/resume", q.handleTorrentsResume)
|
||||
r.Get("/recheck", q.handleTorrentRecheck)
|
||||
r.Get("/properties", q.handleTorrentProperties)
|
||||
r.Get("/files", q.handleTorrentFiles)
|
||||
})
|
||||
|
||||
r.Route("/app", func(r chi.Router) {
|
||||
r.Get("/version", q.handleVersion)
|
||||
r.Get("/webapiVersion", q.handleWebAPIVersion)
|
||||
r.Get("/preferences", q.handlePreferences)
|
||||
r.Get("/buildInfo", q.handleBuildInfo)
|
||||
r.Get("/shutdown", q.handleShutdown)
|
||||
})
|
||||
})
|
||||
return r
|
||||
}
|
||||
@@ -2,22 +2,33 @@ package qbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type TorrentStorage struct {
|
||||
torrents map[string]*Torrent
|
||||
mu sync.RWMutex
|
||||
order []string
|
||||
func keyPair(hash, category string) string {
|
||||
if category == "" {
|
||||
category = "uncategorized"
|
||||
}
|
||||
return fmt.Sprintf("%s|%s", hash, category)
|
||||
}
|
||||
|
||||
func loadTorrentsFromJSON(filename string) (map[string]*Torrent, error) {
|
||||
type Torrents = map[string]*Torrent
|
||||
|
||||
type TorrentStorage struct {
|
||||
torrents Torrents
|
||||
mu sync.RWMutex
|
||||
filename string // Added to store the filename for persistence
|
||||
}
|
||||
|
||||
func loadTorrentsFromJSON(filename string) (Torrents, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
torrents := make(map[string]*Torrent)
|
||||
torrents := make(Torrents)
|
||||
if err := json.Unmarshal(data, &torrents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -25,61 +36,62 @@ func loadTorrentsFromJSON(filename string) (map[string]*Torrent, error) {
|
||||
}
|
||||
|
||||
func NewTorrentStorage(filename string) *TorrentStorage {
|
||||
// Open the json file and read the data
|
||||
// Open the JSON file and read the data
|
||||
torrents, err := loadTorrentsFromJSON(filename)
|
||||
if err != nil {
|
||||
torrents = make(map[string]*Torrent)
|
||||
}
|
||||
order := make([]string, 0, len(torrents))
|
||||
for id := range torrents {
|
||||
order = append(order, id)
|
||||
torrents = make(Torrents)
|
||||
}
|
||||
// Create a new TorrentStorage
|
||||
return &TorrentStorage{
|
||||
torrents: torrents,
|
||||
order: order,
|
||||
filename: filename,
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Add(torrent *Torrent) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.torrents[torrent.Hash] = torrent
|
||||
ts.order = append(ts.order, torrent.Hash)
|
||||
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
|
||||
go func() {
|
||||
err := ts.saveToFile()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) AddOrUpdate(torrent *Torrent) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
if _, exists := ts.torrents[torrent.Hash]; !exists {
|
||||
ts.order = append(ts.order, torrent.Hash)
|
||||
}
|
||||
ts.torrents[torrent.Hash] = torrent
|
||||
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
|
||||
go func() {
|
||||
err := ts.saveToFile()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) GetByID(id string) *Torrent {
|
||||
func (ts *TorrentStorage) Get(hash, category string) *Torrent {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
for _, torrent := range ts.torrents {
|
||||
if torrent.ID == id {
|
||||
return torrent
|
||||
torrent, exists := ts.torrents[keyPair(hash, category)]
|
||||
if !exists && category == "" {
|
||||
// Try to find the torrent without knowing the category
|
||||
for _, t := range ts.torrents {
|
||||
if t.Hash == hash {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Get(hash string) *Torrent {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
return ts.torrents[hash]
|
||||
return torrent
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string) []*Torrent {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
torrents := make([]*Torrent, 0)
|
||||
for _, id := range ts.order {
|
||||
torrent := ts.torrents[id]
|
||||
for _, torrent := range ts.torrents {
|
||||
if category != "" && torrent.Category != category {
|
||||
continue
|
||||
}
|
||||
@@ -88,11 +100,14 @@ func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string
|
||||
}
|
||||
torrents = append(torrents, torrent)
|
||||
}
|
||||
|
||||
if len(hashes) > 0 {
|
||||
filtered := make([]*Torrent, 0, len(torrents))
|
||||
filtered := make([]*Torrent, 0)
|
||||
for _, hash := range hashes {
|
||||
if torrent := ts.torrents[hash]; torrent != nil {
|
||||
filtered = append(filtered, torrent)
|
||||
for _, torrent := range torrents {
|
||||
if torrent.Hash == hash {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
}
|
||||
}
|
||||
torrents = filtered
|
||||
@@ -100,38 +115,120 @@ func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string
|
||||
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
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Update(torrent *Torrent) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.torrents[torrent.Hash] = torrent
|
||||
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
|
||||
go func() {
|
||||
err := ts.saveToFile()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Delete(hash string) {
|
||||
func (ts *TorrentStorage) Delete(hash, category string) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
torrent, exists := ts.torrents[hash]
|
||||
if !exists {
|
||||
return
|
||||
}
|
||||
delete(ts.torrents, hash)
|
||||
for i, id := range ts.order {
|
||||
if id == hash {
|
||||
ts.order = append(ts.order[:i], ts.order[i+1:]...)
|
||||
break
|
||||
key := keyPair(hash, category)
|
||||
torrent, exists := ts.torrents[key]
|
||||
if !exists && category == "" {
|
||||
// Remove the torrent without knowing the category
|
||||
for k, t := range ts.torrents {
|
||||
if t.Hash == hash {
|
||||
key = k
|
||||
torrent = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
delete(ts.torrents, key)
|
||||
if torrent == nil {
|
||||
return
|
||||
}
|
||||
// Delete the torrent folder
|
||||
if torrent.ContentPath != "" {
|
||||
os.RemoveAll(torrent.ContentPath)
|
||||
err := os.RemoveAll(torrent.ContentPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
err := ts.saveToFile()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Save(filename string) error {
|
||||
func (ts *TorrentStorage) DeleteMultiple(hashes []string) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
for _, hash := range hashes {
|
||||
for key, torrent := range ts.torrents {
|
||||
if torrent.Hash == hash {
|
||||
delete(ts.torrents, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
err := ts.saveToFile()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Save() error {
|
||||
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()
|
||||
defer ts.mu.RUnlock()
|
||||
data, err := json.Marshal(ts.torrents)
|
||||
data, err := json.MarshalIndent(ts.torrents, "", " ")
|
||||
ts.mu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(filename, data, 0644)
|
||||
return os.WriteFile(ts.filename, data, 0644)
|
||||
}
|
||||
|
||||
@@ -2,87 +2,216 @@ package qbit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"goBlack/pkg/debrid"
|
||||
"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"
|
||||
debrid "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// All torrent related helpers goes here
|
||||
|
||||
func (q *QBit) AddMagnet(ctx context.Context, url, category string) error {
|
||||
magnet, err := utils.GetMagnetFromUrl(url)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing magnet link: %w", err)
|
||||
}
|
||||
err = q.Process(ctx, magnet, category)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to process torrent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) AddTorrent(ctx context.Context, fileHeader *multipart.FileHeader, category string) error {
|
||||
file, _ := fileHeader.Open()
|
||||
defer file.Close()
|
||||
var reader io.Reader = file
|
||||
magnet, err := utils.GetMagnetFromFile(reader, fileHeader.Filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading file: %s \n %w", fileHeader.Filename, err)
|
||||
}
|
||||
err = q.Process(ctx, magnet, category)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to process torrent: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) Process(ctx context.Context, magnet *utils.Magnet, category string) error {
|
||||
svc := service.GetService()
|
||||
torrent := CreateTorrentFromMagnet(magnet, category, "auto")
|
||||
a, ok := ctx.Value("arr").(*arr.Arr)
|
||||
if !ok {
|
||||
return fmt.Errorf("arr not found in context")
|
||||
}
|
||||
isSymlink := ctx.Value("isSymlink").(bool)
|
||||
debridTorrent, err := db.ProcessTorrent(svc.Debrid, magnet, a, isSymlink, false)
|
||||
if err != nil || debridTorrent == nil {
|
||||
if debridTorrent != nil {
|
||||
dbClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
|
||||
go dbClient.DeleteTorrent(debridTorrent)
|
||||
}
|
||||
if err == nil {
|
||||
err = fmt.Errorf("failed to process torrent")
|
||||
}
|
||||
return err
|
||||
}
|
||||
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||
q.Storage.AddOrUpdate(torrent)
|
||||
go q.ProcessFiles(torrent, debridTorrent, a, isSymlink) // We can send async for file processing not to delay the response
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr *arr.Arr, isSymlink bool) {
|
||||
debridClient := service.GetDebrid().GetByName(debridTorrent.Debrid)
|
||||
for debridTorrent.Status != "downloaded" {
|
||||
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)
|
||||
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
|
||||
err error
|
||||
)
|
||||
debridTorrent.Arr = arr
|
||||
if isSymlink {
|
||||
torrentSymlinkPath, err = q.ProcessSymlink(torrent) // /mnt/symlinks/{category}/MyTVShow/
|
||||
} else {
|
||||
torrentSymlinkPath, err = q.ProcessManualFile(torrent)
|
||||
}
|
||||
if err != nil {
|
||||
q.MarkAsFailed(torrent)
|
||||
go debridClient.DeleteTorrent(debridTorrent)
|
||||
q.logger.Info().Msgf("Error: %v", err)
|
||||
return
|
||||
}
|
||||
torrent.TorrentPath = torrentSymlinkPath
|
||||
q.UpdateTorrent(torrent, debridTorrent)
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent {
|
||||
rcLoneMount := q.debrid.GetMountPath()
|
||||
if debridTorrent == nil && t.ID != "" {
|
||||
debridTorrent, _ = q.debrid.GetTorrent(t.ID)
|
||||
}
|
||||
func (q *QBit) UpdateTorrentMin(t *Torrent, debridTorrent *debrid.Torrent) *Torrent {
|
||||
if debridTorrent == nil {
|
||||
q.logger.Printf("Torrent with ID %s not found in %s", t.ID, q.debrid.GetName())
|
||||
return t
|
||||
}
|
||||
if debridTorrent.Status != "downloaded" {
|
||||
debridTorrent, _ = q.debrid.GetTorrent(t.ID)
|
||||
}
|
||||
|
||||
if t.TorrentPath == "" {
|
||||
t.TorrentPath = filepath.Base(debridTorrent.GetMountFolder(rcLoneMount))
|
||||
addedOn, err := time.Parse(time.RFC3339, debridTorrent.Added)
|
||||
if err != nil {
|
||||
addedOn = time.Now()
|
||||
}
|
||||
|
||||
totalSize := float64(cmp.Or(debridTorrent.Bytes, 1.0))
|
||||
progress := cmp.Or(debridTorrent.Progress, 100.0)
|
||||
totalSize := debridTorrent.Bytes
|
||||
progress := cmp.Or(debridTorrent.Progress, 100)
|
||||
progress = progress / 100.0
|
||||
var sizeCompleted int64
|
||||
|
||||
sizeCompleted = int64(totalSize * progress)
|
||||
savePath := filepath.Join(q.DownloadFolder, t.Category) + string(os.PathSeparator)
|
||||
torrentPath := filepath.Join(savePath, t.TorrentPath) + string(os.PathSeparator)
|
||||
sizeCompleted := int64(float64(totalSize) * progress)
|
||||
|
||||
var speed int64
|
||||
if debridTorrent.Speed != 0 {
|
||||
speed = debridTorrent.Speed
|
||||
}
|
||||
var eta int64
|
||||
var eta int
|
||||
if speed != 0 {
|
||||
eta = int64((totalSize - float64(sizeCompleted)) / float64(speed))
|
||||
eta = int((totalSize - sizeCompleted) / speed)
|
||||
}
|
||||
|
||||
t.Size = debridTorrent.Bytes
|
||||
t.ID = debridTorrent.Id
|
||||
t.Name = debridTorrent.Name
|
||||
t.AddedOn = addedOn.Unix()
|
||||
t.DebridTorrent = debridTorrent
|
||||
t.Debrid = debridTorrent.Debrid
|
||||
t.Size = totalSize
|
||||
t.Completed = sizeCompleted
|
||||
t.Downloaded = sizeCompleted
|
||||
t.DownloadedSession = sizeCompleted
|
||||
t.Uploaded = sizeCompleted
|
||||
t.UploadedSession = sizeCompleted
|
||||
t.AmountLeft = int64(totalSize) - sizeCompleted
|
||||
t.Progress = float32(progress)
|
||||
t.SavePath = savePath
|
||||
t.ContentPath = torrentPath
|
||||
t.AmountLeft = totalSize - sizeCompleted
|
||||
t.Progress = progress
|
||||
t.Eta = eta
|
||||
t.Dlspeed = speed
|
||||
t.Upspeed = speed
|
||||
t.SavePath = filepath.Join(q.DownloadFolder, t.Category) + string(os.PathSeparator)
|
||||
t.ContentPath = filepath.Join(t.SavePath, t.Name) + string(os.PathSeparator)
|
||||
return t
|
||||
}
|
||||
|
||||
func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debrid.Torrent) *Torrent {
|
||||
if debridTorrent == nil {
|
||||
return t
|
||||
}
|
||||
_db := service.GetDebrid().GetByName(debridTorrent.Debrid)
|
||||
if debridTorrent.Status != "downloaded" {
|
||||
debridTorrent, _ = _db.GetTorrent(debridTorrent)
|
||||
}
|
||||
t = q.UpdateTorrentMin(t, debridTorrent)
|
||||
t.ContentPath = t.TorrentPath + string(os.PathSeparator)
|
||||
|
||||
if t.IsReady() {
|
||||
t.State = "pausedUP"
|
||||
q.storage.AddOrUpdate(t)
|
||||
q.Storage.Update(t)
|
||||
return t
|
||||
}
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if t.IsReady() {
|
||||
t.State = "pausedUP"
|
||||
q.storage.AddOrUpdate(t)
|
||||
ticker.Stop()
|
||||
q.Storage.Update(t)
|
||||
return t
|
||||
} else {
|
||||
return q.UpdateTorrent(t, debridTorrent)
|
||||
}
|
||||
updatedT := q.UpdateTorrent(t, debridTorrent)
|
||||
t = updatedT
|
||||
|
||||
case <-time.After(10 * time.Minute): // Add a timeout
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -123,3 +252,61 @@ func (q *QBit) GetTorrentProperties(t *Torrent) *TorrentProperties {
|
||||
ShareRatio: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) GetTorrentFiles(t *Torrent) []*TorrentFile {
|
||||
files := make([]*TorrentFile, 0)
|
||||
if t.DebridTorrent == nil {
|
||||
return files
|
||||
}
|
||||
for _, file := range t.DebridTorrent.Files {
|
||||
files = append(files, &TorrentFile{
|
||||
Name: file.Path,
|
||||
Size: file.Size,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (q *QBit) SetTorrentTags(t *Torrent, tags []string) bool {
|
||||
torrentTags := strings.Split(t.Tags, ",")
|
||||
for _, tag := range tags {
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(torrentTags, tag) {
|
||||
torrentTags = append(torrentTags, tag)
|
||||
}
|
||||
if !slices.Contains(q.Tags, tag) {
|
||||
q.Tags = append(q.Tags, tag)
|
||||
}
|
||||
}
|
||||
t.Tags = strings.Join(torrentTags, ",")
|
||||
q.Storage.Update(t)
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) RemoveTorrentTags(t *Torrent, tags []string) bool {
|
||||
torrentTags := strings.Split(t.Tags, ",")
|
||||
newTorrentTags := utils.RemoveItem(torrentTags, tags...)
|
||||
q.Tags = utils.RemoveItem(q.Tags, tags...)
|
||||
t.Tags = strings.Join(newTorrentTags, ",")
|
||||
q.Storage.Update(t)
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) AddTags(tags []string) bool {
|
||||
for _, tag := range tags {
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if !slices.Contains(q.Tags, tag) {
|
||||
q.Tags = append(q.Tags, tag)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) RemoveTags(tags []string) bool {
|
||||
q.Tags = utils.RemoveItem(q.Tags, tags...)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
package qbit
|
||||
|
||||
import "goBlack/pkg/debrid"
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type BuildInfo struct {
|
||||
Libtorrent string `json:"libtorrent"`
|
||||
@@ -14,40 +18,40 @@ type BuildInfo struct {
|
||||
type AppPreferences struct {
|
||||
AddTrackers string `json:"add_trackers"`
|
||||
AddTrackersEnabled bool `json:"add_trackers_enabled"`
|
||||
AltDlLimit int64 `json:"alt_dl_limit"`
|
||||
AltUpLimit int64 `json:"alt_up_limit"`
|
||||
AltDlLimit int `json:"alt_dl_limit"`
|
||||
AltUpLimit int `json:"alt_up_limit"`
|
||||
AlternativeWebuiEnabled bool `json:"alternative_webui_enabled"`
|
||||
AlternativeWebuiPath string `json:"alternative_webui_path"`
|
||||
AnnounceIp string `json:"announce_ip"`
|
||||
AnnounceToAllTiers bool `json:"announce_to_all_tiers"`
|
||||
AnnounceToAllTrackers bool `json:"announce_to_all_trackers"`
|
||||
AnonymousMode bool `json:"anonymous_mode"`
|
||||
AsyncIoThreads int64 `json:"async_io_threads"`
|
||||
AutoDeleteMode int64 `json:"auto_delete_mode"`
|
||||
AsyncIoThreads int `json:"async_io_threads"`
|
||||
AutoDeleteMode int `json:"auto_delete_mode"`
|
||||
AutoTmmEnabled bool `json:"auto_tmm_enabled"`
|
||||
AutorunEnabled bool `json:"autorun_enabled"`
|
||||
AutorunProgram string `json:"autorun_program"`
|
||||
BannedIPs string `json:"banned_IPs"`
|
||||
BittorrentProtocol int64 `json:"bittorrent_protocol"`
|
||||
BittorrentProtocol int `json:"bittorrent_protocol"`
|
||||
BypassAuthSubnetWhitelist string `json:"bypass_auth_subnet_whitelist"`
|
||||
BypassAuthSubnetWhitelistEnabled bool `json:"bypass_auth_subnet_whitelist_enabled"`
|
||||
BypassLocalAuth bool `json:"bypass_local_auth"`
|
||||
CategoryChangedTmmEnabled bool `json:"category_changed_tmm_enabled"`
|
||||
CheckingMemoryUse int64 `json:"checking_memory_use"`
|
||||
CheckingMemoryUse int `json:"checking_memory_use"`
|
||||
CreateSubfolderEnabled bool `json:"create_subfolder_enabled"`
|
||||
CurrentInterfaceAddress string `json:"current_interface_address"`
|
||||
CurrentNetworkInterface string `json:"current_network_interface"`
|
||||
Dht bool `json:"dht"`
|
||||
DiskCache int64 `json:"disk_cache"`
|
||||
DiskCacheTtl int64 `json:"disk_cache_ttl"`
|
||||
DlLimit int64 `json:"dl_limit"`
|
||||
DiskCache int `json:"disk_cache"`
|
||||
DiskCacheTtl int `json:"disk_cache_ttl"`
|
||||
DlLimit int `json:"dl_limit"`
|
||||
DontCountSlowTorrents bool `json:"dont_count_slow_torrents"`
|
||||
DyndnsDomain string `json:"dyndns_domain"`
|
||||
DyndnsEnabled bool `json:"dyndns_enabled"`
|
||||
DyndnsPassword string `json:"dyndns_password"`
|
||||
DyndnsService int64 `json:"dyndns_service"`
|
||||
DyndnsService int `json:"dyndns_service"`
|
||||
DyndnsUsername string `json:"dyndns_username"`
|
||||
EmbeddedTrackerPort int64 `json:"embedded_tracker_port"`
|
||||
EmbeddedTrackerPort int `json:"embedded_tracker_port"`
|
||||
EnableCoalesceReadWrite bool `json:"enable_coalesce_read_write"`
|
||||
EnableEmbeddedTracker bool `json:"enable_embedded_tracker"`
|
||||
EnableMultiConnectionsFromSameIp bool `json:"enable_multi_connections_from_same_ip"`
|
||||
@@ -55,10 +59,10 @@ type AppPreferences struct {
|
||||
EnablePieceExtentAffinity bool `json:"enable_piece_extent_affinity"`
|
||||
EnableSuperSeeding bool `json:"enable_super_seeding"`
|
||||
EnableUploadSuggestions bool `json:"enable_upload_suggestions"`
|
||||
Encryption int64 `json:"encryption"`
|
||||
Encryption int `json:"encryption"`
|
||||
ExportDir string `json:"export_dir"`
|
||||
ExportDirFin string `json:"export_dir_fin"`
|
||||
FilePoolSize int64 `json:"file_pool_size"`
|
||||
FilePoolSize int `json:"file_pool_size"`
|
||||
IncompleteFilesExt bool `json:"incomplete_files_ext"`
|
||||
IpFilterEnabled bool `json:"ip_filter_enabled"`
|
||||
IpFilterPath string `json:"ip_filter_path"`
|
||||
@@ -66,7 +70,7 @@ type AppPreferences struct {
|
||||
LimitLanPeers bool `json:"limit_lan_peers"`
|
||||
LimitTcpOverhead bool `json:"limit_tcp_overhead"`
|
||||
LimitUtpRate bool `json:"limit_utp_rate"`
|
||||
ListenPort int64 `json:"listen_port"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
Locale string `json:"locale"`
|
||||
Lsd bool `json:"lsd"`
|
||||
MailNotificationAuthEnabled bool `json:"mail_notification_auth_enabled"`
|
||||
@@ -77,79 +81,79 @@ type AppPreferences struct {
|
||||
MailNotificationSmtp string `json:"mail_notification_smtp"`
|
||||
MailNotificationSslEnabled bool `json:"mail_notification_ssl_enabled"`
|
||||
MailNotificationUsername string `json:"mail_notification_username"`
|
||||
MaxActiveDownloads int64 `json:"max_active_downloads"`
|
||||
MaxActiveTorrents int64 `json:"max_active_torrents"`
|
||||
MaxActiveUploads int64 `json:"max_active_uploads"`
|
||||
MaxConnec int64 `json:"max_connec"`
|
||||
MaxConnecPerTorrent int64 `json:"max_connec_per_torrent"`
|
||||
MaxRatio int64 `json:"max_ratio"`
|
||||
MaxRatioAct int64 `json:"max_ratio_act"`
|
||||
MaxActiveDownloads int `json:"max_active_downloads"`
|
||||
MaxActiveTorrents int `json:"max_active_torrents"`
|
||||
MaxActiveUploads int `json:"max_active_uploads"`
|
||||
MaxConnec int `json:"max_connec"`
|
||||
MaxConnecPerTorrent int `json:"max_connec_per_torrent"`
|
||||
MaxRatio int `json:"max_ratio"`
|
||||
MaxRatioAct int `json:"max_ratio_act"`
|
||||
MaxRatioEnabled bool `json:"max_ratio_enabled"`
|
||||
MaxSeedingTime int64 `json:"max_seeding_time"`
|
||||
MaxSeedingTime int `json:"max_seeding_time"`
|
||||
MaxSeedingTimeEnabled bool `json:"max_seeding_time_enabled"`
|
||||
MaxUploads int64 `json:"max_uploads"`
|
||||
MaxUploadsPerTorrent int64 `json:"max_uploads_per_torrent"`
|
||||
OutgoingPortsMax int64 `json:"outgoing_ports_max"`
|
||||
OutgoingPortsMin int64 `json:"outgoing_ports_min"`
|
||||
MaxUploads int `json:"max_uploads"`
|
||||
MaxUploadsPerTorrent int `json:"max_uploads_per_torrent"`
|
||||
OutgoingPortsMax int `json:"outgoing_ports_max"`
|
||||
OutgoingPortsMin int `json:"outgoing_ports_min"`
|
||||
Pex bool `json:"pex"`
|
||||
PreallocateAll bool `json:"preallocate_all"`
|
||||
ProxyAuthEnabled bool `json:"proxy_auth_enabled"`
|
||||
ProxyIp string `json:"proxy_ip"`
|
||||
ProxyPassword string `json:"proxy_password"`
|
||||
ProxyPeerConnections bool `json:"proxy_peer_connections"`
|
||||
ProxyPort int64 `json:"proxy_port"`
|
||||
ProxyPort int `json:"proxy_port"`
|
||||
ProxyTorrentsOnly bool `json:"proxy_torrents_only"`
|
||||
ProxyType int64 `json:"proxy_type"`
|
||||
ProxyType int `json:"proxy_type"`
|
||||
ProxyUsername string `json:"proxy_username"`
|
||||
QueueingEnabled bool `json:"queueing_enabled"`
|
||||
RandomPort bool `json:"random_port"`
|
||||
RecheckCompletedTorrents bool `json:"recheck_completed_torrents"`
|
||||
ResolvePeerCountries bool `json:"resolve_peer_countries"`
|
||||
RssAutoDownloadingEnabled bool `json:"rss_auto_downloading_enabled"`
|
||||
RssMaxArticlesPerFeed int64 `json:"rss_max_articles_per_feed"`
|
||||
RssMaxArticlesPerFeed int `json:"rss_max_articles_per_feed"`
|
||||
RssProcessingEnabled bool `json:"rss_processing_enabled"`
|
||||
RssRefreshInterval int64 `json:"rss_refresh_interval"`
|
||||
RssRefreshInterval int `json:"rss_refresh_interval"`
|
||||
SavePath string `json:"save_path"`
|
||||
SavePathChangedTmmEnabled bool `json:"save_path_changed_tmm_enabled"`
|
||||
SaveResumeDataInterval int64 `json:"save_resume_data_interval"`
|
||||
SaveResumeDataInterval int `json:"save_resume_data_interval"`
|
||||
ScanDirs ScanDirs `json:"scan_dirs"`
|
||||
ScheduleFromHour int64 `json:"schedule_from_hour"`
|
||||
ScheduleFromMin int64 `json:"schedule_from_min"`
|
||||
ScheduleToHour int64 `json:"schedule_to_hour"`
|
||||
ScheduleToMin int64 `json:"schedule_to_min"`
|
||||
SchedulerDays int64 `json:"scheduler_days"`
|
||||
ScheduleFromHour int `json:"schedule_from_hour"`
|
||||
ScheduleFromMin int `json:"schedule_from_min"`
|
||||
ScheduleToHour int `json:"schedule_to_hour"`
|
||||
ScheduleToMin int `json:"schedule_to_min"`
|
||||
SchedulerDays int `json:"scheduler_days"`
|
||||
SchedulerEnabled bool `json:"scheduler_enabled"`
|
||||
SendBufferLowWatermark int64 `json:"send_buffer_low_watermark"`
|
||||
SendBufferWatermark int64 `json:"send_buffer_watermark"`
|
||||
SendBufferWatermarkFactor int64 `json:"send_buffer_watermark_factor"`
|
||||
SlowTorrentDlRateThreshold int64 `json:"slow_torrent_dl_rate_threshold"`
|
||||
SlowTorrentInactiveTimer int64 `json:"slow_torrent_inactive_timer"`
|
||||
SlowTorrentUlRateThreshold int64 `json:"slow_torrent_ul_rate_threshold"`
|
||||
SocketBacklogSize int64 `json:"socket_backlog_size"`
|
||||
SendBufferLowWatermark int `json:"send_buffer_low_watermark"`
|
||||
SendBufferWatermark int `json:"send_buffer_watermark"`
|
||||
SendBufferWatermarkFactor int `json:"send_buffer_watermark_factor"`
|
||||
SlowTorrentDlRateThreshold int `json:"slow_torrent_dl_rate_threshold"`
|
||||
SlowTorrentInactiveTimer int `json:"slow_torrent_inactive_timer"`
|
||||
SlowTorrentUlRateThreshold int `json:"slow_torrent_ul_rate_threshold"`
|
||||
SocketBacklogSize int `json:"socket_backlog_size"`
|
||||
StartPausedEnabled bool `json:"start_paused_enabled"`
|
||||
StopTrackerTimeout int64 `json:"stop_tracker_timeout"`
|
||||
StopTrackerTimeout int `json:"stop_tracker_timeout"`
|
||||
TempPath string `json:"temp_path"`
|
||||
TempPathEnabled bool `json:"temp_path_enabled"`
|
||||
TorrentChangedTmmEnabled bool `json:"torrent_changed_tmm_enabled"`
|
||||
UpLimit int64 `json:"up_limit"`
|
||||
UploadChokingAlgorithm int64 `json:"upload_choking_algorithm"`
|
||||
UploadSlotsBehavior int64 `json:"upload_slots_behavior"`
|
||||
UpLimit int `json:"up_limit"`
|
||||
UploadChokingAlgorithm int `json:"upload_choking_algorithm"`
|
||||
UploadSlotsBehavior int `json:"upload_slots_behavior"`
|
||||
Upnp bool `json:"upnp"`
|
||||
UpnpLeaseDuration int64 `json:"upnp_lease_duration"`
|
||||
UpnpLeaseDuration int `json:"upnp_lease_duration"`
|
||||
UseHttps bool `json:"use_https"`
|
||||
UtpTcpMixedMode int64 `json:"utp_tcp_mixed_mode"`
|
||||
UtpTcpMixedMode int `json:"utp_tcp_mixed_mode"`
|
||||
WebUiAddress string `json:"web_ui_address"`
|
||||
WebUiBanDuration int64 `json:"web_ui_ban_duration"`
|
||||
WebUiBanDuration int `json:"web_ui_ban_duration"`
|
||||
WebUiClickjackingProtectionEnabled bool `json:"web_ui_clickjacking_protection_enabled"`
|
||||
WebUiCsrfProtectionEnabled bool `json:"web_ui_csrf_protection_enabled"`
|
||||
WebUiDomainList string `json:"web_ui_domain_list"`
|
||||
WebUiHostHeaderValidationEnabled bool `json:"web_ui_host_header_validation_enabled"`
|
||||
WebUiHttpsCertPath string `json:"web_ui_https_cert_path"`
|
||||
WebUiHttpsKeyPath string `json:"web_ui_https_key_path"`
|
||||
WebUiMaxAuthFailCount int64 `json:"web_ui_max_auth_fail_count"`
|
||||
WebUiPort int64 `json:"web_ui_port"`
|
||||
WebUiMaxAuthFailCount int `json:"web_ui_max_auth_fail_count"`
|
||||
WebUiPort int `json:"web_ui_port"`
|
||||
WebUiSecureCookieEnabled bool `json:"web_ui_secure_cookie_enabled"`
|
||||
WebUiSessionTimeout int64 `json:"web_ui_session_timeout"`
|
||||
WebUiSessionTimeout int `json:"web_ui_session_timeout"`
|
||||
WebUiUpnp bool `json:"web_ui_upnp"`
|
||||
WebUiUsername string `json:"web_ui_username"`
|
||||
WebUiPassword string `json:"web_ui_password"`
|
||||
@@ -169,84 +173,99 @@ type TorrentCategory struct {
|
||||
}
|
||||
|
||||
type Torrent struct {
|
||||
ID string `json:"-"`
|
||||
DebridTorrent *debrid.Torrent `json:"-"`
|
||||
TorrentPath string `json:"-"`
|
||||
ID string `json:"id"`
|
||||
DebridTorrent *torrent.Torrent `json:"-"`
|
||||
Debrid string `json:"debrid"`
|
||||
TorrentPath string `json:"-"`
|
||||
|
||||
AddedOn int64 `json:"added_on,omitempty"`
|
||||
AmountLeft int64 `json:"amount_left,omitempty"`
|
||||
AmountLeft int64 `json:"amount_left"`
|
||||
AutoTmm bool `json:"auto_tmm"`
|
||||
Availability float64 `json:"availability"`
|
||||
Availability float64 `json:"availability,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Completed int64 `json:"completed,omitempty"`
|
||||
CompletionOn int64 `json:"completion_on,omitempty"`
|
||||
ContentPath string `json:"content_path,omitempty"`
|
||||
DlLimit int64 `json:"dl_limit,omitempty"`
|
||||
Dlspeed int64 `json:"dlspeed,omitempty"`
|
||||
Downloaded int64 `json:"downloaded,omitempty"`
|
||||
DownloadedSession int64 `json:"downloaded_session,omitempty"`
|
||||
Eta int64 `json:"eta,omitempty"`
|
||||
FlPiecePrio bool `json:"f_l_piece_prio"`
|
||||
ForceStart bool `json:"force_start"`
|
||||
Completed int64 `json:"completed"`
|
||||
CompletionOn int `json:"completion_on,omitempty"`
|
||||
ContentPath string `json:"content_path"`
|
||||
DlLimit int `json:"dl_limit"`
|
||||
Dlspeed int64 `json:"dlspeed"`
|
||||
Downloaded int64 `json:"downloaded"`
|
||||
DownloadedSession int64 `json:"downloaded_session"`
|
||||
Eta int `json:"eta"`
|
||||
FlPiecePrio bool `json:"f_l_piece_prio,omitempty"`
|
||||
ForceStart bool `json:"force_start,omitempty"`
|
||||
Hash string `json:"hash"`
|
||||
LastActivity int64 `json:"last_activity,omitempty"`
|
||||
MagnetUri string `json:"magnet_uri,omitempty"`
|
||||
MaxRatio int64 `json:"max_ratio,omitempty"`
|
||||
MaxSeedingTime int64 `json:"max_seeding_time,omitempty"`
|
||||
MaxRatio int `json:"max_ratio,omitempty"`
|
||||
MaxSeedingTime int `json:"max_seeding_time,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
NumComplete int64 `json:"num_complete,omitempty"`
|
||||
NumIncomplete int64 `json:"num_incomplete,omitempty"`
|
||||
NumLeechs int64 `json:"num_leechs,omitempty"`
|
||||
NumSeeds int64 `json:"num_seeds,omitempty"`
|
||||
Priority int64 `json:"priority,omitempty"`
|
||||
Progress float32 `json:"progress"`
|
||||
Ratio int64 `json:"ratio,omitempty"`
|
||||
RatioLimit int64 `json:"ratio_limit,omitempty"`
|
||||
SavePath string `json:"save_path,omitempty"`
|
||||
SeedingTimeLimit int64 `json:"seeding_time_limit,omitempty"`
|
||||
NumComplete int `json:"num_complete,omitempty"`
|
||||
NumIncomplete int `json:"num_incomplete,omitempty"`
|
||||
NumLeechs int `json:"num_leechs,omitempty"`
|
||||
NumSeeds int `json:"num_seeds,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Ratio int `json:"ratio,omitempty"`
|
||||
RatioLimit int `json:"ratio_limit,omitempty"`
|
||||
SavePath string `json:"save_path"`
|
||||
SeedingTimeLimit int `json:"seeding_time_limit,omitempty"`
|
||||
SeenComplete int64 `json:"seen_complete,omitempty"`
|
||||
SeqDl bool `json:"seq_dl"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
SuperSeeding bool `json:"super_seeding"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
TimeActive int64 `json:"time_active,omitempty"`
|
||||
TimeActive int `json:"time_active,omitempty"`
|
||||
TotalSize int64 `json:"total_size,omitempty"`
|
||||
Tracker string `json:"tracker,omitempty"`
|
||||
UpLimit int64 `json:"up_limit,omitempty"`
|
||||
Uploaded int64 `json:"uploaded,omitempty"`
|
||||
UploadedSession int64 `json:"uploaded_session,omitempty"`
|
||||
Upspeed int64 `json:"upspeed,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
|
||||
Mu sync.Mutex `json:"-"`
|
||||
}
|
||||
|
||||
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"`
|
||||
CompletionDate int64 `json:"completion_date,omitempty"`
|
||||
CreatedBy string `json:"created_by,omitempty"`
|
||||
CreationDate int64 `json:"creation_date,omitempty"`
|
||||
DlLimit int64 `json:"dl_limit,omitempty"`
|
||||
DlLimit int `json:"dl_limit,omitempty"`
|
||||
DlSpeed int64 `json:"dl_speed,omitempty"`
|
||||
DlSpeedAvg int64 `json:"dl_speed_avg,omitempty"`
|
||||
Eta int64 `json:"eta,omitempty"`
|
||||
DlSpeedAvg int `json:"dl_speed_avg,omitempty"`
|
||||
Eta int `json:"eta,omitempty"`
|
||||
LastSeen int64 `json:"last_seen,omitempty"`
|
||||
NbConnections int64 `json:"nb_connections,omitempty"`
|
||||
NbConnectionsLimit int64 `json:"nb_connections_limit,omitempty"`
|
||||
Peers int64 `json:"peers,omitempty"`
|
||||
PeersTotal int64 `json:"peers_total,omitempty"`
|
||||
NbConnections int `json:"nb_connections,omitempty"`
|
||||
NbConnectionsLimit int `json:"nb_connections_limit,omitempty"`
|
||||
Peers int `json:"peers,omitempty"`
|
||||
PeersTotal int `json:"peers_total,omitempty"`
|
||||
PieceSize int64 `json:"piece_size,omitempty"`
|
||||
PiecesHave int64 `json:"pieces_have,omitempty"`
|
||||
PiecesNum int64 `json:"pieces_num,omitempty"`
|
||||
Reannounce int64 `json:"reannounce,omitempty"`
|
||||
Reannounce int `json:"reannounce,omitempty"`
|
||||
SavePath string `json:"save_path,omitempty"`
|
||||
SeedingTime int64 `json:"seeding_time,omitempty"`
|
||||
Seeds int64 `json:"seeds,omitempty"`
|
||||
SeedsTotal int64 `json:"seeds_total,omitempty"`
|
||||
ShareRatio int64 `json:"share_ratio,omitempty"`
|
||||
SeedingTime int `json:"seeding_time,omitempty"`
|
||||
Seeds int `json:"seeds,omitempty"`
|
||||
SeedsTotal int `json:"seeds_total,omitempty"`
|
||||
ShareRatio int `json:"share_ratio,omitempty"`
|
||||
TimeElapsed int64 `json:"time_elapsed,omitempty"`
|
||||
TotalDownloaded int64 `json:"total_downloaded,omitempty"`
|
||||
TotalDownloadedSession int64 `json:"total_downloaded_session,omitempty"`
|
||||
@@ -254,9 +273,20 @@ type TorrentProperties struct {
|
||||
TotalUploaded int64 `json:"total_uploaded,omitempty"`
|
||||
TotalUploadedSession int64 `json:"total_uploaded_session,omitempty"`
|
||||
TotalWasted int64 `json:"total_wasted,omitempty"`
|
||||
UpLimit int64 `json:"up_limit,omitempty"`
|
||||
UpLimit int `json:"up_limit,omitempty"`
|
||||
UpSpeed int64 `json:"up_speed,omitempty"`
|
||||
UpSpeedAvg int64 `json:"up_speed_avg,omitempty"`
|
||||
UpSpeedAvg int `json:"up_speed_avg,omitempty"`
|
||||
}
|
||||
|
||||
type TorrentFile struct {
|
||||
Index int `json:"index,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
IsSeed bool `json:"is_seed,omitempty"`
|
||||
PieceRange []int `json:"piece_range,omitempty"`
|
||||
Availability float64 `json:"availability,omitempty"`
|
||||
}
|
||||
|
||||
func NewAppPreferences() *AppPreferences {
|
||||
@@ -1,41 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/debrid"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//func generateSID() (string, error) {
|
||||
// bytes := make([]byte, sidLength)
|
||||
// if _, err := rand.Read(bytes); err != nil {
|
||||
// return "", err
|
||||
// }
|
||||
// return hex.EncodeToString(bytes), nil
|
||||
//}
|
||||
|
||||
func JSONResponse(w http.ResponseWriter, data interface{}, code int) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(code)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func checkFileLoop(wg *sync.WaitGroup, dir string, file debrid.TorrentFile, ready chan<- debrid.TorrentFile) {
|
||||
defer wg.Done()
|
||||
ticker := time.NewTicker(1 * time.Second) // Check every second
|
||||
defer ticker.Stop()
|
||||
path := filepath.Join(dir, file.Path)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if common.FileReady(path) {
|
||||
ready <- file
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"goBlack/pkg/debrid"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (q *QBit) StartWorker(ctx context.Context) {
|
||||
q.logger.Println("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.Println("Qbit Refresh Worker stopped")
|
||||
return
|
||||
case <-refreshTicker.C:
|
||||
torrents := q.storage.GetAll("", "", nil)
|
||||
if len(torrents) > 0 {
|
||||
q.RefreshArrs()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) RefreshArrs() {
|
||||
q.arrs.Range(func(key, value interface{}) bool {
|
||||
host, ok := key.(string)
|
||||
token, ok2 := value.(string)
|
||||
if !ok || !ok2 {
|
||||
return true
|
||||
}
|
||||
arr := &debrid.Arr{
|
||||
Name: "",
|
||||
Token: token,
|
||||
Host: host,
|
||||
}
|
||||
q.RefreshArr(arr)
|
||||
return true
|
||||
})
|
||||
}
|
||||
131
pkg/repair/misc.go
Normal file
131
pkg/repair/misc.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package repair
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseSchedule(schedule string) (time.Duration, error) {
|
||||
if schedule == "" {
|
||||
return time.Hour, nil // default 60m
|
||||
}
|
||||
|
||||
// Check if it's a time-of-day format (HH:MM)
|
||||
if strings.Contains(schedule, ":") {
|
||||
return parseTimeOfDay(schedule)
|
||||
}
|
||||
|
||||
// Otherwise treat as duration interval
|
||||
return parseDurationInterval(schedule)
|
||||
}
|
||||
|
||||
func parseTimeOfDay(schedule string) (time.Duration, error) {
|
||||
now := time.Now()
|
||||
scheduledTime, err := time.Parse("15:04", schedule)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid time format: %s. Use HH:MM in 24-hour format", schedule)
|
||||
}
|
||||
|
||||
// Convert scheduled time to today
|
||||
scheduleToday := time.Date(
|
||||
now.Year(), now.Month(), now.Day(),
|
||||
scheduledTime.Hour(), scheduledTime.Minute(), 0, 0,
|
||||
now.Location(),
|
||||
)
|
||||
|
||||
if scheduleToday.Before(now) {
|
||||
scheduleToday = scheduleToday.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
return scheduleToday.Sub(now), nil
|
||||
}
|
||||
|
||||
func parseDurationInterval(interval string) (time.Duration, error) {
|
||||
if len(interval) < 2 {
|
||||
return 0, fmt.Errorf("invalid interval format: %s", interval)
|
||||
}
|
||||
|
||||
numStr := interval[:len(interval)-1]
|
||||
unit := interval[len(interval)-1]
|
||||
|
||||
num, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid number in interval: %s", numStr)
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case 'm':
|
||||
return time.Duration(num) * time.Minute, nil
|
||||
case 'h':
|
||||
return time.Duration(num) * time.Hour, nil
|
||||
case 'd':
|
||||
return time.Duration(num) * 24 * time.Hour, nil
|
||||
case 's':
|
||||
return time.Duration(num) * time.Second, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid unit in interval: %c", unit)
|
||||
}
|
||||
}
|
||||
|
||||
func fileIsSymlinked(file string) bool {
|
||||
info, err := os.Lstat(file)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeSymlink != 0
|
||||
}
|
||||
|
||||
func getSymlinkTarget(file string) string {
|
||||
if fileIsSymlinked(file) {
|
||||
target, err := os.Readlink(file)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
if !filepath.IsAbs(target) {
|
||||
dir := filepath.Dir(file)
|
||||
target = filepath.Join(dir, target)
|
||||
}
|
||||
return target
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fileIsReadable(filePath string) error {
|
||||
// First check if file exists and is accessible
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if it's a regular file
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("not a regular file")
|
||||
}
|
||||
|
||||
// Try to read the first 1024 bytes
|
||||
err = checkFileStart(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkFileStart(filePath string) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
// Read first 1kb
|
||||
buffer := make([]byte, 1024)
|
||||
_, err = f.Read(buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
678
pkg/repair/repair.go
Normal file
678
pkg/repair/repair.go
Normal file
@@ -0,0 +1,678 @@
|
||||
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"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Repair struct {
|
||||
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(arrs *arr.Storage) *Repair {
|
||||
cfg := config.GetConfig()
|
||||
duration, err := parseSchedule(cfg.Repair.Interval)
|
||||
if err != nil {
|
||||
duration = time.Hour * 24
|
||||
}
|
||||
r := &Repair{
|
||||
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 []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 (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 {
|
||||
// Check if zurg url is reachable
|
||||
if !r.IsZurg {
|
||||
return nil
|
||||
}
|
||||
resp, err := http.Get(fmt.Sprint(r.ZurgURL, "/http/version.txt"))
|
||||
if err != nil {
|
||||
r.logger.Debug().Err(err).Msgf("Precheck failed: Failed to reach zurg at %s", r.ZurgURL)
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
r.logger.Debug().Msgf("Precheck failed: Zurg returned %d", resp.StatusCode)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repair) 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
|
||||
}
|
||||
|
||||
func (r *Repair) repair(job *Job) error {
|
||||
if err := r.preRunChecks(); err != nil {
|
||||
return 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 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...)
|
||||
}
|
||||
}
|
||||
|
||||
// Safely append the found items to the shared slice
|
||||
if len(items) > 0 {
|
||||
mu.Lock()
|
||||
brokenItems[a] = items
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (r *Repair) Start(ctx context.Context) error {
|
||||
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
cfg := config.GetConfig()
|
||||
|
||||
if r.runOnStart {
|
||||
r.logger.Info().Msgf("Running initial repair")
|
||||
go func() {
|
||||
if err := r.AddJob([]string{}, []string{}, r.autoProcess, true); err != nil {
|
||||
r.logger.Error().Err(err).Msg("Error running initial repair")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(r.duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
r.logger.Info().Msgf("Starting repair worker with %v interval", r.duration)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
r.logger.Info().Msg("Repair worker stopped")
|
||||
return nil
|
||||
case t := <-ticker.C:
|
||||
r.logger.Info().Msgf("Running repair at %v", t.Format("15:04:05"))
|
||||
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
|
||||
if strings.Contains(cfg.Repair.Interval, ":") {
|
||||
ticker.Reset(r.duration)
|
||||
}
|
||||
|
||||
r.logger.Info().Msgf("Next scheduled repair at %v", t.Add(r.duration).Format("15:04:05"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repair) repairArr(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.Name, err)
|
||||
return brokenItems, err
|
||||
}
|
||||
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.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 brokenItems, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
brokenItems = append(brokenItems, items...)
|
||||
mu.Unlock()
|
||||
}
|
||||
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 {
|
||||
files := m.Files
|
||||
if len(files) == 0 {
|
||||
return false
|
||||
}
|
||||
firstFile := files[0]
|
||||
r.logger.Debug().Msgf("Checking parent directory for %s", firstFile.Path)
|
||||
if _, err := os.Stat(firstFile.Path); os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
// Check symlink parent directory
|
||||
symlinkPath := getSymlinkTarget(firstFile.Path)
|
||||
|
||||
r.logger.Debug().Msgf("Checking symlink parent directory for %s", symlinkPath)
|
||||
|
||||
if symlinkPath != "" {
|
||||
parentSymlink := filepath.Dir(filepath.Dir(symlinkPath)) // /mnt/zurg/torrents/movie/movie.mkv -> /mnt/zurg/torrents
|
||||
if _, err := os.Stat(parentSymlink); os.IsNotExist(err) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *Repair) getBrokenFiles(media arr.Content) []arr.ContentFile {
|
||||
|
||||
if r.IsZurg {
|
||||
return r.getZurgBrokenFiles(media)
|
||||
} else {
|
||||
return r.getFileBrokenFiles(media)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Repair) getFileBrokenFiles(media arr.Content) []arr.ContentFile {
|
||||
// This checks symlink target, try to get read a tiny bit of the file
|
||||
|
||||
brokenFiles := make([]arr.ContentFile, 0)
|
||||
|
||||
uniqueParents := make(map[string][]arr.ContentFile)
|
||||
files := media.Files
|
||||
for _, file := range files {
|
||||
target := getSymlinkTarget(file.Path)
|
||||
if target != "" {
|
||||
file.IsSymlink = true
|
||||
dir, _ := filepath.Split(target)
|
||||
parent := filepath.Base(filepath.Clean(dir))
|
||||
uniqueParents[parent] = append(uniqueParents[parent], file)
|
||||
}
|
||||
}
|
||||
|
||||
for parent, f := range uniqueParents {
|
||||
// Check stat
|
||||
// Check file stat first
|
||||
firstFile := f[0]
|
||||
// Read a tiny bit of the file
|
||||
if err := fileIsReadable(firstFile.Path); err != nil {
|
||||
r.logger.Debug().Msgf("Broken file found at: %s", parent)
|
||||
brokenFiles = append(brokenFiles, f...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(brokenFiles) == 0 {
|
||||
r.logger.Debug().Msgf("No broken files found for %s", media.Title)
|
||||
return nil
|
||||
}
|
||||
r.logger.Debug().Msgf("%d broken files found for %s", len(brokenFiles), media.Title)
|
||||
return brokenFiles
|
||||
}
|
||||
|
||||
func (r *Repair) getZurgBrokenFiles(media arr.Content) []arr.ContentFile {
|
||||
// Use zurg setup to check file availability with zurg
|
||||
// This reduces bandwidth usage significantly
|
||||
|
||||
brokenFiles := make([]arr.ContentFile, 0)
|
||||
uniqueParents := make(map[string][]arr.ContentFile)
|
||||
files := media.Files
|
||||
for _, file := range files {
|
||||
target := getSymlinkTarget(file.Path)
|
||||
if target != "" {
|
||||
file.IsSymlink = true
|
||||
dir, f := filepath.Split(target)
|
||||
parent := filepath.Base(filepath.Clean(dir))
|
||||
// Set target path folder/file.mkv
|
||||
file.TargetPath = f
|
||||
uniqueParents[parent] = append(uniqueParents[parent], file)
|
||||
}
|
||||
}
|
||||
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)
|
||||
encodedParent := url.PathEscape(parent)
|
||||
encodedFile := url.PathEscape(f[0].TargetPath)
|
||||
fullURL := fmt.Sprintf("%s/http/__all__/%s/%s", r.ZurgURL, encodedParent, encodedFile)
|
||||
// Check file stat first
|
||||
if _, err := os.Stat(f[0].Path); os.IsNotExist(err) {
|
||||
r.logger.Debug().Msgf("Broken symlink found: %s", fullURL)
|
||||
brokenFiles = append(brokenFiles, f...)
|
||||
continue
|
||||
}
|
||||
|
||||
resp, err := client.Get(fullURL)
|
||||
if err != nil {
|
||||
r.logger.Debug().Err(err).Msgf("Failed to reach %s", fullURL)
|
||||
brokenFiles = append(brokenFiles, f...)
|
||||
continue
|
||||
}
|
||||
|
||||
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.Trace().Msgf("Found download url: %s", downloadUrl)
|
||||
} else {
|
||||
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
|
||||
brokenFiles = append(brokenFiles, f...)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if len(brokenFiles) == 0 {
|
||||
r.logger.Debug().Msgf("No broken files found for %s", media.Title)
|
||||
return nil
|
||||
}
|
||||
r.logger.Debug().Msgf("%d broken files found for %s", len(brokenFiles), media.Title)
|
||||
return brokenFiles
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
105
pkg/server/server.go
Normal file
105
pkg/server/server.go
Normal file
@@ -0,0 +1,105 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
router *chi.Mux
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func New() *Server {
|
||||
cfg := config.GetConfig()
|
||||
l := logger.NewLogger("http", cfg.LogLevel, os.Stdout)
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
|
||||
|
||||
return &Server{
|
||||
router: r,
|
||||
logger: l,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
cfg := config.GetConfig()
|
||||
// Register routes
|
||||
// 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)
|
||||
srv := &http.Server{
|
||||
Addr: port,
|
||||
Handler: s.router,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
s.logger.Info().Msgf("Error starting server: %v", err)
|
||||
stop()
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
s.logger.Info().Msg("Shutting down gracefully...")
|
||||
return srv.Shutdown(context.Background())
|
||||
}
|
||||
|
||||
func (s *Server) AddRoutes(routes func(r chi.Router) http.Handler) {
|
||||
routes(s.router)
|
||||
}
|
||||
|
||||
func (s *Server) Mount(pattern string, handler http.Handler) {
|
||||
s.router.Mount(pattern, handler)
|
||||
}
|
||||
|
||||
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
||||
logFile := logger.GetLogPath()
|
||||
|
||||
// Open and read the file
|
||||
file, err := os.Open(logFile)
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading log file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
s.logger.Debug().Err(err).Msg("Error closing log file")
|
||||
}
|
||||
}(file)
|
||||
|
||||
// Set headers
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "inline; filename=application.log")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
|
||||
// Stream the file
|
||||
_, err = io.Copy(w, file)
|
||||
if err != nil {
|
||||
s.logger.Debug().Err(err).Msg("Error streaming log file")
|
||||
http.Error(w, "Error streaming log file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
54
pkg/server/webhook.go
Normal file
54
pkg/server/webhook.go
Normal 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
|
||||
}
|
||||
}
|
||||
56
pkg/service/service.go
Normal file
56
pkg/service/service.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/repair"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
Repair *repair.Repair
|
||||
Arr *arr.Storage
|
||||
Debrid *engine.Engine
|
||||
}
|
||||
|
||||
var (
|
||||
instance *Service
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func New() *Service {
|
||||
once.Do(func() {
|
||||
arrs := arr.NewStorage()
|
||||
deb := debrid.New()
|
||||
instance = &Service{
|
||||
Repair: repair.New(arrs),
|
||||
Arr: arrs,
|
||||
Debrid: deb,
|
||||
}
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
// GetService returns the singleton instance
|
||||
func GetService() *Service {
|
||||
if instance == nil {
|
||||
instance = New()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
func Update() *Service {
|
||||
arrs := arr.NewStorage()
|
||||
deb := debrid.New()
|
||||
instance = &Service{
|
||||
Repair: repair.New(arrs),
|
||||
Arr: arrs,
|
||||
Debrid: deb,
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
||||
func GetDebrid() *engine.Engine {
|
||||
return GetService().Debrid
|
||||
}
|
||||
24
pkg/version/version.go
Normal file
24
pkg/version/version.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package version
|
||||
|
||||
import "fmt"
|
||||
|
||||
type Info struct {
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
}
|
||||
|
||||
func (i Info) String() string {
|
||||
return fmt.Sprintf("%s-%s", i.Version, i.Channel)
|
||||
}
|
||||
|
||||
var (
|
||||
Version = ""
|
||||
Channel = ""
|
||||
)
|
||||
|
||||
func GetInfo() Info {
|
||||
return Info{
|
||||
Version: Version,
|
||||
Channel: Channel,
|
||||
}
|
||||
}
|
||||
38
pkg/web/routes.go
Normal file
38
pkg/web/routes.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
func (ui *Handler) Routes() http.Handler {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Get("/login", ui.LoginHandler)
|
||||
r.Post("/login", ui.LoginHandler)
|
||||
r.Get("/setup", ui.SetupHandler)
|
||||
r.Post("/setup", ui.SetupHandler)
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(ui.authMiddleware)
|
||||
r.Get("/", ui.IndexHandler)
|
||||
r.Get("/download", ui.DownloadHandler)
|
||||
r.Get("/repair", ui.RepairHandler)
|
||||
r.Get("/config", ui.ConfigHandler)
|
||||
r.Route("/internal", func(r chi.Router) {
|
||||
r.Get("/arrs", ui.handleGetArrs)
|
||||
r.Post("/add", ui.handleAddContent)
|
||||
r.Post("/repair", ui.handleRepairMedia)
|
||||
r.Get("/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)
|
||||
r.Get("/config", ui.handleGetConfig)
|
||||
r.Get("/version", ui.handleGetVersion)
|
||||
})
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
490
pkg/web/server.go
Normal file
490
pkg/web/server.go
Normal file
@@ -0,0 +1,490 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/request"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/utils"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/qbit"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/version"
|
||||
)
|
||||
|
||||
type AddRequest struct {
|
||||
Url string `json:"url"`
|
||||
Arr string `json:"arr"`
|
||||
File string `json:"file"`
|
||||
NotSymlink bool `json:"notSymlink"`
|
||||
Content string `json:"content"`
|
||||
Seasons []string `json:"seasons"`
|
||||
Episodes []string `json:"episodes"`
|
||||
}
|
||||
|
||||
type ArrResponse struct {
|
||||
Name string `json:"name"`
|
||||
Url string `json:"url"`
|
||||
}
|
||||
|
||||
type ContentResponse struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Type string `json:"type"`
|
||||
ArrID string `json:"arr"`
|
||||
}
|
||||
|
||||
type RepairRequest struct {
|
||||
ArrName string `json:"arr"`
|
||||
MediaIds []string `json:"mediaIds"`
|
||||
Async bool `json:"async"`
|
||||
AutoProcess bool `json:"autoProcess"`
|
||||
}
|
||||
|
||||
//go:embed web/*
|
||||
var content embed.FS
|
||||
|
||||
type Handler struct {
|
||||
qbit *qbit.QBit
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func New(qbit *qbit.QBit) *Handler {
|
||||
cfg := config.GetConfig()
|
||||
return &Handler{
|
||||
qbit: qbit,
|
||||
logger: logger.NewLogger("ui", cfg.LogLevel, os.Stdout),
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
store = sessions.NewCookieStore([]byte("your-secret-key")) // Change this to a secure key
|
||||
templates *template.Template
|
||||
)
|
||||
|
||||
func init() {
|
||||
templates = template.Must(template.ParseFS(
|
||||
content,
|
||||
"web/layout.html",
|
||||
"web/index.html",
|
||||
"web/download.html",
|
||||
"web/repair.html",
|
||||
"web/config.html",
|
||||
"web/login.html",
|
||||
"web/setup.html",
|
||||
))
|
||||
|
||||
store.Options = &sessions.Options{
|
||||
Path: "/",
|
||||
MaxAge: 86400 * 7,
|
||||
HttpOnly: false,
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if setup is needed
|
||||
cfg := config.GetConfig()
|
||||
if cfg.NeedsSetup() && r.URL.Path != "/setup" {
|
||||
http.Redirect(w, r, "/setup", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if !cfg.UseAuth {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Skip auth check for setup page
|
||||
if r.URL.Path == "/setup" {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
session, _ := store.Get(r, "auth-session")
|
||||
auth, ok := session.Values["authenticated"].(bool)
|
||||
|
||||
if !ok || !auth {
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (ui *Handler) verifyAuth(username, password string) bool {
|
||||
// If you're storing hashed password, use bcrypt to compare
|
||||
if username == "" {
|
||||
return false
|
||||
}
|
||||
auth := config.GetConfig().GetAuth()
|
||||
if auth == nil {
|
||||
return false
|
||||
}
|
||||
if username != auth.Username {
|
||||
return false
|
||||
}
|
||||
err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
data := map[string]interface{}{
|
||||
"Page": "login",
|
||||
"Title": "Login",
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var credentials struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
|
||||
http.Error(w, "Invalid request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if ui.verifyAuth(credentials.Username, credentials.Password) {
|
||||
session, _ := store.Get(r, "auth-session")
|
||||
session.Values["authenticated"] = true
|
||||
session.Values["username"] = credentials.Username
|
||||
if err := session.Save(r, w); err != nil {
|
||||
http.Error(w, "Error saving session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
func (ui *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
|
||||
session, _ := store.Get(r, "auth-session")
|
||||
session.Values["authenticated"] = false
|
||||
session.Options.MaxAge = -1
|
||||
err := session.Save(r, w)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/login", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (ui *Handler) SetupHandler(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := config.GetConfig()
|
||||
authCfg := cfg.GetAuth()
|
||||
|
||||
if !cfg.NeedsSetup() {
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
|
||||
if r.Method == "GET" {
|
||||
data := map[string]interface{}{
|
||||
"Page": "setup",
|
||||
"Title": "Setup",
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Handle POST (setup attempt)
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
confirmPassword := r.FormValue("confirmPassword")
|
||||
|
||||
if password != confirmPassword {
|
||||
http.Error(w, "Passwords do not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
http.Error(w, "Error processing password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Set the credentials
|
||||
authCfg.Username = username
|
||||
authCfg.Password = string(hashedPassword)
|
||||
|
||||
if err := cfg.SaveAuth(authCfg); err != nil {
|
||||
http.Error(w, "Error saving credentials", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Create a session
|
||||
session, _ := store.Get(r, "auth-session")
|
||||
session.Values["authenticated"] = true
|
||||
session.Values["username"] = username
|
||||
if err := session.Save(r, w); err != nil {
|
||||
http.Error(w, "Error saving session", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, "/", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (ui *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "index",
|
||||
"Title": "Torrents",
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *Handler) DownloadHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "download",
|
||||
"Title": "Download",
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *Handler) RepairHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "repair",
|
||||
"Title": "Repair",
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *Handler) ConfigHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "config",
|
||||
"Title": "Config",
|
||||
}
|
||||
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (ui *Handler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
|
||||
svc := service.GetService()
|
||||
request.JSONResponse(w, svc.Arr.GetAll(), http.StatusOK)
|
||||
}
|
||||
|
||||
func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
svc := service.GetService()
|
||||
|
||||
results := make([]*qbit.ImportRequest, 0)
|
||||
errs := make([]string, 0)
|
||||
|
||||
arrName := r.FormValue("arr")
|
||||
notSymlink := r.FormValue("notSymlink") == "true"
|
||||
downloadUncached := r.FormValue("downloadUncached") == "true"
|
||||
|
||||
_arr := svc.Arr.Get(arrName)
|
||||
if _arr == nil {
|
||||
_arr = arr.New(arrName, "", "", false, false, &downloadUncached)
|
||||
}
|
||||
|
||||
// Handle URLs
|
||||
if urls := r.FormValue("urls"); urls != "" {
|
||||
var urlList []string
|
||||
for _, u := range strings.Split(urls, "\n") {
|
||||
if trimmed := strings.TrimSpace(u); trimmed != "" {
|
||||
urlList = append(urlList, trimmed)
|
||||
}
|
||||
}
|
||||
|
||||
for _, url := range urlList {
|
||||
importReq := qbit.NewImportRequest(url, _arr, !notSymlink, downloadUncached)
|
||||
err := importReq.Process(ui.qbit)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
|
||||
continue
|
||||
}
|
||||
results = append(results, importReq)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle torrent/magnet files
|
||||
if files := r.MultipartForm.File["files"]; len(files) > 0 {
|
||||
for _, fileHeader := range files {
|
||||
file, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("Failed to open file %s: %v", fileHeader.Filename, err))
|
||||
continue
|
||||
}
|
||||
|
||||
magnet, err := utils.GetMagnetFromFile(file, fileHeader.Filename)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("Failed to parse torrent file %s: %v", fileHeader.Filename, err))
|
||||
continue
|
||||
}
|
||||
|
||||
importReq := qbit.NewImportRequest(magnet.Link, _arr, !notSymlink, downloadUncached)
|
||||
err = importReq.Process(ui.qbit)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Sprintf("File %s: %v", fileHeader.Filename, err))
|
||||
continue
|
||||
}
|
||||
results = append(results, importReq)
|
||||
}
|
||||
}
|
||||
|
||||
request.JSONResponse(w, struct {
|
||||
Results []*qbit.ImportRequest `json:"results"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}{
|
||||
Results: results,
|
||||
Errors: errs,
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
|
||||
var req RepairRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
svc := service.GetService()
|
||||
|
||||
_arr := svc.Arr.Get(req.ArrName)
|
||||
if _arr == nil {
|
||||
http.Error(w, "No Arrs found to repair", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Async {
|
||||
go func() {
|
||||
if err := svc.Repair.AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess, false); err != nil {
|
||||
ui.logger.Error().Err(err).Msg("Failed to repair media")
|
||||
}
|
||||
}()
|
||||
request.JSONResponse(w, "Repair process started", http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
if err := svc.Repair.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)
|
||||
}
|
||||
|
||||
func (ui *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
|
||||
v := version.GetInfo()
|
||||
request.JSONResponse(w, v, http.StatusOK)
|
||||
}
|
||||
|
||||
func (ui *Handler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
|
||||
request.JSONResponse(w, ui.qbit.Storage.GetAll("", "", nil), http.StatusOK)
|
||||
}
|
||||
|
||||
func (ui *Handler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
|
||||
hash := chi.URLParam(r, "hash")
|
||||
category := r.URL.Query().Get("category")
|
||||
if hash == "" {
|
||||
http.Error(w, "No hash provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
ui.qbit.Storage.Delete(hash, category)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (ui *Handler) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
|
||||
hashesStr := r.URL.Query().Get("hashes")
|
||||
if hashesStr == "" {
|
||||
http.Error(w, "No hashes provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
hashes := strings.Split(hashesStr, ",")
|
||||
ui.qbit.Storage.DeleteMultiple(hashes)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := config.GetConfig()
|
||||
arrCfgs := make([]config.Arr, 0)
|
||||
svc := service.GetService()
|
||||
for _, a := range svc.Arr.GetAll() {
|
||||
arrCfgs = append(arrCfgs, config.Arr{
|
||||
Host: a.Host,
|
||||
Name: a.Name,
|
||||
Token: a.Token,
|
||||
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)
|
||||
}
|
||||
425
pkg/web/web/config.html
Normal file
425
pkg/web/web/config.html
Normal file
@@ -0,0 +1,425 @@
|
||||
{{ define "config" }}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="configForm">
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">General Configuration</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="qbitDebug">Log Level</label>
|
||||
<select class="form-select" name="log_level" id="log-level" disabled>
|
||||
<option value="info">Info</option>
|
||||
<option value="debug">Debug</option>
|
||||
<option value="warn">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
<option value="trace">Trace</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Register Magnet Link Button -->
|
||||
<div class="col-md-6">
|
||||
<label>
|
||||
<!-- Empty label to keep the button aligned -->
|
||||
</label>
|
||||
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler()" id="registerMagnetLink">
|
||||
Open Magnet Links in Decypharr
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-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
|
||||
class="form-control"
|
||||
id="allowedExtensions"
|
||||
name="allowed_file_types"
|
||||
disabled
|
||||
placeholder="mkv, mp4, avi, etc.">
|
||||
</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6 mt-3">
|
||||
<div class="form-group">
|
||||
<label for="minFileSize">Minimum File Size</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="minFileSize"
|
||||
name="min_file_size"
|
||||
disabled
|
||||
placeholder="e.g., 10MB, 1GB">
|
||||
<small class="form-text text-muted">Minimum file size to download (0 for no limit)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mt-3">
|
||||
<div class="form-group">
|
||||
<label for="maxFileSize">Maximum File Size</label>
|
||||
<input type="text"
|
||||
class="form-control"
|
||||
id="maxFileSize"
|
||||
name="max_file_size"
|
||||
disabled
|
||||
placeholder="e.g., 50GB, 100MB">
|
||||
<small class="form-text text-muted">Maximum file size to download (0 for no limit)</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Debrid Configuration -->
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">Debrid Configuration</h5>
|
||||
<div id="debridConfigs"></div>
|
||||
</div>
|
||||
|
||||
<!-- QBitTorrent Configuration -->
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">QBitTorrent Configuration</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Username</label>
|
||||
<input type="text" disabled class="form-control" name="qbit.username">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<input type="password" disabled class="form-control" name="qbit.password">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="text" disabled class="form-control" name="qbit.port">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Symlink/Download Folder</label>
|
||||
<input type="text" disabled class="form-control" name="qbit.download_folder">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Refresh Interval (seconds)</label>
|
||||
<input type="number" class="form-control" name="qbit.refresh_interval">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arr Configurations -->
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">Arr Configurations</h5>
|
||||
<div id="arrConfigs"></div>
|
||||
</div>
|
||||
|
||||
<!-- Repair Configuration -->
|
||||
<div class="section mb-5">
|
||||
<h5 class="border-bottom pb-2">Repair Configuration</h5>
|
||||
<div class="row">
|
||||
<div class="col-md-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-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>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Templates for dynamic elements
|
||||
const debridTemplate = (index) => `
|
||||
<div class="config-item position-relative mb-3 p-3 border rounded">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" disabled class="form-control" name="debrid[${index}].name" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" disabled class="form-control" name="debrid[${index}].host" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">API Key</label>
|
||||
<input type="password" disabled class="form-control" name="debrid[${index}].api_key" required>
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Mount Folder</label>
|
||||
<input type="text" disabled class="form-control" name="debrid[${index}].folder">
|
||||
</div>
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">Rate Limit</label>
|
||||
<input type="text" disabled class="form-control" name="debrid[${index}].rate_limit" placeholder="e.g., 200/minute">
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="form-check me-3 d-inline-block">
|
||||
<input type="checkbox" disabled class="form-check-input" name="debrid[${index}].download_uncached">
|
||||
<label class="form-check-label">Download Uncached</label>
|
||||
</div>
|
||||
<div class="form-check d-inline-block">
|
||||
<input type="checkbox" disabled class="form-check-input" name="debrid[${index}].check_cached">
|
||||
<label class="form-check-label">Check Cached</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const arrTemplate = (index) => `
|
||||
<div class="config-item position-relative mb-3 p-3 border rounded">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Name</label>
|
||||
<input type="text" disabled class="form-control" name="arr[${index}].name" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" disabled class="form-control" name="arr[${index}].host" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label class="form-label">API Token</label>
|
||||
<input type="password" disabled class="form-control" name="arr[${index}].token" required>
|
||||
</div>
|
||||
</div>
|
||||
<div 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>
|
||||
`;
|
||||
|
||||
// Main functionality
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let debridCount = 0;
|
||||
let arrCount = 0;
|
||||
|
||||
// Load existing configuration
|
||||
fetch('/internal/config')
|
||||
.then(response => response.json())
|
||||
.then(config => {
|
||||
// Load Debrid configs
|
||||
config.debrids?.forEach(debrid => {
|
||||
addDebridConfig(debrid);
|
||||
});
|
||||
|
||||
// Load QBitTorrent config
|
||||
if (config.qbittorrent) {
|
||||
Object.entries(config.qbittorrent).forEach(([key, value]) => {
|
||||
const input = document.querySelector(`[name="qbit.${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load Arr configs
|
||||
config.arrs?.forEach(arr => {
|
||||
addArrConfig(arr);
|
||||
});
|
||||
|
||||
// Load Repair config
|
||||
if (config.repair) {
|
||||
Object.entries(config.repair).forEach(([key, value]) => {
|
||||
const input = document.querySelector(`[name="repair.${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Load general config
|
||||
|
||||
const logLevel = document.getElementById('log-level');
|
||||
logLevel.value = config.log_level;
|
||||
if (config.allowed_file_types && Array.isArray(config.allowed_file_types)) {
|
||||
document.querySelector('[name="allowed_file_types"]').value = config.allowed_file_types.join(', ');
|
||||
}
|
||||
if (config.min_file_size) {
|
||||
document.querySelector('[name="min_file_size"]').value = config.min_file_size;
|
||||
}
|
||||
if (config.max_file_size) {
|
||||
document.querySelector('[name="max_file_size"]').value = config.max_file_size;
|
||||
}
|
||||
if (config.discord_webhook_url) {
|
||||
document.querySelector('[name="discord_webhook_url"]').value = config.discord_webhook_url;
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('configForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.target);
|
||||
const config = {
|
||||
debrids: [],
|
||||
qbittorrent: {},
|
||||
arrs: [],
|
||||
repair: {}
|
||||
};
|
||||
|
||||
// Process form data
|
||||
for (let [key, value] of formData.entries()) {
|
||||
if (key.startsWith('debrid[')) {
|
||||
const match = key.match(/debrid\[(\d+)\]\.(.+)/);
|
||||
if (match) {
|
||||
const [_, index, field] = match;
|
||||
if (!config.debrids[index]) config.debrids[index] = {};
|
||||
config.debrids[index][field] = value;
|
||||
}
|
||||
} else if (key.startsWith('qbit.')) {
|
||||
config.qbittorrent[key.replace('qbit.', '')] = value;
|
||||
} else if (key.startsWith('arr[')) {
|
||||
const match = key.match(/arr\[(\d+)\]\.(.+)/);
|
||||
if (match) {
|
||||
const [_, index, field] = match;
|
||||
if (!config.arrs[index]) config.arrs[index] = {};
|
||||
config.arrs[index][field] = value;
|
||||
}
|
||||
} else if (key.startsWith('repair.')) {
|
||||
config.repair[key.replace('repair.', '')] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up arrays (remove empty entries)
|
||||
config.debrids = config.debrids.filter(Boolean);
|
||||
config.arrs = config.arrs.filter(Boolean);
|
||||
|
||||
try {
|
||||
const response = await fetch('/internal/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(config)
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
|
||||
createToast('Configuration saved successfully!');
|
||||
} catch (error) {
|
||||
createToast(`Error saving configuration: ${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
// Helper functions
|
||||
function addDebridConfig(data = {}) {
|
||||
const container = document.getElementById('debridConfigs');
|
||||
container.insertAdjacentHTML('beforeend', debridTemplate(debridCount));
|
||||
|
||||
if (data) {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
const input = container.querySelector(`[name="debrid[${debridCount}].${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
debridCount++;
|
||||
}
|
||||
|
||||
function addArrConfig(data = {}) {
|
||||
const container = document.getElementById('arrConfigs');
|
||||
container.insertAdjacentHTML('beforeend', arrTemplate(arrCount));
|
||||
|
||||
if (data) {
|
||||
Object.entries(data).forEach(([key, value]) => {
|
||||
const input = container.querySelector(`[name="arr[${arrCount}].${key}"]`);
|
||||
if (input) {
|
||||
if (input.type === 'checkbox') {
|
||||
input.checked = value;
|
||||
} else {
|
||||
input.value = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
arrCount++;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Register magnet link handler
|
||||
function registerMagnetLinkHandler() {
|
||||
if ('registerProtocolHandler' in navigator) {
|
||||
try {
|
||||
navigator.registerProtocolHandler(
|
||||
'magnet',
|
||||
`${window.location.origin}/download?magnet=%s`,
|
||||
'DecyphArr'
|
||||
);
|
||||
localStorage.setItem('magnetHandler', 'true');
|
||||
document.getElementById('registerMagnetLink').innerText = '✅ DecyphArr Can Open Magnet Links';
|
||||
document.getElementById('registerMagnetLink').classList.add('bg-white', 'text-black');
|
||||
console.log('Registered magnet link handler successfully.');
|
||||
} catch (error) {
|
||||
console.error('Failed to register magnet link handler:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var magnetHandler = localStorage.getItem('magnetHandler');
|
||||
if (magnetHandler === 'true') {
|
||||
document.getElementById('registerMagnetLink').innerText = '✅ DecyphArr Can Open Magnet Links';
|
||||
document.getElementById('registerMagnetLink').classList.add('bg-white', 'text-black');
|
||||
}
|
||||
</script>
|
||||
{{ end }}
|
||||
154
pkg/web/web/download.html
Normal file
154
pkg/web/web/download.html
Normal file
@@ -0,0 +1,154 @@
|
||||
{{ define "download" }}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0"><i class="bi bi-cloud-download me-2"></i>Add New Download</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="downloadForm" enctype="multipart/form-data">
|
||||
<div class="mb-2">
|
||||
<label for="magnetURI" class="form-label">Torrent(s)</label>
|
||||
<textarea class="form-control" id="magnetURI" name="urls" rows="8" placeholder="Paste your magnet links or torrent URLs here, one per line..."></textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<input type="file" class="form-control" id="torrentFiles" name="torrents" multiple accept=".torrent,.magnet">
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="category" class="form-label">Enter Category</label>
|
||||
<input type="text" class="form-control" id="category" name="arr" placeholder="Enter Category (e.g sonarr, radarr, radarr4k)">
|
||||
</div>
|
||||
|
||||
<div class="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">
|
||||
<i class="bi bi-cloud-upload me-2"></i>Add to Download Queue
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const loadSavedDownloadOptions = () => {
|
||||
const savedCategory = localStorage.getItem('downloadCategory');
|
||||
const savedSymlink = localStorage.getItem('downloadSymlink');
|
||||
const savedDownloadUncached = localStorage.getItem('downloadUncached');
|
||||
document.getElementById('category').value = savedCategory || '';
|
||||
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
|
||||
loadSavedDownloadOptions();
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('downloadForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.getElementById('submitDownload');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding...';
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
|
||||
// Add URLs if present
|
||||
const urls = document.getElementById('magnetURI').value
|
||||
.split('\n')
|
||||
.map(url => url.trim())
|
||||
.filter(url => url.length > 0);
|
||||
|
||||
if (urls.length > 0) {
|
||||
formData.append('urls', urls.join('\n'));
|
||||
}
|
||||
|
||||
// Add torrent files if present
|
||||
const fileInput = document.getElementById('torrentFiles');
|
||||
for (let i = 0; i < fileInput.files.length; i++) {
|
||||
formData.append('files', fileInput.files[i]);
|
||||
}
|
||||
|
||||
if (urls.length + fileInput.files.length === 0) {
|
||||
createToast('Please submit at least one torrent', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (urls.length + fileInput.files.length > 100) {
|
||||
createToast('Please submit up to 100 torrents at a time', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
formData.append('arr', document.getElementById('category').value);
|
||||
formData.append('notSymlink', document.getElementById('isSymlink').checked);
|
||||
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
|
||||
|
||||
const response = await fetch('/internal/add', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (!response.ok) throw new Error(result.error || 'Unknown error');
|
||||
if (result.errors && result.errors.length > 0) {
|
||||
if (result.results.length > 0) {
|
||||
createToast(`Added ${result.results.length} torrents with ${result.errors.length} errors:\n${result.errors.join('\n')}`, 'warning');
|
||||
} else {
|
||||
createToast(`Failed to add torrents:\n${result.errors.join('\n')}`, 'error');
|
||||
}
|
||||
} else {
|
||||
createToast(`Successfully added ${result.results.length} torrents!`);
|
||||
document.getElementById('magnetURI').value = '';
|
||||
document.getElementById('torrentFiles').value = '';
|
||||
}
|
||||
} catch (error) {
|
||||
createToast(`Error adding downloads: ${error.message}`, 'error');
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
}
|
||||
});
|
||||
|
||||
// Save the download options to local storage when they change
|
||||
document.getElementById('category').addEventListener('change', saveCurrentDownloadOptions);
|
||||
document.getElementById('isSymlink').addEventListener('change', saveCurrentDownloadOptions);
|
||||
|
||||
// Read the URL parameters for a magnet link and add it to the download queue if found
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const magnetURI = urlParams.get('magnet');
|
||||
if (magnetURI) {
|
||||
document.getElementById('magnetURI').value = magnetURI;
|
||||
history.replaceState({}, document.title, window.location.pathname);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
416
pkg/web/web/index.html
Normal file
416
pkg/web/web/index.html
Normal file
@@ -0,0 +1,416 @@
|
||||
{{ define "index" }}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-4">
|
||||
<h4 class="mb-0 text-nowrap"><i class="bi bi-table me-2"></i>Active Torrents</h4>
|
||||
<div class="d-flex align-items-center overflow-auto" style="flex-wrap: nowrap; gap: 0.5rem;">
|
||||
<button class="btn btn-outline-danger btn-sm" id="batchDeleteBtn" style="display: none; flex-shrink: 0;">
|
||||
<i class="bi bi-trash me-1"></i>Delete Selected
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm me-2" id="refreshBtn" style="flex-shrink: 0;">
|
||||
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
|
||||
</button>
|
||||
<select class="form-select form-select-sm d-inline-block w-auto me-2" id="stateFilter" style="flex-shrink: 0;">
|
||||
<option value="">All States</option>
|
||||
<option value="pausedup">Completed</option>
|
||||
<option value="downloading">Downloading</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">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" class="form-check-input" id="selectAll">
|
||||
</th>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Progress</th>
|
||||
<th>Speed</th>
|
||||
<th>Category</th>
|
||||
<th>Debrid</th>
|
||||
<th>State</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="torrentsList">
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div 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>
|
||||
<script>
|
||||
let refs = {
|
||||
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: [],
|
||||
selectedTorrents: new Set(),
|
||||
categories: new Set(),
|
||||
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) => `
|
||||
<tr data-hash="${torrent.hash}">
|
||||
<td>
|
||||
<input type="checkbox" class="form-check-input torrent-select" data-hash="${torrent.hash}" ${state.selectedTorrents.has(torrent.hash) ? 'checked' : ''}>
|
||||
</td>
|
||||
<td class="text-nowrap text-truncate overflow-hidden" style="max-width: 350px;" title="${torrent.name}">${torrent.name}</td>
|
||||
<td class="text-nowrap">${formatBytes(torrent.size)}</td>
|
||||
<td style="min-width: 150px;">
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: ${(torrent.progress * 100).toFixed(1)}%"
|
||||
aria-valuenow="${(torrent.progress * 100).toFixed(1)}"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"></div>
|
||||
</div>
|
||||
<small class="text-muted">${(torrent.progress * 100).toFixed(1)}%</small>
|
||||
</td>
|
||||
<td>${formatSpeed(torrent.dlspeed)}</td>
|
||||
<td><span class="badge bg-secondary">${torrent.category || 'None'}</span></td>
|
||||
<td>${torrent.debrid || 'None'}</td>
|
||||
<td><span class="badge ${getStateColor(torrent.state)}">${torrent.state}</span></td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category}')">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
|
||||
function formatBytes(bytes) {
|
||||
if (!bytes) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
function formatSpeed(speed) {
|
||||
return `${formatBytes(speed)}/s`;
|
||||
}
|
||||
|
||||
function getStateColor(state) {
|
||||
const stateColors = {
|
||||
'downloading': 'bg-primary',
|
||||
'pausedup': 'bg-success',
|
||||
'error': 'bg-danger',
|
||||
};
|
||||
return stateColors[state?.toLowerCase()] || 'bg-secondary';
|
||||
}
|
||||
|
||||
function updateUI() {
|
||||
// Filter torrents by selected category and state
|
||||
let filteredTorrents = state.torrents;
|
||||
if (state.selectedCategory) {
|
||||
filteredTorrents = filteredTorrents.filter(t => t.category === state.selectedCategory);
|
||||
}
|
||||
if (state.selectedState) {
|
||||
filteredTorrents = filteredTorrents.filter(t => t.state === state.selectedState);
|
||||
}
|
||||
|
||||
// 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 = paginatedTorrents.map(torrent => torrentRowTemplate(torrent)).join('');
|
||||
|
||||
|
||||
// Update the category filter dropdown
|
||||
const currentCategories = Array.from(state.categories).sort();
|
||||
const categoryOptions = ['<option value="">All Categories</option>']
|
||||
.concat(currentCategories.map(cat =>
|
||||
`<option value="${cat}" ${cat === state.selectedCategory ? 'selected' : ''}>${cat}</option>`
|
||||
));
|
||||
refs.categoryFilter.innerHTML = categoryOptions.join('');
|
||||
|
||||
// Clean up selected torrents that no longer exist
|
||||
state.selectedTorrents = new Set(
|
||||
Array.from(state.selectedTorrents)
|
||||
.filter(hash => filteredTorrents.some(t => t.hash === hash))
|
||||
);
|
||||
|
||||
// Update batch delete button visibility
|
||||
refs.batchDeleteBtn.style.display = state.selectedTorrents.size > 0 ? '' : 'none';
|
||||
|
||||
// Update the select all checkbox state
|
||||
refs.selectAll.checked = filteredTorrents.length > 0 && filteredTorrents.every(torrent => state.selectedTorrents.has(torrent.hash));
|
||||
}
|
||||
|
||||
async function loadTorrents() {
|
||||
try {
|
||||
const response = await fetch('/internal/torrents');
|
||||
const torrents = await response.json();
|
||||
|
||||
state.torrents = torrents;
|
||||
state.categories = new Set(torrents.map(t => t.category).filter(Boolean));
|
||||
|
||||
updateUI();
|
||||
} catch (error) {
|
||||
console.error('Error loading torrents:', error);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
await fetch(`/internal/torrents/${category}/${hash}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await loadTorrents();
|
||||
createToast('Torrent deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('Error deleting torrent:', error);
|
||||
createToast('Failed to delete torrent', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteSelectedTorrents() {
|
||||
if (!confirm(`Are you sure you want to delete ${state.selectedTorrents.size} selected torrents?`)) return;
|
||||
|
||||
try {
|
||||
// COmma separated list of hashes
|
||||
const hashes = Array.from(state.selectedTorrents).join(',');
|
||||
await fetch(`/internal/torrents/?hashes=${encodeURIComponent(hashes)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await loadTorrents();
|
||||
createToast('Selected torrents deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('Error deleting torrents:', error);
|
||||
createToast('Failed to delete some torrents' , 'error');
|
||||
}
|
||||
}
|
||||
|
||||
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">«</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">»</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);
|
||||
|
||||
refs.refreshBtn.addEventListener('click', loadTorrents);
|
||||
refs.batchDeleteBtn.addEventListener('click', deleteSelectedTorrents);
|
||||
|
||||
refs.selectAll.addEventListener('change', (e) => {
|
||||
const filteredTorrents = state.torrents.filter(t => {
|
||||
if (state.selectedCategory && t.category !== state.selectedCategory) return false;
|
||||
if (state.selectedState && t.state?.toLowerCase() !== state.selectedState.toLowerCase()) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
if (e.target.checked) {
|
||||
filteredTorrents.forEach(torrent => state.selectedTorrents.add(torrent.hash));
|
||||
} else {
|
||||
filteredTorrents.forEach(torrent => state.selectedTorrents.delete(torrent.hash));
|
||||
}
|
||||
updateUI();
|
||||
});
|
||||
|
||||
refs.torrentsList.addEventListener('change', (e) => {
|
||||
if (e.target.classList.contains('torrent-select')) {
|
||||
const hash = e.target.dataset.hash;
|
||||
if (e.target.checked) {
|
||||
state.selectedTorrents.add(hash);
|
||||
} else {
|
||||
state.selectedTorrents.delete(hash);
|
||||
}
|
||||
updateUI();
|
||||
}
|
||||
});
|
||||
|
||||
refs.categoryFilter.addEventListener('change', (e) => {
|
||||
state.selectedCategory = e.target.value;
|
||||
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();
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
clearInterval(refreshInterval);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
313
pkg/web/web/layout.html
Normal file
313
pkg/web/web/layout.html
Normal file
@@ -0,0 +1,313 @@
|
||||
{{ define "layout" }}
|
||||
<!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;
|
||||
}
|
||||
|
||||
[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;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-color);
|
||||
color: var(--text-color);
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
padding: 1rem 0;
|
||||
background: var(--card-bg) !important;
|
||||
box-shadow: var(--nav-shadow);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: var(--primary-color) !important;
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--card-shadow);
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem 1rem;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
color: var(--primary-color) !important;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.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">
|
||||
<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>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{{ if eq .Page "index" }}
|
||||
{{ template "index" . }}
|
||||
{{ else if eq .Page "download" }}
|
||||
{{ template "download" . }}
|
||||
{{ else if eq .Page "repair" }}
|
||||
{{ template "repair" . }}
|
||||
{{ else if eq .Page "config" }}
|
||||
{{ template "config" . }}
|
||||
{{ else if eq .Page "login" }}
|
||||
{{ template "login" . }}
|
||||
{{ else if eq .Page "setup" }}
|
||||
{{ template "setup" . }}
|
||||
{{ else }}
|
||||
{{ end }}
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||
<script>
|
||||
/**
|
||||
* Create a toast message
|
||||
* @param {string} message - The message to display
|
||||
* @param {string} [type='success'] - The type of toast (success, warning, error)
|
||||
*/
|
||||
const createToast = (message, type = 'success') => {
|
||||
type = ['success', 'warning', 'error'].includes(type) ? type : 'success';
|
||||
|
||||
const toastTimeouts = {
|
||||
success: 5000,
|
||||
warning: 10000,
|
||||
error: 15000
|
||||
};
|
||||
|
||||
const toastContainer = document.querySelector('.toast-container');
|
||||
const toastId = `toast-${Date.now()}`;
|
||||
|
||||
const toastHtml = `
|
||||
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header ${type === 'error' ? 'bg-danger text-white' : type === 'warning' ? 'bg-warning text-dark' : 'bg-success text-white'}">
|
||||
<strong class="me-auto">
|
||||
${type === 'error' ? 'Error' : type === 'warning' ? 'Warning' : 'Success'}
|
||||
</strong>
|
||||
<button type="button" class="btn-close ${type === 'warning' ? '' : 'btn-close-white'}" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
${message.replace(/\n/g, '<br>')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
|
||||
|
||||
const toastElement = document.getElementById(toastId);
|
||||
const toast = new bootstrap.Toast(toastElement, {
|
||||
autohide: true,
|
||||
delay: toastTimeouts[type]
|
||||
});
|
||||
|
||||
toast.show();
|
||||
|
||||
toastElement.addEventListener('hidden.bs.toast', () => {
|
||||
toastElement.remove();
|
||||
});
|
||||
};
|
||||
|
||||
// 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 }}
|
||||
131
pkg/web/web/login.html
Normal file
131
pkg/web/web/login.html
Normal file
@@ -0,0 +1,131 @@
|
||||
{{ define "login" }}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0 text-center">Login</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="loginForm">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Login</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = {
|
||||
username: document.getElementById('username').value,
|
||||
password: document.getElementById('password').value
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
createToast('Invalid credentials', 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
createToast('Login failed', 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
|
||||
{{ define "setup" }}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0 text-center">First Time Setup</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="setupForm">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Choose Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Choose Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Set Credentials</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('setupForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
createToast('Passwords do not match', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const formData = {
|
||||
username: document.getElementById('username').value,
|
||||
password: password,
|
||||
confirmPassword: confirmPassword
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/setup', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
window.location.href = '/';
|
||||
} else {
|
||||
const error = await response.text();
|
||||
createToast(error, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Setup error:', error);
|
||||
createToast('Setup failed', 'error');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{ end }}
|
||||
577
pkg/web/web/repair.html
Normal file
577
pkg/web/web/repair.html
Normal file
@@ -0,0 +1,577 @@
|
||||
{{ define "repair" }}
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0"><i class="bi bi-tools me-2"></i>Repair Media</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="repairForm">
|
||||
<div class="mb-3">
|
||||
<label for="arrSelect" class="form-label">Select Arr Instance</label>
|
||||
<select class="form-select" id="arrSelect" required>
|
||||
<option value="">Select an Arr instance</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="mediaIds" class="form-label">Media IDs</label>
|
||||
<input type="text" class="form-control" id="mediaIds"
|
||||
placeholder="Enter IDs (comma-separated)">
|
||||
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
|
||||
</div>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="isAsync" checked>
|
||||
<label class="form-check-label" for="isAsync">
|
||||
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>
|
||||
|
||||
<button type="submit" class="btn btn-primary" id="submitRepair">
|
||||
<i class="bi bi-wrench me-2"></i>Start Repair
|
||||
</button>
|
||||
</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
|
||||
fetch('/internal/arrs')
|
||||
.then(response => response.json())
|
||||
.then(arrs => {
|
||||
const select = document.getElementById('arrSelect');
|
||||
arrs.forEach(arr => {
|
||||
const option = document.createElement('option');
|
||||
option.value = arr.name;
|
||||
option.textContent = arr.name;
|
||||
select.appendChild(option);
|
||||
});
|
||||
});
|
||||
|
||||
// Handle form submission
|
||||
document.getElementById('repairForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = document.getElementById('submitRepair');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Repairing...';
|
||||
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
|
||||
let arr = document.getElementById('arrSelect').value;
|
||||
if (!arr) {
|
||||
createToast('Please select an Arr instance', 'warning');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.innerHTML = originalText;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch('/internal/repair', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
arr: document.getElementById('arrSelect').value,
|
||||
mediaIds: mediaIds,
|
||||
async: document.getElementById('isAsync').checked,
|
||||
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 {
|
||||
submitBtn.disabled = false;
|
||||
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">«</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">»</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 }}
|
||||
32
pkg/web/web/setup.html
Normal file
32
pkg/web/web/setup.html
Normal file
@@ -0,0 +1,32 @@
|
||||
{{ define "setup" }}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0 text-center">First Time Setup</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="setupForm" method="POST" action="/setup">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Choose Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Choose Password</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirmPassword" class="form-label">Confirm Password</label>
|
||||
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">Set Credentials</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
160
pkg/webdav/file.go
Normal file
160
pkg/webdav/file.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/cache"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
cache *cache.Cache
|
||||
cachedTorrent *cache.CachedTorrent
|
||||
file *torrent.File
|
||||
offset int64
|
||||
isDir bool
|
||||
children []os.FileInfo
|
||||
reader io.ReadCloser
|
||||
}
|
||||
|
||||
// File interface implementations for File
|
||||
|
||||
func (f *File) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *File) GetDownloadLink() string {
|
||||
file := f.file
|
||||
link, err := f.cache.GetFileDownloadLink(f.cachedTorrent, file)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return link
|
||||
}
|
||||
|
||||
func (f *File) Read(p []byte) (n int, err error) {
|
||||
// Directories cannot be read as a byte stream.
|
||||
if f.isDir {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
// If we haven't started streaming the file yet, open the HTTP connection.
|
||||
if f.reader == nil {
|
||||
// Create an HTTP GET request to the file's URL.
|
||||
req, err := http.NewRequest("GET", f.GetDownloadLink(), nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create HTTP request: %w", err)
|
||||
}
|
||||
|
||||
// If we've already read some data (f.offset > 0), request only the remaining bytes.
|
||||
if f.offset > 0 {
|
||||
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", f.offset))
|
||||
}
|
||||
|
||||
// Execute the HTTP request.
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("HTTP request error: %w", err)
|
||||
}
|
||||
|
||||
// Accept a 200 (OK) or 206 (Partial Content) status.
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
resp.Body.Close()
|
||||
return 0, fmt.Errorf("unexpected HTTP status: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Store the response body as our reader.
|
||||
f.reader = resp.Body
|
||||
}
|
||||
|
||||
// Read data from the HTTP stream.
|
||||
n, err = f.reader.Read(p)
|
||||
f.offset += int64(n)
|
||||
|
||||
// When we reach the end of the stream, close the reader.
|
||||
if err == io.EOF {
|
||||
f.reader.Close()
|
||||
f.reader = nil
|
||||
}
|
||||
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (f *File) Seek(offset int64, whence int) (int64, error) {
|
||||
if f.isDir {
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
switch whence {
|
||||
case io.SeekStart:
|
||||
f.offset = offset
|
||||
case io.SeekCurrent:
|
||||
f.offset += offset
|
||||
case io.SeekEnd:
|
||||
f.offset = f.file.Size - offset
|
||||
default:
|
||||
return 0, os.ErrInvalid
|
||||
}
|
||||
|
||||
if f.offset < 0 {
|
||||
f.offset = 0
|
||||
}
|
||||
if f.offset > f.file.Size {
|
||||
f.offset = f.file.Size
|
||||
}
|
||||
|
||||
return f.offset, nil
|
||||
}
|
||||
|
||||
func (f *File) Readdir(count int) ([]os.FileInfo, error) {
|
||||
if !f.isDir {
|
||||
return nil, os.ErrInvalid
|
||||
}
|
||||
|
||||
if count <= 0 {
|
||||
return f.children, nil
|
||||
}
|
||||
|
||||
if len(f.children) == 0 {
|
||||
return nil, io.EOF
|
||||
}
|
||||
|
||||
if count > len(f.children) {
|
||||
count = len(f.children)
|
||||
}
|
||||
|
||||
files := f.children[:count]
|
||||
f.children = f.children[count:]
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (f *File) Stat() (os.FileInfo, error) {
|
||||
if f.isDir {
|
||||
name := "/"
|
||||
if f.cachedTorrent != nil {
|
||||
name = f.cachedTorrent.Name
|
||||
}
|
||||
return &FileInfo{
|
||||
name: name,
|
||||
size: 0,
|
||||
mode: 0755 | os.ModeDir,
|
||||
modTime: time.Now(),
|
||||
isDir: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
return &FileInfo{
|
||||
name: f.file.Name,
|
||||
size: f.file.Size,
|
||||
mode: 0644,
|
||||
modTime: time.Now(),
|
||||
isDir: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *File) Write(p []byte) (n int, err error) {
|
||||
return 0, os.ErrPermission
|
||||
}
|
||||
22
pkg/webdav/file_info.go
Normal file
22
pkg/webdav/file_info.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FileInfo implements os.FileInfo for our WebDAV files
|
||||
type FileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
mode os.FileMode
|
||||
modTime time.Time
|
||||
isDir bool
|
||||
}
|
||||
|
||||
func (fi *FileInfo) Name() string { return fi.name }
|
||||
func (fi *FileInfo) Size() int64 { return fi.size }
|
||||
func (fi *FileInfo) Mode() os.FileMode { return fi.mode }
|
||||
func (fi *FileInfo) ModTime() time.Time { return fi.modTime }
|
||||
func (fi *FileInfo) IsDir() bool { return fi.isDir }
|
||||
func (fi *FileInfo) Sys() interface{} { return nil }
|
||||
263
pkg/webdav/handler.go
Normal file
263
pkg/webdav/handler.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/cache"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
|
||||
"golang.org/x/net/webdav"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
Name string
|
||||
logger zerolog.Logger
|
||||
cache *cache.Cache
|
||||
rootListing atomic.Value
|
||||
lastRefresh time.Time
|
||||
refreshMutex sync.Mutex
|
||||
RootPath string
|
||||
}
|
||||
|
||||
func NewHandler(name string, cache *cache.Cache, logger zerolog.Logger) *Handler {
|
||||
h := &Handler{
|
||||
Name: name,
|
||||
cache: cache,
|
||||
logger: logger,
|
||||
RootPath: fmt.Sprintf("/%s", name),
|
||||
}
|
||||
|
||||
h.refreshRootListing()
|
||||
|
||||
// Start background refresh
|
||||
go h.backgroundRefresh()
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Handler) backgroundRefresh() {
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
h.refreshRootListing()
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) refreshRootListing() {
|
||||
h.refreshMutex.Lock()
|
||||
defer h.refreshMutex.Unlock()
|
||||
|
||||
if time.Since(h.lastRefresh) < time.Minute {
|
||||
return
|
||||
}
|
||||
|
||||
var files []os.FileInfo
|
||||
h.cache.GetTorrents().Range(func(key, value interface{}) bool {
|
||||
cachedTorrent := value.(*cache.CachedTorrent)
|
||||
if cachedTorrent != nil && cachedTorrent.Torrent != nil {
|
||||
files = append(files, &FileInfo{
|
||||
name: cachedTorrent.Torrent.Name,
|
||||
size: 0,
|
||||
mode: 0755 | os.ModeDir,
|
||||
modTime: time.Now(),
|
||||
isDir: true,
|
||||
})
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
h.rootListing.Store(files)
|
||||
h.lastRefresh = time.Now()
|
||||
}
|
||||
|
||||
func (h *Handler) getParentRootPath() string {
|
||||
return fmt.Sprintf("/webdav/%s", h.Name)
|
||||
}
|
||||
|
||||
func (h *Handler) getRootFileInfos() []os.FileInfo {
|
||||
if listing := h.rootListing.Load(); listing != nil {
|
||||
return listing.([]os.FileInfo)
|
||||
}
|
||||
return []os.FileInfo{}
|
||||
}
|
||||
|
||||
// Mkdir implements webdav.FileSystem
|
||||
func (h *Handler) Mkdir(ctx context.Context, name string, perm os.FileMode) error {
|
||||
return os.ErrPermission // Read-only filesystem
|
||||
}
|
||||
|
||||
// RemoveAll implements webdav.FileSystem
|
||||
func (h *Handler) RemoveAll(ctx context.Context, name string) error {
|
||||
return os.ErrPermission // Read-only filesystem
|
||||
}
|
||||
|
||||
// Rename implements webdav.FileSystem
|
||||
func (h *Handler) Rename(ctx context.Context, oldName, newName string) error {
|
||||
return os.ErrPermission // Read-only filesystem
|
||||
}
|
||||
|
||||
func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.FileMode) (webdav.File, error) {
|
||||
name = path.Clean("/" + name)
|
||||
|
||||
// Fast path for root directory
|
||||
if name == h.getParentRootPath() {
|
||||
return &File{
|
||||
cache: h.cache,
|
||||
isDir: true,
|
||||
children: h.getRootFileInfos(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Remove root directory from path
|
||||
name = strings.TrimPrefix(name, h.getParentRootPath())
|
||||
name = strings.TrimPrefix(name, "/")
|
||||
parts := strings.SplitN(name, "/", 2)
|
||||
|
||||
// Get torrent from cache using sync.Map
|
||||
cachedTorrent := h.cache.GetTorrentByName(parts[0])
|
||||
if cachedTorrent == nil {
|
||||
h.logger.Debug().Msgf("Torrent not found: %s", parts[0])
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
return &File{
|
||||
cache: h.cache,
|
||||
cachedTorrent: cachedTorrent,
|
||||
isDir: true,
|
||||
children: h.getTorrentFileInfos(cachedTorrent.Torrent),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Use a map for faster file lookup
|
||||
fileMap := make(map[string]*torrent.File, len(cachedTorrent.Torrent.Files))
|
||||
for i := range cachedTorrent.Torrent.Files {
|
||||
fileMap[cachedTorrent.Torrent.Files[i].Name] = &cachedTorrent.Torrent.Files[i]
|
||||
}
|
||||
|
||||
if file, ok := fileMap[parts[1]]; ok {
|
||||
return &File{
|
||||
cache: h.cache,
|
||||
cachedTorrent: cachedTorrent,
|
||||
file: file,
|
||||
isDir: false,
|
||||
}, nil
|
||||
}
|
||||
|
||||
h.logger.Debug().Msgf("File not found: %s", name)
|
||||
return nil, os.ErrNotExist
|
||||
}
|
||||
|
||||
// Stat implements webdav.FileSystem
|
||||
func (h *Handler) Stat(ctx context.Context, name string) (os.FileInfo, error) {
|
||||
f, err := h.OpenFile(ctx, name, os.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f.Stat()
|
||||
}
|
||||
|
||||
func (h *Handler) getTorrentFileInfos(torrent *torrent.Torrent) []os.FileInfo {
|
||||
files := make([]os.FileInfo, 0, len(torrent.Files))
|
||||
for _, file := range torrent.Files {
|
||||
files = append(files, &FileInfo{
|
||||
name: file.Name,
|
||||
size: file.Size,
|
||||
mode: 0644,
|
||||
modTime: time.Now(),
|
||||
isDir: false,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle OPTIONS
|
||||
if r.Method == "OPTIONS" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Create WebDAV handler
|
||||
handler := &webdav.Handler{
|
||||
FileSystem: h,
|
||||
LockSystem: webdav.NewMemLS(),
|
||||
Logger: func(r *http.Request, err error) {
|
||||
if err != nil {
|
||||
h.logger.Error().
|
||||
Err(err).
|
||||
Str("method", r.Method).
|
||||
Str("path", r.URL.Path).
|
||||
Msg("WebDAV error")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Special handling for GET requests on directories
|
||||
if r.Method == "GET" {
|
||||
if f, err := h.OpenFile(r.Context(), r.URL.Path, os.O_RDONLY, 0); err == nil {
|
||||
if fi, err := f.Stat(); err == nil && fi.IsDir() {
|
||||
h.serveDirectory(w, r, f)
|
||||
return
|
||||
}
|
||||
f.Close()
|
||||
}
|
||||
}
|
||||
handler.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (h *Handler) serveDirectory(w http.ResponseWriter, r *http.Request, file webdav.File) {
|
||||
var children []os.FileInfo
|
||||
if f, ok := file.(*File); ok {
|
||||
children = f.children
|
||||
} else {
|
||||
var err error
|
||||
children, err = file.Readdir(-1)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to list directory", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Clean and prepare the path
|
||||
cleanPath := path.Clean(r.URL.Path)
|
||||
parentPath := path.Dir(cleanPath)
|
||||
showParent := cleanPath != "/" && parentPath != "." && parentPath != cleanPath
|
||||
|
||||
// Prepare template data
|
||||
data := struct {
|
||||
Path string
|
||||
ParentPath string
|
||||
ShowParent bool
|
||||
Children []os.FileInfo
|
||||
}{
|
||||
Path: cleanPath,
|
||||
ParentPath: parentPath,
|
||||
ShowParent: showParent,
|
||||
Children: children,
|
||||
}
|
||||
|
||||
// Parse and execute template
|
||||
tmpl, err := template.New("directory").Parse(directoryTemplate)
|
||||
if err != nil {
|
||||
h.logger.Error().Err(err).Msg("Failed to parse directory template")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
h.logger.Error().Err(err).Msg("Failed to execute directory template")
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
112
pkg/webdav/templates.go
Normal file
112
pkg/webdav/templates.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package webdav
|
||||
|
||||
const rootTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>WebDAV Shares</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
margin: 10px 0;
|
||||
}
|
||||
a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
padding: 10px;
|
||||
display: block;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
a:hover {
|
||||
background-color: #f7f9fa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Available WebDAV Shares</h1>
|
||||
<ul>
|
||||
{{range .Handlers}}
|
||||
<li><a href="{{$.Prefix}}{{.RootPath}}">{{$.Prefix}}{{.RootPath}}</a></li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const directoryTemplate = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Index of {{.Path}}</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
h1 {
|
||||
color: #2c3e50;
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
li {
|
||||
margin: 10px 0;
|
||||
}
|
||||
a {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
padding: 10px;
|
||||
display: block;
|
||||
border: 1px solid #eee;
|
||||
border-radius: 4px;
|
||||
}
|
||||
a:hover {
|
||||
background-color: #f7f9fa;
|
||||
}
|
||||
.file-info {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
float: right;
|
||||
}
|
||||
.parent-dir {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Index of {{.Path}}</h1>
|
||||
<ul>
|
||||
{{if .ShowParent}}
|
||||
<li><a href="{{.ParentPath}}" class="parent-dir">Parent Directory</a></li>
|
||||
{{end}}
|
||||
{{range .Children}}
|
||||
<li>
|
||||
<a href="{{$.Path}}/{{.Name}}">
|
||||
{{.Name}}{{if .IsDir}}/{{end}}
|
||||
<span class="file-info">
|
||||
{{if not .IsDir}}
|
||||
{{.Size}} bytes
|
||||
{{end}}
|
||||
{{.ModTime.Format "2006-01-02 15:04:05"}}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
126
pkg/webdav/webdav.go
Normal file
126
pkg/webdav/webdav.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package webdav
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type WebDav struct {
|
||||
Handlers []*Handler
|
||||
}
|
||||
|
||||
func New() *WebDav {
|
||||
//svc := service.GetService()
|
||||
//cfg := config.GetConfig()
|
||||
w := &WebDav{
|
||||
Handlers: make([]*Handler, 0),
|
||||
}
|
||||
//for name, c := range svc.DebridCache.GetCaches() {
|
||||
// h := NewHandler(name, c, logger.NewLogger(fmt.Sprintf("%s-webdav", name), cfg.LogLevel, os.Stdout))
|
||||
// w.Handlers = append(w.Handlers, h)
|
||||
//}
|
||||
return w
|
||||
}
|
||||
|
||||
func (wd *WebDav) Routes() http.Handler {
|
||||
chi.RegisterMethod("PROPFIND")
|
||||
chi.RegisterMethod("PROPPATCH")
|
||||
chi.RegisterMethod("MKCOL") // Note: it was "MKOL" in your example, should be "MKCOL"
|
||||
chi.RegisterMethod("COPY")
|
||||
chi.RegisterMethod("MOVE")
|
||||
chi.RegisterMethod("LOCK")
|
||||
chi.RegisterMethod("UNLOCK")
|
||||
wr := chi.NewRouter()
|
||||
wr.Use(wd.commonMiddleware)
|
||||
|
||||
wd.setupRootHandler(wr)
|
||||
wd.mountHandlers(wr)
|
||||
|
||||
return wr
|
||||
}
|
||||
|
||||
func (wd *WebDav) Start(ctx context.Context) error {
|
||||
wg := sync.WaitGroup{}
|
||||
errChan := make(chan error, len(wd.Handlers))
|
||||
|
||||
for _, h := range wd.Handlers {
|
||||
wg.Add(1)
|
||||
go func(h *Handler) {
|
||||
defer wg.Done()
|
||||
if err := h.cache.Start(); err != nil {
|
||||
select {
|
||||
case errChan <- err:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}(h)
|
||||
}
|
||||
|
||||
// Use a separate goroutine to close channel after WaitGroup
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
}()
|
||||
|
||||
// Collect all errors
|
||||
var errors []error
|
||||
for err := range errChan {
|
||||
if err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("multiple handlers failed: %v", errors)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (wd *WebDav) mountHandlers(r chi.Router) {
|
||||
for _, h := range wd.Handlers {
|
||||
r.Mount(h.RootPath, h)
|
||||
}
|
||||
}
|
||||
|
||||
func (wd *WebDav) setupRootHandler(r chi.Router) {
|
||||
r.Get("/", wd.handleRoot())
|
||||
}
|
||||
|
||||
func (wd *WebDav) commonMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("DAV", "1, 2")
|
||||
w.Header().Set("Allow", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
w.Header().Set("Access-Control-Allow-Methods", "OPTIONS, PROPFIND, GET, HEAD, POST, PUT, DELETE, MKCOL, PROPPATCH, COPY, MOVE, LOCK, UNLOCK")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "Depth, Content-Type, Authorization")
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (wd *WebDav) handleRoot() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
tmpl, err := template.New("root").Parse(rootTemplate)
|
||||
if err != nil {
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
data := struct {
|
||||
Handlers []*Handler
|
||||
Prefix string
|
||||
}{
|
||||
Handlers: wd.Handlers,
|
||||
Prefix: "/webdav",
|
||||
}
|
||||
if err := tmpl.Execute(w, data); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
108
pkg/worker/worker.go
Normal file
108
pkg/worker/worker.go
Normal file
@@ -0,0 +1,108 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/config"
|
||||
"github.com/sirrobot01/debrid-blackhole/internal/logger"
|
||||
"github.com/sirrobot01/debrid-blackhole/pkg/service"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
_logInstance zerolog.Logger
|
||||
once sync.Once
|
||||
)
|
||||
|
||||
func getLogger() zerolog.Logger {
|
||||
|
||||
once.Do(func() {
|
||||
cfg := config.GetConfig()
|
||||
_logInstance = logger.NewLogger("worker", cfg.LogLevel, os.Stdout)
|
||||
})
|
||||
return _logInstance
|
||||
}
|
||||
|
||||
func Start(ctx context.Context) error {
|
||||
cfg := config.GetConfig()
|
||||
// Start Arr Refresh Worker
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
cleanUpQueuesWorker(ctx, cfg)
|
||||
}()
|
||||
wg.Wait()
|
||||
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)
|
||||
//
|
||||
// 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()
|
||||
_logger.Debug().Msg("Clean up Queues Worker started")
|
||||
cleanupCtx := context.WithValue(ctx, "worker", "cleanup")
|
||||
cleanupTicker := time.NewTicker(time.Duration(10) * time.Second)
|
||||
|
||||
var cleanupMutex sync.Mutex
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-cleanupCtx.Done():
|
||||
_logger.Debug().Msg("Clean up Queues Worker stopped")
|
||||
return
|
||||
case <-cleanupTicker.C:
|
||||
if cleanupMutex.TryLock() {
|
||||
go func() {
|
||||
defer cleanupMutex.Unlock()
|
||||
cleanUpQueues()
|
||||
}()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//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() {
|
||||
// Clean up queues
|
||||
_logger := getLogger()
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user