Compare commits
222 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d313ed0712 | ||
|
|
09202b88e9 | ||
|
|
d10a6ddedd | ||
|
|
7c1defc684 | ||
|
|
83a453cd0c | ||
|
|
a2bdad7c2a | ||
|
|
57ccd67c83 | ||
|
|
5aa1c67544 | ||
|
|
53748ea297 | ||
|
|
109d0a0c1c | ||
|
|
35a74d8dba | ||
|
|
b984697fe3 | ||
|
|
690d7668c1 | ||
|
|
3c8e6bae81 | ||
|
|
64edc5547d | ||
|
|
03a1d73825 | ||
|
|
3b018b3571 | ||
|
|
e5b3e0741e | ||
|
|
36e681d0e6 | ||
|
|
7c1bb52793 | ||
|
|
9de7cfd73b | ||
|
|
ffb1745bf6 | ||
|
|
0f56badb45 | ||
|
|
8e464cdcea | ||
|
|
4cdfd051f3 | ||
|
|
e05c6d5028 | ||
|
|
57de04b164 | ||
|
|
0deb88e265 | ||
|
|
21354529e7 | ||
|
|
ef820b5bf4 | ||
|
|
130433203f | ||
|
|
1248d99680 | ||
|
|
c0703cb622 | ||
|
|
6c2bfa811a | ||
|
|
75a5bb90a3 | ||
|
|
1f190e3cb6 | ||
|
|
5f06a244b8 | ||
|
|
10467ff9f8 | ||
|
|
f977c52571 | ||
|
|
a3e64cc269 | ||
|
|
e8112a4647 | ||
|
|
6e2d1e1a7f | ||
|
|
bce51ecd4f | ||
|
|
ae5e237379 | ||
|
|
07f1d0f28d | ||
|
|
267430e6fb | ||
|
|
1a4db69b20 | ||
|
|
3cc8ad3cdc | ||
|
|
fb39e92a88 | ||
|
|
2139d3a175 | ||
|
|
32935ce3aa | ||
|
|
a27c5dd491 | ||
|
|
dc8ee3d150 | ||
|
|
52877107c9 | ||
|
|
f34a371274 | ||
|
|
8c78da3f69 | ||
|
|
1983e27124 | ||
|
|
80615e06d1 | ||
|
|
b5b6f0ff73 | ||
|
|
3fe9053aa8 | ||
|
|
c07a85f4d0 | ||
|
|
af067cace9 | ||
|
|
ea79e2a6fb | ||
|
|
58fb4e6e14 | ||
|
|
39945616f3 | ||
|
|
8029cd3840 | ||
|
|
19b8664146 | ||
|
|
8ea128446c | ||
|
|
391900e93d | ||
|
|
5987028f05 | ||
|
|
7492f629f9 | ||
|
|
101ae4197e | ||
|
|
a357897222 | ||
|
|
92177b150b | ||
|
|
9011420ac3 | ||
|
|
4b5e18df94 | ||
|
|
003f73c456 | ||
|
|
b34935d490 | ||
|
|
4659cd4273 | ||
|
|
7d954052ae | ||
|
|
8bf164451c | ||
|
|
5792305a66 | ||
|
|
f9addaed36 | ||
|
|
face86e151 | ||
|
|
cf28f42db4 | ||
|
|
dc2301eb98 | ||
|
|
f9bc7ad914 | ||
|
|
4ae5de99e8 | ||
|
|
d49fbea60f | ||
|
|
7bd38736b1 | ||
|
|
56bca562f4 | ||
|
|
9469c98df7 | ||
|
|
8c13da5d30 | ||
|
|
dc6ee2f020 | ||
|
|
fce0fc0215 | ||
|
|
e2f792d5ab | ||
|
|
49875446b4 | ||
|
|
738474be16 | ||
|
|
d10b679584 | ||
|
|
f93d489956 | ||
|
|
8d494fc277 | ||
|
|
0c68364a6a | ||
|
|
4e2fb9c74f | ||
|
|
50c775ca74 | ||
|
|
0d178992ef | ||
|
|
5d2fabe20b | ||
|
|
fa469c64c6 | ||
|
|
26f6f384a3 | ||
|
|
b91aa1db38 | ||
|
|
e2ff3b26de | ||
|
|
2d29996d2c | ||
|
|
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 | ||
|
|
f6c6144601 | ||
|
|
ff74e279d9 | ||
|
|
ba147ac56c | ||
|
|
01981114cb | ||
|
|
2ec0354881 | ||
|
|
329e4c60f5 | ||
|
|
d5e07dc961 | ||
|
|
f622cbfe63 | ||
|
|
9511f3e99e | ||
|
|
60c6cb32d3 | ||
|
|
d405e0d8e0 | ||
|
|
74791d6e62 | ||
|
|
511df1a296 | ||
|
|
ad1ba7b505 | ||
|
|
7f0f71ba64 |
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/decypharr/pkg/version.Version=0.0.0 -X github.com/sirrobot01/decypharr/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,9 @@ docker-compose.yml
|
||||
.DS_Store
|
||||
**/.idea/
|
||||
*.magnet
|
||||
**.torrent
|
||||
**.torrent
|
||||
torrents.json
|
||||
**/dist/
|
||||
*.json
|
||||
.ven/**
|
||||
docs/**
|
||||
|
||||
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
|
||||
28
.github/workflows/deploy-docs.yml
vendored
Normal file
28
.github/workflows/deploy-docs.yml
vendored
Normal file
@@ -0,0 +1,28 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
permissions:
|
||||
contents: write
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Configure Git Credentials
|
||||
run: |
|
||||
git config user.name github-actions[bot]
|
||||
git config user.email 41898282+github-actions[bot]@users.noreply.github.com
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: 3.x
|
||||
- run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV
|
||||
- uses: actions/cache@v4
|
||||
with:
|
||||
key: mkdocs-material-${{ env.cache_id }}
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
- run: pip install mkdocs-material
|
||||
- run: cd docs && mkdocs gh-deploy --force
|
||||
0
.github/workflows/docker-publish.yml
vendored
0
.github/workflows/docker-publish.yml
vendored
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.24'
|
||||
|
||||
- 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
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,6 +1,5 @@
|
||||
data/
|
||||
config.json
|
||||
docker-compose.yml
|
||||
.idea/
|
||||
.DS_Store
|
||||
*.torrent
|
||||
@@ -9,3 +8,8 @@ docker-compose.yml
|
||||
*.log
|
||||
*.log.*
|
||||
dist/
|
||||
tmp/**
|
||||
torrents.json
|
||||
logs/**
|
||||
auth.json
|
||||
.ven/
|
||||
@@ -1,16 +1,7 @@
|
||||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
|
||||
# The lines below are called `modelines`. See `:help modeline`
|
||||
# Feel free to remove those if you don't want/need to use them.
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
|
||||
version: 2
|
||||
version: 1
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
|
||||
builds:
|
||||
@@ -20,18 +11,26 @@ builds:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X github.com/sirrobot01/decypharr/pkg/version.Version={{.Version}}
|
||||
- -X github.com/sirrobot01/decypharr/pkg/version.Channel={{.Env.RELEASE_CHANNEL}}
|
||||
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
name_template: >-
|
||||
{{ .ProjectName }}_
|
||||
decypharr_
|
||||
{{- title .Os }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
# use zip for windows archives
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
@@ -42,3 +41,7 @@ changelog:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
|
||||
# Environment setup
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
|
||||
72
Dockerfile
72
Dockerfile
@@ -1,24 +1,66 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:1.22 as builder
|
||||
# Stage 1: Build binaries
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24-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/decypharr/pkg/version.Version=${VERSION} -X github.com/sirrobot01/decypharr/pkg/version.Channel=${CHANNEL}" \
|
||||
-o /decypharr
|
||||
|
||||
FROM scratch
|
||||
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
|
||||
|
||||
# Run
|
||||
CMD ["/blackhole", "--config", "/app/config.json"]
|
||||
# Stage 2: Create directory structure
|
||||
FROM alpine:3.19 as dirsetup
|
||||
RUN mkdir -p /app/logs && \
|
||||
mkdir -p /app/cache && \
|
||||
chmod 777 /app/logs && \
|
||||
touch /app/logs/decypharr.log && \
|
||||
chmod 666 /app/logs/decypharr.log
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
|
||||
LABEL version = "${VERSION}-${CHANNEL}"
|
||||
|
||||
LABEL org.opencontainers.image.source = "https://github.com/sirrobot01/decypharr"
|
||||
LABEL org.opencontainers.image.title = "decypharr"
|
||||
LABEL org.opencontainers.image.authors = "sirrobot01"
|
||||
LABEL org.opencontainers.image.documentation = "https://github.com/sirrobot01/decypharr/blob/main/README.md"
|
||||
|
||||
# Copy binaries
|
||||
COPY --from=builder --chown=nonroot:nonroot /decypharr /usr/bin/decypharr
|
||||
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 8282
|
||||
VOLUME ["/app"]
|
||||
USER nonroot:nonroot
|
||||
|
||||
HEALTHCHECK --interval=3s --retries=10 CMD ["/usr/bin/healthcheck", "--config", "/app"]
|
||||
|
||||
CMD ["/usr/bin/decypharr", "--config", "/app"]
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Mukhtar Akere
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
132
README.md
132
README.md
@@ -1,100 +1,90 @@
|
||||
### GoBlackHole(with Debrid Proxy Support)
|
||||
# Decypharr
|
||||
|
||||
This is a Golang implementation go Torrent Blackhole with a **Real Debrid Proxy Support**.
|
||||

|
||||
|
||||
#### Uses
|
||||
- Torrent Blackhole that supports the Arrs.
|
||||
- Proxy support for the Arrs
|
||||
**Decypharr** is an implementation of QbitTorrent with **Multiple Debrid service support**, written in Go.
|
||||
|
||||
The proxy is useful in filtering out un-cached Real Debrid torrents
|
||||
## What is Decypharr?
|
||||
|
||||
Decypharr combines the power of QBittorrent with popular Debrid services to enhance your media management. It provides a familiar interface for Sonarr, Radarr, and other \*Arr applications while leveraging the capabilities of Debrid providers.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔄 Mock Qbittorent API that supports the Arrs (Sonarr, Radarr, Lidarr etc)
|
||||
- 🖥️ Full-fledged UI for managing torrents
|
||||
- 🛡️ Proxy support for filtering out un-cached Debrid torrents
|
||||
- 🔌 Multiple Debrid providers support
|
||||
- 📁 WebDAV server support for each debrid provider
|
||||
- 🔧 Repair Worker for missing files
|
||||
|
||||
## Supported Debrid Providers
|
||||
|
||||
- [Real Debrid](https://real-debrid.com)
|
||||
- [Torbox](https://torbox.app)
|
||||
- [Debrid Link](https://debrid-link.com)
|
||||
- [All Debrid](https://alldebrid.com)
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Docker (Recommended)
|
||||
|
||||
#### Installation
|
||||
##### Docker Compose
|
||||
```yaml
|
||||
version: '3.7'
|
||||
services:
|
||||
blackhole:
|
||||
image: cy01/blackhole:latest
|
||||
container_name: blackhole
|
||||
decypharr:
|
||||
image: cy01/blackhole:latest # or cy01/blackhole:beta
|
||||
container_name: decypharr
|
||||
ports:
|
||||
- "8282:8282" # qBittorrent
|
||||
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
|
||||
- ./configs/:/app # config.json must be in this directory
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- UMASK=002
|
||||
restart: unless-stopped
|
||||
|
||||
```
|
||||
|
||||
##### Binary
|
||||
Download the binary from the releases page and run it with the config file.
|
||||
## Documentation
|
||||
|
||||
```bash
|
||||
./blackhole --config /path/to/config.json
|
||||
```
|
||||
For complete documentation, please visit our [Documentation](https://sirrobot01.github.io/decypharr/).
|
||||
|
||||
The documentation includes:
|
||||
|
||||
- Detailed installation instructions
|
||||
- Configuration guide
|
||||
- Usage with Sonarr/Radarr
|
||||
- WebDAV setup
|
||||
- Repair Worker information
|
||||
- ...and more!
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
#### 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"
|
||||
},
|
||||
"arrs": [
|
||||
"debrids": [
|
||||
{
|
||||
"watch_folder": "data/sonarr/",
|
||||
"completed_folder": "data/sonarr/completed/",
|
||||
"token": "sonarr_api_key",
|
||||
"url": "http://localhost:8787"
|
||||
},
|
||||
{
|
||||
"watch_folder": "data/radarr/",
|
||||
"completed_folder": "data/radarr/completed/",
|
||||
"token": "radarr_api_key",
|
||||
"url": "http://localhost:7878"
|
||||
},
|
||||
{
|
||||
"watch_folder": "data/radarr4k/",
|
||||
"completed_folder": "data/radarr4k/completed/",
|
||||
"token": "radarr4k_api_key",
|
||||
"url": "http://localhost:7878"
|
||||
"name": "realdebrid",
|
||||
"api_key": "your_api_key_here",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"use_webdav": true
|
||||
}
|
||||
],
|
||||
"proxy": {
|
||||
"enabled": true,
|
||||
"port": "8181",
|
||||
"debug": false,
|
||||
"username": "username",
|
||||
"password": "password"
|
||||
}
|
||||
"qbittorrent": {
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr"]
|
||||
},
|
||||
"use_auth": false,
|
||||
"log_level": "info",
|
||||
"port": "8282"
|
||||
}
|
||||
```
|
||||
|
||||
#### Proxy
|
||||
## Contributing
|
||||
|
||||
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.
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
|
||||
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`
|
||||
|
||||
### TODO
|
||||
- [ ] Add more Debrid Providers
|
||||
- [ ] Add more Proxy features
|
||||
- [ ] Add more tests
|
||||
## License
|
||||
This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details.
|
||||
169
cmd/blackhole.go
169
cmd/blackhole.go
@@ -1,169 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/fsnotify/fsnotify"
|
||||
"goBlack/common"
|
||||
"goBlack/debrid"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func fileReady(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return !os.IsNotExist(err) // Returns true if the file exists
|
||||
}
|
||||
|
||||
func checkFileLoop(wg *sync.WaitGroup, dir string, file debrid.TorrentFile, ready chan<- debrid.TorrentFile) {
|
||||
defer wg.Done()
|
||||
ticker := time.NewTicker(1 * time.Second) // Check every second
|
||||
defer ticker.Stop()
|
||||
path := filepath.Join(dir, file.Path)
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if fileReady(path) {
|
||||
ready <- file
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func ProcessFiles(arr *debrid.Arr, torrent *debrid.Torrent) {
|
||||
var wg sync.WaitGroup
|
||||
files := torrent.Files
|
||||
ready := make(chan debrid.TorrentFile, len(files))
|
||||
|
||||
log.Println("Checking files...")
|
||||
|
||||
for _, file := range files {
|
||||
wg.Add(1)
|
||||
go checkFileLoop(&wg, arr.Debrid.Folder, file, ready)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(ready)
|
||||
}()
|
||||
|
||||
for r := range ready {
|
||||
log.Println("File is ready:", r.Name)
|
||||
CreateSymLink(arr, torrent)
|
||||
|
||||
}
|
||||
go torrent.Cleanup(true)
|
||||
fmt.Printf("%s downloaded", torrent.Name)
|
||||
}
|
||||
|
||||
func CreateSymLink(config *debrid.Arr, torrent *debrid.Torrent) {
|
||||
path := filepath.Join(config.CompletedFolder, torrent.Folder)
|
||||
err := os.MkdirAll(path, os.ModePerm)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create directory: %s\n", path)
|
||||
}
|
||||
for _, file := range torrent.Files {
|
||||
// Combine the directory and filename to form a full path
|
||||
fullPath := filepath.Join(config.CompletedFolder, file.Path)
|
||||
|
||||
// Create a symbolic link if file doesn't exist
|
||||
_ = os.Symlink(filepath.Join(config.Debrid.Folder, file.Path), fullPath)
|
||||
}
|
||||
}
|
||||
|
||||
func watchFiles(watcher *fsnotify.Watcher, events map[string]time.Time) {
|
||||
for {
|
||||
select {
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if event.Op&fsnotify.Write == fsnotify.Write {
|
||||
if filepath.Ext(event.Name) == ".torrent" || filepath.Ext(event.Name) == ".magnet" {
|
||||
events[event.Name] = time.Now()
|
||||
}
|
||||
|
||||
}
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Println("ERROR:", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processFilesDebounced(arr *debrid.Arr, db debrid.Service, events map[string]time.Time, debouncePeriod time.Duration) {
|
||||
ticker := time.NewTicker(1 * time.Second) // Check every second
|
||||
defer ticker.Stop()
|
||||
|
||||
for range ticker.C {
|
||||
for file, lastEventTime := range events {
|
||||
if time.Since(lastEventTime) >= debouncePeriod {
|
||||
log.Printf("Torrent file detected: %s", file)
|
||||
// Process the torrent file
|
||||
torrent, err := db.Process(arr, file)
|
||||
if err != nil && torrent != nil {
|
||||
// remove torrent file
|
||||
torrent.Cleanup(true)
|
||||
_ = torrent.MarkAsFailed()
|
||||
log.Printf("Error processing torrent file: %s", err)
|
||||
}
|
||||
if err == nil && torrent != nil && len(torrent.Files) > 0 {
|
||||
go ProcessFiles(arr, torrent)
|
||||
}
|
||||
delete(events, file) // remove file from channel
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func StartArr(conf *debrid.Arr, db debrid.Service) {
|
||||
log.Printf("Watching: %s", conf.WatchFolder)
|
||||
w, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer func(w *fsnotify.Watcher) {
|
||||
err := w.Close()
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}(w)
|
||||
events := make(map[string]time.Time)
|
||||
|
||||
go watchFiles(w, events)
|
||||
if err = w.Add(conf.WatchFolder); err != nil {
|
||||
log.Println("Error Watching folder:", err)
|
||||
return
|
||||
}
|
||||
|
||||
processFilesDebounced(conf, db, events, 1*time.Second)
|
||||
}
|
||||
|
||||
func StartBlackhole(config *common.Config, deb debrid.Service) {
|
||||
var wg sync.WaitGroup
|
||||
for _, conf := range config.Arrs {
|
||||
wg.Add(1)
|
||||
defer wg.Done()
|
||||
headers := map[string]string{
|
||||
"X-Api-Key": conf.Token,
|
||||
}
|
||||
client := common.NewRLHTTPClient(nil, headers)
|
||||
|
||||
arr := &debrid.Arr{
|
||||
Debrid: config.Debrid,
|
||||
WatchFolder: conf.WatchFolder,
|
||||
CompletedFolder: conf.CompletedFolder,
|
||||
Token: conf.Token,
|
||||
URL: conf.URL,
|
||||
Client: client,
|
||||
}
|
||||
go StartArr(arr, deb)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
190
cmd/decypharr/main.go
Normal file
190
cmd/decypharr/main.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package decypharr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/pkg/qbit"
|
||||
"github.com/sirrobot01/decypharr/pkg/server"
|
||||
"github.com/sirrobot01/decypharr/pkg/service"
|
||||
"github.com/sirrobot01/decypharr/pkg/version"
|
||||
"github.com/sirrobot01/decypharr/pkg/web"
|
||||
"github.com/sirrobot01/decypharr/pkg/webdav"
|
||||
"github.com/sirrobot01/decypharr/pkg/worker"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
"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))
|
||||
}
|
||||
|
||||
restartCh := make(chan struct{}, 1)
|
||||
web.SetRestartFunc(func() {
|
||||
select {
|
||||
case restartCh <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
})
|
||||
|
||||
svcCtx, cancelSvc := context.WithCancel(ctx)
|
||||
defer cancelSvc()
|
||||
|
||||
for {
|
||||
cfg := config.Get()
|
||||
_log := logger.Default()
|
||||
|
||||
// ascii banner
|
||||
fmt.Printf(`
|
||||
+-------------------------------------------------------+
|
||||
| |
|
||||
| ╔╦╗╔═╗╔═╗╦ ╦╔═╗╦ ╦╔═╗╦═╗╦═╗ |
|
||||
| ║║║╣ ║ └┬┘╠═╝╠═╣╠═╣╠╦╝╠╦╝ (%s) |
|
||||
| ═╩╝╚═╝╚═╝ ┴ ╩ ╩ ╩╩ ╩╩╚═╩╚═ |
|
||||
| |
|
||||
+-------------------------------------------------------+
|
||||
| Log Level: %s |
|
||||
+-------------------------------------------------------+
|
||||
`, version.GetInfo(), cfg.LogLevel)
|
||||
|
||||
// Initialize services
|
||||
qb := qbit.New()
|
||||
wd := webdav.New()
|
||||
|
||||
ui := web.New(qb).Routes()
|
||||
webdavRoutes := wd.Routes()
|
||||
qbitRoutes := qb.Routes()
|
||||
|
||||
// Register routes
|
||||
handlers := map[string]http.Handler{
|
||||
"/": ui,
|
||||
"/api/v2": qbitRoutes,
|
||||
"/webdav": webdavRoutes,
|
||||
}
|
||||
srv := server.New(handlers)
|
||||
|
||||
done := make(chan struct{})
|
||||
go func(ctx context.Context) {
|
||||
if err := startServices(ctx, wd, srv); err != nil {
|
||||
_log.Error().Err(err).Msg("Error starting services")
|
||||
cancelSvc()
|
||||
}
|
||||
close(done)
|
||||
}(svcCtx)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// graceful shutdown
|
||||
cancelSvc() // propagate to services
|
||||
<-done // wait for them to finish
|
||||
return nil
|
||||
|
||||
case <-restartCh:
|
||||
cancelSvc() // tell existing services to shut down
|
||||
_log.Info().Msg("Restarting Decypharr...")
|
||||
<-done // wait for them to finish
|
||||
qb.Reset()
|
||||
service.Reset()
|
||||
|
||||
// rebuild svcCtx off the original parent
|
||||
svcCtx, cancelSvc = context.WithCancel(ctx)
|
||||
runtime.GC()
|
||||
|
||||
config.Reload()
|
||||
service.Reset()
|
||||
// loop will restart services automatically
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startServices(ctx context.Context, wd *webdav.WebDav, srv *server.Server) error {
|
||||
var wg sync.WaitGroup
|
||||
errChan := make(chan error)
|
||||
|
||||
_log := logger.Default()
|
||||
|
||||
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
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
safeGo(func() error {
|
||||
return wd.Start(ctx)
|
||||
})
|
||||
|
||||
safeGo(func() error {
|
||||
return srv.Start(ctx)
|
||||
})
|
||||
|
||||
safeGo(func() error {
|
||||
return worker.Start(ctx)
|
||||
})
|
||||
|
||||
safeGo(func() error {
|
||||
arr := service.GetService().Arr
|
||||
if arr == nil {
|
||||
return nil
|
||||
}
|
||||
return arr.StartSchedule(ctx)
|
||||
})
|
||||
|
||||
if cfg := config.Get(); cfg.Repair.Enabled {
|
||||
safeGo(func() error {
|
||||
r := service.GetService().Repair
|
||||
if r != nil {
|
||||
if err := r.Start(ctx); err != nil {
|
||||
_log.Error().Err(err).Msg("repair failed")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(errChan)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for err := range errChan {
|
||||
if err != nil {
|
||||
_log.Error().Err(err).Msg("Service error detected")
|
||||
// Don't shut down the whole app
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for context cancellation
|
||||
<-ctx.Done()
|
||||
_log.Debug().Msg("Services context cancelled")
|
||||
return nil
|
||||
}
|
||||
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
|
||||
}
|
||||
149
cmd/healthcheck/main.go
Normal file
149
cmd/healthcheck/main.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthStatus represents the status of various components
|
||||
type HealthStatus struct {
|
||||
QbitAPI bool `json:"qbit_api"`
|
||||
WebUI bool `json:"web_ui"`
|
||||
WebDAVService bool `json:"webdav_service"`
|
||||
OverallStatus bool `json:"overall_status"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var configPath string
|
||||
flag.StringVar(&configPath, "config", "/data", "path to the data folder")
|
||||
flag.Parse()
|
||||
config.SetConfigPath(configPath)
|
||||
cfg := config.Get()
|
||||
// Get port from environment variable or use default
|
||||
port := getEnvOrDefault("QBIT_PORT", cfg.Port)
|
||||
webdavPath := ""
|
||||
for _, debrid := range cfg.Debrids {
|
||||
if debrid.UseWebDav {
|
||||
webdavPath = debrid.Name
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize status
|
||||
status := HealthStatus{
|
||||
QbitAPI: false,
|
||||
WebUI: false,
|
||||
WebDAVService: false,
|
||||
OverallStatus: false,
|
||||
}
|
||||
|
||||
// Create a context with timeout for all HTTP requests
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
baseUrl := cmp.Or(cfg.URLBase, "/")
|
||||
if !strings.HasPrefix(baseUrl, "/") {
|
||||
baseUrl = "/" + baseUrl
|
||||
}
|
||||
|
||||
// Check qBittorrent API
|
||||
if checkQbitAPI(ctx, baseUrl, port) {
|
||||
status.QbitAPI = true
|
||||
}
|
||||
|
||||
// Check Web UI
|
||||
if checkWebUI(ctx, baseUrl, port) {
|
||||
status.WebUI = true
|
||||
}
|
||||
|
||||
// Check WebDAV if enabled
|
||||
if webdavPath != "" {
|
||||
if checkWebDAV(ctx, baseUrl, port, webdavPath) {
|
||||
status.WebDAVService = true
|
||||
}
|
||||
} else {
|
||||
// If WebDAV is not enabled, consider it healthy
|
||||
status.WebDAVService = true
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
// Consider the application healthy if core services are running
|
||||
status.OverallStatus = status.QbitAPI && status.WebUI
|
||||
if webdavPath != "" {
|
||||
status.OverallStatus = status.OverallStatus && status.WebDAVService
|
||||
}
|
||||
|
||||
// Optional: output health status as JSON for logging
|
||||
if os.Getenv("DEBUG") == "true" {
|
||||
statusJSON, _ := json.MarshalIndent(status, "", " ")
|
||||
fmt.Println(string(statusJSON))
|
||||
}
|
||||
|
||||
// Exit with appropriate code
|
||||
if status.OverallStatus {
|
||||
os.Exit(0)
|
||||
} else {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value, exists := os.LookupEnv(key); exists {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func checkQbitAPI(ctx context.Context, baseUrl, port string) bool {
|
||||
url := fmt.Sprintf("http://localhost:%s%sapi/v2/app/version", port, baseUrl)
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
func checkWebUI(ctx context.Context, baseUrl, port string) bool {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("http://localhost:%s%s", port, baseUrl), nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode == http.StatusOK
|
||||
}
|
||||
|
||||
func checkWebDAV(ctx context.Context, baseUrl, port, path string) bool {
|
||||
url := fmt.Sprintf("http://localhost:%s%swebdav/%s", port, baseUrl, path)
|
||||
req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
return resp.StatusCode == 207 || resp.StatusCode == http.StatusOK
|
||||
}
|
||||
18
cmd/main.go
18
cmd/main.go
@@ -1,18 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"goBlack/common"
|
||||
"goBlack/debrid"
|
||||
"log"
|
||||
)
|
||||
|
||||
func Start(config *common.Config) {
|
||||
|
||||
log.Print("[*] BlackHole running")
|
||||
deb := debrid.NewDebrid(config.Debrid)
|
||||
if config.Proxy.Enabled {
|
||||
go StartProxy(config, deb)
|
||||
}
|
||||
StartBlackhole(config, deb)
|
||||
|
||||
}
|
||||
274
cmd/proxy.go
274
cmd/proxy.go
@@ -1,274 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"github.com/elazarl/goproxy"
|
||||
"github.com/elazarl/goproxy/ext/auth"
|
||||
"github.com/valyala/fastjson"
|
||||
"goBlack/common"
|
||||
"goBlack/debrid"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type RSS struct {
|
||||
XMLName xml.Name `xml:"rss"`
|
||||
Version string `xml:"version,attr"`
|
||||
Channel Channel `xml:"channel"`
|
||||
}
|
||||
|
||||
type Channel struct {
|
||||
XMLName xml.Name `xml:"channel"`
|
||||
Title string `xml:"title"`
|
||||
AtomLink AtomLink `xml:"link"`
|
||||
Items []Item `xml:"item"`
|
||||
}
|
||||
|
||||
type AtomLink struct {
|
||||
XMLName xml.Name `xml:"link"`
|
||||
Rel string `xml:"rel,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
XMLName xml.Name `xml:"item"`
|
||||
Title string `xml:"title"`
|
||||
Description string `xml:"description"`
|
||||
GUID string `xml:"guid"`
|
||||
ProwlarrIndexer ProwlarrIndexer `xml:"prowlarrindexer"`
|
||||
Comments string `xml:"comments"`
|
||||
PubDate string `xml:"pubDate"`
|
||||
Size int64 `xml:"size"`
|
||||
Link string `xml:"link"`
|
||||
Categories []string `xml:"category"`
|
||||
Enclosure Enclosure `xml:"enclosure"`
|
||||
TorznabAttrs []TorznabAttr `xml:"torznab:attr"`
|
||||
}
|
||||
|
||||
type ProwlarrIndexer struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
type Enclosure struct {
|
||||
URL string `xml:"url,attr"`
|
||||
Length int64 `xml:"length,attr"`
|
||||
Type string `xml:"type,attr"`
|
||||
}
|
||||
|
||||
type TorznabAttr struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Value string `xml:"value,attr"`
|
||||
}
|
||||
|
||||
type SafeItems struct {
|
||||
mu sync.Mutex
|
||||
Items []Item
|
||||
}
|
||||
|
||||
func (s *SafeItems) Add(item Item) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.Items = append(s.Items, item)
|
||||
}
|
||||
|
||||
func (s *SafeItems) Get() []Item {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.Items
|
||||
}
|
||||
|
||||
func ProcessJSONResponse(resp *http.Response, deb debrid.Service) *http.Response {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return resp
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Println("Error reading response body:", err)
|
||||
return resp
|
||||
}
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var p fastjson.Parser
|
||||
v, err := p.ParseBytes(body)
|
||||
if err != nil {
|
||||
// If it's not JSON, return the original response
|
||||
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return resp
|
||||
}
|
||||
|
||||
// Modify the JSON
|
||||
|
||||
// Serialize the modified JSON back to bytes
|
||||
modifiedBody := v.MarshalTo(nil)
|
||||
|
||||
// Set the modified body back to the response
|
||||
resp.Body = io.NopCloser(bytes.NewReader(modifiedBody))
|
||||
resp.ContentLength = int64(len(modifiedBody))
|
||||
resp.Header.Set("Content-Length", string(rune(len(modifiedBody))))
|
||||
|
||||
return resp
|
||||
|
||||
}
|
||||
|
||||
func ProcessResponse(resp *http.Response, deb debrid.Service) *http.Response {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return resp
|
||||
}
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
switch contentType {
|
||||
case "application/json":
|
||||
return ProcessJSONResponse(resp, deb)
|
||||
case "application/xml":
|
||||
return ProcessXMLResponse(resp, deb)
|
||||
case "application/rss+xml":
|
||||
return ProcessXMLResponse(resp, deb)
|
||||
default:
|
||||
return resp
|
||||
}
|
||||
}
|
||||
|
||||
func XMLItemIsCached(item Item, deb debrid.Service) bool {
|
||||
magnetLink := ""
|
||||
infohash := ""
|
||||
|
||||
// Extract magnet link from the link or comments
|
||||
if strings.Contains(item.Link, "magnet:?") {
|
||||
magnetLink = item.Link
|
||||
} else if strings.Contains(item.GUID, "magnet:?") {
|
||||
magnetLink = item.GUID
|
||||
}
|
||||
|
||||
// Extract infohash from <torznab:attr> elements
|
||||
for _, attr := range item.TorznabAttrs {
|
||||
if attr.Name == "infohash" {
|
||||
infohash = attr.Value
|
||||
}
|
||||
}
|
||||
if magnetLink == "" && infohash == "" {
|
||||
// We can't check the availability of the torrent without a magnet link or infohash
|
||||
return false
|
||||
}
|
||||
var magnet *common.Magnet
|
||||
var err error
|
||||
|
||||
if infohash == "" {
|
||||
magnet, err = common.GetMagnetInfo(magnetLink)
|
||||
if err != nil {
|
||||
log.Println("Error getting magnet info:", err)
|
||||
return false
|
||||
}
|
||||
} else {
|
||||
magnet = &common.Magnet{
|
||||
InfoHash: infohash,
|
||||
Name: item.Title,
|
||||
Link: magnetLink,
|
||||
}
|
||||
}
|
||||
if magnet == nil {
|
||||
log.Println("Error getting magnet info")
|
||||
return false
|
||||
}
|
||||
return deb.IsAvailable(magnet)
|
||||
|
||||
}
|
||||
|
||||
func ProcessXMLResponse(resp *http.Response, deb debrid.Service) *http.Response {
|
||||
if resp == nil || resp.Body == nil {
|
||||
return resp
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Println("Error reading response body:", err)
|
||||
return resp
|
||||
}
|
||||
err = resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var rss RSS
|
||||
err = xml.Unmarshal(body, &rss)
|
||||
if err != nil {
|
||||
log.Fatalf("Error unmarshalling XML: %v", err)
|
||||
return resp
|
||||
}
|
||||
newItems := &SafeItems{}
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Step 4: Extract infohash or magnet URI, manipulate data
|
||||
for _, item := range rss.Channel.Items {
|
||||
wg.Add(1)
|
||||
go func(item Item) {
|
||||
defer wg.Done()
|
||||
if XMLItemIsCached(item, deb) {
|
||||
newItems.Add(item)
|
||||
}
|
||||
}(item)
|
||||
}
|
||||
wg.Wait()
|
||||
rss.Channel.Items = newItems.Get()
|
||||
|
||||
// rss.Channel.Items = newItems
|
||||
modifiedBody, err := xml.MarshalIndent(rss, "", " ")
|
||||
if err != nil {
|
||||
log.Printf("Error marshalling XML: %v", err)
|
||||
return resp
|
||||
}
|
||||
modifiedBody = append([]byte(xml.Header), modifiedBody...)
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Error marshalling XML: %v", err)
|
||||
return resp
|
||||
}
|
||||
|
||||
// Set the modified body back to the response
|
||||
resp.Body = io.NopCloser(bytes.NewReader(modifiedBody))
|
||||
resp.ContentLength = int64(len(modifiedBody))
|
||||
resp.Header.Set("Content-Length", string(rune(len(modifiedBody))))
|
||||
|
||||
return resp
|
||||
}
|
||||
|
||||
func UrlMatches(re *regexp.Regexp) goproxy.ReqConditionFunc {
|
||||
return func(req *http.Request, ctx *goproxy.ProxyCtx) bool {
|
||||
return re.MatchString(req.URL.String())
|
||||
}
|
||||
}
|
||||
|
||||
func StartProxy(config *common.Config, deb debrid.Service) {
|
||||
username, password := config.Proxy.Username, config.Proxy.Password
|
||||
cfg := config.Proxy
|
||||
proxy := goproxy.NewProxyHttpServer()
|
||||
if username != "" || password != "" {
|
||||
// Set up basic auth for proxy
|
||||
auth.ProxyBasic(proxy, "my_realm", func(user, pwd string) bool {
|
||||
return user == username && password == pwd
|
||||
})
|
||||
}
|
||||
|
||||
proxy.OnRequest(goproxy.ReqHostMatches(regexp.MustCompile("^.443$"))).HandleConnect(goproxy.AlwaysMitm)
|
||||
proxy.OnResponse(UrlMatches(regexp.MustCompile("^.*/api\\?t=(search|tvsearch|movie)(&.*)?$"))).DoFunc(
|
||||
func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
|
||||
return ProcessResponse(resp, deb)
|
||||
})
|
||||
|
||||
port := cmp.Or(cfg.Port, "8181")
|
||||
proxy.Verbose = cfg.Debug
|
||||
port = fmt.Sprintf(":%s", port)
|
||||
log.Printf("Starting proxy server on %s\n", port)
|
||||
log.Fatal(http.ListenAndServe(fmt.Sprintf("%s", port), proxy))
|
||||
}
|
||||
@@ -1,57 +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 Config struct {
|
||||
DbDSN string `json:"db_dsn"`
|
||||
Debrid DebridConfig `json:"debrid"`
|
||||
Arrs []struct {
|
||||
WatchFolder string `json:"watch_folder"`
|
||||
CompletedFolder string `json:"completed_folder"`
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
} `json:"arrs"`
|
||||
Proxy struct {
|
||||
Port string `json:"port"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Debug bool `json:"debug"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
)
|
||||
|
||||
var (
|
||||
VIDEOMATCH = "(?i)(\\.)(YUV|WMV|WEBM|VOB|VIV|SVI|ROQ|RMVB|RM|OGV|OGG|NSV|MXF|MTS|M2TS|TS|MPG|MPEG|M2V|MP2|MPE|MPV|MP4|M4P|M4V|MOV|QT|MNG|MKV|FLV|DRC|AVI|ASF|AMV)$"
|
||||
SUBMATCH = "(?i)(\\.)(SRT|SUB|SBV|ASS|VTT|TTML|DFXP|STL|SCC|CAP|SMI|TTXT|TDS|USF|JSS|SSA|PSB|RT|LRC|SSB)$"
|
||||
)
|
||||
|
||||
func RegexMatch(regex string, value string) bool {
|
||||
re := regexp.MustCompile(regex)
|
||||
return re.MatchString(value)
|
||||
}
|
||||
|
||||
func RemoveExtension(value string) string {
|
||||
re := regexp.MustCompile(VIDEOMATCH)
|
||||
|
||||
// Find the last index of the matched extension
|
||||
loc := re.FindStringIndex(value)
|
||||
if loc != nil {
|
||||
return value[:loc[0]]
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -1,130 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"golang.org/x/time/rate"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RLHTTPClient struct {
|
||||
client *http.Client
|
||||
Ratelimiter *rate.Limiter
|
||||
Headers map[string]string
|
||||
}
|
||||
|
||||
func (c *RLHTTPClient) Doer(req *http.Request) (*http.Response, error) {
|
||||
if c.Ratelimiter != nil {
|
||||
err := c.Ratelimiter.Wait(req.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
func (c *RLHTTPClient) Do(req *http.Request) (*http.Response, error) {
|
||||
var resp *http.Response
|
||||
var err error
|
||||
backoff := time.Millisecond * 500
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
resp, err = c.Doer(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Close the response body to prevent resource leakage
|
||||
resp.Body.Close()
|
||||
|
||||
// Wait for the backoff duration before retrying
|
||||
time.Sleep(backoff)
|
||||
|
||||
// Exponential backoff
|
||||
backoff *= 2
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if c.Headers != nil {
|
||||
for key, value := range c.Headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
res, err := c.Do(req)
|
||||
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)
|
||||
}
|
||||
|
||||
func NewRLHTTPClient(rl *rate.Limiter, headers map[string]string) *RLHTTPClient {
|
||||
tr := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
}
|
||||
c := &RLHTTPClient{
|
||||
client: &http.Client{
|
||||
Transport: tr,
|
||||
},
|
||||
Ratelimiter: rl,
|
||||
Headers: headers,
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func ParseRateLimit(rateStr string) *rate.Limiter {
|
||||
if rateStr == "" {
|
||||
return nil
|
||||
}
|
||||
re := regexp.MustCompile(`(\d+)/(minute|second)`)
|
||||
matches := re.FindStringSubmatch(rateStr)
|
||||
if len(matches) != 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
count, err := strconv.Atoi(matches[1])
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
unit := matches[2]
|
||||
switch unit {
|
||||
case "minute":
|
||||
reqsPerSecond := float64(count) / 60.0
|
||||
return rate.NewLimiter(rate.Limit(reqsPerSecond), 5)
|
||||
case "second":
|
||||
return rate.NewLimiter(rate.Limit(float64(count)), 5)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/rand"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Magnet struct {
|
||||
Name string
|
||||
InfoHash string
|
||||
Size int64
|
||||
Link string
|
||||
}
|
||||
|
||||
func OpenMagnetFile(filePath string) string {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Println("Error opening file:", err)
|
||||
return ""
|
||||
}
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}(file) // Ensure the file is closed after the function ends
|
||||
|
||||
// Create a scanner to read the file line by line
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
magnetLink := scanner.Text()
|
||||
if magnetLink != "" {
|
||||
return magnetLink
|
||||
}
|
||||
}
|
||||
|
||||
// Check for any errors during scanning
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Println("Error reading file:", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func GetMagnetInfo(magnetLink string) (*Magnet, error) {
|
||||
if magnetLink == "" {
|
||||
return nil, fmt.Errorf("error getting magnet from file")
|
||||
}
|
||||
magnetURI, err := url.Parse(magnetLink)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing magnet link")
|
||||
}
|
||||
query := magnetURI.Query()
|
||||
xt := query.Get("xt")
|
||||
dn := query.Get("dn")
|
||||
|
||||
// Extract BTIH
|
||||
parts := strings.Split(xt, ":")
|
||||
btih := ""
|
||||
if len(parts) > 2 {
|
||||
btih = parts[2]
|
||||
}
|
||||
magnet := &Magnet{
|
||||
InfoHash: btih,
|
||||
Name: dn,
|
||||
Size: 0,
|
||||
Link: magnetLink,
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"goBlack/common"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
SubmitMagnet(torrent *Torrent) (*Torrent, error)
|
||||
CheckStatus(torrent *Torrent) (*Torrent, error)
|
||||
DownloadLink(torrent *Torrent) error
|
||||
Process(arr *Arr, magnet string) (*Torrent, error)
|
||||
IsAvailable(magnet *common.Magnet) bool
|
||||
}
|
||||
|
||||
type Debrid struct {
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
}
|
||||
|
||||
func NewDebrid(dc common.DebridConfig) Service {
|
||||
switch dc.Name {
|
||||
case "realdebrid":
|
||||
return NewRealDebrid(dc)
|
||||
default:
|
||||
return NewRealDebrid(dc)
|
||||
}
|
||||
}
|
||||
|
||||
func GetTorrentInfo(filePath string) (*Torrent, error) {
|
||||
// Open and read the .torrent file
|
||||
if filepath.Ext(filePath) == ".torrent" {
|
||||
return getTorrentInfo(filePath)
|
||||
} else {
|
||||
return torrentFromMagnetFile(filePath)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func torrentFromMagnetFile(filePath string) (*Torrent, error) {
|
||||
magnetLink := common.OpenMagnetFile(filePath)
|
||||
magnet, err := common.GetMagnetInfo(magnetLink)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
torrent := &Torrent{
|
||||
InfoHash: magnet.InfoHash,
|
||||
Name: magnet.Name,
|
||||
Size: magnet.Size,
|
||||
Magnet: magnet,
|
||||
Filename: filePath,
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func getTorrentInfo(filePath string) (*Torrent, error) {
|
||||
mi, err := metainfo.LoadFromFile(filePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hash := mi.HashInfoBytes()
|
||||
infoHash := hash.HexString()
|
||||
info, err := mi.UnmarshalInfo()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
magnet := &common.Magnet{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Length,
|
||||
Link: mi.Magnet(&hash, &info).String(),
|
||||
}
|
||||
torrent := &Torrent{
|
||||
InfoHash: infoHash,
|
||||
Name: info.Name,
|
||||
Size: info.Length,
|
||||
Magnet: magnet,
|
||||
Filename: filePath,
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"goBlack/common"
|
||||
"goBlack/debrid/structs"
|
||||
"log"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type RealDebrid struct {
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
DownloadUncached bool
|
||||
client *common.RLHTTPClient
|
||||
}
|
||||
|
||||
func (r *RealDebrid) Process(arr *Arr, magnet string) (*Torrent, error) {
|
||||
torrent, err := GetTorrentInfo(magnet)
|
||||
torrent.Arr = arr
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
log.Printf("Torrent Name: %s", torrent.Name)
|
||||
if !r.DownloadUncached {
|
||||
if !r.IsAvailable(torrent.Magnet) {
|
||||
return torrent, fmt.Errorf("torrent is not cached")
|
||||
}
|
||||
}
|
||||
|
||||
return r.CheckStatus(torrent)
|
||||
}
|
||||
|
||||
func (r *RealDebrid) IsAvailable(magnet *common.Magnet) bool {
|
||||
url := fmt.Sprintf("%s/torrents/instantAvailability/%s", r.Host, magnet.InfoHash)
|
||||
resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
var data structs.RealDebridAvailabilityResponse
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hosters, exists := data[strings.ToLower(magnet.InfoHash)]
|
||||
if !exists || len(hosters) < 1 {
|
||||
log.Printf("Torrent: %s not cached", magnet.Name)
|
||||
return false
|
||||
}
|
||||
log.Printf("Torrent: %s is cached", magnet.Name)
|
||||
return true
|
||||
}
|
||||
|
||||
func (r *RealDebrid) SubmitMagnet(torrent *Torrent) (*Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/addMagnet", r.Host)
|
||||
payload := gourl.Values{
|
||||
"magnet": {torrent.Magnet.Link},
|
||||
}
|
||||
var data structs.RealDebridAddMagnetSchema
|
||||
resp, err := r.client.MakeRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = json.Unmarshal(resp, &data)
|
||||
log.Printf("Torrent: %s added with id: %s\n", torrent.Name, data.Id)
|
||||
torrent.Id = data.Id
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) CheckStatus(torrent *Torrent) (*Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrent.Id)
|
||||
for {
|
||||
resp, err := r.client.MakeRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
var data structs.RealDebridTorrentInfo
|
||||
err = json.Unmarshal(resp, &data)
|
||||
status := data.Status
|
||||
torrent.Folder = common.RemoveExtension(data.OriginalFilename)
|
||||
if status == "error" || status == "dead" || status == "magnet_error" {
|
||||
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||
} else if status == "waiting_files_selection" {
|
||||
files := make([]TorrentFile, 0)
|
||||
for _, f := range data.Files {
|
||||
name := f.Path
|
||||
if !common.RegexMatch(common.VIDEOMATCH, name) && !common.RegexMatch(common.SUBMATCH, name) {
|
||||
continue
|
||||
}
|
||||
fileId := f.ID
|
||||
file := &TorrentFile{
|
||||
Name: name,
|
||||
Path: filepath.Join(torrent.Folder, name),
|
||||
Size: int64(f.Bytes),
|
||||
Id: strconv.Itoa(fileId),
|
||||
}
|
||||
files = append(files, *file)
|
||||
}
|
||||
torrent.Files = files
|
||||
if len(files) == 0 {
|
||||
return torrent, fmt.Errorf("no video files found")
|
||||
}
|
||||
filesId := make([]string, 0)
|
||||
for _, f := range files {
|
||||
filesId = append(filesId, f.Id)
|
||||
}
|
||||
p := gourl.Values{
|
||||
"files": {strings.Join(filesId, ",")},
|
||||
}
|
||||
payload := strings.NewReader(p.Encode())
|
||||
_, err = r.client.MakeRequest(http.MethodPost, fmt.Sprintf("%s/torrents/selectFiles/%s", r.Host, torrent.Id), payload)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
} else if status == "downloaded" {
|
||||
log.Printf("Torrent: %s downloaded\n", torrent.Name)
|
||||
err = r.DownloadLink(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
break
|
||||
} else if status == "downloading" {
|
||||
return torrent, fmt.Errorf("torrent is uncached")
|
||||
}
|
||||
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) DownloadLink(torrent *Torrent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func NewRealDebrid(dc common.DebridConfig) *RealDebrid {
|
||||
rl := common.ParseRateLimit(dc.RateLimit)
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
}
|
||||
client := common.NewRLHTTPClient(rl, headers)
|
||||
return &RealDebrid{
|
||||
Host: dc.Host,
|
||||
APIKey: dc.APIKey,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package structs
|
||||
|
||||
type RealDebridAvailabilityResponse map[string]Hosters
|
||||
|
||||
type Hosters map[string][]FileIDs
|
||||
|
||||
type FileIDs map[string]FileVariant
|
||||
|
||||
type FileVariant struct {
|
||||
Filename string `json:"filename"`
|
||||
Filesize int `json:"filesize"`
|
||||
}
|
||||
|
||||
type RealDebridAddMagnetSchema struct {
|
||||
Id string `json:"id"`
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
type RealDebridTorrentInfo struct {
|
||||
ID string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
OriginalFilename string `json:"original_filename"`
|
||||
Hash string `json:"hash"`
|
||||
Bytes int `json:"bytes"`
|
||||
OriginalBytes int `json:"original_bytes"`
|
||||
Host string `json:"host"`
|
||||
Split int `json:"split"`
|
||||
Progress int `json:"progress"`
|
||||
Status string `json:"status"`
|
||||
Added string `json:"added"`
|
||||
Files []struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Bytes int `json:"bytes"`
|
||||
Selected int `json:"selected"`
|
||||
} `json:"files"`
|
||||
Links []string `json:"links"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
Speed int `json:"speed,omitempty"`
|
||||
Seeders int `json:"seeders,omitempty"`
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"goBlack/common"
|
||||
"log"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Arr struct {
|
||||
WatchFolder string `json:"watch_folder"`
|
||||
CompletedFolder string `json:"completed_folder"`
|
||||
Debrid common.DebridConfig `json:"debrid"`
|
||||
Token string `json:"token"`
|
||||
URL string `json:"url"`
|
||||
Client *common.RLHTTPClient
|
||||
}
|
||||
|
||||
type ArrHistorySchema struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"pageSize"`
|
||||
SortKey string `json:"sortKey"`
|
||||
SortDirection string `json:"sortDirection"`
|
||||
TotalRecords int `json:"totalRecords"`
|
||||
Records []struct {
|
||||
ID int `json:"id"`
|
||||
DownloadID string `json:"downloadId"`
|
||||
} `json:"records"`
|
||||
}
|
||||
|
||||
type Torrent struct {
|
||||
Id string `json:"id"`
|
||||
InfoHash string `json:"info_hash"`
|
||||
Name string `json:"name"`
|
||||
Folder string `json:"folder"`
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
Magnet *common.Magnet `json:"magnet"`
|
||||
Files []TorrentFile `json:"files"`
|
||||
Status string `json:"status"`
|
||||
|
||||
Arr *Arr
|
||||
}
|
||||
|
||||
type TorrentFile struct {
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
func (arr *Arr) GetHeaders() map[string]string {
|
||||
return map[string]string{
|
||||
"X-Api-Key": arr.Token,
|
||||
}
|
||||
}
|
||||
|
||||
func (arr *Arr) GetURL() string {
|
||||
url, _ := gourl.JoinPath(arr.URL, "api/v3/")
|
||||
return url
|
||||
}
|
||||
|
||||
func getEventId(eventType string) int {
|
||||
switch eventType {
|
||||
case "grabbed":
|
||||
return 1
|
||||
case "seriesFolderDownloaded":
|
||||
return 2
|
||||
case "DownloadFolderImported":
|
||||
return 3
|
||||
case "DownloadFailed":
|
||||
return 4
|
||||
case "DownloadIgnored":
|
||||
return 7
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func (arr *Arr) GetHistory(downloadId, eventType string) *ArrHistorySchema {
|
||||
eventId := getEventId(eventType)
|
||||
query := gourl.Values{}
|
||||
if downloadId != "" {
|
||||
query.Add("downloadId", downloadId)
|
||||
}
|
||||
if eventId != 0 {
|
||||
query.Add("eventId", strconv.Itoa(eventId))
|
||||
|
||||
}
|
||||
query.Add("pageSize", "100")
|
||||
url := arr.GetURL() + "history/" + "?" + query.Encode()
|
||||
resp, err := arr.Client.MakeRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var data *ArrHistorySchema
|
||||
err = json.Unmarshal(resp, &data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return data
|
||||
|
||||
}
|
||||
|
||||
func (t *Torrent) Cleanup(remove bool) {
|
||||
if remove {
|
||||
err := os.Remove(t.Filename)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Torrent) MarkAsFailed() error {
|
||||
downloadId := strings.ToUpper(t.Magnet.InfoHash)
|
||||
history := t.Arr.GetHistory(downloadId, "grabbed")
|
||||
if history == nil {
|
||||
return nil
|
||||
}
|
||||
torrentId := 0
|
||||
for _, record := range history.Records {
|
||||
if strings.EqualFold(record.DownloadID, downloadId) {
|
||||
torrentId = record.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if torrentId != 0 {
|
||||
url, err := gourl.JoinPath(t.Arr.GetURL(), "history/failed/", strconv.Itoa(torrentId))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = t.Arr.Client.MakeRequest(http.MethodPost, url, nil)
|
||||
if err == nil {
|
||||
log.Printf("Marked torrent: %s as failed", t.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
186
docs/docs/changelog.md
Normal file
186
docs/docs/changelog.md
Normal file
@@ -0,0 +1,186 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- Add WebDAV support for debrid providers
|
||||
- Some refactoring and code cleanup
|
||||
- Fixes
|
||||
- Fix Alldebrid not downloading torrents
|
||||
- Fix Alldebrid not downloading uncached torrents
|
||||
- Fix uncached torrents not being downloaded for RealDebrid
|
||||
- Add support for multiple download API keys for debrid providers
|
||||
- Add support for editable config.json via the UI
|
||||
- Fix downloading timeout
|
||||
- Fix UMASK for Windows
|
||||
- Retries 50x(except 503) errors for RD
|
||||
|
||||
|
||||
## 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.4.2
|
||||
|
||||
- Hotfixes
|
||||
- Fix saving torrents error
|
||||
- Fix bugs with the UI
|
||||
- Speed improvements
|
||||
|
||||
## 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.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.3.3
|
||||
|
||||
- Add AllDebrid Support
|
||||
- Fix Torbox not downloading uncached torrents
|
||||
- Fix Rar files being downloaded
|
||||
|
||||
## 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.1
|
||||
|
||||
- Add DebridLink Support
|
||||
- Refactor error handling
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Add UI for adding torrents
|
||||
- Refraction of the code
|
||||
- Fix Torbox bug
|
||||
- Update CI/CD
|
||||
- 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.2.6
|
||||
|
||||
- Delete torrent for empty matched files
|
||||
- Update Readme
|
||||
|
||||
## 0.2.5
|
||||
|
||||
- Fix ContentPath not being set prior
|
||||
- Rewrote Readme
|
||||
- Cleaned up the code
|
||||
|
||||
## 0.2.4
|
||||
|
||||
- Add file download support (Sequential Download)
|
||||
- Fix http handler error
|
||||
- Fix *arrs map failing concurrently
|
||||
- Fix cache not being updated
|
||||
|
||||
## 0.2.3
|
||||
|
||||
- Delete uncached items from RD
|
||||
- Fail if the torrent is not cached (optional)
|
||||
- Fix cache not being updated
|
||||
|
||||
## 0.2.2
|
||||
|
||||
- Fix name mismatch in the cache
|
||||
- Fix directory mapping with mounts
|
||||
- Add Support for refreshing the *arrs
|
||||
|
||||
## 0.2.1
|
||||
|
||||
- Fix Uncached torrents not being downloaded/downloaded
|
||||
- Minor bug fixed
|
||||
- Fix Race condition in the cache and file system
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Implement 0.2.0-beta changes
|
||||
- Removed Blackhole
|
||||
- Added QbitTorrent API
|
||||
- Cleaned up the code
|
||||
|
||||
## 0.2.0-beta
|
||||
|
||||
- Switch to QbitTorrent API instead of Blackhole
|
||||
- Rewrote the whole codebase
|
||||
|
||||
## 0.1.4
|
||||
|
||||
- Rewrote Report log
|
||||
- Fix YTS, 1337x not grabbing infohash
|
||||
- Fix Torrent symlink bug
|
||||
|
||||
## 0.1.3
|
||||
|
||||
- Searching for infohashes in the xml description/summary/comments
|
||||
- Added local cache support
|
||||
- Added max cache size
|
||||
- Rewrite blackhole.go
|
||||
- Bug fixes
|
||||
- Fixed indexer getting disabled
|
||||
- Fixed blackhole not working
|
||||
|
||||
## 0.1.2
|
||||
|
||||
- Bug fixes
|
||||
- Code cleanup
|
||||
- Get available hashes at once
|
||||
|
||||
## 0.1.1
|
||||
|
||||
- Added support for "No Blackhole" for Arrs
|
||||
- Added support for "Cached Only" for Proxy
|
||||
- Bug Fixes
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial Release
|
||||
- Added Real Debrid Support
|
||||
- Added Arrs Support
|
||||
- Added Proxy Support
|
||||
- Added Basic Authentication for Proxy
|
||||
- Added Rate Limiting for Debrid Providers
|
||||
77
docs/docs/configuration/arrs.md
Normal file
77
docs/docs/configuration/arrs.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Arr Applications Configuration
|
||||
|
||||
Decypharr can integrate directly with Sonarr, Radarr, and other Arr applications. This section explains how to configure the Arr integration in your `config.json` file.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
The Arr applications are configured under the `arrs` key:
|
||||
|
||||
```json
|
||||
"arrs": [
|
||||
{
|
||||
"name": "sonarr",
|
||||
"host": "http://sonarr:8989",
|
||||
"token": "your-sonarr-api-key",
|
||||
"cleanup": true
|
||||
},
|
||||
{
|
||||
"name": "radarr",
|
||||
"host": "http://radarr:7878",
|
||||
"token": "your-radarr-api-key",
|
||||
"cleanup": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### !!! note
|
||||
This configuration is optional if you've already set up the qBittorrent client in your Arr applications with the correct host and token information. It's particularly useful for the Repair Worker functionality.
|
||||
|
||||
|
||||
### Configuration Options
|
||||
Each Arr application supports the following options:
|
||||
|
||||
- `name`: The name of the Arr application, which should match the category in qBittorrent
|
||||
- `host`: The host URL of the Arr application, including protocol and port
|
||||
- `token`: The API token/key of the Arr application
|
||||
- `cleanup`: Whether to clean up the Arr queue (removes completed downloads). This is only useful for Sonarr.
|
||||
- `skip_repair`: Automated repair will be skipped for this *arr.
|
||||
- `download_uncached`: Whether to download uncached torrents (defaults to debrid/manual setting)
|
||||
|
||||
### Finding Your API Key
|
||||
#### Sonarr/Radarr/Lidarr
|
||||
|
||||
1. Go to Sonarr > Settings > General
|
||||
2. Look for "API Key" in the "Security" section
|
||||
3. Copy the API key
|
||||
|
||||
### Multiple Arr Applications
|
||||
You can configure multiple Arr applications by adding more entries to the arrs array:
|
||||
|
||||
```json
|
||||
"arrs": [
|
||||
{
|
||||
"name": "sonarr",
|
||||
"host": "http://sonarr:8989",
|
||||
"token": "your-sonarr-api-key",
|
||||
"cleanup": true
|
||||
},
|
||||
{
|
||||
"name": "sonarr-anime",
|
||||
"host": "http://sonarr-anime:8989",
|
||||
"token": "your-sonarr-anime-api-key",
|
||||
"cleanup": true
|
||||
},
|
||||
{
|
||||
"name": "radarr",
|
||||
"host": "http://radarr:7878",
|
||||
"token": "your-radarr-api-key",
|
||||
"cleanup": false
|
||||
},
|
||||
{
|
||||
"name": "lidarr",
|
||||
"host": "http://lidarr:8686",
|
||||
"token": "your-lidarr-api-key",
|
||||
"cleanup": false
|
||||
}
|
||||
]
|
||||
```
|
||||
131
docs/docs/configuration/debrid.md
Normal file
131
docs/docs/configuration/debrid.md
Normal file
@@ -0,0 +1,131 @@
|
||||
|
||||
# Debrid Providers Configuration
|
||||
|
||||
Decypharr supports multiple Debrid providers. This section explains how to configure each provider in your `config.json` file.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
Each Debrid provider is configured in the `debrids` array:
|
||||
|
||||
```json
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
},
|
||||
{
|
||||
"name": "alldebrid",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/alldebrid/downloads/"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Provider Options
|
||||
|
||||
Each Debrid provider accepts the following configuration options:
|
||||
|
||||
|
||||
#### Basic(Required) Options
|
||||
|
||||
- `name`: The name of the Debrid provider (realdebrid, alldebrid, debridlink, torbox)
|
||||
- `host`: The API endpoint of the Debrid provider
|
||||
- `api_key`: Your API key for the Debrid service (can be comma-separated for multiple keys)
|
||||
- `folder`: The folder where your Debrid content is mounted (via webdav, rclone, zurg, etc.)
|
||||
|
||||
#### Advanced Options
|
||||
|
||||
- `rate_limit`: Rate limit for API requests (null by default)
|
||||
- `download_uncached`: Whether to download uncached torrents (disabled by default)
|
||||
- `check_cached`: Whether to check if torrents are cached (disabled by default)
|
||||
- `use_webdav`: Whether to create a WebDAV server for this Debrid provider (disabled by default)
|
||||
- `proxy`: Proxy URL for the Debrid provider (optional)
|
||||
|
||||
#### WebDAV and Rclone Options
|
||||
- `torrents_refresh_interval`: Interval for refreshing torrent data (e.g., `15s`, `1m`, `1h`).
|
||||
- `download_links_refresh_interval`: Interval for refreshing download links (e.g., `40m`, `1h`).
|
||||
- `workers`: Number of concurrent workers for processing requests.
|
||||
- `serve_from_rclone`: Whether to serve files directly from Rclone (disabled by default)
|
||||
- `add_samples`: Whether to add sample files when adding torrents to debrid (disabled by default)
|
||||
- `folder_naming`: Naming convention for folders:
|
||||
- `original_no_ext`: Original file name without extension
|
||||
- `original`: Original file name with extension
|
||||
- `filename`: Torrent filename
|
||||
- `filename_no_ext`: Torrent filename without extension
|
||||
- `id`: Torrent ID
|
||||
- `hash`: Torrent hash
|
||||
- `auto_expire_links_after`: Time after which download links will expire (e.g., `3d`, `1w`).
|
||||
- `rc_url`, `rc_user`, `rc_pass`, `rc_refresh_dirs`: Rclone RC configuration for VFS refreshes
|
||||
- `directories`: A map of virtual folders to serve via the webDAV server. The key is the virtual folder name, and the values are map of filters and their value
|
||||
|
||||
#### Example of `directories` configuration
|
||||
```json
|
||||
"directories": {
|
||||
"Newly Added": {
|
||||
"filters": {
|
||||
"exclude": "9-1-1",
|
||||
"last_added": "20h"
|
||||
}
|
||||
},
|
||||
"Spiderman Collection": {
|
||||
"filters": {
|
||||
"regex": "(?i)spider[-\\s]?man(\\s+collection|\\s+\\d|\\s+trilogy|\\s+complete|\\s+ultimate|\\s+box\\s+set|:?\\s+homecoming|:?\\s+far\\s+from\\s+home|:?\\s+no\\s+way\\s+home)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example Configuration
|
||||
|
||||
#### Real Debrid
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"rate_limit": null,
|
||||
"download_uncached": false,
|
||||
"use_webdav": true
|
||||
}
|
||||
```
|
||||
|
||||
#### All Debrid
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "alldebrid",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/alldebrid/torrents/",
|
||||
"rate_limit": null,
|
||||
"download_uncached": false,
|
||||
"use_webdav": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Debrid Link
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "debridlink",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/debridlink/torrents/",
|
||||
"rate_limit": null,
|
||||
"download_uncached": false,
|
||||
"use_webdav": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Torbox
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "torbox",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/torbox/torrents/",
|
||||
"rate_limit": null,
|
||||
"download_uncached": false,
|
||||
"use_webdav": true
|
||||
}
|
||||
```
|
||||
81
docs/docs/configuration/general.md
Normal file
81
docs/docs/configuration/general.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# General Configuration
|
||||
|
||||
This section covers the basic configuration options for Decypharr that apply to the entire application.
|
||||
|
||||
## Basic Settings
|
||||
|
||||
Here are the fundamental configuration options:
|
||||
|
||||
```json
|
||||
{
|
||||
"use_auth": false,
|
||||
"port": 8282,
|
||||
"log_level": "info",
|
||||
"discord_webhook_url": "",
|
||||
"min_file_size": 0,
|
||||
"max_file_size": 0,
|
||||
"allowed_file_types": [".mp4", ".mkv", ".avi", ...],
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### Log Level
|
||||
The `log_level` setting determines how verbose the application logs will be:
|
||||
|
||||
- `debug`: Detailed information, useful for troubleshooting
|
||||
- `info`: General operational information (default)
|
||||
- `warn`: Warning messages
|
||||
- `error`: Error messages only
|
||||
- `trace`: Very detailed information, including all requests and responses
|
||||
|
||||
#### Port
|
||||
|
||||
The `port` setting specifies the port on which Decypharr will run. The default is `8282`. You can change this to any available port on your server.
|
||||
|
||||
Ensure this port:
|
||||
|
||||
- Is not used by other applications
|
||||
- Is accessible to your Arr applications
|
||||
- Is properly exposed if using Docker (see the Docker Compose example in the Installation guide)
|
||||
|
||||
|
||||
#### Authentication
|
||||
The `use_auth` option enables basic authentication for the UI:
|
||||
|
||||
```json
|
||||
"use_auth": true
|
||||
```
|
||||
|
||||
When enabled, you'll need to provide a username and password to access the Decypharr interface.
|
||||
|
||||
|
||||
#### File Size Limits
|
||||
|
||||
You can set minimum and maximum file size limits for torrents:
|
||||
```json
|
||||
"min_file_size": 0, // Minimum file size in bytes (0 = no minimum)
|
||||
"max_file_size": 0 // Maximum file size in bytes (0 = no maximum)
|
||||
```
|
||||
|
||||
#### Allowed File Types
|
||||
You can restrict the types of files that Decypharr will process by specifying allowed file extensions. This is useful for filtering out unwanted file types.
|
||||
|
||||
```json
|
||||
"allowed_file_types": [
|
||||
".mp4", ".mkv", ".avi", ".mov",
|
||||
".m4v", ".mpg", ".mpeg", ".wmv",
|
||||
".m4a", ".mp3", ".flac", ".wav"
|
||||
]
|
||||
```
|
||||
|
||||
If not specified, all movie, TV show, and music file types are allowed by default.
|
||||
|
||||
|
||||
#### Discord Notifications
|
||||
To receive notifications on Discord, add your webhook URL:
|
||||
```json
|
||||
"discord_webhook_url": "https://discord.com/api/webhooks/..."
|
||||
```
|
||||
This will send notifications for various events, such as successful downloads or errors.
|
||||
44
docs/docs/configuration/index.md
Normal file
44
docs/docs/configuration/index.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# Configuration Overview
|
||||
|
||||
Decypharr uses a JSON configuration file to manage its settings. This file should be named `config.json` and placed in your configured directory.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
Here's a minimal configuration to get started:
|
||||
|
||||
```json
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "realdebrid_key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"use_webdav": true
|
||||
}
|
||||
],
|
||||
"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"
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Sections
|
||||
|
||||
Decypharr's configuration is divided into several sections:
|
||||
|
||||
- [General Configuration](general.md) - Basic settings like logging and authentication
|
||||
- [Debrid Providers](debrid.md) - Configure one or more Debrid services
|
||||
- [qBittorrent Settings](qbittorrent.md) - Settings for the qBittorrent API
|
||||
- [Arr Integration](arrs.md) - Configuration for Sonarr, Radarr, etc.
|
||||
|
||||
Full Configuration Example
|
||||
For a complete configuration file with all available options, see our [full configuration example](../extras/config.full.json).
|
||||
61
docs/docs/configuration/qbittorrent.md
Normal file
61
docs/docs/configuration/qbittorrent.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# qBittorrent Configuration
|
||||
|
||||
Decypharr emulates a qBittorrent instance to integrate with Arr applications. This section explains how to configure the qBittorrent settings in your `config.json` file.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
The qBittorrent functionality is configured under the `qbittorrent` key:
|
||||
|
||||
```json
|
||||
"qbittorrent": {
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr", "lidarr"],
|
||||
"refresh_interval": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
#### Required Settings
|
||||
|
||||
- `download_folder`: The folder where symlinks or downloaded files will be placed
|
||||
- `categories`: An array of categories to organize downloads (usually matches your Arr applications)
|
||||
|
||||
#### Advanced Settings
|
||||
|
||||
- `refresh_interval`: How often (in seconds) to refresh the Arrs Monitored Downloads (default: 5)
|
||||
- `max_downloads`: The maximum number of concurrent downloads. This is only for downloading real files(Not symlinks). If you set this to 0, it will download all files at once. This is not recommended for most users.(default: 5)
|
||||
- `skip_pre_cache`: This option disables the process of pre-caching files. This caches a small portion of the file to speed up your *arrs import process.
|
||||
|
||||
#### Categories
|
||||
Categories help organize your downloads and match them to specific Arr applications. Typically, you'll want to configure categories that match your Sonarr, Radarr, or other Arr applications:
|
||||
|
||||
```json
|
||||
"categories": ["sonarr", "radarr", "lidarr", "readarr"]
|
||||
```
|
||||
|
||||
When setting up your Arr applications to connect to Decypharr, you'll specify these same category names.
|
||||
|
||||
#### Download Folder
|
||||
|
||||
The `download_folder` setting specifies where Decypharr will place downloaded files or create symlinks:
|
||||
|
||||
```json
|
||||
"download_folder": "/mnt/symlinks/"
|
||||
```
|
||||
|
||||
This folder should be:
|
||||
|
||||
- Accessible to Decypharr
|
||||
- Accessible to your Arr applications
|
||||
- Have sufficient space if downloading files locally
|
||||
|
||||
|
||||
#### Refresh Interval
|
||||
The refresh_interval setting controls how often Decypharr checks for updates from your Arr applications:
|
||||
|
||||
```json
|
||||
"refresh_interval": 5
|
||||
```
|
||||
|
||||
|
||||
This value is in seconds. Lower values provide more responsive updates but may increase CPU usage.
|
||||
88
docs/docs/extras/config.full.json
Normal file
88
docs/docs/extras/config.full.json
Normal file
@@ -0,0 +1,88 @@
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "realdebrid_key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"download_api_keys": [],
|
||||
"proxy": "",
|
||||
"rate_limit": "250/minute",
|
||||
"download_uncached": false,
|
||||
"use_webdav": true,
|
||||
"torrents_refresh_interval": "15s",
|
||||
"folder_naming": "original_no_ext",
|
||||
"auto_expire_links_after": "3d",
|
||||
"rc_url": "http://your-ip-address:9990",
|
||||
"rc_user": "your_rclone_rc_user",
|
||||
"rc_pass": "your_rclone_rc_pass"
|
||||
},
|
||||
{
|
||||
"name": "torbox",
|
||||
"api_key": "torbox_api_key",
|
||||
"folder": "/mnt/remote/torbox/torrents/",
|
||||
"rate_limit": "250/minute",
|
||||
"download_uncached": false,
|
||||
},
|
||||
{
|
||||
"name": "debridlink",
|
||||
"api_key": "debridlink_key",
|
||||
"folder": "/mnt/remote/debridlink/torrents/",
|
||||
"rate_limit": "250/minute",
|
||||
"download_uncached": false,
|
||||
},
|
||||
{
|
||||
"name": "alldebrid",
|
||||
"api_key": "alldebrid_key",
|
||||
"folder": "/mnt/remote/alldebrid/magnet/",
|
||||
"rate_limit": "600/minute",
|
||||
"download_uncached": false,
|
||||
}
|
||||
],
|
||||
"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://sonarr: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:8686",
|
||||
"token": "arr_key",
|
||||
"cleanup": false,
|
||||
"skip_repair": true,
|
||||
"download_uncached": false
|
||||
}
|
||||
],
|
||||
"repair": {
|
||||
"enabled": false,
|
||||
"interval": "12h",
|
||||
"run_on_start": false,
|
||||
"zurg_url": "",
|
||||
"use_webdav": false,
|
||||
"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/..."
|
||||
}
|
||||
5
docs/docs/extras/rclone.conf
Normal file
5
docs/docs/extras/rclone.conf
Normal file
@@ -0,0 +1,5 @@
|
||||
[decypharr]
|
||||
type = webdav
|
||||
url = http://decypharr:8282/webdav/realdebrid
|
||||
vendor = other
|
||||
pacer_min_sleep = 0
|
||||
40
docs/docs/features/index.md
Normal file
40
docs/docs/features/index.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Features Overview
|
||||
|
||||
Decypharr extends the functionality of qBittorrent by integrating with Debrid services, providing several powerful features that enhance your media management experience.
|
||||
|
||||
## Core Features
|
||||
|
||||
### Mock qBittorrent API
|
||||
|
||||
Decypharr implements a complete qBittorrent-compatible API that can be used with Sonarr, Radarr, Lidarr, and other Arr applications. This allows you to:
|
||||
|
||||
- Seamlessly integrate with your existing Arr setup
|
||||
- Use familiar interfaces to manage your downloads
|
||||
- Benefit from Debrid services without changing your workflow
|
||||
|
||||
### Comprehensive UI
|
||||
|
||||
The Decypharr user interface provides:
|
||||
|
||||
- Torrent management capabilities
|
||||
- Status monitoring
|
||||
- Configuration options
|
||||
- Multiple Debrid provider integration
|
||||
|
||||
## Advanced Features
|
||||
|
||||
Decypharr includes several advanced features that extend its capabilities:
|
||||
|
||||
- [Repair Worker](repair-worker.md): Identifies and fixes issues with your media files
|
||||
- [WebDAV Server](webdav.md): Provides direct access to your Debrid files
|
||||
|
||||
## Supported Debrid Providers
|
||||
|
||||
Decypharr supports multiple Debrid providers:
|
||||
|
||||
- Real Debrid
|
||||
- Torbox
|
||||
- Debrid Link
|
||||
- All Debrid
|
||||
|
||||
Each provider can be configured separately, allowing you to use one or multiple services simultaneously.
|
||||
41
docs/docs/features/repair-worker.md
Normal file
41
docs/docs/features/repair-worker.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Repair Worker
|
||||
|
||||
The Repair Worker is a powerful feature that helps maintain the health of your media library by scanning for and fixing issues with files.
|
||||
|
||||
## What It Does
|
||||
|
||||
The Repair Worker performs the following tasks:
|
||||
|
||||
- Searches for broken symlinks or file references
|
||||
- Identifies missing files in your library
|
||||
- Locates deleted or unreadable files
|
||||
- Automatically repairs issues when possible
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable and configure the Repair Worker, add the following to your `config.json`:
|
||||
|
||||
```json
|
||||
"repair": {
|
||||
"enabled": true,
|
||||
"interval": "12h",
|
||||
"run_on_start": false,
|
||||
"use_webdav": false,
|
||||
"zurg_url": "http://localhost:9999",
|
||||
"auto_process": true
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `enabled`: Set to `true` to enable the Repair Worker.
|
||||
- `interval`: The time interval for the Repair Worker to run (e.g., `12h`, `1d`).
|
||||
- `run_on_start`: If set to `true`, the Repair Worker will run immediately after Decypharr starts.
|
||||
- `use_webdav`: If set to `true`, the Repair Worker will use WebDAV for file operations.
|
||||
- `zurg_url`: The URL for the Zurg service (if using).
|
||||
- `auto_process`: If set to `true`, the Repair Worker will automatically process files that it finds issues with.
|
||||
|
||||
|
||||
### Performance Tips
|
||||
- For users of the WebDAV server, enable `use_webdav` for exponentially faster repair processes
|
||||
- If using Zurg, set the `zurg_url` parameter to greatly improve repair speed
|
||||
72
docs/docs/features/webdav.md
Normal file
72
docs/docs/features/webdav.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# WebDAV Server
|
||||
|
||||
Decypharr includes a built-in WebDAV server that provides direct access to your Debrid files, making them easily accessible to media players and other applications.
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
While most Debrid providers have their own WebDAV servers, Decypharr's implementation offers faster access and additional features.
|
||||
|
||||
## Accessing the WebDAV Server
|
||||
|
||||
- URL: `http://localhost:8282/webdav` or `http://<your-server-ip>:8282/webdav`
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure WebDAV settings either globally or per-Debrid provider in your `config.json`:
|
||||
|
||||
```json
|
||||
"webdav": {
|
||||
"torrents_refresh_interval": "15s",
|
||||
"download_links_refresh_interval": "40m",
|
||||
"folder_naming": "original_no_ext",
|
||||
"auto_expire_links_after": "3d",
|
||||
"rc_url": "http://localhost:5572",
|
||||
"rc_user": "username",
|
||||
"rc_pass": "password",
|
||||
"serve_from_rclone": false,
|
||||
"directories": {
|
||||
"Newly Added": {
|
||||
"filters": {
|
||||
"exclude": "9-1-1",
|
||||
"last_added": "20h"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `torrents_refresh_interval`: Interval for refreshing torrent data (e.g., `15s`, `1m`, `1h`).
|
||||
- `download_links_refresh_interval`: Interval for refreshing download links (e.g., `40m`, `1h`).
|
||||
- `workers`: Number of concurrent workers for processing requests.
|
||||
- folder_naming: Naming convention for folders:
|
||||
- `original_no_ext`: Original file name without extension
|
||||
- `original`: Original file name with extension
|
||||
- `filename`: Torrent filename
|
||||
- `filename_no_ext`: Torrent filename without extension
|
||||
- `id`: Torrent ID
|
||||
- `auto_expire_links_after`: Time after which download links will expire (e.g., `3d`, `1w`).
|
||||
- `rc_url`, `rc_user`, `rc_pass`: Rclone RC configuration for VFS refreshes
|
||||
- `directories`: A map of virtual folders to serve via the WebDAV server. The key is the virtual folder name, and the values are a map of filters and their values.
|
||||
- `serve_from_rclone`: Whether to serve files directly from Rclone (disabled by default).
|
||||
|
||||
### Using with Media Players
|
||||
The WebDAV server works well with media players like:
|
||||
|
||||
- Infuse
|
||||
- VidHub
|
||||
- Plex, Emby, Jellyfin (with rclone, Check [this guide](../guides/rclone.md))
|
||||
- Kodi
|
||||
|
||||
### Mounting with Rclone
|
||||
You can mount the WebDAV server locally using Rclone. Example configuration:
|
||||
|
||||
```conf
|
||||
[decypharr]
|
||||
type = webdav
|
||||
url = http://localhost:8282/webdav/realdebrid
|
||||
vendor = other
|
||||
```
|
||||
For a complete Rclone configuration example, see our [sample rclone.conf](../extras/rclone.conf).
|
||||
4
docs/docs/guides/index.md
Normal file
4
docs/docs/guides/index.md
Normal file
@@ -0,0 +1,4 @@
|
||||
# Guides for setting up Decypharr
|
||||
|
||||
|
||||
- [Setting up with Rclone](rclone.md)
|
||||
142
docs/docs/guides/rclone.md
Normal file
142
docs/docs/guides/rclone.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Setting up Decypharr with Rclone
|
||||
|
||||
This guide will help you set up Decypharr with Rclone, allowing you to use your Debrid providers as a remote storage solution.
|
||||
|
||||
#### Rclone
|
||||
Make sure you have Rclone installed and configured on your system. You can follow the [Rclone installation guide](https://rclone.org/install/) for instructions.
|
||||
|
||||
It's recommended to use docker version of Rclone, as it provides a consistent environment across different platforms.
|
||||
|
||||
|
||||
### Steps
|
||||
|
||||
We'll be using docker compose to set up Rclone and Decypharr together.
|
||||
|
||||
#### Note
|
||||
This guide assumes you have a basic understanding of Docker and Docker Compose. If you're new to Docker, consider checking out the [Docker documentation](https://docs.docker.com/get-started/) for more information.
|
||||
|
||||
Also, ensure you have Docker and Docker Compose installed on your system. You can find installation instructions in the [Docker documentation](https://docs.docker.com/get-docker/) and [Docker Compose documentation](https://docs.docker.com/compose/install/).
|
||||
|
||||
|
||||
Create a directory for your Decypharr and Rclone setup:
|
||||
```bash
|
||||
mkdir -p /opt/decypharr
|
||||
mkdir -p /opt/rclone
|
||||
mkdir -p /mnt/remote/realdebrid
|
||||
|
||||
# Set permissions
|
||||
chown -R $USER:$USER /opt/decypharr
|
||||
chown -R $USER:$USER /opt/rclone
|
||||
chown -R $USER:$USER /mnt/remote/realdebrid
|
||||
```
|
||||
|
||||
Create a `rclone.conf` file in `/opt/rclone/` with your Rclone configuration.
|
||||
|
||||
```conf
|
||||
[decypharr]
|
||||
type = webdav
|
||||
url = https://your-ip-or-domain:8282/webdav/realdebrid
|
||||
vendor = other
|
||||
pacer_min_sleep = 0
|
||||
```
|
||||
|
||||
Create a `config.json` file in `/opt/decypharr/` with your Decypharr configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "realdebrid_key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"rate_limit": "250/minute",
|
||||
"use_webdav": true,
|
||||
"rc_url": "http://your-ip-address:5572" // Rclone RC URL
|
||||
}
|
||||
],
|
||||
"qbittorrent": {
|
||||
"download_folder": "data/media/symlinks/",
|
||||
"refresh_interval": 10
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
Create a `docker-compose.yml` file with the following content:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
decypharr:
|
||||
image: cy01/blackhole:latest
|
||||
container_name: decypharr
|
||||
user: "1000:1000"
|
||||
volumes:
|
||||
- /mnt/:/mnt
|
||||
- /opt/decypharr/:/app
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- UMASK=002
|
||||
ports:
|
||||
- "8282:8282/tcp"
|
||||
restart: unless-stopped
|
||||
|
||||
rclone:
|
||||
image: rclone/rclone:latest
|
||||
container_name: rclone
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: UTC
|
||||
PUID: 1000
|
||||
PGID: 1000
|
||||
ports:
|
||||
- 5572:5572
|
||||
volumes:
|
||||
- /mnt/remote/realdebrid:/data:rshared
|
||||
- /opt/rclone/rclone.conf:/config/rclone/rclone.conf
|
||||
- /mnt:/mnt
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse:rwm
|
||||
depends_on:
|
||||
decypharr:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
command: "mount decypharr: /data --allow-non-empty --allow-other --uid=1000 --gid=1000 --umask=002 --dir-cache-time 10s --rc --rc-addr :5572 --rc-no-auth "
|
||||
```
|
||||
|
||||
Start the containers:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Access the Decypharr web interface at `http://your-ip-address:8282` and configure your settings as needed.
|
||||
|
||||
- Access your webdav server at `http://your-ip-address:8282/webdav` to see your files.
|
||||
- You should be able to see your files in the `/mnt/remote/realdebrid/__all__/` directory.
|
||||
- You can now use your Debrid provider as a remote storage solution with Rclone and Decypharr.
|
||||
- You can also use the Rclone mount command to mount your Debrid provider locally. For example:
|
||||
|
||||
|
||||
### Notes
|
||||
|
||||
- Make sure to replace `your-ip-address` with the actual IP address of your server.
|
||||
- You can use multiple Debrid providers by adding them to the `debrids` array in the `config.json` file.
|
||||
|
||||
For each provider, you'll need a different rclone. OR you can change your `rclone.conf`
|
||||
|
||||
|
||||
```apache
|
||||
[decypharr]
|
||||
type = webdav
|
||||
url = https://your-ip-or-domain:8282/webdav/
|
||||
vendor = other
|
||||
pacer_min_sleep = 0
|
||||
```
|
||||
|
||||
You'll still be able to access the directories via `/mnt/remote/realdebrid, /mnt/remote/alldebrid` etc
|
||||
|
||||
|
||||
BIN
docs/docs/images/logo.png
Normal file
BIN
docs/docs/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
BIN
docs/docs/images/main.png
Normal file
BIN
docs/docs/images/main.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 188 KiB |
BIN
docs/docs/images/sonarr-setup.png
Normal file
BIN
docs/docs/images/sonarr-setup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 264 KiB |
29
docs/docs/index.md
Normal file
29
docs/docs/index.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# Decypharr
|
||||
|
||||

|
||||
|
||||
**Decypharr** is an implementation of QbitTorrent with **Multiple Debrid service support**, written in Go.
|
||||
|
||||
## What is Decypharr?
|
||||
|
||||
TLDR; Decypharr is a self-hosted, open-source torrent client that integrates with multiple Debrid services. It provides a user-friendly interface for managing torrents and supports popular media management applications like Sonarr and Radarr.
|
||||
|
||||
|
||||
## Key Features
|
||||
|
||||
- Mock Qbittorent API that supports Sonarr, Radarr, Lidarr, and other Arr applications
|
||||
- Full-fledged UI for managing torrents
|
||||
- Multiple Debrid providers support
|
||||
- WebDAV server support for each Debrid provider
|
||||
- Repair Worker for missing files, symlinks etc
|
||||
|
||||
## Supported Debrid Providers
|
||||
|
||||
- [Real Debrid](https://real-debrid.com)
|
||||
- [Torbox](https://torbox.app)
|
||||
- [Debrid Link](https://debrid-link.com)
|
||||
- [All Debrid](https://alldebrid.com)
|
||||
|
||||
## Getting Started
|
||||
|
||||
Check out our [Installation Guide](installation.md) to get started with Decypharr.
|
||||
116
docs/docs/installation.md
Normal file
116
docs/docs/installation.md
Normal file
@@ -0,0 +1,116 @@
|
||||
# Installation
|
||||
|
||||
There are multiple ways to install and run Decypharr. Choose the method that works best for your setup.
|
||||
|
||||
## Docker Installation (Recommended)
|
||||
|
||||
Docker is the easiest way to get started with Decypharr.
|
||||
|
||||
### Available Docker Registries
|
||||
|
||||
You can use either Docker Hub or GitHub Container Registry to pull the image:
|
||||
|
||||
- Docker Hub: `cy01/blackhole:latest`
|
||||
- GitHub Container Registry: `ghcr.io/sirrobot01/decypharr:latest`
|
||||
|
||||
### Docker 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 (usually unstable)
|
||||
- `experimental`: The latest experimental build (highly unstable)
|
||||
|
||||
### Docker CLI Setup
|
||||
|
||||
Pull the Docker image:
|
||||
```bash
|
||||
docker pull cy01/blackhole:latest
|
||||
```
|
||||
Run the Docker container:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name decypharr \
|
||||
-p 8282:8282 \
|
||||
-v /mnt/:/mnt \
|
||||
-v ./config/:/app \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e UMASK=002 \
|
||||
cy01/blackhole:latest
|
||||
```
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
Create a `docker-compose.yml` file with the following content:
|
||||
|
||||
```yaml
|
||||
version: '3.7'
|
||||
services:
|
||||
decypharr:
|
||||
image: cy01/blackhole:latest
|
||||
container_name: decypharr
|
||||
ports:
|
||||
- "8282:8282"
|
||||
user: "1000:1000"
|
||||
volumes:
|
||||
- /mnt/:/mnt # Mount your media directory
|
||||
- ./config/:/app # config.json must be in this directory
|
||||
environment:
|
||||
- PUID=1000
|
||||
- PGID=1000
|
||||
- UMASK=002
|
||||
- QBIT_PORT=8282 # qBittorrent Port (optional)
|
||||
restart: unless-stopped
|
||||
```
|
||||
|
||||
Run the Docker Compose setup:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
|
||||
## Binary Installation
|
||||
If you prefer not to use Docker, you can download and run the binary directly.
|
||||
|
||||
Download the binary from the releases page
|
||||
Create a configuration file (see Configuration)
|
||||
Run the binary:
|
||||
```bash
|
||||
chmod +x decypharr
|
||||
./decypharr --config /path/to/config/folder
|
||||
```
|
||||
|
||||
The config directory should contain your config.json file.
|
||||
|
||||
## config.json
|
||||
|
||||
The `config.json` file is where you configure Decypharr. You can find a sample configuration file in the `configs` directory of the repository.
|
||||
|
||||
You can also configure Decypharr through the web interface, but it's recommended to start with the config file for initial setup.
|
||||
|
||||
```json
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "your_api_key_here",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"use_webdav": true
|
||||
}
|
||||
],
|
||||
"qbittorrent": {
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr"]
|
||||
},
|
||||
"use_auth": false,
|
||||
"log_level": "info",
|
||||
"port": "8282"
|
||||
}
|
||||
```
|
||||
|
||||
### Few Notes
|
||||
|
||||
- Make sure decypharr has access to the directories specified in the configuration file.
|
||||
- Ensure decypharr have write permissions to the qbittorrent download folder.
|
||||
- Make sure decypharr can write to the `./config/` directory.
|
||||
39
docs/docs/usage.md
Normal file
39
docs/docs/usage.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Usage Guide
|
||||
|
||||
This guide will help you get started with Decypharr after installation.
|
||||
|
||||
## Basic Setup
|
||||
|
||||
1. Create your `config.json` file (see [Configuration](configuration/index.md) for details)
|
||||
2. Start the Decypharr service using Docker or binary
|
||||
3. Access the UI at `http://localhost:8282` (or your configured host/port)
|
||||
4. Connect your Arr applications (Sonarr, Radarr, etc.)
|
||||
|
||||
## Connecting to Sonarr/Radarr
|
||||
|
||||
To connect Decypharr to your Sonarr or Radarr instance:
|
||||
|
||||
1. In Sonarr/Radarr, go to **Settings → Download Client → Add Client → qBittorrent**
|
||||
2. Configure the following settings:
|
||||
- **Host**: `localhost` (or the IP of your Decypharr server)
|
||||
- **Port**: `8282` (or your configured qBittorrent port)
|
||||
- **Username**: `http://sonarr:8989` (your Arr host with http/https)
|
||||
- **Password**: `sonarr_token` (your Arr API token)
|
||||
- **Category**: e.g., `sonarr`, `radarr` (match what you configured in Decypharr)
|
||||
- **Use SSL**: `No`
|
||||
- **Sequential Download**: `No` or `Yes` (if you want to download torrents locally instead of symlink)
|
||||
3. Click **Test** to verify the connection
|
||||
4. Click **Save** to add the download client
|
||||
|
||||

|
||||
|
||||
## Using the UI
|
||||
|
||||
The Decypharr UI provides a familiar qBittorrent-like interface with additional features for Debrid services:
|
||||
|
||||
- Add new torrents
|
||||
- Monitor download status
|
||||
- Access WebDAV functionality
|
||||
- Edit your configuration
|
||||
|
||||
Access the UI at `http://localhost:8282` or your configured host/port.
|
||||
80
docs/mkdocs.yml
Normal file
80
docs/mkdocs.yml
Normal file
@@ -0,0 +1,80 @@
|
||||
site_name: Decypharr
|
||||
site_url: https://sirrobot01.github.io/decypharr
|
||||
site_description: QbitTorrent with Debrid Support
|
||||
repo_url: https://github.com/sirrobot01/decypharr
|
||||
repo_name: sirrobot01/decypharr
|
||||
edit_uri: blob/main/docs
|
||||
|
||||
|
||||
theme:
|
||||
name: material
|
||||
logo: images/logo.png
|
||||
font:
|
||||
text: Roboto
|
||||
code: Roboto Mono
|
||||
palette:
|
||||
- media: "(prefers-color-scheme: light)"
|
||||
scheme: default
|
||||
primary: indigo
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/weather-night
|
||||
name: Switch to dark mode
|
||||
- media: "(prefers-color-scheme: dark)"
|
||||
scheme: slate
|
||||
primary: indigo
|
||||
accent: indigo
|
||||
toggle:
|
||||
icon: material/weather-sunny
|
||||
name: Switch to light mode
|
||||
features:
|
||||
- navigation.search.highlight
|
||||
- navigation.search.suggest
|
||||
- navigation.search.share
|
||||
- navigation.search.suggest
|
||||
- navigation.search.share
|
||||
- navigation.search.highlight
|
||||
- navigation.search.suggest
|
||||
- navigation.search.share
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- pymdownx.details
|
||||
- pymdownx.superfences
|
||||
- pymdownx.highlight
|
||||
- pymdownx.inlinehilite
|
||||
- pymdownx.tabbed
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- def_list
|
||||
- toc:
|
||||
permalink: true
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Installation: installation.md
|
||||
- Usage: usage.md
|
||||
- Configuration:
|
||||
- Overview: configuration/index.md
|
||||
- General: configuration/general.md
|
||||
- Debrid Providers: configuration/debrid.md
|
||||
- qBittorrent: configuration/qbittorrent.md
|
||||
- Arr Integration: configuration/arrs.md
|
||||
- Features:
|
||||
- Overview: features/index.md
|
||||
- Repair Worker: features/repair-worker.md
|
||||
- WebDAV: features/webdav.md
|
||||
- Guides:
|
||||
- Overview: guides/index.md
|
||||
- Setting Up with Rclone: guides/rclone.md
|
||||
- Changelog: changelog.md
|
||||
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- tags
|
||||
35
go.mod
35
go.mod
@@ -1,23 +1,38 @@
|
||||
module goBlack
|
||||
module github.com/sirrobot01/decypharr
|
||||
|
||||
go 1.22
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.24.3
|
||||
|
||||
require (
|
||||
github.com/anacrolix/torrent v1.55.0
|
||||
github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380
|
||||
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2
|
||||
github.com/fsnotify/fsnotify v1.7.0
|
||||
github.com/valyala/fastjson v1.6.4
|
||||
golang.org/x/time v0.6.0
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-co-op/gocron/v2 v2.16.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/stanNthe5/stringbuf v0.0.3
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/sync v0.12.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/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
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.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.13.1 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
)
|
||||
|
||||
79
go.sum
79
go.sum
@@ -44,37 +44,40 @@ github.com/bradfitz/iter v0.0.0-20140124041915-454541ec3da2/go.mod h1:PyRFw1Lt2w
|
||||
github.com/bradfitz/iter v0.0.0-20190303215204-33e6a9893b0c/go.mod h1:PyRFw1Lt2wKX4ZVSQ2mk+PeDa1rxyObEDlApuIsUKuo=
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 h1:GKTyiRCL6zVf5wWaqKnf+7Qs6GbEPfd4iMOitWzXJx8=
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8/go.mod h1:spo1JLcs67NmW1aVLEgtA8Yy1elc+X8y5SRW1sFW4Og=
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1 h1:4z7TkBfmPjmLAAmkkAZNX/6QJ1nNFdv3SdIHXju0Fr4=
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1/go.mod h1:1U/KNnD+Ft6JJiYoYBAimKH2XrYptb8Kl3DFGmsjpq4=
|
||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
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=
|
||||
github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs=
|
||||
github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU=
|
||||
github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I=
|
||||
github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380 h1:1NyRx2f4W4WBRyg0Kys0ZbaNmDDzZ2R/C7DTi+bbsJ0=
|
||||
github.com/elazarl/goproxy v0.0.0-20240726154733-8b0c20506380/go.mod h1:thX175TtLTzLj3p7N/Q9IiKZ7NF+p72cvL91emV0hzo=
|
||||
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2 h1:dWB6v3RcOy03t/bUadywsbyrQwCqZeNIEX6M1OtSZOM=
|
||||
github.com/elazarl/goproxy/ext v0.0.0-20190711103511-473e67f1d7d2/go.mod h1:gNh8nYJoAm43RfaxurUnxr+N1PwuFV3ZMl/efxlIlY8=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20180323001048-9f0cb55181dd/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20181221182339-f9677308dec2/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod h1:/20jfyN9Y5QPEAprSgKAUr+glWDY39ZiUEAYOEv5dsE=
|
||||
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-co-op/gocron/v2 v2.16.1 h1:ux/5zxVRveCaCuTtNI3DiOk581KC1KpJbpJFYUEVYwo=
|
||||
github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
|
||||
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
@@ -100,12 +103,20 @@ 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=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181103185306-d547d1d9531e/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20190309154008-847fc94819f9/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
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=
|
||||
@@ -113,6 +124,8 @@ github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63
|
||||
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/huandu/xstrings v1.3.2 h1:L18LIDzqlW6xN2rEkpdV8+oL/IXWJ1APd+vsdYy4Wdw=
|
||||
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
@@ -128,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=
|
||||
@@ -145,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=
|
||||
@@ -164,9 +184,13 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
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=
|
||||
@@ -174,26 +198,30 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1
|
||||
github.com/smartystreets/assertions v0.0.0-20190215210624-980c5ac6f3ac/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v0.0.0-20181108003508-044398e4856c/go.mod h1:XDJAKZRPZ1CvBcN2aX5YOUTYGHki24fSF0Iv48Ibg0s=
|
||||
github.com/smartystreets/goconvey v0.0.0-20190306220146-200a235640ff/go.mod h1:KSQcGKpxUMHk3nbYzs/tIBAM2iDooCn0BmttHOJEbLs=
|
||||
github.com/stanNthe5/stringbuf v0.0.3 h1:3ChRipDckEY6FykaQ1Dowy3B+ZQa72EDBCasvT5+D1w=
|
||||
github.com/stanNthe5/stringbuf v0.0.3/go.mod h1:hii5Vr+mucoWkNJlIYQVp8YvuPtq45fFnJEAhcPf2cQ=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
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/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=
|
||||
github.com/willf/bitset v1.1.10/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
|
||||
go.opencensus.io v0.20.1/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.20.2/go.mod h1:6WKK9ahsWS3RSO+PY9ZHZUfv2irvY6gN279GOPZjmmk=
|
||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
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=
|
||||
@@ -209,8 +237,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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
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=
|
||||
@@ -218,6 +246,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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
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=
|
||||
@@ -229,14 +259,15 @@ 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.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
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/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=
|
||||
@@ -263,6 +294,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=
|
||||
|
||||
381
internal/config/config.go
Normal file
381
internal/config/config.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
instance *Config
|
||||
once sync.Once
|
||||
configPath string
|
||||
)
|
||||
|
||||
type Debrid struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
DownloadAPIKeys []string `json:"download_api_keys,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
DownloadUncached bool `json:"download_uncached,omitempty"`
|
||||
CheckCached bool `json:"check_cached,omitempty"`
|
||||
RateLimit string `json:"rate_limit,omitempty"` // 200/minute or 10/second
|
||||
Proxy string `json:"proxy,omitempty"`
|
||||
AddSamples bool `json:"add_samples,omitempty"`
|
||||
|
||||
UseWebDav bool `json:"use_webdav,omitempty"`
|
||||
WebDav
|
||||
}
|
||||
|
||||
type QBitTorrent struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
Port string `json:"port,omitempty"` // deprecated
|
||||
DownloadFolder string `json:"download_folder,omitempty"`
|
||||
Categories []string `json:"categories,omitempty"`
|
||||
RefreshInterval int `json:"refresh_interval,omitempty"`
|
||||
SkipPreCache bool `json:"skip_pre_cache,omitempty"`
|
||||
MaxDownloads int `json:"max_downloads,omitempty"`
|
||||
}
|
||||
|
||||
type Arr struct {
|
||||
Name string `json:"name,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
Cleanup bool `json:"cleanup,omitempty"`
|
||||
SkipRepair bool `json:"skip_repair,omitempty"`
|
||||
DownloadUncached *bool `json:"download_uncached,omitempty"`
|
||||
}
|
||||
|
||||
type Repair struct {
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
Interval string `json:"interval,omitempty"`
|
||||
RunOnStart bool `json:"run_on_start,omitempty"`
|
||||
ZurgURL string `json:"zurg_url,omitempty"`
|
||||
AutoProcess bool `json:"auto_process,omitempty"`
|
||||
UseWebDav bool `json:"use_webdav,omitempty"`
|
||||
Workers int `json:"workers,omitempty"`
|
||||
ReInsert bool `json:"reinsert,omitempty"`
|
||||
}
|
||||
|
||||
type Auth struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
// server
|
||||
BindAddress string `json:"bind_address,omitempty"`
|
||||
URLBase string `json:"url_base,omitempty"`
|
||||
Port string `json:"port,omitempty"`
|
||||
|
||||
LogLevel string `json:"log_level,omitempty"`
|
||||
Debrids []Debrid `json:"debrids,omitempty"`
|
||||
QBitTorrent QBitTorrent `json:"qbittorrent,omitempty"`
|
||||
Arrs []Arr `json:"arrs,omitempty"`
|
||||
Repair Repair `json:"repair,omitempty"`
|
||||
WebDav WebDav `json:"webdav,omitempty"`
|
||||
AllowedExt []string `json:"allowed_file_types,omitempty"`
|
||||
MinFileSize string `json:"min_file_size,omitempty"` // Minimum file size to download, 10MB, 1GB, etc
|
||||
MaxFileSize string `json:"max_file_size,omitempty"` // Maximum file size to download (0 means no limit)
|
||||
Path string `json:"-"` // Path to save the config file
|
||||
UseAuth bool `json:"use_auth,omitempty"`
|
||||
Auth *Auth `json:"-"`
|
||||
DiscordWebhook string `json:"discord_webhook_url,omitempty"`
|
||||
}
|
||||
|
||||
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 {
|
||||
if os.IsNotExist(err) {
|
||||
fmt.Printf("Config file not found, creating a new one at %s\n", c.JsonFile())
|
||||
// Create a default config file if it doesn't exist
|
||||
if err := c.createConfig(c.Path); err != nil {
|
||||
return fmt.Errorf("failed to create config file: %w", err)
|
||||
}
|
||||
return c.Save()
|
||||
}
|
||||
return fmt.Errorf("error reading config file: %w", err)
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(file, &c); err != nil {
|
||||
return fmt.Errorf("error unmarshaling config: %w", err)
|
||||
}
|
||||
c.setDefaults()
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateDebrids(debrids []Debrid) error {
|
||||
if len(debrids) == 0 {
|
||||
return errors.New("no debrids configured")
|
||||
}
|
||||
|
||||
for _, debrid := range debrids {
|
||||
// Basic field validation
|
||||
if debrid.APIKey == "" {
|
||||
return errors.New("debrid api key is required")
|
||||
}
|
||||
if debrid.Folder == "" {
|
||||
return errors.New("debrid folder is required")
|
||||
}
|
||||
}
|
||||
|
||||
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 fmt.Errorf("qbittorent download folder(%s) does not exist", config.DownloadFolder)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRepair(config *Repair) error {
|
||||
if !config.Enabled {
|
||||
return nil
|
||||
}
|
||||
if config.Interval == "" {
|
||||
return errors.New("repair interval is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateConfig(config *Config) error {
|
||||
// Run validations concurrently
|
||||
|
||||
if err := validateDebrids(config.Debrids); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateQbitTorrent(&config.QBitTorrent); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := validateRepair(&config.Repair); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SetConfigPath(path string) {
|
||||
configPath = path
|
||||
}
|
||||
|
||||
func Get() *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() error {
|
||||
return ValidateConfig(c)
|
||||
}
|
||||
|
||||
func (c *Config) NeedsAuth() bool {
|
||||
if c.UseAuth {
|
||||
return c.GetAuth().Username == ""
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Config) updateDebrid(d Debrid) Debrid {
|
||||
workers := runtime.NumCPU() * 50
|
||||
perDebrid := workers / len(c.Debrids)
|
||||
|
||||
if len(d.DownloadAPIKeys) == 0 {
|
||||
d.DownloadAPIKeys = append(d.DownloadAPIKeys, d.APIKey)
|
||||
}
|
||||
|
||||
if !d.UseWebDav {
|
||||
return d
|
||||
}
|
||||
|
||||
if d.TorrentsRefreshInterval == "" {
|
||||
d.TorrentsRefreshInterval = cmp.Or(c.WebDav.TorrentsRefreshInterval, "15s") // 15 seconds
|
||||
}
|
||||
if d.WebDav.DownloadLinksRefreshInterval == "" {
|
||||
d.DownloadLinksRefreshInterval = cmp.Or(c.WebDav.DownloadLinksRefreshInterval, "40m") // 40 minutes
|
||||
}
|
||||
if d.Workers == 0 {
|
||||
d.Workers = perDebrid
|
||||
}
|
||||
if d.FolderNaming == "" {
|
||||
d.FolderNaming = cmp.Or(c.WebDav.FolderNaming, "original_no_ext")
|
||||
}
|
||||
if d.AutoExpireLinksAfter == "" {
|
||||
d.AutoExpireLinksAfter = cmp.Or(c.WebDav.AutoExpireLinksAfter, "3d") // 2 days
|
||||
}
|
||||
|
||||
// Merge debrid specified directories with global directories
|
||||
|
||||
directories := c.WebDav.Directories
|
||||
if directories == nil {
|
||||
directories = make(map[string]WebdavDirectories)
|
||||
}
|
||||
|
||||
for name, dir := range d.Directories {
|
||||
directories[name] = dir
|
||||
}
|
||||
d.Directories = directories
|
||||
|
||||
d.RcUrl = cmp.Or(d.RcUrl, c.WebDav.RcUrl)
|
||||
d.RcUser = cmp.Or(d.RcUser, c.WebDav.RcUser)
|
||||
d.RcPass = cmp.Or(d.RcPass, c.WebDav.RcPass)
|
||||
|
||||
return d
|
||||
}
|
||||
|
||||
func (c *Config) setDefaults() {
|
||||
for i, debrid := range c.Debrids {
|
||||
c.Debrids[i] = c.updateDebrid(debrid)
|
||||
}
|
||||
|
||||
if len(c.AllowedExt) == 0 {
|
||||
c.AllowedExt = getDefaultExtensions()
|
||||
}
|
||||
|
||||
c.Port = cmp.Or(c.Port, c.QBitTorrent.Port)
|
||||
|
||||
if c.URLBase == "" {
|
||||
c.URLBase = "/"
|
||||
}
|
||||
// validate url base starts with /
|
||||
if !strings.HasPrefix(c.URLBase, "/") {
|
||||
c.URLBase = "/" + c.URLBase
|
||||
}
|
||||
if !strings.HasSuffix(c.URLBase, "/") {
|
||||
c.URLBase += "/"
|
||||
}
|
||||
|
||||
// Load the auth file
|
||||
c.Auth = c.GetAuth()
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
|
||||
c.setDefaults()
|
||||
|
||||
data, err := json.MarshalIndent(c, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.WriteFile(c.JsonFile(), data, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) createConfig(path string) error {
|
||||
// Create the directory if it doesn't exist
|
||||
if err := os.MkdirAll(path, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
c.Path = path
|
||||
c.URLBase = "/"
|
||||
c.Port = "8282"
|
||||
c.LogLevel = "info"
|
||||
c.UseAuth = true
|
||||
c.QBitTorrent = QBitTorrent{
|
||||
DownloadFolder: filepath.Join(path, "downloads"),
|
||||
Categories: []string{"sonarr", "radarr"},
|
||||
RefreshInterval: 15,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reload forces a reload of the configuration from disk
|
||||
func Reload() {
|
||||
instance = nil
|
||||
once = sync.Once{}
|
||||
}
|
||||
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("webm,m4v,3gp,nsv,ty,strm,rm,rmvb,m3u,ifo,mov,qt,divx,xvid,bivx,nrg,pva,wmv,asf,asx,ogm,ogv,m2v,avi,bin,dat,dvr-ms,mpg,mpeg,mp4,avc,vp3,svq3,nuv,viv,dv,fli,flv,wpl,img,iso,vob,mkv,mk3d,ts,wtv,m2ts'", ",")
|
||||
musicExts := strings.Split("MP3,WAV,FLAC,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]struct{})
|
||||
var unique []string
|
||||
|
||||
for _, ext := range allExts {
|
||||
if _, ok := seen[ext]; !ok {
|
||||
seen[ext] = struct{}{}
|
||||
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
|
||||
}
|
||||
26
internal/config/webdav.go
Normal file
26
internal/config/webdav.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package config
|
||||
|
||||
type WebdavDirectories struct {
|
||||
Filters map[string]string `json:"filters,omitempty"`
|
||||
//SaveStrms bool `json:"save_streams,omitempty"`
|
||||
}
|
||||
|
||||
type WebDav struct {
|
||||
TorrentsRefreshInterval string `json:"torrents_refresh_interval,omitempty"`
|
||||
DownloadLinksRefreshInterval string `json:"download_links_refresh_interval,omitempty"`
|
||||
Workers int `json:"workers,omitempty"`
|
||||
AutoExpireLinksAfter string `json:"auto_expire_links_after,omitempty"`
|
||||
ServeFromRclone bool `json:"serve_from_rclone,omitempty"`
|
||||
|
||||
// Folder
|
||||
FolderNaming string `json:"folder_naming,omitempty"`
|
||||
|
||||
// Rclone
|
||||
RcUrl string `json:"rc_url,omitempty"`
|
||||
RcUser string `json:"rc_user,omitempty"`
|
||||
RcPass string `json:"rc_pass,omitempty"`
|
||||
RcRefreshDirs string `json:"rc_refresh_dirs,omitempty"` // comma separated list of directories to refresh
|
||||
|
||||
// Directories
|
||||
Directories map[string]WebdavDirectories `json:"directories,omitempty"`
|
||||
}
|
||||
114
internal/logger/logger.go
Normal file
114
internal/logger/logger.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
logger zerolog.Logger
|
||||
)
|
||||
|
||||
func GetLogPath() string {
|
||||
cfg := config.Get()
|
||||
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 New(prefix string) zerolog.Logger {
|
||||
|
||||
level := config.Get().LogLevel
|
||||
|
||||
rotatingLogFile := &lumberjack.Logger{
|
||||
Filename: GetLogPath(),
|
||||
MaxSize: 10,
|
||||
MaxAge: 15,
|
||||
Compress: true,
|
||||
}
|
||||
|
||||
consoleWriter := zerolog.ConsoleWriter{
|
||||
Out: os.Stdout,
|
||||
TimeFormat: "2006-01-02 15:04:05",
|
||||
NoColor: false, // Set to true if you don't want colors
|
||||
FormatLevel: func(i interface{}) string {
|
||||
var colorCode string
|
||||
switch strings.ToLower(fmt.Sprintf("%s", i)) {
|
||||
case "debug":
|
||||
colorCode = "\033[36m"
|
||||
case "info":
|
||||
colorCode = "\033[32m"
|
||||
case "warn":
|
||||
colorCode = "\033[33m"
|
||||
case "error":
|
||||
colorCode = "\033[31m"
|
||||
case "fatal":
|
||||
colorCode = "\033[35m"
|
||||
case "panic":
|
||||
colorCode = "\033[41m"
|
||||
default:
|
||||
colorCode = "\033[37m" // White
|
||||
}
|
||||
return fmt.Sprintf("%s| %-6s|\033[0m", colorCode, strings.ToUpper(fmt.Sprintf("%s", 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
|
||||
level = strings.ToLower(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)
|
||||
case "trace":
|
||||
logger = logger.Level(zerolog.TraceLevel)
|
||||
}
|
||||
return logger
|
||||
}
|
||||
|
||||
func Default() zerolog.Logger {
|
||||
once.Do(func() {
|
||||
logger = New("decypharr")
|
||||
})
|
||||
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/decypharr/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.Get()
|
||||
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
|
||||
}
|
||||
35
internal/request/errors.go
Normal file
35
internal/request/errors.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package request
|
||||
|
||||
type HTTPError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
Code string
|
||||
}
|
||||
|
||||
func (e *HTTPError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
var HosterUnavailableError = &HTTPError{
|
||||
StatusCode: 503,
|
||||
Message: "Hoster is unavailable",
|
||||
Code: "hoster_unavailable",
|
||||
}
|
||||
|
||||
var TrafficExceededError = &HTTPError{
|
||||
StatusCode: 503,
|
||||
Message: "Traffic exceeded",
|
||||
Code: "traffic_exceeded",
|
||||
}
|
||||
|
||||
var ErrLinkBroken = &HTTPError{
|
||||
StatusCode: 404,
|
||||
Message: "File is unavailable",
|
||||
Code: "file_unavailable",
|
||||
}
|
||||
|
||||
var TorrentNotFoundError = &HTTPError{
|
||||
StatusCode: 404,
|
||||
Message: "Torrent not found",
|
||||
Code: "torrent_not_found",
|
||||
}
|
||||
443
internal/request/request.go
Normal file
443
internal/request/request.go
Normal file
@@ -0,0 +1,443 @@
|
||||
package request
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"golang.org/x/net/proxy"
|
||||
"golang.org/x/time/rate"
|
||||
"io"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"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
|
||||
}
|
||||
|
||||
var (
|
||||
once sync.Once
|
||||
instance *Client
|
||||
)
|
||||
|
||||
type ClientOption func(*Client)
|
||||
|
||||
// Client represents an HTTP client with additional capabilities
|
||||
type Client struct {
|
||||
client *http.Client
|
||||
rateLimiter *rate.Limiter
|
||||
headers map[string]string
|
||||
headersMu sync.RWMutex
|
||||
maxRetries int
|
||||
timeout time.Duration
|
||||
skipTLSVerify bool
|
||||
retryableStatus map[int]struct{}
|
||||
logger zerolog.Logger
|
||||
proxy string
|
||||
}
|
||||
|
||||
// WithMaxRetries sets the maximum number of retry attempts
|
||||
func WithMaxRetries(maxRetries int) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.maxRetries = maxRetries
|
||||
}
|
||||
}
|
||||
|
||||
// WithTimeout sets the request timeout
|
||||
func WithTimeout(timeout time.Duration) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.timeout = timeout
|
||||
}
|
||||
}
|
||||
|
||||
func WithRedirectPolicy(policy func(req *http.Request, via []*http.Request) error) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.client.CheckRedirect = policy
|
||||
}
|
||||
}
|
||||
|
||||
// WithRateLimiter sets a rate limiter
|
||||
func WithRateLimiter(rl *rate.Limiter) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.rateLimiter = rl
|
||||
}
|
||||
}
|
||||
|
||||
// WithHeaders sets default headers
|
||||
func WithHeaders(headers map[string]string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.headersMu.Lock()
|
||||
c.headers = headers
|
||||
c.headersMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) SetHeader(key, value string) {
|
||||
c.headersMu.Lock()
|
||||
c.headers[key] = value
|
||||
c.headersMu.Unlock()
|
||||
}
|
||||
|
||||
func WithLogger(logger zerolog.Logger) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.logger = logger
|
||||
}
|
||||
}
|
||||
|
||||
func WithTransport(transport *http.Transport) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.client.Transport = transport
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryableStatus adds status codes that should trigger a retry
|
||||
func WithRetryableStatus(statusCodes ...int) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.retryableStatus = make(map[int]struct{}) // reset the map
|
||||
for _, code := range statusCodes {
|
||||
c.retryableStatus[code] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithProxy(proxyURL string) ClientOption {
|
||||
return func(c *Client) {
|
||||
c.proxy = proxyURL
|
||||
}
|
||||
}
|
||||
|
||||
// doRequest performs a single HTTP request with rate limiting
|
||||
func (c *Client) doRequest(req *http.Request) (*http.Response, error) {
|
||||
if c.rateLimiter != nil {
|
||||
err := c.rateLimiter.Wait(req.Context())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rate limiter wait: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return c.client.Do(req)
|
||||
}
|
||||
|
||||
// Do performs an HTTP request with retries for certain status codes
|
||||
func (c *Client) Do(req *http.Request) (*http.Response, error) {
|
||||
// Save the request body for reuse in retries
|
||||
var bodyBytes []byte
|
||||
var err error
|
||||
|
||||
if req.Body != nil {
|
||||
bodyBytes, err = io.ReadAll(req.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading request body: %w", err)
|
||||
}
|
||||
req.Body.Close()
|
||||
}
|
||||
|
||||
backoff := time.Millisecond * 500
|
||||
var resp *http.Response
|
||||
|
||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||
// Reset the request body if it exists
|
||||
if bodyBytes != nil {
|
||||
req.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||
}
|
||||
|
||||
// Apply headers
|
||||
c.headersMu.RLock()
|
||||
if c.headers != nil {
|
||||
for key, value := range c.headers {
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
c.headersMu.RUnlock()
|
||||
|
||||
resp, err = c.doRequest(req)
|
||||
if err != nil {
|
||||
// Check if this is a network error that might be worth retrying
|
||||
if isRetryableError(err) && attempt < c.maxRetries {
|
||||
// Apply backoff with jitter
|
||||
jitter := time.Duration(rand.Int63n(int64(backoff / 4)))
|
||||
sleepTime := backoff + jitter
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
return nil, req.Context().Err()
|
||||
case <-time.After(sleepTime):
|
||||
// Continue to next retry attempt
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
backoff *= 2
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if the status code is retryable
|
||||
if _, ok := c.retryableStatus[resp.StatusCode]; !ok || attempt == c.maxRetries {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Close the response body before retrying
|
||||
resp.Body.Close()
|
||||
|
||||
// Apply backoff with jitter
|
||||
jitter := time.Duration(rand.Int63n(int64(backoff / 4)))
|
||||
sleepTime := backoff + jitter
|
||||
|
||||
select {
|
||||
case <-req.Context().Done():
|
||||
return nil, req.Context().Err()
|
||||
case <-time.After(sleepTime):
|
||||
// Continue to next retry attempt
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
backoff *= 2
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("max retries exceeded")
|
||||
}
|
||||
|
||||
// MakeRequest performs an HTTP request and returns the response body as bytes
|
||||
func (c *Client) MakeRequest(req *http.Request) ([]byte, error) {
|
||||
res, err := c.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := res.Body.Close(); err != nil {
|
||||
c.logger.Printf("Failed to close response body: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
bodyBytes, err := io.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("HTTP error %d: %s", res.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return bodyBytes, nil
|
||||
}
|
||||
|
||||
func (c *Client) Get(url string) (*http.Response, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("creating GET request: %w", err)
|
||||
}
|
||||
|
||||
return c.Do(req)
|
||||
}
|
||||
|
||||
// New creates a new HTTP client with the specified options
|
||||
func New(options ...ClientOption) *Client {
|
||||
client := &Client{
|
||||
maxRetries: 3,
|
||||
skipTLSVerify: true,
|
||||
retryableStatus: map[int]struct{}{
|
||||
http.StatusTooManyRequests: struct{}{},
|
||||
http.StatusInternalServerError: struct{}{},
|
||||
http.StatusBadGateway: struct{}{},
|
||||
http.StatusServiceUnavailable: struct{}{},
|
||||
http.StatusGatewayTimeout: struct{}{},
|
||||
},
|
||||
logger: logger.New("request"),
|
||||
timeout: 60 * time.Second,
|
||||
proxy: "",
|
||||
headers: make(map[string]string),
|
||||
}
|
||||
|
||||
// default http client
|
||||
client.client = &http.Client{
|
||||
Timeout: client.timeout,
|
||||
}
|
||||
|
||||
// Apply options before configuring transport
|
||||
for _, option := range options {
|
||||
option(client)
|
||||
}
|
||||
|
||||
// Check if transport was set by WithTransport option
|
||||
if client.client.Transport == nil {
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
InsecureSkipVerify: client.skipTLSVerify,
|
||||
},
|
||||
DisableKeepAlives: false,
|
||||
}
|
||||
|
||||
// Configure proxy if needed
|
||||
if client.proxy != "" {
|
||||
if strings.HasPrefix(client.proxy, "socks5://") {
|
||||
// Handle SOCKS5 proxy
|
||||
socksURL, err := url.Parse(client.proxy)
|
||||
if err != nil {
|
||||
client.logger.Error().Msgf("Failed to parse SOCKS5 proxy URL: %v", err)
|
||||
} else {
|
||||
auth := &proxy.Auth{}
|
||||
if socksURL.User != nil {
|
||||
auth.User = socksURL.User.Username()
|
||||
password, _ := socksURL.User.Password()
|
||||
auth.Password = password
|
||||
}
|
||||
|
||||
dialer, err := proxy.SOCKS5("tcp", socksURL.Host, auth, proxy.Direct)
|
||||
if err != nil {
|
||||
client.logger.Error().Msgf("Failed to create SOCKS5 dialer: %v", err)
|
||||
} else {
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
proxyURL, err := url.Parse(client.proxy)
|
||||
if err != nil {
|
||||
client.logger.Error().Msgf("Failed to parse proxy URL: %v", err)
|
||||
} else {
|
||||
transport.Proxy = http.ProxyURL(proxyURL)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
|
||||
// Set the transport to the client
|
||||
client.client.Transport = transport
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
func ParseRateLimit(rateStr string) *rate.Limiter {
|
||||
parts := strings.SplitN(rateStr, "/", 2)
|
||||
if len(parts) != 2 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parse count
|
||||
count, err := strconv.Atoi(strings.TrimSpace(parts[0]))
|
||||
if err != nil || count <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalize unit
|
||||
unit := strings.ToLower(strings.TrimSpace(parts[1]))
|
||||
unit = strings.TrimSuffix(unit, "s")
|
||||
burstSize := int(math.Ceil(float64(count) * 0.1))
|
||||
if burstSize < 1 {
|
||||
burstSize = 1
|
||||
}
|
||||
if burstSize > count {
|
||||
burstSize = count
|
||||
}
|
||||
switch unit {
|
||||
case "minute", "min":
|
||||
return rate.NewLimiter(rate.Limit(float64(count)/60.0), burstSize)
|
||||
case "second", "sec":
|
||||
return rate.NewLimiter(rate.Limit(float64(count)), burstSize)
|
||||
case "hour", "hr":
|
||||
return rate.NewLimiter(rate.Limit(float64(count)/3600.0), burstSize)
|
||||
default:
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
func Gzip(body []byte) []byte {
|
||||
if len(body) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the pool is nil
|
||||
buf := bytes.NewBuffer(make([]byte, 0, len(body)))
|
||||
|
||||
gz, err := gzip.NewWriterLevel(buf, gzip.BestSpeed)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := gz.Write(body); err != nil {
|
||||
return nil
|
||||
}
|
||||
if err := gz.Close(); err != nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]byte, buf.Len())
|
||||
copy(result, buf.Bytes())
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func Default() *Client {
|
||||
once.Do(func() {
|
||||
instance = New()
|
||||
})
|
||||
return instance
|
||||
}
|
||||
|
||||
func isRetryableError(err error) bool {
|
||||
errString := err.Error()
|
||||
|
||||
// Connection reset and other network errors
|
||||
if strings.Contains(errString, "connection reset by peer") ||
|
||||
strings.Contains(errString, "read: connection reset") ||
|
||||
strings.Contains(errString, "connection refused") ||
|
||||
strings.Contains(errString, "network is unreachable") ||
|
||||
strings.Contains(errString, "connection timed out") ||
|
||||
strings.Contains(errString, "no such host") ||
|
||||
strings.Contains(errString, "i/o timeout") ||
|
||||
strings.Contains(errString, "unexpected EOF") ||
|
||||
strings.Contains(errString, "TLS handshake timeout") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for net.Error type which can provide more information
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
// Retry on timeout errors and temporary errors
|
||||
return netErr.Timeout() || netErr.Temporary()
|
||||
}
|
||||
|
||||
// Not a retryable error
|
||||
return false
|
||||
}
|
||||
43
internal/utils/debouncer.go
Normal file
43
internal/utils/debouncer.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Debouncer[T any] struct {
|
||||
mu sync.Mutex
|
||||
timer *time.Timer
|
||||
interval time.Duration
|
||||
caller func(arg T)
|
||||
}
|
||||
|
||||
func NewDebouncer[T any](interval time.Duration, caller func(arg T)) *Debouncer[T] {
|
||||
return &Debouncer[T]{
|
||||
interval: interval,
|
||||
caller: caller,
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Debouncer[T]) Call(arg T) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.timer != nil {
|
||||
d.timer.Stop()
|
||||
}
|
||||
|
||||
d.timer = time.AfterFunc(d.interval, func() {
|
||||
d.caller(arg)
|
||||
})
|
||||
}
|
||||
|
||||
func (d *Debouncer[T]) Stop() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
if d.timer != nil {
|
||||
d.timer.Stop()
|
||||
d.timer = nil
|
||||
}
|
||||
}
|
||||
21
internal/utils/file.go
Normal file
21
internal/utils/file.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func PathUnescape(path string) string {
|
||||
|
||||
// try to use url.PathUnescape
|
||||
if unescaped, err := url.PathUnescape(path); err == nil {
|
||||
return unescaped
|
||||
}
|
||||
|
||||
// unescape %
|
||||
unescapedPath := strings.ReplaceAll(path, "%25", "%")
|
||||
|
||||
// add others
|
||||
|
||||
return unescapedPath
|
||||
}
|
||||
270
internal/utils/magnet.go
Normal file
270
internal/utils/magnet.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base32"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"github.com/anacrolix/torrent/metainfo"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
hexRegex = regexp.MustCompile("^[0-9a-fA-F]{40}$")
|
||||
)
|
||||
|
||||
type Magnet struct {
|
||||
Name string
|
||||
InfoHash string
|
||||
Size int64
|
||||
Link string
|
||||
File []byte
|
||||
}
|
||||
|
||||
func (m *Magnet) IsTorrent() bool {
|
||||
return m.File != nil
|
||||
}
|
||||
|
||||
func GetMagnetFromFile(file io.Reader, filePath string) (*Magnet, error) {
|
||||
var (
|
||||
m *Magnet
|
||||
err error
|
||||
)
|
||||
if filepath.Ext(filePath) == ".torrent" {
|
||||
torrentData, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err = GetMagnetFromBytes(torrentData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// .magnet file
|
||||
magnetLink := ReadMagnetFile(file)
|
||||
m, err = GetMagnetInfo(magnetLink)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
m.Name = strings.TrimSuffix(filePath, filepath.Ext(filePath))
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func GetMagnetFromUrl(url string) (*Magnet, error) {
|
||||
if strings.HasPrefix(url, "magnet:") {
|
||||
return GetMagnetInfo(url)
|
||||
} else if strings.HasPrefix(url, "http") {
|
||||
return OpenMagnetHttpURL(url)
|
||||
}
|
||||
return nil, fmt.Errorf("invalid url")
|
||||
}
|
||||
|
||||
func 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(),
|
||||
File: torrentData,
|
||||
}
|
||||
return magnet, nil
|
||||
}
|
||||
|
||||
func OpenMagnetFile(filePath string) string {
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
log.Println("Error opening file:", err)
|
||||
return ""
|
||||
}
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}(file) // Ensure the file is closed after the function ends
|
||||
return ReadMagnetFile(file)
|
||||
}
|
||||
|
||||
func ReadMagnetFile(file io.Reader) string {
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
content := scanner.Text()
|
||||
if content != "" {
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
// Check for any errors during scanning
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Println("Error reading file:", err)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func OpenMagnetHttpURL(magnetLink string) (*Magnet, error) {
|
||||
resp, err := http.Get(magnetLink)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error making GET request: %v", err)
|
||||
}
|
||||
defer func(resp *http.Response) {
|
||||
err := resp.Body.Close()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}(resp) // Ensure the response is closed after the function ends
|
||||
torrentData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error reading response body: %v", err)
|
||||
}
|
||||
return GetMagnetFromBytes(torrentData)
|
||||
}
|
||||
|
||||
func GetMagnetInfo(magnetLink string) (*Magnet, error) {
|
||||
if magnetLink == "" {
|
||||
return nil, fmt.Errorf("error getting magnet from file")
|
||||
}
|
||||
|
||||
magnetURI, err := url.Parse(magnetLink)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing magnet link")
|
||||
}
|
||||
|
||||
query := magnetURI.Query()
|
||||
xt := query.Get("xt")
|
||||
dn := query.Get("dn")
|
||||
|
||||
// Extract BTIH
|
||||
parts := strings.Split(xt, ":")
|
||||
btih := ""
|
||||
if len(parts) > 2 {
|
||||
btih = parts[2]
|
||||
}
|
||||
magnet := &Magnet{
|
||||
InfoHash: btih,
|
||||
Name: dn,
|
||||
Size: 0,
|
||||
Link: magnetLink,
|
||||
}
|
||||
return magnet, nil
|
||||
}
|
||||
|
||||
func ExtractInfoHash(magnetDesc string) string {
|
||||
const prefix = "xt=urn:btih:"
|
||||
start := strings.Index(magnetDesc, prefix)
|
||||
if start == -1 {
|
||||
return ""
|
||||
}
|
||||
hash := ""
|
||||
start += len(prefix)
|
||||
end := strings.IndexAny(magnetDesc[start:], "&#")
|
||||
if end == -1 {
|
||||
hash = magnetDesc[start:]
|
||||
} else {
|
||||
hash = magnetDesc[start : start+end]
|
||||
}
|
||||
hash, _ = processInfoHash(hash) // Convert to hex if needed
|
||||
return hash
|
||||
}
|
||||
|
||||
func processInfoHash(input string) (string, error) {
|
||||
// Regular expression for a valid 40-character hex infohash
|
||||
|
||||
// If it's already a valid hex infohash, return it as is
|
||||
if hexRegex.MatchString(input) {
|
||||
return strings.ToLower(input), nil
|
||||
}
|
||||
|
||||
// If it's 32 characters long, it might be Base32 encoded
|
||||
if len(input) == 32 {
|
||||
// Ensure the input is uppercase and remove any padding
|
||||
input = strings.ToUpper(strings.TrimRight(input, "="))
|
||||
|
||||
// Try to decode from Base32
|
||||
decoded, err := base32.StdEncoding.DecodeString(input)
|
||||
if err == nil && len(decoded) == 20 {
|
||||
// If successful and the result is 20 bytes, encode to hex
|
||||
return hex.EncodeToString(decoded), nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, it's not a valid infohash and we couldn't convert it
|
||||
return "", fmt.Errorf("invalid infohash: %s", input)
|
||||
}
|
||||
|
||||
func GetInfohashFromURL(url string) (string, error) {
|
||||
// Download the torrent file
|
||||
var magnetLink string
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
redirectFunc := func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 3 {
|
||||
return fmt.Errorf("stopped after 3 redirects")
|
||||
}
|
||||
if strings.HasPrefix(req.URL.String(), "magnet:") {
|
||||
// Stop the redirect chain
|
||||
magnetLink = req.URL.String()
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
return nil
|
||||
}
|
||||
client := request.New(
|
||||
request.WithTimeout(30*time.Second),
|
||||
request.WithRedirectPolicy(redirectFunc),
|
||||
)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if magnetLink != "" {
|
||||
return ExtractInfoHash(magnetLink), nil
|
||||
}
|
||||
|
||||
mi, err := metainfo.Load(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := mi.HashInfoBytes()
|
||||
infoHash := hash.HexString()
|
||||
return infoHash, nil
|
||||
}
|
||||
|
||||
func ConstructMagnet(infoHash, name string) *Magnet {
|
||||
// Create a magnet link from the infohash and name
|
||||
name = url.QueryEscape(strings.TrimSpace(name))
|
||||
magnetUri := fmt.Sprintf("magnet:?xt=urn:btih:%s&dn=%s", infoHash, name)
|
||||
return &Magnet{
|
||||
InfoHash: infoHash,
|
||||
Name: name,
|
||||
Size: 0,
|
||||
Link: magnetUri,
|
||||
}
|
||||
}
|
||||
24
internal/utils/misc.go
Normal file
24
internal/utils/misc.go
Normal file
@@ -0,0 +1,24 @@
|
||||
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
|
||||
}
|
||||
|
||||
func Contains(slice []string, value string) bool {
|
||||
for _, item := range slice {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
60
internal/utils/regex.go
Normal file
60
internal/utils/regex.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
videoMatch = "(?i)(\\.)(webm|m4v|3gp|nsv|ty|strm|rm|rmvb|m3u|ifo|mov|qt|divx|xvid|bivx|nrg|pva|wmv|asf|asx|ogm|ogv|m2v|avi|bin|dat|dvr-ms|mpg|mpeg|mp4|avc|vp3|svq3|nuv|viv|dv|fli|flv|wpl|img|iso|vob|mkv|mk3d|ts|wtv|m2ts)$"
|
||||
musicMatch = "(?i)(\\.)(mp2|mp3|m4a|m4b|m4p|ogg|oga|opus|wma|wav|wv|flac|ape|aif|aiff|aifc)$"
|
||||
sampleMatch = `(?i)(^|[\s/\\])(sample|trailer|thumb|special|extras?)s?[-/]|(\((sample|trailer|thumb|special|extras?)s?\))|(-\s*(sample|trailer|thumb|special|extras?)s?)`
|
||||
)
|
||||
|
||||
var (
|
||||
mediaRegex = regexp.MustCompile(videoMatch + "|" + musicMatch)
|
||||
sampleRegex = regexp.MustCompile(sampleMatch)
|
||||
)
|
||||
|
||||
func RegexMatch(re *regexp.Regexp, value string) bool {
|
||||
return re.MatchString(value)
|
||||
}
|
||||
|
||||
func RemoveInvalidChars(value string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if r == filepath.Separator || r == ':' {
|
||||
return r
|
||||
}
|
||||
if filepath.IsAbs(string(r)) {
|
||||
return r
|
||||
}
|
||||
if strings.ContainsRune(filepath.VolumeName("C:"+string(r)), r) {
|
||||
return r
|
||||
}
|
||||
if r < 32 || strings.ContainsRune(`<>:"/\|?*`, r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, value)
|
||||
}
|
||||
|
||||
func RemoveExtension(value string) string {
|
||||
loc := mediaRegex.FindStringIndex(value)
|
||||
if loc != nil {
|
||||
return value[:loc[0]]
|
||||
} else {
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
func IsMediaFile(path string) bool {
|
||||
return RegexMatch(mediaRegex, path)
|
||||
}
|
||||
|
||||
func IsSampleFile(path string) bool {
|
||||
if strings.HasSuffix(strings.ToLower(path), "sample.mkv") {
|
||||
return true
|
||||
}
|
||||
return RegexMatch(sampleRegex, path)
|
||||
}
|
||||
76
internal/utils/scheduler.go
Normal file
76
internal/utils/scheduler.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/robfig/cron/v3"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func ScheduleJob(ctx context.Context, interval string, loc *time.Location, jobFunc func()) (gocron.Scheduler, error) {
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
s, err := gocron.NewScheduler(gocron.WithLocation(loc))
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("failed to create scheduler: %w", err)
|
||||
}
|
||||
jd, err := ConvertToJobDef(interval)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("failed to convert interval to job definition: %w", err)
|
||||
}
|
||||
// Schedule the job
|
||||
if _, err = s.NewJob(jd, gocron.NewTask(jobFunc), gocron.WithContext(ctx)); err != nil {
|
||||
return s, fmt.Errorf("failed to create job: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ConvertToJobDef converts a string interval to a gocron.JobDefinition.
|
||||
func ConvertToJobDef(interval string) (gocron.JobDefinition, error) {
|
||||
// Parse the interval string
|
||||
// Interval could be in the format "1h", "30m", "15s" or "1h30m" or "04:05"
|
||||
var jd gocron.JobDefinition
|
||||
|
||||
if t, ok := parseClockTime(interval); ok {
|
||||
return gocron.DailyJob(1, gocron.NewAtTimes(
|
||||
gocron.NewAtTime(uint(t.Hour()), uint(t.Minute()), uint(t.Second())),
|
||||
)), nil
|
||||
}
|
||||
|
||||
if _, err := cron.ParseStandard(interval); err == nil {
|
||||
return gocron.CronJob(interval, false), nil
|
||||
}
|
||||
|
||||
if dur, err := time.ParseDuration(interval); err == nil {
|
||||
return gocron.DurationJob(dur), nil
|
||||
}
|
||||
|
||||
return jd, fmt.Errorf("invalid interval format: %s", interval)
|
||||
}
|
||||
|
||||
func parseClockTime(s string) (time.Time, bool) {
|
||||
parts := strings.Split(s, ":")
|
||||
if len(parts) != 2 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
h, err := strconv.Atoi(parts[0])
|
||||
if err != nil || h < 0 || h > 23 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
m, err := strconv.Atoi(parts[1])
|
||||
if err != nil || m < 0 || m > 59 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
now := time.Now()
|
||||
// build a time.Time for today at h:m:00 in the local zone
|
||||
t := time.Date(
|
||||
now.Year(), now.Month(), now.Day(),
|
||||
h, m, 0, 0,
|
||||
time.Local,
|
||||
)
|
||||
return t, true
|
||||
}
|
||||
29
main.go
29
main.go
@@ -1,22 +1,35 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"goBlack/cmd"
|
||||
"goBlack/common"
|
||||
"github.com/sirrobot01/decypharr/cmd/decypharr"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
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()
|
||||
config.SetConfigPath(configPath)
|
||||
config.Get()
|
||||
|
||||
// Load the config file
|
||||
conf, err := common.LoadConfig(configPath)
|
||||
if err != nil {
|
||||
// Create a context canceled on SIGINT/SIGTERM
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
if err := decypharr.Start(ctx); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
cmd.Start(conf)
|
||||
|
||||
}
|
||||
|
||||
237
pkg/arr/arr.go
Normal file
237
pkg/arr/arr.go
Normal file
@@ -0,0 +1,237 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"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 *request.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: request.New(),
|
||||
}
|
||||
}
|
||||
|
||||
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 = request.New()
|
||||
}
|
||||
|
||||
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.Mutex
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func (as *Storage) Cleanup() {
|
||||
as.mu.Lock()
|
||||
defer as.mu.Unlock()
|
||||
as.Arrs = make(map[string]*Arr)
|
||||
}
|
||||
|
||||
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.Get().Arrs {
|
||||
name := a.Name
|
||||
arrs[name] = New(name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached)
|
||||
}
|
||||
return &Storage{
|
||||
Arrs: arrs,
|
||||
logger: logger.New("arr"),
|
||||
}
|
||||
}
|
||||
|
||||
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.Lock()
|
||||
defer as.mu.Unlock()
|
||||
return as.Arrs[name]
|
||||
}
|
||||
|
||||
func (as *Storage) GetAll() []*Arr {
|
||||
as.mu.Lock()
|
||||
defer as.mu.Unlock()
|
||||
arrs := make([]*Arr, 0, len(as.Arrs))
|
||||
for _, arr := range as.Arrs {
|
||||
if arr.Host != "" && arr.Token != "" {
|
||||
arrs = append(arrs, arr)
|
||||
}
|
||||
}
|
||||
return arrs
|
||||
}
|
||||
|
||||
func (as *Storage) Clear() {
|
||||
as.mu.Lock()
|
||||
defer as.mu.Unlock()
|
||||
as.Arrs = make(map[string]*Arr)
|
||||
}
|
||||
|
||||
func (as *Storage) StartSchedule(ctx context.Context) error {
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
|
||||
select {
|
||||
case <-ticker.C:
|
||||
as.cleanupArrsQueue()
|
||||
case <-ctx.Done():
|
||||
ticker.Stop()
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (as *Storage) cleanupArrsQueue() {
|
||||
arrs := make([]*Arr, 0)
|
||||
for _, arr := range as.Arrs {
|
||||
if !arr.Cleanup {
|
||||
continue
|
||||
}
|
||||
arrs = append(arrs, arr)
|
||||
}
|
||||
if len(arrs) > 0 {
|
||||
for _, arr := range arrs {
|
||||
if err := arr.CleanupQueue(); err != nil {
|
||||
as.logger.Error().Err(err).Msgf("Failed to cleanup arr %s", arr.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
282
pkg/arr/content.go
Normal file
282
pkg/arr/content.go
Normal file
@@ -0,0 +1,282 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"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
|
||||
}
|
||||
var ct Content
|
||||
var seriesFiles []seriesFile
|
||||
episodeFileIDMap := make(map[int]int)
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
if err = json.NewDecoder(resp.Body).Decode(&seriesFiles); err != nil {
|
||||
return
|
||||
}
|
||||
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
|
||||
}
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
var episodes []episode
|
||||
if err = json.NewDecoder(resp.Body).Decode(&episodes); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
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 {
|
||||
if movie.MovieFile.Id == 0 || movie.MovieFile.Path == "" {
|
||||
// Skip movies without files
|
||||
continue
|
||||
}
|
||||
ct := Content{
|
||||
Title: movie.Title,
|
||||
Id: movie.Id,
|
||||
}
|
||||
files := make([]ContentFile, 0)
|
||||
|
||||
files = append(files, ContentFile{
|
||||
FileId: movie.MovieFile.Id,
|
||||
Id: movie.Id,
|
||||
Path: movie.MovieFile.Path,
|
||||
})
|
||||
ct.Files = files
|
||||
contents = append(contents, ct)
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
g, ctx := errgroup.WithContext(context.Background())
|
||||
|
||||
// Limit concurrent goroutines
|
||||
g.SetLimit(10)
|
||||
for id := range ids {
|
||||
id := id
|
||||
g.Go(func() error {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
parts := strings.Split(id, "-")
|
||||
if len(parts) != 2 {
|
||||
return fmt.Errorf("invalid id: %s", id)
|
||||
}
|
||||
seriesId, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seasonNumber, err := strconv.Atoi(parts[1])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := sonarrSearch{
|
||||
Name: "SeasonSearch",
|
||||
SeasonNumber: seasonNumber,
|
||||
SeriesId: seriesId,
|
||||
}
|
||||
resp, err := a.Request(http.MethodPost, "api/v3/command", payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to automatic search: %v", err)
|
||||
}
|
||||
if resp.StatusCode >= 300 || resp.StatusCode < 200 {
|
||||
return fmt.Errorf("failed to automatic search. Status Code: %s", resp.Status)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
if err := g.Wait(); 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)
|
||||
}
|
||||
defer func() {
|
||||
// Delete files, or at least try
|
||||
for _, f := range files {
|
||||
f.Delete()
|
||||
}
|
||||
}()
|
||||
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
|
||||
}
|
||||
191
pkg/arr/history.go
Normal file
191
pkg/arr/history.go
Normal file
@@ -0,0 +1,191 @@
|
||||
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 strings.Contains(m.Title, "One or more episodes expected in this release were not imported or missing from the release") {
|
||||
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
|
||||
|
||||
}
|
||||
47
pkg/arr/types.go
Normal file
47
pkg/arr/types.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package arr
|
||||
|
||||
import "os"
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
func (file *ContentFile) Delete() {
|
||||
// This is useful for when sonarr bulk delete fails(this usually happens)
|
||||
// and we need to delete the file manually
|
||||
_ = os.Remove(file.Path) // nolint:errcheck
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
442
pkg/debrid/alldebrid/alldebrid.go
Normal file
442
pkg/debrid/alldebrid/alldebrid.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package alldebrid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AllDebrid struct {
|
||||
Name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
accounts map[string]types.Account
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
checkCached bool
|
||||
addSamples bool
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) *AllDebrid {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
}
|
||||
_log := logger.New(dc.Name)
|
||||
client := request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithLogger(_log),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithProxy(dc.Proxy),
|
||||
)
|
||||
|
||||
accounts := make(map[string]types.Account)
|
||||
for idx, key := range dc.DownloadAPIKeys {
|
||||
id := strconv.Itoa(idx)
|
||||
accounts[id] = types.Account{
|
||||
Name: key,
|
||||
ID: id,
|
||||
Token: key,
|
||||
}
|
||||
}
|
||||
return &AllDebrid{
|
||||
Name: "alldebrid",
|
||||
Host: "http://api.alldebrid.com/v4.1",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: accounts,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
MountPath: dc.Folder,
|
||||
logger: logger.New(dc.Name),
|
||||
checkCached: dc.CheckCached,
|
||||
addSamples: dc.AddSamples,
|
||||
}
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetName() string {
|
||||
return ad.Name
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetLogger() zerolog.Logger {
|
||||
return ad.logger
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) IsAvailable(hashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
result := make(map[string]bool)
|
||||
|
||||
// Divide hashes into groups of 100
|
||||
// AllDebrid does not support checking cached infohashes
|
||||
return result
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) SubmitMagnet(torrent *types.Torrent) (*types.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 (ad *AllDebrid) flattenFiles(torrentId string, files []MagnetFile, parentPath string, index *int) map[string]types.File {
|
||||
result := make(map[string]types.File)
|
||||
|
||||
cfg := config.Get()
|
||||
|
||||
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
|
||||
subFiles := ad.flattenFiles(torrentId, f.Elements, currentPath, index)
|
||||
for k, v := range subFiles {
|
||||
if _, ok := result[k]; ok {
|
||||
// File already exists, use path as key
|
||||
result[v.Path] = v
|
||||
} else {
|
||||
result[k] = v
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// This is a file
|
||||
fileName := filepath.Base(f.Name)
|
||||
|
||||
// Skip sample files
|
||||
if !ad.addSamples && utils.IsSampleFile(f.Name) {
|
||||
continue
|
||||
}
|
||||
if !cfg.IsAllowedFile(fileName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
|
||||
*index++
|
||||
file := types.File{
|
||||
TorrentId: torrentId,
|
||||
Id: strconv.Itoa(*index),
|
||||
Name: fileName,
|
||||
Size: f.Size,
|
||||
Path: currentPath,
|
||||
Link: f.Link,
|
||||
}
|
||||
result[file.Name] = file
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetTorrent(torrentId string) (*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/magnet/status?id=%s", ad.Host, torrentId)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := ad.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res TorrentInfoResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
|
||||
return nil, err
|
||||
}
|
||||
data := res.Data.Magnets
|
||||
status := getAlldebridStatus(data.StatusCode)
|
||||
name := data.Filename
|
||||
t := &types.Torrent{
|
||||
Id: strconv.Itoa(data.Id),
|
||||
Name: name,
|
||||
Status: status,
|
||||
Filename: name,
|
||||
OriginalFilename: name,
|
||||
Files: make(map[string]types.File),
|
||||
InfoHash: data.Hash,
|
||||
Debrid: ad.Name,
|
||||
MountPath: ad.MountPath,
|
||||
Added: time.Unix(data.CompletionDate, 0).Format(time.RFC3339),
|
||||
}
|
||||
t.Bytes = data.Size
|
||||
t.Seeders = data.Seeders
|
||||
if status == "downloaded" {
|
||||
t.Progress = 100
|
||||
index := -1
|
||||
files := ad.flattenFiles(t.Id, data.Files, "", &index)
|
||||
t.Files = files
|
||||
} else {
|
||||
t.Progress = float64(data.Downloaded) / float64(data.Size) * 100
|
||||
t.Speed = data.DownloadSpeed
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) UpdateTorrent(t *types.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 err
|
||||
}
|
||||
var res TorrentInfoResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
|
||||
return 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.Bytes = data.Size
|
||||
t.Seeders = data.Seeders
|
||||
t.Added = time.Unix(data.CompletionDate, 0).Format(time.RFC3339)
|
||||
if status == "downloaded" {
|
||||
t.Progress = 100
|
||||
index := -1
|
||||
files := ad.flattenFiles(t.Id, data.Files, "", &index)
|
||||
t.Files = files
|
||||
} else {
|
||||
t.Progress = float64(data.Downloaded) / float64(data.Size) * 100
|
||||
t.Speed = data.DownloadSpeed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) CheckStatus(torrent *types.Torrent, isSymlink bool) (*types.Torrent, error) {
|
||||
for {
|
||||
err := ad.UpdateTorrent(torrent)
|
||||
|
||||
if err != nil || torrent == nil {
|
||||
return torrent, err
|
||||
}
|
||||
status := torrent.Status
|
||||
if status == "downloaded" {
|
||||
ad.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||
if !isSymlink {
|
||||
err = ad.GenerateDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if utils.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
|
||||
return torrent, nil
|
||||
} else {
|
||||
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||
}
|
||||
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) DeleteTorrent(torrentId string) error {
|
||||
url := fmt.Sprintf("%s/magnet/delete?id=%s", ad.Host, torrentId)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
if _, err := ad.client.MakeRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
ad.logger.Info().Msgf("Torrent %s deleted from AD", torrentId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GenerateDownloadLinks(t *types.Torrent) error {
|
||||
filesCh := make(chan types.File, len(t.Files))
|
||||
errCh := make(chan error, len(t.Files))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(t.Files))
|
||||
for _, file := range t.Files {
|
||||
go func(file types.File) {
|
||||
defer wg.Done()
|
||||
link, err := ad.GetDownloadLink(t, &file)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
file.DownloadLink = link
|
||||
if link != nil {
|
||||
errCh <- fmt.Errorf("download link is empty")
|
||||
return
|
||||
}
|
||||
filesCh <- file
|
||||
}(file)
|
||||
}
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(filesCh)
|
||||
close(errCh)
|
||||
}()
|
||||
files := make(map[string]types.File, len(t.Files))
|
||||
for file := range filesCh {
|
||||
files[file.Name] = file
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
return err // Return the first error encountered
|
||||
}
|
||||
}
|
||||
|
||||
t.Files = files
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
|
||||
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, err
|
||||
}
|
||||
var data DownloadLink
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if data.Error != nil {
|
||||
return nil, fmt.Errorf("error getting download link: %s", data.Error.Message)
|
||||
}
|
||||
link := data.Data.Link
|
||||
if link == "" {
|
||||
return nil, fmt.Errorf("download link is empty")
|
||||
}
|
||||
return &types.DownloadLink{
|
||||
Link: file.Link,
|
||||
DownloadLink: link,
|
||||
Id: data.Data.Id,
|
||||
Size: file.Size,
|
||||
Filename: file.Name,
|
||||
Generated: time.Now(),
|
||||
AccountId: "0",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetCheckCached() bool {
|
||||
return ad.checkCached
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/magnet/status?status=ready", ad.Host)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := ad.client.MakeRequest(req)
|
||||
torrents := make([]*types.Torrent, 0)
|
||||
if err != nil {
|
||||
return torrents, err
|
||||
}
|
||||
var res TorrentsListResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
|
||||
return torrents, err
|
||||
}
|
||||
for _, magnet := range res.Data.Magnets {
|
||||
torrents = append(torrents, &types.Torrent{
|
||||
Id: strconv.Itoa(magnet.Id),
|
||||
Name: magnet.Filename,
|
||||
Bytes: magnet.Size,
|
||||
Status: getAlldebridStatus(magnet.StatusCode),
|
||||
Filename: magnet.Filename,
|
||||
OriginalFilename: magnet.Filename,
|
||||
Files: make(map[string]types.File),
|
||||
InfoHash: magnet.Hash,
|
||||
Debrid: ad.Name,
|
||||
MountPath: ad.MountPath,
|
||||
Added: time.Unix(magnet.CompletionDate, 0).Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloads() (map[string]types.DownloadLink, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloadingStatus() []string {
|
||||
return []string{"downloading"}
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloadUncached() bool {
|
||||
return ad.DownloadUncached
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) CheckLink(link string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetMountPath() string {
|
||||
return ad.MountPath
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) DisableAccount(accountId string) {
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) ResetActiveDownloadKeys() {
|
||||
|
||||
}
|
||||
func (ad *AllDebrid) DeleteDownloadLink(linkId string) error {
|
||||
return nil
|
||||
}
|
||||
83
pkg/debrid/alldebrid/types.go
Normal file
83
pkg/debrid/alldebrid/types.go
Normal file
@@ -0,0 +1,83 @@
|
||||
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 int64 `json:"uploadDate"`
|
||||
Downloaded int64 `json:"downloaded"`
|
||||
Uploaded int64 `json:"uploaded"`
|
||||
DownloadSpeed int64 `json:"downloadSpeed"`
|
||||
UploadSpeed int64 `json:"uploadSpeed"`
|
||||
Seeders int `json:"seeders"`
|
||||
CompletionDate int64 `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 TorrentsListResponse 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"`
|
||||
}
|
||||
820
pkg/debrid/debrid/cache.go
Normal file
820
pkg/debrid/debrid/cache.go
Normal file
@@ -0,0 +1,820 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"encoding/json"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
|
||||
type WebDavFolderNaming string
|
||||
|
||||
const (
|
||||
WebDavUseFileName WebDavFolderNaming = "filename"
|
||||
WebDavUseOriginalName WebDavFolderNaming = "original"
|
||||
WebDavUseFileNameNoExt WebDavFolderNaming = "filename_no_ext"
|
||||
WebDavUseOriginalNameNoExt WebDavFolderNaming = "original_no_ext"
|
||||
WebDavUseID WebDavFolderNaming = "id"
|
||||
WebdavUseHash WebDavFolderNaming = "infohash"
|
||||
)
|
||||
|
||||
type CachedTorrent struct {
|
||||
*types.Torrent
|
||||
AddedOn time.Time `json:"added_on"`
|
||||
IsComplete bool `json:"is_complete"`
|
||||
Bad bool `json:"bad"`
|
||||
}
|
||||
|
||||
func (c CachedTorrent) copy() CachedTorrent {
|
||||
return CachedTorrent{
|
||||
Torrent: c.Torrent,
|
||||
AddedOn: c.AddedOn,
|
||||
IsComplete: c.IsComplete,
|
||||
Bad: c.Bad,
|
||||
}
|
||||
}
|
||||
|
||||
type RepairType string
|
||||
|
||||
const (
|
||||
RepairTypeReinsert RepairType = "reinsert"
|
||||
RepairTypeDelete RepairType = "delete"
|
||||
)
|
||||
|
||||
type RepairRequest struct {
|
||||
Type RepairType
|
||||
TorrentID string
|
||||
Priority int
|
||||
FileName string
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
dir string
|
||||
client types.Client
|
||||
logger zerolog.Logger
|
||||
|
||||
torrents *torrentCache
|
||||
downloadLinks *downloadLinkCache
|
||||
invalidDownloadLinks sync.Map
|
||||
folderNaming WebDavFolderNaming
|
||||
|
||||
listingDebouncer *utils.Debouncer[bool]
|
||||
// monitors
|
||||
repairRequest sync.Map
|
||||
failedToReinsert sync.Map
|
||||
downloadLinkRequests sync.Map
|
||||
|
||||
// repair
|
||||
repairChan chan RepairRequest
|
||||
|
||||
// readiness
|
||||
ready chan struct{}
|
||||
|
||||
// config
|
||||
workers int
|
||||
torrentRefreshInterval string
|
||||
downloadLinksRefreshInterval string
|
||||
autoExpiresLinksAfterDuration time.Duration
|
||||
|
||||
// refresh mutex
|
||||
downloadLinksRefreshMu sync.RWMutex // for refreshing download links
|
||||
torrentsRefreshMu sync.RWMutex // for refreshing torrents
|
||||
|
||||
scheduler gocron.Scheduler
|
||||
cetScheduler gocron.Scheduler
|
||||
|
||||
saveSemaphore chan struct{}
|
||||
|
||||
config config.Debrid
|
||||
customFolders []string
|
||||
}
|
||||
|
||||
func New(dc config.Debrid, client types.Client) *Cache {
|
||||
cfg := config.Get()
|
||||
cet, _ := time.LoadLocation("CET")
|
||||
cetSc, _ := gocron.NewScheduler(gocron.WithLocation(cet))
|
||||
scheduler, _ := gocron.NewScheduler(gocron.WithLocation(time.Local))
|
||||
|
||||
autoExpiresLinksAfter, err := time.ParseDuration(dc.AutoExpireLinksAfter)
|
||||
if autoExpiresLinksAfter == 0 || err != nil {
|
||||
autoExpiresLinksAfter = 48 * time.Hour
|
||||
}
|
||||
var customFolders []string
|
||||
dirFilters := map[string][]directoryFilter{}
|
||||
for name, value := range dc.Directories {
|
||||
for filterType, v := range value.Filters {
|
||||
df := directoryFilter{filterType: filterType, value: v}
|
||||
switch filterType {
|
||||
case filterByRegex, filterByNotRegex:
|
||||
df.regex = regexp.MustCompile(v)
|
||||
case filterBySizeGT, filterBySizeLT:
|
||||
df.sizeThreshold, _ = config.ParseSize(v)
|
||||
case filterBLastAdded:
|
||||
df.ageThreshold, _ = time.ParseDuration(v)
|
||||
}
|
||||
dirFilters[name] = append(dirFilters[name], df)
|
||||
}
|
||||
customFolders = append(customFolders, name)
|
||||
|
||||
}
|
||||
_log := logger.New(fmt.Sprintf("%s-webdav", client.GetName()))
|
||||
c := &Cache{
|
||||
dir: filepath.Join(cfg.Path, "cache", dc.Name), // path to save cache files
|
||||
|
||||
torrents: newTorrentCache(dirFilters),
|
||||
client: client,
|
||||
logger: _log,
|
||||
workers: dc.Workers,
|
||||
downloadLinks: newDownloadLinkCache(),
|
||||
torrentRefreshInterval: dc.TorrentsRefreshInterval,
|
||||
downloadLinksRefreshInterval: dc.DownloadLinksRefreshInterval,
|
||||
folderNaming: WebDavFolderNaming(dc.FolderNaming),
|
||||
autoExpiresLinksAfterDuration: autoExpiresLinksAfter,
|
||||
saveSemaphore: make(chan struct{}, 50),
|
||||
cetScheduler: cetSc,
|
||||
scheduler: scheduler,
|
||||
|
||||
config: dc,
|
||||
customFolders: customFolders,
|
||||
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
|
||||
c.listingDebouncer = utils.NewDebouncer[bool](100*time.Millisecond, func(refreshRclone bool) {
|
||||
c.RefreshListings(refreshRclone)
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Cache) IsReady() chan struct{} {
|
||||
return c.ready
|
||||
}
|
||||
|
||||
func (c *Cache) StreamWithRclone() bool {
|
||||
return c.config.ServeFromRclone
|
||||
}
|
||||
|
||||
// Reset clears all internal state so the Cache can be reused without leaks.
|
||||
// Call this after stopping the old Cache (so no goroutines are holding references),
|
||||
// and before you discard the instance on a restart.
|
||||
func (c *Cache) Reset() {
|
||||
|
||||
if err := c.scheduler.StopJobs(); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to stop scheduler jobs")
|
||||
}
|
||||
|
||||
if err := c.scheduler.Shutdown(); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to stop scheduler")
|
||||
}
|
||||
|
||||
// Stop the listing debouncer
|
||||
c.listingDebouncer.Stop()
|
||||
|
||||
// Close the repair channel
|
||||
close(c.repairChan)
|
||||
|
||||
// 1. Reset torrent storage
|
||||
c.torrents.reset()
|
||||
|
||||
// 2. Reset download-link cache
|
||||
c.downloadLinks.reset()
|
||||
|
||||
// 3. Clear any sync.Maps
|
||||
c.invalidDownloadLinks = sync.Map{}
|
||||
c.repairRequest = sync.Map{}
|
||||
c.failedToReinsert = sync.Map{}
|
||||
c.downloadLinkRequests = sync.Map{}
|
||||
|
||||
// 5. Rebuild the listing debouncer
|
||||
c.listingDebouncer = utils.NewDebouncer[bool](
|
||||
100*time.Millisecond,
|
||||
func(refreshRclone bool) {
|
||||
c.RefreshListings(refreshRclone)
|
||||
},
|
||||
)
|
||||
|
||||
// 6. Reset repair channel so the next Start() can spin it up
|
||||
c.repairChan = make(chan RepairRequest, 100)
|
||||
}
|
||||
|
||||
func (c *Cache) Start(ctx context.Context) error {
|
||||
if err := os.MkdirAll(c.dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
if err := c.Sync(ctx); err != nil {
|
||||
return fmt.Errorf("failed to sync cache: %w", err)
|
||||
}
|
||||
|
||||
// initial download links
|
||||
go c.refreshDownloadLinks(ctx)
|
||||
|
||||
if err := c.StartSchedule(ctx); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to start cache worker")
|
||||
}
|
||||
|
||||
c.repairChan = make(chan RepairRequest, 100)
|
||||
go c.repairWorker(ctx)
|
||||
|
||||
// Fire the ready channel
|
||||
close(c.ready)
|
||||
cfg := config.Get()
|
||||
name := c.client.GetName()
|
||||
addr := cfg.BindAddress + ":" + cfg.Port + cfg.URLBase + "webdav/" + name + "/"
|
||||
c.logger.Info().Msgf("%s WebDav server running at %s", name, addr)
|
||||
|
||||
<-ctx.Done()
|
||||
c.logger.Info().Msgf("Stopping %s WebDav server", name)
|
||||
c.Reset()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) load(ctx context.Context) (map[string]CachedTorrent, error) {
|
||||
mu := sync.Mutex{}
|
||||
|
||||
if err := os.MkdirAll(c.dir, 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
files, err := os.ReadDir(c.dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read cache directory: %w", err)
|
||||
}
|
||||
|
||||
// Get only json files
|
||||
var jsonFiles []os.DirEntry
|
||||
for _, file := range files {
|
||||
if !file.IsDir() && filepath.Ext(file.Name()) == ".json" {
|
||||
jsonFiles = append(jsonFiles, file)
|
||||
}
|
||||
}
|
||||
|
||||
if len(jsonFiles) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Create channels with appropriate buffering
|
||||
workChan := make(chan os.DirEntry, min(c.workers, len(jsonFiles)))
|
||||
|
||||
// Create a wait group for workers
|
||||
var wg sync.WaitGroup
|
||||
|
||||
torrents := make(map[string]CachedTorrent, len(jsonFiles))
|
||||
|
||||
// Start workers
|
||||
for i := 0; i < c.workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for {
|
||||
file, ok := <-workChan
|
||||
if !ok {
|
||||
return // Channel closed, exit goroutine
|
||||
}
|
||||
|
||||
fileName := file.Name()
|
||||
filePath := filepath.Join(c.dir, fileName)
|
||||
data, err := os.ReadFile(filePath)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to read file: %s", filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
var ct CachedTorrent
|
||||
if err := json.Unmarshal(data, &ct); err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to unmarshal file: %s", filePath)
|
||||
continue
|
||||
}
|
||||
|
||||
isComplete := true
|
||||
if len(ct.Files) != 0 {
|
||||
// Check if all files are valid, if not, delete the file.json and remove from cache.
|
||||
fs := make(map[string]types.File, len(ct.Files))
|
||||
for _, f := range ct.Files {
|
||||
if f.Link == "" {
|
||||
isComplete = false
|
||||
break
|
||||
}
|
||||
f.TorrentId = ct.Id
|
||||
fs[f.Name] = f
|
||||
}
|
||||
|
||||
if isComplete {
|
||||
|
||||
if addedOn, err := time.Parse(time.RFC3339, ct.Added); err == nil {
|
||||
ct.AddedOn = addedOn
|
||||
}
|
||||
ct.IsComplete = true
|
||||
ct.Files = fs
|
||||
ct.Name = path.Clean(ct.Name)
|
||||
mu.Lock()
|
||||
torrents[ct.Id] = ct
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Feed work to workers
|
||||
for _, file := range jsonFiles {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
break // Context cancelled
|
||||
default:
|
||||
workChan <- file
|
||||
}
|
||||
}
|
||||
|
||||
// Signal workers that no more work is coming
|
||||
close(workChan)
|
||||
|
||||
// Wait for all workers to complete
|
||||
wg.Wait()
|
||||
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (c *Cache) Sync(ctx context.Context) error {
|
||||
cachedTorrents, err := c.load(ctx)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to load cache")
|
||||
}
|
||||
|
||||
torrents, err := c.client.GetTorrents()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync torrents: %v", err)
|
||||
}
|
||||
|
||||
totalTorrents := len(torrents)
|
||||
|
||||
c.logger.Info().Msgf("%d torrents found from %s", totalTorrents, c.client.GetName())
|
||||
|
||||
newTorrents := make([]*types.Torrent, 0)
|
||||
idStore := make(map[string]struct{}, totalTorrents)
|
||||
for _, t := range torrents {
|
||||
idStore[t.Id] = struct{}{}
|
||||
if _, ok := cachedTorrents[t.Id]; !ok {
|
||||
newTorrents = append(newTorrents, t)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for deleted torrents
|
||||
deletedTorrents := make([]string, 0)
|
||||
for _, t := range cachedTorrents {
|
||||
if _, ok := idStore[t.Id]; !ok {
|
||||
deletedTorrents = append(deletedTorrents, t.Id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(deletedTorrents) > 0 {
|
||||
c.logger.Info().Msgf("Found %d deleted torrents", len(deletedTorrents))
|
||||
for _, id := range deletedTorrents {
|
||||
if _, ok := cachedTorrents[id]; ok {
|
||||
c.deleteTorrent(id, false) // delete from cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write these torrents to the cache
|
||||
c.setTorrents(cachedTorrents, func() {
|
||||
c.listingDebouncer.Call(false)
|
||||
}) // Initial calls
|
||||
c.logger.Info().Msgf("Loaded %d torrents from cache", len(cachedTorrents))
|
||||
|
||||
if len(newTorrents) > 0 {
|
||||
c.logger.Info().Msgf("Found %d new torrents", len(newTorrents))
|
||||
if err := c.sync(ctx, newTorrents); err != nil {
|
||||
return fmt.Errorf("failed to sync torrents: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) sync(ctx context.Context, torrents []*types.Torrent) error {
|
||||
|
||||
// Create channels with appropriate buffering
|
||||
workChan := make(chan *types.Torrent, min(c.workers, len(torrents)))
|
||||
|
||||
// Use an atomic counter for progress tracking
|
||||
var processed int64
|
||||
var errorCount int64
|
||||
|
||||
// Create a wait group for workers
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// Start workers
|
||||
for i := 0; i < c.workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case t, ok := <-workChan:
|
||||
if !ok {
|
||||
return // Channel closed, exit goroutine
|
||||
}
|
||||
|
||||
if err := c.ProcessTorrent(t); err != nil {
|
||||
c.logger.Error().Err(err).Str("torrent", t.Name).Msg("sync error")
|
||||
atomic.AddInt64(&errorCount, 1)
|
||||
}
|
||||
|
||||
count := atomic.AddInt64(&processed, 1)
|
||||
if count%1000 == 0 {
|
||||
c.logger.Info().Msgf("Progress: %d/%d torrents processed", count, len(torrents))
|
||||
}
|
||||
|
||||
case <-ctx.Done():
|
||||
return // Context cancelled, exit goroutine
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Feed work to workers
|
||||
for _, t := range torrents {
|
||||
select {
|
||||
case workChan <- t:
|
||||
// Work sent successfully
|
||||
case <-ctx.Done():
|
||||
break // Context cancelled
|
||||
}
|
||||
}
|
||||
|
||||
// Signal workers that no more work is coming
|
||||
close(workChan)
|
||||
|
||||
// Wait for all workers to complete
|
||||
wg.Wait()
|
||||
|
||||
c.listingDebouncer.Call(false) // final refresh
|
||||
c.logger.Info().Msgf("Sync complete: %d torrents processed, %d errors", len(torrents), errorCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) GetTorrentFolder(torrent *types.Torrent) string {
|
||||
switch c.folderNaming {
|
||||
case WebDavUseFileName:
|
||||
return path.Clean(torrent.Filename)
|
||||
case WebDavUseOriginalName:
|
||||
return path.Clean(torrent.OriginalFilename)
|
||||
case WebDavUseFileNameNoExt:
|
||||
return path.Clean(utils.RemoveExtension(torrent.Filename))
|
||||
case WebDavUseOriginalNameNoExt:
|
||||
return path.Clean(utils.RemoveExtension(torrent.OriginalFilename))
|
||||
case WebDavUseID:
|
||||
return torrent.Id
|
||||
case WebdavUseHash:
|
||||
return strings.ToLower(torrent.InfoHash)
|
||||
default:
|
||||
return path.Clean(torrent.Filename)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) setTorrent(t CachedTorrent, callback func(torrent CachedTorrent)) {
|
||||
torrentName := c.GetTorrentFolder(t.Torrent)
|
||||
updatedTorrent := t.copy()
|
||||
if o, ok := c.torrents.getByName(torrentName); ok && o.Id != t.Id {
|
||||
// If another torrent with the same name exists, merge the files, if the same file exists,
|
||||
// keep the one with the most recent added date
|
||||
|
||||
// Save the most recent torrent
|
||||
mergedFiles := mergeFiles(o, updatedTorrent) // Useful for merging files across multiple torrents, while keeping the most recent
|
||||
updatedTorrent.Files = mergedFiles
|
||||
}
|
||||
c.torrents.set(torrentName, t, updatedTorrent)
|
||||
c.SaveTorrent(t)
|
||||
if callback != nil {
|
||||
callback(updatedTorrent)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) setTorrents(torrents map[string]CachedTorrent, callback func()) {
|
||||
for _, t := range torrents {
|
||||
torrentName := c.GetTorrentFolder(t.Torrent)
|
||||
updatedTorrent := t.copy()
|
||||
if o, ok := c.torrents.getByName(torrentName); ok && o.Id != t.Id {
|
||||
// Save the most recent torrent
|
||||
mergedFiles := mergeFiles(o, updatedTorrent)
|
||||
updatedTorrent.Files = mergedFiles
|
||||
}
|
||||
c.torrents.set(torrentName, t, updatedTorrent)
|
||||
}
|
||||
c.SaveTorrents()
|
||||
if callback != nil {
|
||||
callback()
|
||||
}
|
||||
}
|
||||
|
||||
// GetListing returns a sorted list of torrents(READ-ONLY)
|
||||
func (c *Cache) GetListing(folder string) []os.FileInfo {
|
||||
switch folder {
|
||||
case "__all__", "torrents":
|
||||
return c.torrents.getListing()
|
||||
default:
|
||||
return c.torrents.getFolderListing(folder)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) GetCustomFolders() []string {
|
||||
return c.customFolders
|
||||
}
|
||||
|
||||
func (c *Cache) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) GetTorrents() map[string]CachedTorrent {
|
||||
return c.torrents.getAll()
|
||||
}
|
||||
|
||||
func (c *Cache) GetTorrentByName(name string) *CachedTorrent {
|
||||
if torrent, ok := c.torrents.getByName(name); ok {
|
||||
return &torrent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) GetTorrent(torrentId string) *CachedTorrent {
|
||||
if torrent, ok := c.torrents.getByID(torrentId); ok {
|
||||
return &torrent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) SaveTorrents() {
|
||||
torrents := c.torrents.getAll()
|
||||
for _, torrent := range torrents {
|
||||
c.SaveTorrent(torrent)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) SaveTorrent(ct CachedTorrent) {
|
||||
marshaled, err := json.MarshalIndent(ct, "", " ")
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to marshal torrent: %s", ct.Id)
|
||||
return
|
||||
}
|
||||
|
||||
// Store just the essential info needed for the file operation
|
||||
saveInfo := struct {
|
||||
id string
|
||||
jsonData []byte
|
||||
}{
|
||||
id: ct.Torrent.Id,
|
||||
jsonData: marshaled,
|
||||
}
|
||||
|
||||
// Try to acquire semaphore without blocking
|
||||
select {
|
||||
case c.saveSemaphore <- struct{}{}:
|
||||
go func() {
|
||||
defer func() { <-c.saveSemaphore }()
|
||||
c.saveTorrent(saveInfo.id, saveInfo.jsonData)
|
||||
}()
|
||||
default:
|
||||
c.saveTorrent(saveInfo.id, saveInfo.jsonData)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) saveTorrent(id string, data []byte) {
|
||||
|
||||
fileName := id + ".json"
|
||||
filePath := filepath.Join(c.dir, fileName)
|
||||
|
||||
// Use a unique temporary filename for concurrent safety
|
||||
tmpFile := filePath + ".tmp." + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
|
||||
f, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to create file: %s", tmpFile)
|
||||
return
|
||||
}
|
||||
|
||||
// Track if we've closed the file
|
||||
fileClosed := false
|
||||
defer func() {
|
||||
// Only close if not already closed
|
||||
if !fileClosed {
|
||||
_ = f.Close()
|
||||
}
|
||||
// Clean up the temp file if it still exists and rename failed
|
||||
_ = os.Remove(tmpFile)
|
||||
}()
|
||||
|
||||
w := bufio.NewWriter(f)
|
||||
if _, err := w.Write(data); err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to write data: %s", tmpFile)
|
||||
return
|
||||
}
|
||||
|
||||
if err := w.Flush(); err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to flush data: %s", tmpFile)
|
||||
return
|
||||
}
|
||||
|
||||
// Close the file before renaming
|
||||
_ = f.Close()
|
||||
fileClosed = true
|
||||
|
||||
if err := os.Rename(tmpFile, filePath); err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to rename file: %s", tmpFile)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) ProcessTorrent(t *types.Torrent) error {
|
||||
|
||||
isComplete := func(files map[string]types.File) bool {
|
||||
_complete := len(files) > 0
|
||||
for _, file := range files {
|
||||
if file.Link == "" {
|
||||
_complete = false
|
||||
break
|
||||
}
|
||||
}
|
||||
return _complete
|
||||
}
|
||||
|
||||
if !isComplete(t.Files) {
|
||||
if err := c.client.UpdateTorrent(t); err != nil {
|
||||
return fmt.Errorf("failed to update torrent: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !isComplete(t.Files) {
|
||||
c.logger.Debug().Msgf("Torrent %s is still not complete. Triggering a reinsert(disabled)", t.Id)
|
||||
} else {
|
||||
addedOn, err := time.Parse(time.RFC3339, t.Added)
|
||||
if err != nil {
|
||||
addedOn = time.Now()
|
||||
}
|
||||
ct := CachedTorrent{
|
||||
Torrent: t,
|
||||
IsComplete: len(t.Files) > 0,
|
||||
AddedOn: addedOn,
|
||||
}
|
||||
c.setTorrent(ct, func(tor CachedTorrent) {
|
||||
c.listingDebouncer.Call(false)
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) AddTorrent(t *types.Torrent) error {
|
||||
if len(t.Files) == 0 {
|
||||
if err := c.client.UpdateTorrent(t); err != nil {
|
||||
return fmt.Errorf("failed to update torrent: %w", err)
|
||||
}
|
||||
}
|
||||
addedOn, err := time.Parse(time.RFC3339, t.Added)
|
||||
if err != nil {
|
||||
addedOn = time.Now()
|
||||
}
|
||||
ct := CachedTorrent{
|
||||
Torrent: t,
|
||||
IsComplete: len(t.Files) > 0,
|
||||
AddedOn: addedOn,
|
||||
}
|
||||
c.setTorrent(ct, func(tor CachedTorrent) {
|
||||
c.RefreshListings(true)
|
||||
})
|
||||
go c.GenerateDownloadLinks(ct)
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func (c *Cache) GetClient() types.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
func (c *Cache) DeleteTorrent(id string) error {
|
||||
c.torrentsRefreshMu.Lock()
|
||||
defer c.torrentsRefreshMu.Unlock()
|
||||
|
||||
if c.deleteTorrent(id, true) {
|
||||
go c.RefreshListings(true)
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) validateAndDeleteTorrents(torrents []string) {
|
||||
wg := sync.WaitGroup{}
|
||||
for _, torrent := range torrents {
|
||||
wg.Add(1)
|
||||
go func(t string) {
|
||||
defer wg.Done()
|
||||
// Check if torrent is truly deleted
|
||||
if _, err := c.client.GetTorrent(t); err != nil {
|
||||
c.deleteTorrent(t, false) // Since it's removed from debrid already
|
||||
}
|
||||
}(torrent)
|
||||
}
|
||||
wg.Wait()
|
||||
c.listingDebouncer.Call(true)
|
||||
}
|
||||
|
||||
// deleteTorrent deletes the torrent from the cache and debrid service
|
||||
// It also handles torrents with the same name but different IDs
|
||||
func (c *Cache) deleteTorrent(id string, removeFromDebrid bool) bool {
|
||||
|
||||
if torrent, ok := c.torrents.getByID(id); ok {
|
||||
c.torrents.removeId(id) // Delete id from cache
|
||||
defer func() {
|
||||
c.removeFromDB(id)
|
||||
if removeFromDebrid {
|
||||
_ = c.client.DeleteTorrent(id) // Skip error handling, we don't care if it fails
|
||||
}
|
||||
}() // defer delete from debrid
|
||||
|
||||
torrentName := torrent.Name
|
||||
|
||||
if t, ok := c.torrents.getByName(torrentName); ok {
|
||||
|
||||
newFiles := map[string]types.File{}
|
||||
newId := ""
|
||||
for _, file := range t.Files {
|
||||
if file.TorrentId != "" && file.TorrentId != id {
|
||||
if newId == "" && file.TorrentId != "" {
|
||||
newId = file.TorrentId
|
||||
}
|
||||
newFiles[file.Name] = file
|
||||
}
|
||||
}
|
||||
if len(newFiles) == 0 {
|
||||
// Delete the torrent since no files are left
|
||||
c.torrents.remove(torrentName)
|
||||
} else {
|
||||
t.Files = newFiles
|
||||
newId = cmp.Or(newId, t.Id)
|
||||
t.Id = newId
|
||||
c.setTorrent(t, nil) // This gets called after calling deleteTorrent
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Cache) DeleteTorrents(ids []string) {
|
||||
c.logger.Info().Msgf("Deleting %d torrents", len(ids))
|
||||
for _, id := range ids {
|
||||
_ = c.deleteTorrent(id, true)
|
||||
}
|
||||
c.listingDebouncer.Call(true)
|
||||
}
|
||||
|
||||
func (c *Cache) removeFromDB(torrentId string) {
|
||||
// Moves the torrent file to the trash
|
||||
filePath := filepath.Join(c.dir, torrentId+".json")
|
||||
|
||||
// Check if the file exists
|
||||
if _, err := os.Stat(filePath); errors.Is(err, os.ErrNotExist) {
|
||||
return
|
||||
}
|
||||
|
||||
// Move the file to the trash
|
||||
trashPath := filepath.Join(c.dir, "trash", torrentId+".json")
|
||||
if err := os.MkdirAll(filepath.Dir(trashPath), 0755); err != nil {
|
||||
return
|
||||
}
|
||||
if err := os.Rename(filePath, trashPath); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) OnRemove(torrentId string) {
|
||||
c.logger.Debug().Msgf("OnRemove triggered for %s", torrentId)
|
||||
err := c.DeleteTorrent(torrentId)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to delete torrent: %s", torrentId)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) GetLogger() zerolog.Logger {
|
||||
return c.logger
|
||||
}
|
||||
103
pkg/debrid/debrid/debrid.go
Normal file
103
pkg/debrid/debrid/debrid.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/alldebrid"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/debrid_link"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/realdebrid"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/torbox"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func createDebridClient(dc config.Debrid) types.Client {
|
||||
switch dc.Name {
|
||||
case "realdebrid":
|
||||
return realdebrid.New(dc)
|
||||
case "torbox":
|
||||
return torbox.New(dc)
|
||||
case "debridlink":
|
||||
return debrid_link.New(dc)
|
||||
case "alldebrid":
|
||||
return alldebrid.New(dc)
|
||||
default:
|
||||
return realdebrid.New(dc)
|
||||
}
|
||||
}
|
||||
|
||||
func ProcessTorrent(d *Engine, magnet *utils.Magnet, a *arr.Arr, isSymlink, overrideDownloadUncached bool) (*types.Torrent, error) {
|
||||
|
||||
debridTorrent := &types.Torrent{
|
||||
InfoHash: magnet.InfoHash,
|
||||
Magnet: magnet,
|
||||
Name: magnet.Name,
|
||||
Arr: a,
|
||||
Size: magnet.Size,
|
||||
Files: make(map[string]types.File),
|
||||
}
|
||||
|
||||
errs := make([]error, 0, len(d.Clients))
|
||||
|
||||
// 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 = false
|
||||
}
|
||||
|
||||
for index, db := range d.Clients {
|
||||
logger := db.GetLogger()
|
||||
logger.Info().Str("Debrid", db.GetName()).Str("Hash", debridTorrent.InfoHash).Msg("Processing torrent")
|
||||
|
||||
if !overrideDownloadUncached && a.DownloadUncached == nil {
|
||||
debridTorrent.DownloadUncached = db.GetDownloadUncached()
|
||||
}
|
||||
|
||||
//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 err != nil || dbt == nil || dbt.Id == "" {
|
||||
errs = append(errs, err)
|
||||
continue
|
||||
}
|
||||
dbt.Arr = a
|
||||
logger.Info().Str("id", dbt.Id).Msgf("Torrent: %s submitted to %s", dbt.Name, db.GetName())
|
||||
d.LastUsed = index
|
||||
|
||||
torrent, err := db.CheckStatus(dbt, isSymlink)
|
||||
if err != nil && torrent != nil && torrent.Id != "" {
|
||||
// Delete the torrent if it was not downloaded
|
||||
go func(id string) {
|
||||
_ = db.DeleteTorrent(id)
|
||||
}(torrent.Id)
|
||||
}
|
||||
return torrent, err
|
||||
}
|
||||
if len(errs) == 0 {
|
||||
return nil, fmt.Errorf("failed to process torrent: no clients available")
|
||||
}
|
||||
if len(errs) == 1 {
|
||||
return nil, fmt.Errorf("failed to process torrent: %w", errs[0])
|
||||
} else {
|
||||
errStrings := make([]string, 0, len(errs))
|
||||
for _, err := range errs {
|
||||
errStrings = append(errStrings, err.Error())
|
||||
}
|
||||
return nil, fmt.Errorf("failed to process torrent: %s", strings.Join(errStrings, ", "))
|
||||
}
|
||||
}
|
||||
224
pkg/debrid/debrid/download_link.go
Normal file
224
pkg/debrid/debrid/download_link.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type linkCache struct {
|
||||
Id string
|
||||
link string
|
||||
accountId string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type downloadLinkCache struct {
|
||||
data map[string]linkCache
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newDownloadLinkCache() *downloadLinkCache {
|
||||
return &downloadLinkCache{
|
||||
data: make(map[string]linkCache),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *downloadLinkCache) reset() {
|
||||
c.mu.Lock()
|
||||
c.data = make(map[string]linkCache)
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *downloadLinkCache) Load(key string) (linkCache, bool) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
dl, ok := c.data[key]
|
||||
return dl, ok
|
||||
}
|
||||
func (c *downloadLinkCache) Store(key string, value linkCache) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.data[key] = value
|
||||
}
|
||||
func (c *downloadLinkCache) Delete(key string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.data, key)
|
||||
}
|
||||
|
||||
type downloadLinkRequest struct {
|
||||
result string
|
||||
err error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newDownloadLinkRequest() *downloadLinkRequest {
|
||||
return &downloadLinkRequest{
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *downloadLinkRequest) Complete(result string, err error) {
|
||||
r.result = result
|
||||
r.err = err
|
||||
close(r.done)
|
||||
}
|
||||
|
||||
func (r *downloadLinkRequest) Wait() (string, error) {
|
||||
<-r.done
|
||||
return r.result, r.err
|
||||
}
|
||||
|
||||
func (c *Cache) GetDownloadLink(torrentName, filename, fileLink string) (string, error) {
|
||||
// Check link cache
|
||||
if dl := c.checkDownloadLink(fileLink); dl != "" {
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
if req, inFlight := c.downloadLinkRequests.Load(fileLink); inFlight {
|
||||
// Wait for the other request to complete and use its result
|
||||
result := req.(*downloadLinkRequest)
|
||||
return result.Wait()
|
||||
}
|
||||
|
||||
// Create a new request object
|
||||
req := newDownloadLinkRequest()
|
||||
c.downloadLinkRequests.Store(fileLink, req)
|
||||
|
||||
downloadLink, err := c.fetchDownloadLink(torrentName, filename, fileLink)
|
||||
|
||||
// Complete the request and remove it from the map
|
||||
req.Complete(downloadLink, err)
|
||||
c.downloadLinkRequests.Delete(fileLink)
|
||||
|
||||
return downloadLink, err
|
||||
}
|
||||
|
||||
func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (string, error) {
|
||||
ct := c.GetTorrentByName(torrentName)
|
||||
if ct == nil {
|
||||
return "", fmt.Errorf("torrent not found")
|
||||
}
|
||||
file := ct.Files[filename]
|
||||
|
||||
if file.Link == "" {
|
||||
// file link is empty, refresh the torrent to get restricted links
|
||||
ct = c.refreshTorrent(file.TorrentId) // Refresh the torrent from the debrid
|
||||
if ct == nil {
|
||||
return "", fmt.Errorf("failed to refresh torrent")
|
||||
} else {
|
||||
file = ct.Files[filename]
|
||||
}
|
||||
}
|
||||
|
||||
// If file.Link is still empty, return
|
||||
if file.Link == "" {
|
||||
// Try to reinsert the torrent?
|
||||
newCt, err := c.reInsertTorrent(ct)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reinsert torrent. %w", err)
|
||||
}
|
||||
ct = newCt
|
||||
file = ct.Files[filename]
|
||||
}
|
||||
|
||||
c.logger.Trace().Msgf("Getting download link for %s(%s)", filename, file.Link)
|
||||
downloadLink, err := c.client.GetDownloadLink(ct.Torrent, &file)
|
||||
if err != nil {
|
||||
if errors.Is(err, request.HosterUnavailableError) {
|
||||
newCt, err := c.reInsertTorrent(ct)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to reinsert torrent: %w", err)
|
||||
}
|
||||
ct = newCt
|
||||
file = ct.Files[filename]
|
||||
// Retry getting the download link
|
||||
downloadLink, err = c.client.GetDownloadLink(ct.Torrent, &file)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if downloadLink == nil {
|
||||
return "", fmt.Errorf("download link is empty for")
|
||||
}
|
||||
c.updateDownloadLink(downloadLink)
|
||||
return "", nil
|
||||
} else if errors.Is(err, request.TrafficExceededError) {
|
||||
// This is likely a fair usage limit error
|
||||
return "", err
|
||||
} else {
|
||||
return "", fmt.Errorf("failed to get download link: %w", err)
|
||||
}
|
||||
}
|
||||
if downloadLink == nil {
|
||||
return "", fmt.Errorf("download link is empty")
|
||||
}
|
||||
c.updateDownloadLink(downloadLink)
|
||||
return downloadLink.DownloadLink, nil
|
||||
}
|
||||
|
||||
func (c *Cache) GenerateDownloadLinks(t CachedTorrent) {
|
||||
if err := c.client.GenerateDownloadLinks(t.Torrent); err != nil {
|
||||
c.logger.Error().Err(err).Str("torrent", t.Name).Msg("Failed to generate download links")
|
||||
return
|
||||
}
|
||||
for _, file := range t.Files {
|
||||
if file.DownloadLink != nil {
|
||||
c.updateDownloadLink(file.DownloadLink)
|
||||
}
|
||||
}
|
||||
c.setTorrent(t, nil)
|
||||
}
|
||||
|
||||
func (c *Cache) updateDownloadLink(dl *types.DownloadLink) {
|
||||
c.downloadLinks.Store(dl.Link, linkCache{
|
||||
Id: dl.Id,
|
||||
link: dl.DownloadLink,
|
||||
expiresAt: time.Now().Add(c.autoExpiresLinksAfterDuration),
|
||||
accountId: dl.AccountId,
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Cache) checkDownloadLink(link string) string {
|
||||
if dl, ok := c.downloadLinks.Load(link); ok {
|
||||
if dl.expiresAt.After(time.Now()) && !c.IsDownloadLinkInvalid(dl.link) {
|
||||
return dl.link
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c *Cache) MarkDownloadLinkAsInvalid(link, downloadLink, reason string) {
|
||||
c.invalidDownloadLinks.Store(downloadLink, reason)
|
||||
// Remove the download api key from active
|
||||
if reason == "bandwidth_exceeded" {
|
||||
if dl, ok := c.downloadLinks.Load(link); ok {
|
||||
if dl.accountId != "" && dl.link == downloadLink {
|
||||
c.client.DisableAccount(dl.accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
c.removeDownloadLink(link)
|
||||
}
|
||||
|
||||
func (c *Cache) removeDownloadLink(link string) {
|
||||
if dl, ok := c.downloadLinks.Load(link); ok {
|
||||
// Delete dl from cache
|
||||
c.downloadLinks.Delete(link)
|
||||
// Delete dl from debrid
|
||||
if dl.Id != "" {
|
||||
_ = c.client.DeleteDownloadLink(dl.Id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) IsDownloadLinkInvalid(downloadLink string) bool {
|
||||
if reason, ok := c.invalidDownloadLinks.Load(downloadLink); ok {
|
||||
c.logger.Debug().Msgf("Download link %s is invalid: %s", downloadLink, reason)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
61
pkg/debrid/debrid/engine.go
Normal file
61
pkg/debrid/debrid/engine.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type Engine struct {
|
||||
Clients map[string]types.Client
|
||||
clientsMu sync.Mutex
|
||||
Caches map[string]*Cache
|
||||
CacheMu sync.Mutex
|
||||
LastUsed string
|
||||
}
|
||||
|
||||
func NewEngine() *Engine {
|
||||
cfg := config.Get()
|
||||
clients := make(map[string]types.Client)
|
||||
|
||||
caches := make(map[string]*Cache)
|
||||
|
||||
for _, dc := range cfg.Debrids {
|
||||
client := createDebridClient(dc)
|
||||
logger := client.GetLogger()
|
||||
if dc.UseWebDav {
|
||||
caches[dc.Name] = New(dc, client)
|
||||
logger.Info().Msg("Debrid Service started with WebDAV")
|
||||
} else {
|
||||
logger.Info().Msg("Debrid Service started")
|
||||
}
|
||||
clients[dc.Name] = client
|
||||
}
|
||||
|
||||
d := &Engine{
|
||||
Clients: clients,
|
||||
LastUsed: "",
|
||||
Caches: caches,
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *Engine) GetClient(name string) types.Client {
|
||||
d.clientsMu.Lock()
|
||||
defer d.clientsMu.Unlock()
|
||||
return d.Clients[name]
|
||||
}
|
||||
|
||||
func (d *Engine) Reset() {
|
||||
d.clientsMu.Lock()
|
||||
d.Clients = make(map[string]types.Client)
|
||||
d.clientsMu.Unlock()
|
||||
|
||||
d.CacheMu.Lock()
|
||||
d.Caches = make(map[string]*Cache)
|
||||
d.CacheMu.Unlock()
|
||||
}
|
||||
|
||||
func (d *Engine) GetDebrids() map[string]types.Client {
|
||||
return d.Clients
|
||||
}
|
||||
27
pkg/debrid/debrid/misc.go
Normal file
27
pkg/debrid/debrid/misc.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// MergeFiles merges the files from multiple torrents into a single map.
|
||||
// It uses the file name as the key and the file object as the value.
|
||||
// This is useful for deduplicating files across multiple torrents.
|
||||
// The order of the torrents is determined by the AddedOn time, with the earliest added torrent first.
|
||||
// If a file with the same name exists in multiple torrents, the last one will be used.
|
||||
func mergeFiles(torrents ...CachedTorrent) map[string]types.File {
|
||||
merged := make(map[string]types.File)
|
||||
|
||||
// order torrents by added time
|
||||
sort.Slice(torrents, func(i, j int) bool {
|
||||
return torrents[i].AddedOn.Before(torrents[j].AddedOn)
|
||||
})
|
||||
|
||||
for _, torrent := range torrents {
|
||||
for _, file := range torrent.Files {
|
||||
merged[file.Name] = file
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
267
pkg/debrid/debrid/refresh.go
Normal file
267
pkg/debrid/debrid/refresh.go
Normal file
@@ -0,0 +1,267 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type fileInfo struct {
|
||||
id string
|
||||
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) ID() string { return fi.id }
|
||||
func (fi *fileInfo) Sys() interface{} { return nil }
|
||||
|
||||
func (c *Cache) RefreshListings(refreshRclone bool) {
|
||||
// Copy the torrents to a string|time map
|
||||
c.torrents.refreshListing() // refresh torrent listings
|
||||
|
||||
if refreshRclone {
|
||||
if err := c.refreshRclone(); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to refresh rclone") // silent error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) refreshTorrents(ctx context.Context) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if !c.torrentsRefreshMu.TryLock() {
|
||||
return
|
||||
}
|
||||
defer c.torrentsRefreshMu.Unlock()
|
||||
|
||||
// Get all torrents from the debrid service
|
||||
debTorrents, err := c.client.GetTorrents()
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to get torrents")
|
||||
return
|
||||
}
|
||||
|
||||
if len(debTorrents) == 0 {
|
||||
// Maybe an error occurred
|
||||
return
|
||||
}
|
||||
|
||||
currentTorrentIds := make(map[string]struct{}, len(debTorrents))
|
||||
for _, t := range debTorrents {
|
||||
currentTorrentIds[t.Id] = struct{}{}
|
||||
}
|
||||
|
||||
// Let's implement deleting torrents removed from debrid
|
||||
deletedTorrents := make([]string, 0)
|
||||
cachedTorrents := c.torrents.getIdMaps()
|
||||
for id := range cachedTorrents {
|
||||
if _, exists := currentTorrentIds[id]; !exists {
|
||||
deletedTorrents = append(deletedTorrents, id)
|
||||
}
|
||||
}
|
||||
|
||||
if len(deletedTorrents) > 0 {
|
||||
go c.validateAndDeleteTorrents(deletedTorrents)
|
||||
}
|
||||
|
||||
newTorrents := make([]*types.Torrent, 0)
|
||||
for _, t := range debTorrents {
|
||||
if _, exists := cachedTorrents[t.Id]; !exists {
|
||||
newTorrents = append(newTorrents, t)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newTorrents) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
c.logger.Trace().Msgf("Found %d new torrents", len(newTorrents))
|
||||
|
||||
workChan := make(chan *types.Torrent, min(100, len(newTorrents)))
|
||||
errChan := make(chan error, len(newTorrents))
|
||||
var wg sync.WaitGroup
|
||||
counter := 0
|
||||
|
||||
for i := 0; i < c.workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for t := range workChan {
|
||||
if err := c.ProcessTorrent(t); err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to process new torrent %s", t.Id)
|
||||
errChan <- err
|
||||
}
|
||||
counter++
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
for _, t := range newTorrents {
|
||||
workChan <- t
|
||||
}
|
||||
close(workChan)
|
||||
wg.Wait()
|
||||
|
||||
c.listingDebouncer.Call(false)
|
||||
|
||||
c.logger.Debug().Msgf("Processed %d new torrents", counter)
|
||||
}
|
||||
|
||||
func (c *Cache) refreshRclone() error {
|
||||
cfg := c.config
|
||||
|
||||
if cfg.RcUrl == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.RcUrl == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
DisableCompression: false,
|
||||
MaxIdleConnsPerHost: 5,
|
||||
},
|
||||
}
|
||||
// Create form data
|
||||
data := ""
|
||||
dirs := strings.FieldsFunc(cfg.RcRefreshDirs, func(r rune) bool {
|
||||
return r == ',' || r == '&'
|
||||
})
|
||||
if len(dirs) == 0 {
|
||||
data = "dir=__all__"
|
||||
} else {
|
||||
for index, dir := range dirs {
|
||||
if dir != "" {
|
||||
if index == 0 {
|
||||
data += "dir=" + dir
|
||||
} else {
|
||||
data += "&dir" + fmt.Sprint(index+1) + "=" + dir
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendRequest := func(endpoint string) error {
|
||||
req, err := http.NewRequest("POST", fmt.Sprintf("%s/%s", cfg.RcUrl, endpoint), strings.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
if cfg.RcUser != "" && cfg.RcPass != "" {
|
||||
req.SetBasicAuth(cfg.RcUser, cfg.RcPass)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != 200 {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return fmt.Errorf("failed to perform %s: %s - %s", endpoint, resp.Status, string(body))
|
||||
}
|
||||
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := sendRequest("vfs/forget"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := sendRequest("vfs/refresh"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) refreshTorrent(torrentId string) *CachedTorrent {
|
||||
|
||||
if torrentId == "" {
|
||||
c.logger.Error().Msg("Torrent ID is empty")
|
||||
return nil
|
||||
}
|
||||
|
||||
torrent, err := c.client.GetTorrent(torrentId)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to get torrent %s", torrentId)
|
||||
return nil
|
||||
}
|
||||
addedOn, err := time.Parse(time.RFC3339, torrent.Added)
|
||||
if err != nil {
|
||||
addedOn = time.Now()
|
||||
}
|
||||
ct := CachedTorrent{
|
||||
Torrent: torrent,
|
||||
AddedOn: addedOn,
|
||||
IsComplete: len(torrent.Files) > 0,
|
||||
}
|
||||
c.setTorrent(ct, func(torrent CachedTorrent) {
|
||||
go c.listingDebouncer.Call(true)
|
||||
})
|
||||
|
||||
return &ct
|
||||
}
|
||||
|
||||
func (c *Cache) refreshDownloadLinks(ctx context.Context) {
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if !c.downloadLinksRefreshMu.TryLock() {
|
||||
return
|
||||
}
|
||||
defer c.downloadLinksRefreshMu.Unlock()
|
||||
|
||||
downloadLinks, err := c.client.GetDownloads()
|
||||
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to get download links")
|
||||
return
|
||||
}
|
||||
for k, v := range downloadLinks {
|
||||
// if link is generated in the last 24 hours, add it to cache
|
||||
timeSince := time.Since(v.Generated)
|
||||
if timeSince < c.autoExpiresLinksAfterDuration {
|
||||
c.downloadLinks.Store(k, linkCache{
|
||||
Id: v.Id,
|
||||
accountId: v.AccountId,
|
||||
link: v.DownloadLink,
|
||||
expiresAt: v.Generated.Add(c.autoExpiresLinksAfterDuration - timeSince),
|
||||
})
|
||||
} else {
|
||||
c.downloadLinks.Delete(k)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.Trace().Msgf("Refreshed %d download links", len(downloadLinks))
|
||||
|
||||
}
|
||||
262
pkg/debrid/debrid/repair.go
Normal file
262
pkg/debrid/debrid/repair.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type reInsertRequest struct {
|
||||
result *CachedTorrent
|
||||
err error
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func newReInsertRequest() *reInsertRequest {
|
||||
return &reInsertRequest{
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *reInsertRequest) Complete(result *CachedTorrent, err error) {
|
||||
r.result = result
|
||||
r.err = err
|
||||
close(r.done)
|
||||
}
|
||||
|
||||
func (r *reInsertRequest) Wait() (*CachedTorrent, error) {
|
||||
<-r.done
|
||||
return r.result, r.err
|
||||
}
|
||||
|
||||
func (c *Cache) markAsFailedToReinsert(torrentId string) {
|
||||
c.failedToReinsert.Store(torrentId, struct{}{})
|
||||
|
||||
// Remove the torrent from the directory if it has failed to reinsert, max retries are hardcoded to 5
|
||||
if torrent, ok := c.torrents.getByID(torrentId); ok {
|
||||
torrent.Bad = true
|
||||
c.setTorrent(torrent, func(t CachedTorrent) {
|
||||
c.RefreshListings(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) markAsSuccessfullyReinserted(torrentId string) {
|
||||
if _, ok := c.failedToReinsert.Load(torrentId); !ok {
|
||||
return
|
||||
}
|
||||
c.failedToReinsert.Delete(torrentId)
|
||||
if torrent, ok := c.torrents.getByID(torrentId); ok {
|
||||
torrent.Bad = false
|
||||
c.setTorrent(torrent, func(torrent CachedTorrent) {
|
||||
c.RefreshListings(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) IsTorrentBroken(t *CachedTorrent, filenames []string) bool {
|
||||
// Check torrent files
|
||||
|
||||
isBroken := false
|
||||
files := make(map[string]types.File)
|
||||
if len(filenames) > 0 {
|
||||
for name, f := range t.Files {
|
||||
if utils.Contains(filenames, name) {
|
||||
files[name] = f
|
||||
}
|
||||
}
|
||||
} else {
|
||||
files = t.Files
|
||||
}
|
||||
|
||||
// Check empty links
|
||||
for _, f := range files {
|
||||
// Check if file is missing
|
||||
if f.Link == "" {
|
||||
// refresh torrent and then break
|
||||
if newT := c.refreshTorrent(f.TorrentId); newT != nil {
|
||||
t = newT
|
||||
} else {
|
||||
c.logger.Error().Str("torrentId", t.Torrent.Id).Msg("Failed to refresh torrent")
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.Torrent == nil {
|
||||
c.logger.Error().Str("torrentId", t.Torrent.Id).Msg("Failed to refresh torrent")
|
||||
return true
|
||||
}
|
||||
|
||||
files = t.Files
|
||||
|
||||
for _, f := range files {
|
||||
// Check if file link is still missing
|
||||
if f.Link == "" {
|
||||
isBroken = true
|
||||
break
|
||||
} else {
|
||||
// Check if file.Link not in the downloadLink Cache
|
||||
if err := c.client.CheckLink(f.Link); err != nil {
|
||||
if errors.Is(err, request.HosterUnavailableError) {
|
||||
isBroken = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Try to reinsert the torrent if it's broken
|
||||
if isBroken && t.Torrent != nil {
|
||||
// Check if the torrent is already in progress
|
||||
if _, err := c.reInsertTorrent(t); err != nil {
|
||||
c.logger.Error().Err(err).Str("torrentId", t.Torrent.Id).Msg("Failed to reinsert torrent")
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
return isBroken
|
||||
}
|
||||
|
||||
func (c *Cache) repairWorker(ctx context.Context) {
|
||||
// This watches a channel for torrents to repair and can be cancelled via context
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
|
||||
case req, ok := <-c.repairChan:
|
||||
// Channel was closed
|
||||
if !ok {
|
||||
c.logger.Debug().Msg("Repair channel closed, shutting down worker")
|
||||
return
|
||||
}
|
||||
|
||||
torrentId := req.TorrentID
|
||||
c.logger.Debug().Str("torrentId", req.TorrentID).Msg("Received repair request")
|
||||
|
||||
// Get the torrent from the cache
|
||||
cachedTorrent := c.GetTorrent(torrentId)
|
||||
if cachedTorrent == nil {
|
||||
c.logger.Warn().Str("torrentId", torrentId).Msg("Torrent not found in cache")
|
||||
continue
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case RepairTypeReinsert:
|
||||
c.logger.Debug().Str("torrentId", torrentId).Msg("Reinserting torrent")
|
||||
if _, err := c.reInsertTorrent(cachedTorrent); err != nil {
|
||||
c.logger.Error().Err(err).Str("torrentId", cachedTorrent.Id).Msg("Failed to reinsert torrent")
|
||||
continue
|
||||
}
|
||||
case RepairTypeDelete:
|
||||
c.logger.Debug().Str("torrentId", torrentId).Msg("Deleting torrent")
|
||||
if err := c.DeleteTorrent(torrentId); err != nil {
|
||||
c.logger.Error().Err(err).Str("torrentId", torrentId).Msg("Failed to delete torrent")
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
|
||||
// Check if Magnet is not empty, if empty, reconstruct the magnet
|
||||
torrent := ct.Torrent
|
||||
oldID := torrent.Id // Store the old ID
|
||||
if _, ok := c.failedToReinsert.Load(oldID); ok {
|
||||
return ct, fmt.Errorf("can't retry re-insert for %s", torrent.Id)
|
||||
}
|
||||
if reqI, inFlight := c.repairRequest.Load(oldID); inFlight {
|
||||
req := reqI.(*reInsertRequest)
|
||||
c.logger.Debug().Msgf("Waiting for existing reinsert request to complete for torrent %s", oldID)
|
||||
return req.Wait()
|
||||
}
|
||||
req := newReInsertRequest()
|
||||
c.repairRequest.Store(oldID, req)
|
||||
|
||||
// Make sure we clean up even if there's a panic
|
||||
defer func() {
|
||||
c.repairRequest.Delete(oldID)
|
||||
}()
|
||||
|
||||
// Submit the magnet to the debrid service
|
||||
newTorrent := &types.Torrent{
|
||||
Name: torrent.Name,
|
||||
Magnet: utils.ConstructMagnet(torrent.InfoHash, torrent.Name),
|
||||
InfoHash: torrent.InfoHash,
|
||||
Size: torrent.Size,
|
||||
Files: make(map[string]types.File),
|
||||
Arr: torrent.Arr,
|
||||
}
|
||||
var err error
|
||||
newTorrent, err = c.client.SubmitMagnet(newTorrent)
|
||||
if err != nil {
|
||||
c.markAsFailedToReinsert(oldID)
|
||||
// Remove the old torrent from the cache and debrid service
|
||||
return ct, fmt.Errorf("failed to submit magnet: %w", err)
|
||||
}
|
||||
|
||||
// Check if the torrent was submitted
|
||||
if newTorrent == nil || newTorrent.Id == "" {
|
||||
c.markAsFailedToReinsert(oldID)
|
||||
return ct, fmt.Errorf("failed to submit magnet: empty torrent")
|
||||
}
|
||||
newTorrent.DownloadUncached = false // Set to false, avoid re-downloading
|
||||
newTorrent, err = c.client.CheckStatus(newTorrent, true)
|
||||
if err != nil {
|
||||
if newTorrent != nil && newTorrent.Id != "" {
|
||||
// Delete the torrent if it was not downloaded
|
||||
_ = c.client.DeleteTorrent(newTorrent.Id)
|
||||
}
|
||||
c.markAsFailedToReinsert(oldID)
|
||||
return ct, err
|
||||
}
|
||||
|
||||
// Update the torrent in the cache
|
||||
addedOn, err := time.Parse(time.RFC3339, newTorrent.Added)
|
||||
if err != nil {
|
||||
addedOn = time.Now()
|
||||
}
|
||||
for _, f := range newTorrent.Files {
|
||||
if f.Link == "" {
|
||||
c.markAsFailedToReinsert(oldID)
|
||||
return ct, fmt.Errorf("failed to reinsert torrent: empty link")
|
||||
}
|
||||
}
|
||||
// Set torrent to newTorrent
|
||||
newCt := CachedTorrent{
|
||||
Torrent: newTorrent,
|
||||
AddedOn: addedOn,
|
||||
IsComplete: len(newTorrent.Files) > 0,
|
||||
}
|
||||
c.setTorrent(newCt, func(torrent CachedTorrent) {
|
||||
c.RefreshListings(true)
|
||||
})
|
||||
|
||||
ct = &newCt // Update ct to point to the new torrent
|
||||
|
||||
// We can safely delete the old torrent here
|
||||
if oldID != "" {
|
||||
if err := c.DeleteTorrent(oldID); err != nil {
|
||||
return ct, fmt.Errorf("failed to delete old torrent: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
req.Complete(ct, err)
|
||||
c.markAsSuccessfullyReinserted(oldID)
|
||||
|
||||
c.logger.Debug().Str("torrentId", torrent.Id).Msg("Torrent successfully reinserted")
|
||||
|
||||
return ct, nil
|
||||
}
|
||||
|
||||
func (c *Cache) resetInvalidLinks() {
|
||||
c.invalidDownloadLinks = sync.Map{}
|
||||
c.client.ResetActiveDownloadKeys() // Reset the active download keys
|
||||
}
|
||||
298
pkg/debrid/debrid/torrent.go
Normal file
298
pkg/debrid/debrid/torrent.go
Normal file
@@ -0,0 +1,298 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
filterByInclude string = "include"
|
||||
filterByExclude string = "exclude"
|
||||
|
||||
filterByStartsWith string = "starts_with"
|
||||
filterByEndsWith string = "ends_with"
|
||||
filterByNotStartsWith string = "not_starts_with"
|
||||
filterByNotEndsWith string = "not_ends_with"
|
||||
|
||||
filterByRegex string = "regex"
|
||||
filterByNotRegex string = "not_regex"
|
||||
|
||||
filterByExactMatch string = "exact_match"
|
||||
filterByNotExactMatch string = "not_exact_match"
|
||||
|
||||
filterBySizeGT string = "size_gt"
|
||||
filterBySizeLT string = "size_lt"
|
||||
|
||||
filterBLastAdded string = "last_added"
|
||||
)
|
||||
|
||||
type directoryFilter struct {
|
||||
filterType string
|
||||
value string
|
||||
regex *regexp.Regexp // only for regex/not_regex
|
||||
sizeThreshold int64 // only for size_gt/size_lt
|
||||
ageThreshold time.Duration // only for last_added
|
||||
}
|
||||
|
||||
type torrentCache struct {
|
||||
mu sync.Mutex
|
||||
byID map[string]CachedTorrent
|
||||
byName map[string]CachedTorrent
|
||||
listing atomic.Value
|
||||
folderListing map[string][]os.FileInfo
|
||||
folderListingMu sync.RWMutex
|
||||
directoriesFilters map[string][]directoryFilter
|
||||
sortNeeded atomic.Bool
|
||||
}
|
||||
|
||||
type sortableFile struct {
|
||||
id string
|
||||
name string
|
||||
modTime time.Time
|
||||
size int64
|
||||
bad bool
|
||||
}
|
||||
|
||||
func newTorrentCache(dirFilters map[string][]directoryFilter) *torrentCache {
|
||||
|
||||
tc := &torrentCache{
|
||||
byID: make(map[string]CachedTorrent),
|
||||
byName: make(map[string]CachedTorrent),
|
||||
folderListing: make(map[string][]os.FileInfo),
|
||||
directoriesFilters: dirFilters,
|
||||
}
|
||||
|
||||
tc.sortNeeded.Store(false)
|
||||
tc.listing.Store(make([]os.FileInfo, 0))
|
||||
return tc
|
||||
}
|
||||
|
||||
func (tc *torrentCache) reset() {
|
||||
tc.mu.Lock()
|
||||
tc.byID = make(map[string]CachedTorrent)
|
||||
tc.byName = make(map[string]CachedTorrent)
|
||||
tc.mu.Unlock()
|
||||
|
||||
// reset the sorted listing
|
||||
tc.sortNeeded.Store(false)
|
||||
tc.listing.Store(make([]os.FileInfo, 0))
|
||||
|
||||
// reset any per-folder views
|
||||
tc.folderListingMu.Lock()
|
||||
tc.folderListing = make(map[string][]os.FileInfo)
|
||||
tc.folderListingMu.Unlock()
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getByID(id string) (CachedTorrent, bool) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
torrent, exists := tc.byID[id]
|
||||
return torrent, exists
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getByName(name string) (CachedTorrent, bool) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
torrent, exists := tc.byName[name]
|
||||
return torrent, exists
|
||||
}
|
||||
|
||||
func (tc *torrentCache) set(name string, torrent, newTorrent CachedTorrent) {
|
||||
tc.mu.Lock()
|
||||
// Set the id first
|
||||
tc.byID[newTorrent.Id] = torrent // This is the unadulterated torrent
|
||||
tc.byName[name] = newTorrent // This is likely the modified torrent
|
||||
tc.mu.Unlock()
|
||||
tc.sortNeeded.Store(true)
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getListing() []os.FileInfo {
|
||||
// Fast path: if we have a sorted list and no changes since last sort
|
||||
if !tc.sortNeeded.Load() {
|
||||
return tc.listing.Load().([]os.FileInfo)
|
||||
}
|
||||
|
||||
// Slow path: need to sort
|
||||
tc.refreshListing()
|
||||
return tc.listing.Load().([]os.FileInfo)
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getFolderListing(folderName string) []os.FileInfo {
|
||||
tc.folderListingMu.RLock()
|
||||
defer tc.folderListingMu.RUnlock()
|
||||
if folderName == "" {
|
||||
return tc.getListing()
|
||||
}
|
||||
if folder, ok := tc.folderListing[folderName]; ok {
|
||||
return folder
|
||||
}
|
||||
// If folder not found, return empty slice
|
||||
return []os.FileInfo{}
|
||||
}
|
||||
|
||||
func (tc *torrentCache) refreshListing() {
|
||||
|
||||
tc.mu.Lock()
|
||||
all := make([]sortableFile, 0, len(tc.byName))
|
||||
for name, t := range tc.byName {
|
||||
all = append(all, sortableFile{t.Id, name, t.AddedOn, t.Bytes, t.Bad})
|
||||
}
|
||||
tc.sortNeeded.Store(false)
|
||||
tc.mu.Unlock()
|
||||
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].name != all[j].name {
|
||||
return all[i].name < all[j].name
|
||||
}
|
||||
return all[i].modTime.Before(all[j].modTime)
|
||||
})
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
|
||||
wg.Add(1) // for all listing
|
||||
go func() {
|
||||
listing := make([]os.FileInfo, len(all))
|
||||
for i, sf := range all {
|
||||
listing[i] = &fileInfo{sf.id, sf.name, sf.size, 0755 | os.ModeDir, sf.modTime, true}
|
||||
}
|
||||
tc.listing.Store(listing)
|
||||
}()
|
||||
wg.Done()
|
||||
|
||||
wg.Add(1)
|
||||
// For __bad__
|
||||
go func() {
|
||||
listing := make([]os.FileInfo, 0)
|
||||
for _, sf := range all {
|
||||
if sf.bad {
|
||||
listing = append(listing, &fileInfo{
|
||||
id: sf.id,
|
||||
name: fmt.Sprintf("%s || %s", sf.name, sf.id),
|
||||
size: sf.size,
|
||||
mode: 0755 | os.ModeDir,
|
||||
modTime: sf.modTime,
|
||||
isDir: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
tc.folderListingMu.Lock()
|
||||
if len(listing) > 0 {
|
||||
tc.folderListing["__bad__"] = listing
|
||||
} else {
|
||||
delete(tc.folderListing, "__bad__")
|
||||
}
|
||||
tc.folderListingMu.Unlock()
|
||||
}()
|
||||
wg.Done()
|
||||
|
||||
now := time.Now()
|
||||
wg.Add(len(tc.directoriesFilters)) // for each directory filter
|
||||
for dir, filters := range tc.directoriesFilters {
|
||||
go func(dir string, filters []directoryFilter) {
|
||||
defer wg.Done()
|
||||
var matched []os.FileInfo
|
||||
for _, sf := range all {
|
||||
if tc.torrentMatchDirectory(filters, sf, now) {
|
||||
matched = append(matched, &fileInfo{
|
||||
id: sf.id,
|
||||
name: sf.name, size: sf.size,
|
||||
mode: 0755 | os.ModeDir, modTime: sf.modTime, isDir: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
tc.folderListingMu.Lock()
|
||||
if len(matched) > 0 {
|
||||
tc.folderListing[dir] = matched
|
||||
} else {
|
||||
delete(tc.folderListing, dir)
|
||||
}
|
||||
tc.folderListingMu.Unlock()
|
||||
}(dir, filters)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (tc *torrentCache) torrentMatchDirectory(filters []directoryFilter, file sortableFile, now time.Time) bool {
|
||||
|
||||
torrentName := strings.ToLower(file.name)
|
||||
for _, filter := range filters {
|
||||
matched := false
|
||||
|
||||
switch filter.filterType {
|
||||
case filterByInclude:
|
||||
matched = strings.Contains(torrentName, filter.value)
|
||||
case filterByStartsWith:
|
||||
matched = strings.HasPrefix(torrentName, filter.value)
|
||||
case filterByEndsWith:
|
||||
matched = strings.HasSuffix(torrentName, filter.value)
|
||||
case filterByExactMatch:
|
||||
matched = torrentName == filter.value
|
||||
case filterByExclude:
|
||||
matched = !strings.Contains(torrentName, filter.value)
|
||||
case filterByNotStartsWith:
|
||||
matched = !strings.HasPrefix(torrentName, filter.value)
|
||||
case filterByNotEndsWith:
|
||||
matched = !strings.HasSuffix(torrentName, filter.value)
|
||||
case filterByRegex:
|
||||
matched = filter.regex.MatchString(torrentName)
|
||||
case filterByNotRegex:
|
||||
matched = !filter.regex.MatchString(torrentName)
|
||||
case filterByNotExactMatch:
|
||||
matched = torrentName != filter.value
|
||||
case filterBySizeGT:
|
||||
matched = file.size > filter.sizeThreshold
|
||||
case filterBySizeLT:
|
||||
matched = file.size < filter.sizeThreshold
|
||||
case filterBLastAdded:
|
||||
matched = file.modTime.After(now.Add(-filter.ageThreshold))
|
||||
}
|
||||
if !matched {
|
||||
return false // All filters must match
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all filters matched
|
||||
return true
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getAll() map[string]CachedTorrent {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
result := make(map[string]CachedTorrent)
|
||||
for name, torrent := range tc.byID {
|
||||
result[name] = torrent
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getIdMaps() map[string]struct{} {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
res := make(map[string]struct{}, len(tc.byID))
|
||||
for id := range tc.byID {
|
||||
res[id] = struct{}{}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (tc *torrentCache) removeId(id string) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
delete(tc.byID, id)
|
||||
tc.sortNeeded.Store(true)
|
||||
}
|
||||
|
||||
func (tc *torrentCache) remove(name string) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
delete(tc.byName, name)
|
||||
tc.sortNeeded.Store(true)
|
||||
}
|
||||
60
pkg/debrid/debrid/worker.go
Normal file
60
pkg/debrid/debrid/worker.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
)
|
||||
|
||||
func (c *Cache) StartSchedule(ctx context.Context) error {
|
||||
// For now, we just want to refresh the listing and download links
|
||||
|
||||
// Schedule download link refresh job
|
||||
if jd, err := utils.ConvertToJobDef(c.downloadLinksRefreshInterval); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to convert download link refresh interval to job definition")
|
||||
} else {
|
||||
// Schedule the job
|
||||
if _, err := c.scheduler.NewJob(jd, gocron.NewTask(func() {
|
||||
c.refreshDownloadLinks(ctx)
|
||||
}), gocron.WithContext(ctx)); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to create download link refresh job")
|
||||
} else {
|
||||
c.logger.Debug().Msgf("Download link refresh job scheduled for every %s", c.downloadLinksRefreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule torrent refresh job
|
||||
if jd, err := utils.ConvertToJobDef(c.torrentRefreshInterval); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to convert torrent refresh interval to job definition")
|
||||
} else {
|
||||
// Schedule the job
|
||||
if _, err := c.scheduler.NewJob(jd, gocron.NewTask(func() {
|
||||
c.refreshTorrents(ctx)
|
||||
}), gocron.WithContext(ctx)); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to create torrent refresh job")
|
||||
} else {
|
||||
c.logger.Debug().Msgf("Torrent refresh job scheduled for every %s", c.torrentRefreshInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the reset invalid links job
|
||||
// This job will run every at 00:00 CET
|
||||
// and reset the invalid links in the cache
|
||||
if jd, err := utils.ConvertToJobDef("00:00"); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to convert link reset interval to job definition")
|
||||
} else {
|
||||
// Schedule the job
|
||||
if _, err := c.cetScheduler.NewJob(jd, gocron.NewTask(func() {
|
||||
c.resetInvalidLinks()
|
||||
}), gocron.WithContext(ctx)); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to create link reset job")
|
||||
} else {
|
||||
c.logger.Debug().Msgf("Link reset job scheduled for every midnight, CET")
|
||||
}
|
||||
}
|
||||
|
||||
// Start the scheduler
|
||||
c.scheduler.Start()
|
||||
c.cetScheduler.Start()
|
||||
return nil
|
||||
}
|
||||
1
pkg/debrid/debrid/xml.go
Normal file
1
pkg/debrid/debrid/xml.go
Normal file
@@ -0,0 +1 @@
|
||||
package debrid
|
||||
474
pkg/debrid/debrid_link/debrid_link.go
Normal file
474
pkg/debrid/debrid_link/debrid_link.go
Normal file
@@ -0,0 +1,474 @@
|
||||
package debrid_link
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DebridLink struct {
|
||||
Name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
accounts map[string]types.Account
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
checkCached bool
|
||||
addSamples bool
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetName() string {
|
||||
return dl.Name
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetLogger() zerolog.Logger {
|
||||
return dl.logger
|
||||
}
|
||||
|
||||
func (dl *DebridLink) IsAvailable(hashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
result := make(map[string]bool)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetTorrent(torrentId string) (*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/seedbox/%s", dl.Host, torrentId)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := dl.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res torrentInfo
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !res.Success || res.Value == nil {
|
||||
return nil, fmt.Errorf("error getting torrent")
|
||||
}
|
||||
data := *res.Value
|
||||
|
||||
if len(data) == 0 {
|
||||
return nil, fmt.Errorf("torrent not found")
|
||||
}
|
||||
t := data[0]
|
||||
name := utils.RemoveInvalidChars(t.Name)
|
||||
torrent := &types.Torrent{
|
||||
Id: t.ID,
|
||||
Name: name,
|
||||
Bytes: t.TotalSize,
|
||||
Status: "downloaded",
|
||||
Filename: name,
|
||||
OriginalFilename: name,
|
||||
MountPath: dl.MountPath,
|
||||
Debrid: dl.Name,
|
||||
Added: time.Unix(t.Created, 0).Format(time.RFC3339),
|
||||
}
|
||||
cfg := config.Get()
|
||||
for _, f := range t.Files {
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
file := types.File{
|
||||
TorrentId: t.ID,
|
||||
Id: f.ID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
Path: f.Name,
|
||||
DownloadLink: &types.DownloadLink{
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: time.Now(),
|
||||
AccountId: "0",
|
||||
},
|
||||
Link: f.DownloadURL,
|
||||
}
|
||||
torrent.Files[file.Name] = file
|
||||
}
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) UpdateTorrent(t *types.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 err
|
||||
}
|
||||
var res torrentInfo
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !res.Success {
|
||||
return fmt.Errorf("error getting torrent")
|
||||
}
|
||||
if res.Value == nil {
|
||||
return fmt.Errorf("torrent not found")
|
||||
}
|
||||
dt := *res.Value
|
||||
|
||||
if len(dt) == 0 {
|
||||
return 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
|
||||
t.Added = time.Unix(data.Created, 0).Format(time.RFC3339)
|
||||
cfg := config.Get()
|
||||
for _, f := range data.Files {
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: f.ID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
Path: f.Name,
|
||||
DownloadLink: &types.DownloadLink{
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: time.Now(),
|
||||
AccountId: "0",
|
||||
},
|
||||
Link: f.DownloadURL,
|
||||
}
|
||||
t.Files[f.Name] = file
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) SubmitMagnet(t *types.Torrent) (*types.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.Added = time.Unix(data.Created, 0).Format(time.RFC3339)
|
||||
for _, f := range data.Files {
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: f.ID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
Path: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: &types.DownloadLink{
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: time.Now(),
|
||||
AccountId: "0",
|
||||
},
|
||||
Generated: time.Now(),
|
||||
}
|
||||
t.Files[f.Name] = file
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) CheckStatus(torrent *types.Torrent, isSymlink bool) (*types.Torrent, error) {
|
||||
for {
|
||||
err := dl.UpdateTorrent(torrent)
|
||||
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.GenerateDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
break
|
||||
} else if utils.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
|
||||
return torrent, nil
|
||||
} else {
|
||||
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||
}
|
||||
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) DeleteTorrent(torrentId string) error {
|
||||
url := fmt.Sprintf("%s/seedbox/%s/remove", dl.Host, torrentId)
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
if _, err := dl.client.MakeRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
dl.logger.Info().Msgf("Torrent: %s deleted from DebridLink", torrentId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GenerateDownloadLinks(t *types.Torrent) error {
|
||||
// Download links are already generated
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetDownloads() (map[string]types.DownloadLink, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
|
||||
return file.DownloadLink, nil
|
||||
}
|
||||
|
||||
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) *DebridLink {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
_log := logger.New(dc.Name)
|
||||
client := request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithLogger(_log),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithProxy(dc.Proxy),
|
||||
)
|
||||
|
||||
accounts := make(map[string]types.Account)
|
||||
for idx, key := range dc.DownloadAPIKeys {
|
||||
id := strconv.Itoa(idx)
|
||||
accounts[id] = types.Account{
|
||||
Name: key,
|
||||
ID: id,
|
||||
Token: key,
|
||||
}
|
||||
}
|
||||
return &DebridLink{
|
||||
Name: "debridlink",
|
||||
Host: "https://debrid-link.com/api/v2",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: accounts,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
MountPath: dc.Folder,
|
||||
logger: logger.New(dc.Name),
|
||||
checkCached: dc.CheckCached,
|
||||
addSamples: dc.AddSamples,
|
||||
}
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetTorrents() ([]*types.Torrent, error) {
|
||||
page := 0
|
||||
perPage := 100
|
||||
torrents := make([]*types.Torrent, 0)
|
||||
for {
|
||||
t, err := dl.getTorrents(page, perPage)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if len(t) == 0 {
|
||||
break
|
||||
}
|
||||
torrents = append(torrents, t...)
|
||||
page++
|
||||
}
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) getTorrents(page, perPage int) ([]*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/seedbox/list?page=%d&perPage=%d", dl.Host, page, perPage)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := dl.client.MakeRequest(req)
|
||||
torrents := make([]*types.Torrent, 0)
|
||||
if err != nil {
|
||||
return torrents, err
|
||||
}
|
||||
var res torrentInfo
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
dl.logger.Info().Msgf("Error unmarshalling torrent info: %s", err)
|
||||
return torrents, err
|
||||
}
|
||||
|
||||
data := *res.Value
|
||||
|
||||
if len(data) == 0 {
|
||||
return torrents, nil
|
||||
}
|
||||
for _, t := range data {
|
||||
if t.Status != 100 {
|
||||
continue
|
||||
}
|
||||
torrent := &types.Torrent{
|
||||
Id: t.ID,
|
||||
Name: t.Name,
|
||||
Bytes: t.TotalSize,
|
||||
Status: "downloaded",
|
||||
Filename: t.Name,
|
||||
OriginalFilename: t.Name,
|
||||
InfoHash: t.HashString,
|
||||
Files: make(map[string]types.File),
|
||||
Debrid: dl.Name,
|
||||
MountPath: dl.MountPath,
|
||||
Added: time.Unix(t.Created, 0).Format(time.RFC3339),
|
||||
}
|
||||
cfg := config.Get()
|
||||
for _, f := range t.Files {
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
file := types.File{
|
||||
TorrentId: torrent.Id,
|
||||
Id: f.ID,
|
||||
Name: f.Name,
|
||||
Size: f.Size,
|
||||
Path: f.Name,
|
||||
DownloadLink: &types.DownloadLink{
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: time.Now(),
|
||||
AccountId: "0",
|
||||
},
|
||||
Link: f.DownloadURL,
|
||||
}
|
||||
torrent.Files[f.Name] = file
|
||||
}
|
||||
torrents = append(torrents, torrent)
|
||||
}
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) CheckLink(link string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetMountPath() string {
|
||||
return dl.MountPath
|
||||
}
|
||||
|
||||
func (dl *DebridLink) DisableAccount(accountId string) {
|
||||
}
|
||||
|
||||
func (dl *DebridLink) ResetActiveDownloadKeys() {
|
||||
}
|
||||
|
||||
func (dl *DebridLink) DeleteDownloadLink(linkId string) error {
|
||||
return nil
|
||||
}
|
||||
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 _torrentInfo 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[[]_torrentInfo]
|
||||
|
||||
type SubmitTorrentInfo APIResponse[_torrentInfo]
|
||||
800
pkg/debrid/realdebrid/realdebrid.go
Normal file
800
pkg/debrid/realdebrid/realdebrid.go
Normal file
@@ -0,0 +1,800 @@
|
||||
package realdebrid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"io"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RealDebrid struct {
|
||||
Name string
|
||||
Host string `json:"host"`
|
||||
|
||||
APIKey string
|
||||
currentDownloadKey string
|
||||
accounts map[string]types.Account
|
||||
accountsMutex sync.RWMutex
|
||||
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
downloadClient *request.Client
|
||||
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
checkCached bool
|
||||
addSamples bool
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) *RealDebrid {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
}
|
||||
_log := logger.New(dc.Name)
|
||||
|
||||
accounts := make(map[string]types.Account)
|
||||
currentDownloadKey := dc.DownloadAPIKeys[0]
|
||||
for idx, key := range dc.DownloadAPIKeys {
|
||||
id := strconv.Itoa(idx)
|
||||
accounts[id] = types.Account{
|
||||
Name: key,
|
||||
ID: id,
|
||||
Token: key,
|
||||
}
|
||||
}
|
||||
|
||||
downloadHeaders := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", currentDownloadKey),
|
||||
}
|
||||
|
||||
return &RealDebrid{
|
||||
Name: "realdebrid",
|
||||
Host: "https://api.real-debrid.com/rest/1.0",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: accounts,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithLogger(_log),
|
||||
request.WithMaxRetries(5),
|
||||
request.WithRetryableStatus(429, 502),
|
||||
request.WithProxy(dc.Proxy),
|
||||
),
|
||||
downloadClient: request.New(
|
||||
request.WithHeaders(downloadHeaders),
|
||||
request.WithLogger(_log),
|
||||
request.WithMaxRetries(10),
|
||||
request.WithRetryableStatus(429, 447, 502),
|
||||
request.WithProxy(dc.Proxy),
|
||||
),
|
||||
currentDownloadKey: currentDownloadKey,
|
||||
MountPath: dc.Folder,
|
||||
logger: logger.New(dc.Name),
|
||||
checkCached: dc.CheckCached,
|
||||
addSamples: dc.AddSamples,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetName() string {
|
||||
return r.Name
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetLogger() zerolog.Logger {
|
||||
return r.logger
|
||||
}
|
||||
|
||||
func getSelectedFiles(t *types.Torrent, data torrentInfo) map[string]types.File {
|
||||
selectedFiles := make([]types.File, 0)
|
||||
for _, f := range data.Files {
|
||||
if f.Selected == 1 {
|
||||
name := filepath.Base(f.Path)
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Name: name,
|
||||
Path: name,
|
||||
Size: f.Bytes,
|
||||
Id: strconv.Itoa(f.ID),
|
||||
}
|
||||
selectedFiles = append(selectedFiles, file)
|
||||
}
|
||||
}
|
||||
files := make(map[string]types.File)
|
||||
for index, f := range selectedFiles {
|
||||
if index >= len(data.Links) {
|
||||
break
|
||||
}
|
||||
f.Link = data.Links[index]
|
||||
files[f.Name] = f
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
// 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 (r *RealDebrid) getTorrentFiles(t *types.Torrent, data torrentInfo) map[string]types.File {
|
||||
files := make(map[string]types.File)
|
||||
cfg := config.Get()
|
||||
idx := 0
|
||||
|
||||
for _, f := range data.Files {
|
||||
name := filepath.Base(f.Path)
|
||||
if !r.addSamples && utils.IsSampleFile(f.Path) {
|
||||
// Skip sample files
|
||||
continue
|
||||
}
|
||||
|
||||
if !cfg.IsAllowedFile(name) {
|
||||
continue
|
||||
}
|
||||
if !cfg.IsSizeAllowed(f.Bytes) {
|
||||
continue
|
||||
}
|
||||
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Name: name,
|
||||
Path: name,
|
||||
Size: f.Bytes,
|
||||
Id: strconv.Itoa(f.ID),
|
||||
}
|
||||
files[name] = file
|
||||
idx++
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (r *RealDebrid) IsAvailable(hashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
result := make(map[string]bool)
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (r *RealDebrid) SubmitMagnet(t *types.Torrent) (*types.Torrent, error) {
|
||||
if t.Magnet.IsTorrent() {
|
||||
return r.addTorrent(t)
|
||||
}
|
||||
return r.addMagnet(t)
|
||||
}
|
||||
|
||||
func (r *RealDebrid) addTorrent(t *types.Torrent) (*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/addTorrent", r.Host)
|
||||
var data AddMagnetSchema
|
||||
req, err := http.NewRequest(http.MethodPut, url, bytes.NewReader(t.Magnet.File))
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Add("Content-Type", "application/x-bittorrent")
|
||||
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) addMagnet(t *types.Torrent) (*types.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(torrentId string) (*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, torrentId)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, request.TorrentNotFoundError
|
||||
}
|
||||
return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var data torrentInfo
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := &types.Torrent{
|
||||
Id: data.ID,
|
||||
Name: data.Filename,
|
||||
Bytes: data.Bytes,
|
||||
Folder: data.OriginalFilename,
|
||||
Progress: data.Progress,
|
||||
Speed: data.Speed,
|
||||
Seeders: data.Seeders,
|
||||
Added: data.Added,
|
||||
Status: data.Status,
|
||||
Filename: data.Filename,
|
||||
OriginalFilename: data.OriginalFilename,
|
||||
Links: data.Links,
|
||||
Debrid: r.Name,
|
||||
MountPath: r.MountPath,
|
||||
}
|
||||
t.Files = r.getTorrentFiles(t, data) // Get selected files
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) UpdateTorrent(t *types.Torrent) error {
|
||||
url := fmt.Sprintf("%s/torrents/info/%s", r.Host, t.Id)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return request.TorrentNotFoundError
|
||||
}
|
||||
return fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
var data torrentInfo
|
||||
err = json.Unmarshal(bodyBytes, &data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Name = data.Filename
|
||||
t.Bytes = data.Bytes
|
||||
t.Folder = data.OriginalFilename
|
||||
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.Added = data.Added
|
||||
t.Files = getSelectedFiles(t, data) // Get selected files
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) CheckStatus(t *types.Torrent, isSymlink bool) (*types.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
|
||||
t.Name = data.Filename // Important because some magnet changes the name
|
||||
t.Folder = data.OriginalFilename
|
||||
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" {
|
||||
t.Files = r.getTorrentFiles(t, data)
|
||||
if len(t.Files) == 0 {
|
||||
return t, fmt.Errorf("no video files found")
|
||||
}
|
||||
filesId := make([]string, 0)
|
||||
for _, f := range t.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)
|
||||
res, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
if res.StatusCode != http.StatusNoContent {
|
||||
return t, fmt.Errorf("realdebrid API error: Status: %d", res.StatusCode)
|
||||
}
|
||||
} else if status == "downloaded" {
|
||||
t.Files = getSelectedFiles(t, data) // Get selected files
|
||||
r.logger.Info().Msgf("Torrent: %s downloaded to RD", t.Name)
|
||||
if !isSymlink {
|
||||
err = r.GenerateDownloadLinks(t)
|
||||
if err != nil {
|
||||
return t, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if utils.Contains(r.GetDownloadingStatus(), status) {
|
||||
if !t.DownloadUncached {
|
||||
return t, fmt.Errorf("torrent: %s not cached", t.Name)
|
||||
}
|
||||
return t, nil
|
||||
} else {
|
||||
return t, fmt.Errorf("torrent: %s has error: %s", t.Name, status)
|
||||
}
|
||||
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) DeleteTorrent(torrentId string) error {
|
||||
url := fmt.Sprintf("%s/torrents/delete/%s", r.Host, torrentId)
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
if _, err := r.client.MakeRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
r.logger.Info().Msgf("Torrent: %s deleted from RD", torrentId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GenerateDownloadLinks(t *types.Torrent) error {
|
||||
filesCh := make(chan types.File, len(t.Files))
|
||||
errCh := make(chan error, len(t.Files))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(t.Files))
|
||||
for _, f := range t.Files {
|
||||
go func(file types.File) {
|
||||
defer wg.Done()
|
||||
|
||||
link, err := r.GetDownloadLink(t, &file)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
|
||||
file.DownloadLink = link
|
||||
filesCh <- file
|
||||
}(f)
|
||||
}
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(filesCh)
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
// Collect results
|
||||
files := make(map[string]types.File, len(t.Files))
|
||||
for file := range filesCh {
|
||||
files[file.Name] = file
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
return err // Return the first error encountered
|
||||
}
|
||||
}
|
||||
|
||||
t.Files = files
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) CheckLink(link string) error {
|
||||
url := fmt.Sprintf("%s/unrestrict/check", r.Host)
|
||||
payload := gourl.Values{
|
||||
"link": {link},
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
resp, err := r.client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return request.HosterUnavailableError // File has been removed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) _getDownloadLink(file *types.File) (*types.DownloadLink, error) {
|
||||
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.downloadClient.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Read the response body to get the error message
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data ErrorResponse
|
||||
if err = json.Unmarshal(b, &data); err != nil {
|
||||
return nil, fmt.Errorf("error unmarshalling %d || %s \n %s", resp.StatusCode, err, string(b))
|
||||
}
|
||||
switch data.ErrorCode {
|
||||
case 19:
|
||||
return nil, request.HosterUnavailableError // File has been removed
|
||||
case 23:
|
||||
return nil, request.TrafficExceededError
|
||||
case 24:
|
||||
return nil, request.HosterUnavailableError // Link has been nerfed
|
||||
case 34:
|
||||
return nil, request.TrafficExceededError // traffic exceeded
|
||||
case 35:
|
||||
return nil, request.HosterUnavailableError
|
||||
case 36:
|
||||
return nil, request.TrafficExceededError // traffic exceeded
|
||||
default:
|
||||
return nil, fmt.Errorf("realdebrid API error: Status: %d || Code: %d", resp.StatusCode, data.ErrorCode)
|
||||
}
|
||||
}
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data UnrestrictResponse
|
||||
if err = json.Unmarshal(b, &data); err != nil {
|
||||
return nil, fmt.Errorf("realdebrid API error: Error unmarshalling response: %w", err)
|
||||
}
|
||||
if data.Download == "" {
|
||||
return nil, fmt.Errorf("realdebrid API error: download link not found")
|
||||
}
|
||||
return &types.DownloadLink{
|
||||
Filename: data.Filename,
|
||||
Size: data.Filesize,
|
||||
Link: data.Link,
|
||||
DownloadLink: data.Download,
|
||||
Generated: time.Now(),
|
||||
}, nil
|
||||
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
|
||||
|
||||
if r.currentDownloadKey == "" {
|
||||
// If no download key is set, use the first one
|
||||
accounts := r.getActiveAccounts()
|
||||
if len(accounts) < 1 {
|
||||
// No active download keys. It's likely that the key has reached bandwidth limit
|
||||
return nil, fmt.Errorf("no active download keys")
|
||||
}
|
||||
r.currentDownloadKey = accounts[0].Token
|
||||
}
|
||||
|
||||
r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", r.currentDownloadKey))
|
||||
downloadLink, err := r._getDownloadLink(file)
|
||||
retries := 0
|
||||
if err != nil {
|
||||
if errors.Is(err, request.TrafficExceededError) {
|
||||
// Retries generating
|
||||
retries = 5
|
||||
} else {
|
||||
// If the error is not traffic exceeded, return the error
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
backOff := 1 * time.Second
|
||||
for retries > 0 {
|
||||
downloadLink, err = r._getDownloadLink(file)
|
||||
if err == nil {
|
||||
return downloadLink, nil
|
||||
}
|
||||
if !errors.Is(err, request.TrafficExceededError) {
|
||||
return nil, err
|
||||
}
|
||||
// Add a delay before retrying
|
||||
time.Sleep(backOff)
|
||||
backOff *= 2 // Exponential backoff
|
||||
}
|
||||
return downloadLink, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetCheckCached() bool {
|
||||
return r.checkCached
|
||||
}
|
||||
|
||||
func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/torrents?limit=%d", r.Host, limit)
|
||||
torrents := make([]*types.Torrent, 0)
|
||||
if offset > 0 {
|
||||
url = fmt.Sprintf("%s&offset=%d", url, offset)
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := r.client.Do(req)
|
||||
|
||||
if err != nil {
|
||||
return 0, torrents, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNoContent {
|
||||
return 0, torrents, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return 0, torrents, fmt.Errorf("realdebrid API error: %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
defer resp.Body.Close()
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, torrents, err
|
||||
}
|
||||
totalItems, _ := strconv.Atoi(resp.Header.Get("X-Total-Count"))
|
||||
var data []TorrentsResponse
|
||||
if err = json.Unmarshal(body, &data); err != nil {
|
||||
return 0, torrents, err
|
||||
}
|
||||
filenames := map[string]struct{}{}
|
||||
for _, t := range data {
|
||||
if t.Status != "downloaded" {
|
||||
continue
|
||||
}
|
||||
torrents = append(torrents, &types.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,
|
||||
Files: make(map[string]types.File),
|
||||
InfoHash: t.Hash,
|
||||
Debrid: r.Name,
|
||||
MountPath: r.MountPath,
|
||||
Added: t.Added.Format(time.RFC3339),
|
||||
})
|
||||
filenames[t.Filename] = struct{}{}
|
||||
}
|
||||
return totalItems, torrents, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
limit := 5000
|
||||
|
||||
// Get first batch and total count
|
||||
allTorrents := make([]*types.Torrent, 0)
|
||||
var fetchError error
|
||||
offset := 0
|
||||
for {
|
||||
// Fetch next batch of torrents
|
||||
_, torrents, err := r.getTorrents(offset, limit)
|
||||
if err != nil {
|
||||
fetchError = err
|
||||
break
|
||||
}
|
||||
totalTorrents := len(torrents)
|
||||
if totalTorrents == 0 {
|
||||
break
|
||||
}
|
||||
allTorrents = append(allTorrents, torrents...)
|
||||
offset += totalTorrents
|
||||
}
|
||||
|
||||
if fetchError != nil {
|
||||
return nil, fetchError
|
||||
}
|
||||
|
||||
return allTorrents, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloads() (map[string]types.DownloadLink, error) {
|
||||
links := make(map[string]types.DownloadLink)
|
||||
offset := 0
|
||||
limit := 1000
|
||||
|
||||
accounts := r.getActiveAccounts()
|
||||
|
||||
if len(accounts) < 1 {
|
||||
// No active download keys. It's likely that the key has reached bandwidth limit
|
||||
return nil, fmt.Errorf("no active download keys")
|
||||
}
|
||||
r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", accounts[0].Token))
|
||||
for {
|
||||
dl, err := r._getDownloads(offset, limit)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if len(dl) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
for _, d := range dl {
|
||||
if _, exists := links[d.Link]; exists {
|
||||
// This is ordered by date, so we can skip the rest
|
||||
continue
|
||||
}
|
||||
links[d.Link] = d
|
||||
}
|
||||
|
||||
offset += len(dl)
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) _getDownloads(offset int, limit int) ([]types.DownloadLink, error) {
|
||||
url := fmt.Sprintf("%s/downloads?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.downloadClient.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var data []DownloadsResponse
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
links := make([]types.DownloadLink, 0)
|
||||
for _, d := range data {
|
||||
links = append(links, types.DownloadLink{
|
||||
Filename: d.Filename,
|
||||
Size: d.Filesize,
|
||||
Link: d.Link,
|
||||
DownloadLink: d.Download,
|
||||
Generated: d.Generated,
|
||||
Id: d.Id,
|
||||
})
|
||||
|
||||
}
|
||||
return links, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadingStatus() []string {
|
||||
return []string{"downloading", "magnet_conversion", "queued", "compressing", "uploading"}
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadUncached() bool {
|
||||
return r.DownloadUncached
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetMountPath() string {
|
||||
return r.MountPath
|
||||
}
|
||||
|
||||
func (r *RealDebrid) DisableAccount(accountId string) {
|
||||
r.accountsMutex.Lock()
|
||||
defer r.accountsMutex.Unlock()
|
||||
if len(r.accounts) == 1 {
|
||||
r.logger.Info().Msgf("Cannot disable last account: %s", accountId)
|
||||
return
|
||||
}
|
||||
r.currentDownloadKey = ""
|
||||
if value, ok := r.accounts[accountId]; ok {
|
||||
value.Disabled = true
|
||||
r.accounts[accountId] = value
|
||||
r.logger.Info().Msgf("Disabled account Index: %s", value.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RealDebrid) ResetActiveDownloadKeys() {
|
||||
r.accountsMutex.Lock()
|
||||
defer r.accountsMutex.Unlock()
|
||||
for key, value := range r.accounts {
|
||||
value.Disabled = false
|
||||
r.accounts[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RealDebrid) getActiveAccounts() []types.Account {
|
||||
r.accountsMutex.RLock()
|
||||
defer r.accountsMutex.RUnlock()
|
||||
accounts := make([]types.Account, 0)
|
||||
|
||||
for _, value := range r.accounts {
|
||||
if value.Disabled {
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, value)
|
||||
}
|
||||
|
||||
// Sort accounts by ID
|
||||
sort.Slice(accounts, func(i, j int) bool {
|
||||
return accounts[i].ID < accounts[j].ID
|
||||
})
|
||||
return accounts
|
||||
}
|
||||
|
||||
func (r *RealDebrid) DeleteDownloadLink(linkId string) error {
|
||||
url := fmt.Sprintf("%s/downloads/delete/%s", r.Host, linkId)
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
if _, err := r.downloadClient.MakeRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
141
pkg/debrid/realdebrid/types.go
Normal file
141
pkg/debrid/realdebrid/types.go
Normal file
@@ -0,0 +1,141 @@
|
||||
package realdebrid
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AvailabilityResponse map[string]Hoster
|
||||
|
||||
func (r *AvailabilityResponse) UnmarshalJSON(data []byte) error {
|
||||
// First, try to unmarshal as an object
|
||||
var objectData map[string]Hoster
|
||||
err := json.Unmarshal(data, &objectData)
|
||||
if err == nil {
|
||||
*r = objectData
|
||||
return nil
|
||||
}
|
||||
|
||||
// If that fails, try to unmarshal as an array
|
||||
var arrayData []map[string]Hoster
|
||||
err = json.Unmarshal(data, &arrayData)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to unmarshal as both object and array: %v", err)
|
||||
}
|
||||
|
||||
// If it's an array, use the first element
|
||||
if len(arrayData) > 0 {
|
||||
*r = arrayData[0]
|
||||
return nil
|
||||
}
|
||||
|
||||
// If it's an empty array, initialize as an empty map
|
||||
*r = make(map[string]Hoster)
|
||||
return nil
|
||||
}
|
||||
|
||||
type Hoster struct {
|
||||
Rd []map[string]FileVariant `json:"rd"`
|
||||
}
|
||||
|
||||
func (h *Hoster) UnmarshalJSON(data []byte) error {
|
||||
// Attempt to unmarshal into the expected structure (an object with an "rd" key)
|
||||
type Alias Hoster
|
||||
var obj Alias
|
||||
if err := json.Unmarshal(data, &obj); err == nil {
|
||||
*h = Hoster(obj)
|
||||
return nil
|
||||
}
|
||||
|
||||
// If unmarshalling into an object fails, check if it's an empty array
|
||||
var arr []interface{}
|
||||
if err := json.Unmarshal(data, &arr); err == nil && len(arr) == 0 {
|
||||
// It's an empty array; initialize with no entries
|
||||
*h = Hoster{Rd: nil}
|
||||
return nil
|
||||
}
|
||||
|
||||
// If both attempts fail, return an error
|
||||
return fmt.Errorf("hoster: cannot unmarshal JSON data: %s", string(data))
|
||||
}
|
||||
|
||||
type FileVariant struct {
|
||||
Filename string `json:"filename"`
|
||||
Filesize int `json:"filesize"`
|
||||
}
|
||||
|
||||
type AddMagnetSchema struct {
|
||||
Id string `json:"id"`
|
||||
Uri string `json:"uri"`
|
||||
}
|
||||
|
||||
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 int64 `json:"original_bytes"`
|
||||
Host string `json:"host"`
|
||||
Split int `json:"split"`
|
||||
Progress float64 `json:"progress"`
|
||||
Status string `json:"status"`
|
||||
Added string `json:"added"`
|
||||
Files []struct {
|
||||
ID int `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Selected int `json:"selected"`
|
||||
} `json:"files"`
|
||||
Links []string `json:"links"`
|
||||
Ended string `json:"ended,omitempty"`
|
||||
Speed int64 `json:"speed,omitempty"`
|
||||
Seeders int `json:"seeders,omitempty"`
|
||||
}
|
||||
|
||||
type UnrestrictResponse struct {
|
||||
Id string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
Link string `json:"link"`
|
||||
Host string `json:"host"`
|
||||
Chunks int `json:"chunks"`
|
||||
Crc int `json:"crc"`
|
||||
Download string `json:"download"`
|
||||
Streamable int `json:"streamable"`
|
||||
}
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
type DownloadsResponse struct {
|
||||
Id string `json:"id"`
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mimeType"`
|
||||
Filesize int64 `json:"filesize"`
|
||||
Link string `json:"link"`
|
||||
Host string `json:"host"`
|
||||
HostIcon string `json:"host_icon"`
|
||||
Chunks int64 `json:"chunks"`
|
||||
Download string `json:"download"`
|
||||
Streamable int `json:"streamable"`
|
||||
Generated time.Time `json:"generated"`
|
||||
}
|
||||
|
||||
type ErrorResponse struct {
|
||||
Error string `json:"error"`
|
||||
ErrorCode int `json:"error_code"`
|
||||
}
|
||||
468
pkg/debrid/torbox/torbox.go
Normal file
468
pkg/debrid/torbox/torbox.go
Normal file
@@ -0,0 +1,468 @@
|
||||
package torbox
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"github.com/sirrobot01/decypharr/pkg/version"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Torbox struct {
|
||||
Name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
accounts map[string]types.Account
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
checkCached bool
|
||||
addSamples bool
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) *Torbox {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
"User-Agent": fmt.Sprintf("Decypharr/%s (%s; %s)", version.GetInfo(), runtime.GOOS, runtime.GOARCH),
|
||||
}
|
||||
_log := logger.New(dc.Name)
|
||||
client := request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithLogger(_log),
|
||||
request.WithProxy(dc.Proxy),
|
||||
)
|
||||
|
||||
accounts := make(map[string]types.Account)
|
||||
for idx, key := range dc.DownloadAPIKeys {
|
||||
id := strconv.Itoa(idx)
|
||||
accounts[id] = types.Account{
|
||||
Name: key,
|
||||
ID: id,
|
||||
Token: key,
|
||||
}
|
||||
}
|
||||
|
||||
return &Torbox{
|
||||
Name: "torbox",
|
||||
Host: "https://api.torbox.app/v1",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: accounts,
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
client: client,
|
||||
MountPath: dc.Folder,
|
||||
logger: _log,
|
||||
checkCached: dc.CheckCached,
|
||||
addSamples: dc.AddSamples,
|
||||
}
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetName() string {
|
||||
return tb.Name
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetLogger() zerolog.Logger {
|
||||
return tb.logger
|
||||
}
|
||||
|
||||
func (tb *Torbox) IsAvailable(hashes []string) map[string]bool {
|
||||
// Check if the infohashes are available in the local cache
|
||||
result := make(map[string]bool)
|
||||
|
||||
// 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, c := range *res.Data {
|
||||
if c.Size > 0 {
|
||||
result[strings.ToUpper(h)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (tb *Torbox) SubmitMagnet(torrent *types.Torrent) (*types.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 utils.Contains(downloading, status):
|
||||
return "downloading"
|
||||
default:
|
||||
return "error"
|
||||
}
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/api/torrents/mylist/?id=%s", tb.Host, torrentId)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := tb.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res InfoResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data := res.Data
|
||||
if data == nil {
|
||||
return nil, fmt.Errorf("error getting torrent")
|
||||
}
|
||||
t := &types.Torrent{
|
||||
Id: strconv.Itoa(data.Id),
|
||||
Name: data.Name,
|
||||
Bytes: data.Size,
|
||||
Folder: data.Name,
|
||||
Progress: data.Progress * 100,
|
||||
Status: getTorboxStatus(data.DownloadState, data.DownloadFinished),
|
||||
Speed: data.DownloadSpeed,
|
||||
Seeders: data.Seeds,
|
||||
Filename: data.Name,
|
||||
OriginalFilename: data.Name,
|
||||
MountPath: tb.MountPath,
|
||||
Debrid: tb.Name,
|
||||
Files: make(map[string]types.File),
|
||||
Added: data.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
cfg := config.Get()
|
||||
for _, f := range data.Files {
|
||||
fileName := filepath.Base(f.Name)
|
||||
if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) {
|
||||
// Skip sample files
|
||||
continue
|
||||
}
|
||||
if !cfg.IsAllowedFile(fileName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: strconv.Itoa(f.Id),
|
||||
Name: fileName,
|
||||
Size: f.Size,
|
||||
Path: fileName,
|
||||
}
|
||||
t.Files[fileName] = file
|
||||
}
|
||||
var cleanPath string
|
||||
if len(t.Files) > 0 {
|
||||
cleanPath = path.Clean(data.Files[0].Name)
|
||||
} else {
|
||||
cleanPath = path.Clean(data.Name)
|
||||
}
|
||||
|
||||
t.OriginalFilename = strings.Split(cleanPath, "/")[0]
|
||||
t.Debrid = tb.Name
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) UpdateTorrent(t *types.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 err
|
||||
}
|
||||
var res InfoResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
return 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
|
||||
cfg := config.Get()
|
||||
for _, f := range data.Files {
|
||||
fileName := filepath.Base(f.Name)
|
||||
if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) {
|
||||
// Skip sample files
|
||||
continue
|
||||
}
|
||||
if !cfg.IsAllowedFile(fileName) {
|
||||
continue
|
||||
}
|
||||
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: strconv.Itoa(f.Id),
|
||||
Name: fileName,
|
||||
Size: f.Size,
|
||||
Path: fileName,
|
||||
}
|
||||
t.Files[fileName] = file
|
||||
}
|
||||
var cleanPath string
|
||||
if len(t.Files) > 0 {
|
||||
cleanPath = path.Clean(data.Files[0].Name)
|
||||
} else {
|
||||
cleanPath = path.Clean(data.Name)
|
||||
}
|
||||
|
||||
t.OriginalFilename = strings.Split(cleanPath, "/")[0]
|
||||
t.Debrid = tb.Name
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) CheckStatus(torrent *types.Torrent, isSymlink bool) (*types.Torrent, error) {
|
||||
for {
|
||||
err := tb.UpdateTorrent(torrent)
|
||||
|
||||
if err != nil || torrent == nil {
|
||||
return torrent, err
|
||||
}
|
||||
status := torrent.Status
|
||||
if status == "downloaded" {
|
||||
tb.logger.Info().Msgf("Torrent: %s downloaded", torrent.Name)
|
||||
if !isSymlink {
|
||||
err = tb.GenerateDownloadLinks(torrent)
|
||||
if err != nil {
|
||||
return torrent, err
|
||||
}
|
||||
}
|
||||
break
|
||||
} else if utils.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
|
||||
return torrent, nil
|
||||
} else {
|
||||
return torrent, fmt.Errorf("torrent: %s has error", torrent.Name)
|
||||
}
|
||||
|
||||
}
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) DeleteTorrent(torrentId string) error {
|
||||
url := fmt.Sprintf("%s/api/torrents/controltorrent/%s", tb.Host, torrentId)
|
||||
payload := map[string]string{"torrent_id": torrentId, "action": "Delete"}
|
||||
jsonPayload, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, bytes.NewBuffer(jsonPayload))
|
||||
if _, err := tb.client.MakeRequest(req); err != nil {
|
||||
return err
|
||||
}
|
||||
tb.logger.Info().Msgf("Torrent %s deleted from Torbox", torrentId)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GenerateDownloadLinks(t *types.Torrent) error {
|
||||
filesCh := make(chan types.File, len(t.Files))
|
||||
errCh := make(chan error, len(t.Files))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(t.Files))
|
||||
for _, file := range t.Files {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
link, err := tb.GetDownloadLink(t, &file)
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
file.DownloadLink = link
|
||||
filesCh <- file
|
||||
}()
|
||||
}
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(filesCh)
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
// Collect results
|
||||
files := make(map[string]types.File, len(t.Files))
|
||||
for file := range filesCh {
|
||||
files[file.Name] = file
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
return err // Return the first error encountered
|
||||
}
|
||||
}
|
||||
|
||||
t.Files = files
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
|
||||
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, err
|
||||
}
|
||||
var data DownloadLinksResponse
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if data.Data == nil {
|
||||
return nil, fmt.Errorf("error getting download links")
|
||||
}
|
||||
link := *data.Data
|
||||
if link == "" {
|
||||
return nil, fmt.Errorf("error getting download links")
|
||||
}
|
||||
return &types.DownloadLink{
|
||||
Link: file.Link,
|
||||
DownloadLink: link,
|
||||
Id: file.Id,
|
||||
AccountId: "0",
|
||||
Generated: time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadingStatus() []string {
|
||||
return []string{"downloading"}
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetCheckCached() bool {
|
||||
return tb.checkCached
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetTorrents() ([]*types.Torrent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadUncached() bool {
|
||||
return tb.DownloadUncached
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloads() (map[string]types.DownloadLink, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) CheckLink(link string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetMountPath() string {
|
||||
return tb.MountPath
|
||||
}
|
||||
|
||||
func (tb *Torbox) DisableAccount(accountId string) {
|
||||
}
|
||||
|
||||
func (tb *Torbox) ResetActiveDownloadKeys() {
|
||||
|
||||
}
|
||||
|
||||
func (tb *Torbox) DeleteDownloadLink(linkId string) error {
|
||||
return nil
|
||||
}
|
||||
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]
|
||||
28
pkg/debrid/types/client.go
Normal file
28
pkg/debrid/types/client.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
SubmitMagnet(tr *Torrent) (*Torrent, error)
|
||||
CheckStatus(tr *Torrent, isSymlink bool) (*Torrent, error)
|
||||
GenerateDownloadLinks(tr *Torrent) error
|
||||
GetDownloadLink(tr *Torrent, file *File) (*DownloadLink, error)
|
||||
DeleteTorrent(torrentId string) error
|
||||
IsAvailable(infohashes []string) map[string]bool
|
||||
GetCheckCached() bool
|
||||
GetDownloadUncached() bool
|
||||
UpdateTorrent(torrent *Torrent) error
|
||||
GetTorrent(torrentId string) (*Torrent, error)
|
||||
GetTorrents() ([]*Torrent, error)
|
||||
GetName() string
|
||||
GetLogger() zerolog.Logger
|
||||
GetDownloadingStatus() []string
|
||||
GetDownloads() (map[string]DownloadLink, error)
|
||||
CheckLink(link string) error
|
||||
GetMountPath() string
|
||||
DisableAccount(string)
|
||||
ResetActiveDownloadKeys()
|
||||
DeleteDownloadLink(linkId string) error
|
||||
}
|
||||
113
pkg/debrid/types/torrent.go
Normal file
113
pkg/debrid/types/torrent.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 map[string]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"`
|
||||
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 DownloadLink struct {
|
||||
Filename string `json:"filename"`
|
||||
Link string `json:"link"`
|
||||
DownloadLink string `json:"download_link"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Size int64 `json:"size"`
|
||||
Id string `json:"id"`
|
||||
AccountId string `json:"account_id"`
|
||||
}
|
||||
|
||||
func (d *DownloadLink) String() string {
|
||||
return d.DownloadLink
|
||||
}
|
||||
|
||||
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.Default()
|
||||
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 {
|
||||
TorrentId string `json:"torrent_id"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
Path string `json:"path"`
|
||||
Link string `json:"link"`
|
||||
DownloadLink *DownloadLink `json:"-"`
|
||||
AccountId string `json:"account_id"`
|
||||
Generated time.Time `json:"generated"`
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
ID string `json:"id"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Name string `json:"name"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
357
pkg/qbit/downloader.go
Normal file
357
pkg/qbit/downloader.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/cavaliergopher/grab/v3"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
debridTypes "github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Download(client *grab.Client, url, filename string, progressCallback func(int64, int64)) error {
|
||||
req, err := grab.NewRequest(filename, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp := client.Do(req)
|
||||
|
||||
t := time.NewTicker(time.Second * 2)
|
||||
defer t.Stop()
|
||||
|
||||
var lastReported int64
|
||||
Loop:
|
||||
for {
|
||||
select {
|
||||
case <-t.C:
|
||||
current := resp.BytesComplete()
|
||||
speed := int64(resp.BytesPerSecond())
|
||||
if current != lastReported {
|
||||
if progressCallback != nil {
|
||||
progressCallback(current-lastReported, speed)
|
||||
}
|
||||
lastReported = current
|
||||
}
|
||||
case <-resp.Done:
|
||||
break Loop
|
||||
}
|
||||
}
|
||||
|
||||
// Report final bytes
|
||||
if progressCallback != nil {
|
||||
progressCallback(resp.BytesComplete()-lastReported, 0)
|
||||
}
|
||||
|
||||
return resp.Err()
|
||||
}
|
||||
|
||||
func (q *QBit) ProcessManualFile(torrent *Torrent) (string, error) {
|
||||
debridTorrent := torrent.DebridTorrent
|
||||
q.logger.Info().Msgf("Downloading %d files...", len(debridTorrent.Files))
|
||||
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
|
||||
|
||||
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)
|
||||
}
|
||||
client := &grab.Client{
|
||||
UserAgent: "Decypharr[QBitTorrent]",
|
||||
HTTPClient: &http.Client{
|
||||
Transport: &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
},
|
||||
},
|
||||
}
|
||||
errChan := make(chan error, len(debridTorrent.Files))
|
||||
for _, file := range debridTorrent.Files {
|
||||
if file.DownloadLink == nil {
|
||||
q.logger.Info().Msgf("No download link found for %s", file.Name)
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
q.downloadSemaphore <- struct{}{}
|
||||
go func(file debridTypes.File) {
|
||||
defer wg.Done()
|
||||
defer func() { <-q.downloadSemaphore }()
|
||||
filename := file.Name
|
||||
|
||||
err := Download(
|
||||
client,
|
||||
file.DownloadLink.DownloadLink,
|
||||
filepath.Join(parent, filename),
|
||||
progressCallback,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
q.logger.Error().Msgf("Failed to download %s: %v", filename, err)
|
||||
errChan <- err
|
||||
} else {
|
||||
q.logger.Info().Msgf("Downloaded %s", filename)
|
||||
}
|
||||
}(file)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
close(errChan)
|
||||
var errors []error
|
||||
for err := range errChan {
|
||||
if err != nil {
|
||||
errors = append(errors, err)
|
||||
}
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
q.logger.Error().Msgf("Errors occurred during download: %v", errors)
|
||||
return
|
||||
}
|
||||
q.logger.Info().Msgf("Downloaded all files for %s", debridTorrent.Name)
|
||||
}
|
||||
|
||||
func (q *QBit) ProcessSymlink(torrent *Torrent) (string, error) {
|
||||
debridTorrent := torrent.DebridTorrent
|
||||
files := debridTorrent.Files
|
||||
if len(files) == 0 {
|
||||
return "", fmt.Errorf("no video files found")
|
||||
}
|
||||
q.logger.Info().Msgf("Checking symlinks for %d files...", len(files))
|
||||
rCloneBase := debridTorrent.MountPath
|
||||
torrentPath, err := q.getTorrentPath(rCloneBase, debridTorrent) // /MyTVShow/
|
||||
// This returns filename.ext for alldebrid instead of the parent folder filename/
|
||||
torrentFolder := torrentPath
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get torrent path: %v", err)
|
||||
}
|
||||
// Check if the torrent path is a file
|
||||
torrentRclonePath := filepath.Join(rCloneBase, torrentPath) // leave it as is
|
||||
if debridTorrent.Debrid == "alldebrid" && utils.IsMediaFile(torrentPath) {
|
||||
// Alldebrid hotfix for single file torrents
|
||||
torrentFolder = utils.RemoveExtension(torrentFolder)
|
||||
torrentRclonePath = rCloneBase // /mnt/rclone/magnets/ // Remove the filename since it's in the root folder
|
||||
}
|
||||
torrentSymlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentFolder) // /mnt/symlinks/{category}/MyTVShow/
|
||||
err = os.MkdirAll(torrentSymlinkPath, os.ModePerm)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create directory: %s: %v", torrentSymlinkPath, err)
|
||||
}
|
||||
|
||||
pending := make(map[string]debridTypes.File)
|
||||
filePaths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
pending[file.Path] = file
|
||||
}
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
timeout := time.After(30 * time.Minute) // Adjust timeout duration as needed
|
||||
|
||||
for len(pending) > 0 {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
for path, file := range pending {
|
||||
fullFilePath := filepath.Join(torrentRclonePath, file.Path)
|
||||
if _, err := os.Stat(fullFilePath); !os.IsNotExist(err) {
|
||||
fileSymlinkPath := filepath.Join(torrentSymlinkPath, file.Name)
|
||||
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
||||
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||
} else {
|
||||
filePaths = append(filePaths, fileSymlinkPath)
|
||||
delete(pending, path)
|
||||
q.logger.Info().Msgf("File is ready: %s", file.Name)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
case <-timeout:
|
||||
q.logger.Warn().Msgf("Timeout waiting for files, %d files still pending", len(pending))
|
||||
return torrentSymlinkPath, fmt.Errorf("timeout waiting for files: %d files still pending", len(pending))
|
||||
}
|
||||
}
|
||||
if q.SkipPreCache {
|
||||
return torrentSymlinkPath, nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
if err := q.preCacheFile(debridTorrent.Name, filePaths); err != nil {
|
||||
q.logger.Error().Msgf("Failed to pre-cache file: %s", err)
|
||||
} else {
|
||||
q.logger.Trace().Msgf("Pre-cached %d files", len(filePaths))
|
||||
}
|
||||
}()
|
||||
return torrentSymlinkPath, nil
|
||||
}
|
||||
|
||||
func (q *QBit) createSymlinksWebdav(debridTorrent *debridTypes.Torrent, rclonePath, torrentFolder string) (string, error) {
|
||||
files := debridTorrent.Files
|
||||
symlinkPath := filepath.Join(q.DownloadFolder, debridTorrent.Arr.Name, torrentFolder) // /mnt/symlinks/{category}/MyTVShow/
|
||||
err := os.MkdirAll(symlinkPath, os.ModePerm)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create directory: %s: %v", symlinkPath, err)
|
||||
}
|
||||
|
||||
remainingFiles := make(map[string]debridTypes.File)
|
||||
for _, file := range files {
|
||||
remainingFiles[file.Name] = file
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
timeout := time.After(30 * time.Minute)
|
||||
filePaths := make([]string, 0, len(files))
|
||||
|
||||
for len(remainingFiles) > 0 {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
entries, err := os.ReadDir(rclonePath)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check which files exist in this batch
|
||||
for _, entry := range entries {
|
||||
filename := entry.Name()
|
||||
if file, exists := remainingFiles[filename]; exists {
|
||||
fullFilePath := filepath.Join(rclonePath, filename)
|
||||
fileSymlinkPath := filepath.Join(symlinkPath, file.Name)
|
||||
|
||||
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
||||
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||
} else {
|
||||
filePaths = append(filePaths, fileSymlinkPath)
|
||||
delete(remainingFiles, filename)
|
||||
q.logger.Info().Msgf("File is ready: %s", file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case <-timeout:
|
||||
q.logger.Warn().Msgf("Timeout waiting for files, %d files still pending", len(remainingFiles))
|
||||
return symlinkPath, fmt.Errorf("timeout waiting for files")
|
||||
}
|
||||
}
|
||||
|
||||
if q.SkipPreCache {
|
||||
return symlinkPath, nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
|
||||
if err := q.preCacheFile(debridTorrent.Name, filePaths); err != nil {
|
||||
q.logger.Error().Msgf("Failed to pre-cache file: %s", err)
|
||||
} else {
|
||||
q.logger.Debug().Msgf("Pre-cached %d files", len(filePaths))
|
||||
}
|
||||
}() // Pre-cache the files in the background
|
||||
// Pre-cache the first 256KB and 1MB of the file
|
||||
return symlinkPath, nil
|
||||
}
|
||||
|
||||
func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debridTypes.Torrent) (string, error) {
|
||||
for {
|
||||
torrentPath, err := debridTorrent.GetMountFolder(rclonePath)
|
||||
if err == nil {
|
||||
q.logger.Debug().Msgf("Found torrent path: %s", torrentPath)
|
||||
return torrentPath, err
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) preCacheFile(name string, filePaths []string) error {
|
||||
q.logger.Trace().Msgf("Pre-caching torrent: %s", name)
|
||||
if len(filePaths) == 0 {
|
||||
return fmt.Errorf("no file paths provided")
|
||||
}
|
||||
|
||||
for _, filePath := range filePaths {
|
||||
err := func(f string) error {
|
||||
|
||||
file, err := os.Open(f)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
// File has probably been moved by arr, return silently
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("failed to open file: %s: %v", f, err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Pre-cache the file header (first 256KB) using 16KB chunks.
|
||||
if err := q.readSmallChunks(file, 0, 256*1024, 16*1024); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := q.readSmallChunks(file, 1024*1024, 64*1024, 16*1024); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) readSmallChunks(file *os.File, startPos int64, totalToRead int, chunkSize int) error {
|
||||
_, err := file.Seek(startPos, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
|
||||
bytesRemaining -= n
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/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, false)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
80
pkg/qbit/import.go
Normal file
80
pkg/qbit/import.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/debrid"
|
||||
"github.com/sirrobot01/decypharr/pkg/service"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
)
|
||||
|
||||
type ImportRequest struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Magnet *utils.Magnet `json:"magnet"`
|
||||
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(magnet *utils.Magnet, arr *arr.Arr, isSymlink, downloadUncached bool) *ImportRequest {
|
||||
return &ImportRequest{
|
||||
ID: uuid.NewString(),
|
||||
Magnet: magnet,
|
||||
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()
|
||||
torrent := createTorrentFromMagnet(i.Magnet, i.Arr.Name, "manual")
|
||||
debridTorrent, err := debrid.ProcessTorrent(svc.Debrid, i.Magnet, i.Arr, i.IsSymlink, i.DownloadUncached)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||
q.Storage.AddOrUpdate(torrent)
|
||||
go q.ProcessFiles(torrent, debridTorrent, i.Arr, i.IsSymlink)
|
||||
return nil
|
||||
}
|
||||
27
pkg/qbit/misc.go
Normal file
27
pkg/qbit/misc.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func createTorrentFromMagnet(magnet *utils.Magnet, category, source string) *Torrent {
|
||||
torrent := &Torrent{
|
||||
ID: "",
|
||||
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
|
||||
}
|
||||
52
pkg/qbit/qbit.go
Normal file
52
pkg/qbit/qbit.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
downloadSemaphore chan struct{}
|
||||
}
|
||||
|
||||
func New() *QBit {
|
||||
_cfg := config.Get()
|
||||
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.New("qbit"),
|
||||
RefreshInterval: refreshInterval,
|
||||
SkipPreCache: cfg.SkipPreCache,
|
||||
downloadSemaphore: make(chan struct{}, cmp.Or(cfg.MaxDownloads, 5)),
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) Reset() {
|
||||
if q.Storage != nil {
|
||||
q.Storage.Reset()
|
||||
}
|
||||
q.Tags = nil
|
||||
close(q.downloadSemaphore)
|
||||
}
|
||||
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
|
||||
}
|
||||
280
pkg/qbit/storage.go
Normal file
280
pkg/qbit/storage.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/service"
|
||||
"os"
|
||||
"sort"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func keyPair(hash, category string) string {
|
||||
if category == "" {
|
||||
category = "uncategorized"
|
||||
}
|
||||
return fmt.Sprintf("%s|%s", hash, category)
|
||||
}
|
||||
|
||||
type Torrents = map[string]*Torrent
|
||||
|
||||
type TorrentStorage struct {
|
||||
torrents Torrents
|
||||
mu sync.RWMutex
|
||||
filename string // Added to store the filename for persistence
|
||||
}
|
||||
|
||||
func loadTorrentsFromJSON(filename string) (Torrents, error) {
|
||||
data, err := os.ReadFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
torrents := make(Torrents)
|
||||
if err := json.Unmarshal(data, &torrents); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func NewTorrentStorage(filename string) *TorrentStorage {
|
||||
// Open the JSON file and read the data
|
||||
torrents, err := loadTorrentsFromJSON(filename)
|
||||
if err != nil {
|
||||
torrents = make(Torrents)
|
||||
}
|
||||
// Create a new TorrentStorage
|
||||
return &TorrentStorage{
|
||||
torrents: torrents,
|
||||
filename: filename,
|
||||
}
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Add(torrent *Torrent) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
|
||||
go func() {
|
||||
err := ts.saveToFile()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) AddOrUpdate(torrent *Torrent) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
|
||||
go func() {
|
||||
err := ts.saveToFile()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Get(hash, category string) *Torrent {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
torrent, exists := ts.torrents[keyPair(hash, category)]
|
||||
if !exists && category == "" {
|
||||
// Try to find the torrent without knowing the category
|
||||
for _, t := range ts.torrents {
|
||||
if t.Hash == hash {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return torrent
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string) []*Torrent {
|
||||
ts.mu.RLock()
|
||||
defer ts.mu.RUnlock()
|
||||
torrents := make([]*Torrent, 0)
|
||||
for _, torrent := range ts.torrents {
|
||||
if category != "" && torrent.Category != category {
|
||||
continue
|
||||
}
|
||||
if filter != "" && torrent.State != filter {
|
||||
continue
|
||||
}
|
||||
torrents = append(torrents, torrent)
|
||||
}
|
||||
|
||||
if len(hashes) > 0 {
|
||||
filtered := make([]*Torrent, 0)
|
||||
for _, hash := range hashes {
|
||||
for _, torrent := range torrents {
|
||||
if torrent.Hash == hash {
|
||||
filtered = append(filtered, torrent)
|
||||
}
|
||||
}
|
||||
}
|
||||
torrents = filtered
|
||||
}
|
||||
return torrents
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) GetAllSorted(category string, filter string, hashes []string, sortBy string, ascending bool) []*Torrent {
|
||||
torrents := ts.GetAll(category, filter, hashes)
|
||||
if sortBy != "" {
|
||||
sort.Slice(torrents, func(i, j int) bool {
|
||||
// If ascending is false, swap i and j to get descending order
|
||||
if !ascending {
|
||||
i, j = j, i
|
||||
}
|
||||
|
||||
switch sortBy {
|
||||
case "name":
|
||||
return torrents[i].Name < torrents[j].Name
|
||||
case "size":
|
||||
return torrents[i].Size < torrents[j].Size
|
||||
case "added_on":
|
||||
return torrents[i].AddedOn < torrents[j].AddedOn
|
||||
case "completed":
|
||||
return torrents[i].Completed < torrents[j].Completed
|
||||
case "progress":
|
||||
return torrents[i].Progress < torrents[j].Progress
|
||||
case "state":
|
||||
return torrents[i].State < torrents[j].State
|
||||
case "category":
|
||||
return torrents[i].Category < torrents[j].Category
|
||||
case "dlspeed":
|
||||
return torrents[i].Dlspeed < torrents[j].Dlspeed
|
||||
case "upspeed":
|
||||
return torrents[i].Upspeed < torrents[j].Upspeed
|
||||
case "ratio":
|
||||
return torrents[i].Ratio < torrents[j].Ratio
|
||||
default:
|
||||
// Default sort by added_on
|
||||
return torrents[i].AddedOn < torrents[j].AddedOn
|
||||
}
|
||||
})
|
||||
}
|
||||
return torrents
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Update(torrent *Torrent) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.torrents[keyPair(torrent.Hash, torrent.Category)] = torrent
|
||||
go func() {
|
||||
err := ts.saveToFile()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Delete(hash, category string, removeFromDebrid bool) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
key := keyPair(hash, category)
|
||||
torrent, exists := ts.torrents[key]
|
||||
if !exists && category == "" {
|
||||
// Remove the torrent without knowing the category
|
||||
for k, t := range ts.torrents {
|
||||
if t.Hash == hash {
|
||||
key = k
|
||||
torrent = t
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if torrent == nil {
|
||||
return
|
||||
}
|
||||
if removeFromDebrid && torrent.ID != "" && torrent.Debrid != "" {
|
||||
dbClient := service.GetDebrid().GetClient(torrent.Debrid)
|
||||
if dbClient != nil {
|
||||
err := dbClient.DeleteTorrent(torrent.ID)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete(ts.torrents, key)
|
||||
|
||||
// Delete the torrent folder
|
||||
if 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) DeleteMultiple(hashes []string, removeFromDebrid bool) {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
toDelete := make(map[string]string)
|
||||
|
||||
for _, hash := range hashes {
|
||||
for key, torrent := range ts.torrents {
|
||||
if torrent == nil {
|
||||
continue
|
||||
}
|
||||
if torrent.Hash == hash {
|
||||
if removeFromDebrid && torrent.ID != "" && torrent.Debrid != "" {
|
||||
toDelete[torrent.ID] = torrent.Debrid
|
||||
}
|
||||
delete(ts.torrents, key)
|
||||
if torrent.ContentPath != "" {
|
||||
err := os.RemoveAll(torrent.ContentPath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
err := ts.saveToFile()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for id, debrid := range toDelete {
|
||||
dbClient := service.GetDebrid().GetClient(debrid)
|
||||
if dbClient == nil {
|
||||
continue
|
||||
}
|
||||
err := dbClient.DeleteTorrent(id)
|
||||
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()
|
||||
data, err := json.MarshalIndent(ts.torrents, "", " ")
|
||||
ts.mu.RUnlock()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(ts.filename, data, 0644)
|
||||
}
|
||||
|
||||
func (ts *TorrentStorage) Reset() {
|
||||
ts.mu.Lock()
|
||||
defer ts.mu.Unlock()
|
||||
ts.torrents = make(Torrents)
|
||||
}
|
||||
343
pkg/qbit/torrent.go
Normal file
343
pkg/qbit/torrent.go
Normal file
@@ -0,0 +1,343 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/debrid"
|
||||
debridTypes "github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"github.com/sirrobot01/decypharr/pkg/service"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 := debrid.ProcessTorrent(svc.Debrid, magnet, a, isSymlink, false)
|
||||
if err != nil || debridTorrent == nil {
|
||||
if err == nil {
|
||||
err = fmt.Errorf("failed to process torrent")
|
||||
}
|
||||
return err
|
||||
}
|
||||
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
|
||||
q.Storage.AddOrUpdate(torrent)
|
||||
go q.ProcessFiles(torrent, debridTorrent, a, isSymlink) // We can send async for file processing not to delay the response
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debridTypes.Torrent, arr *arr.Arr, isSymlink bool) {
|
||||
svc := service.GetService()
|
||||
client := svc.Debrid.GetClient(debridTorrent.Debrid)
|
||||
downloadingStatuses := client.GetDownloadingStatus()
|
||||
for debridTorrent.Status != "downloaded" {
|
||||
q.logger.Debug().Msgf("%s <- (%s) Download Progress: %.2f%%", debridTorrent.Debrid, debridTorrent.Name, debridTorrent.Progress)
|
||||
dbT, err := client.CheckStatus(debridTorrent, isSymlink)
|
||||
if err != nil {
|
||||
if dbT != nil && dbT.Id != "" {
|
||||
// Delete the torrent if it was not downloaded
|
||||
go func() {
|
||||
_ = client.DeleteTorrent(dbT.Id)
|
||||
}()
|
||||
}
|
||||
q.logger.Error().Msgf("Error checking status: %v", err)
|
||||
q.MarkAsFailed(torrent)
|
||||
go func() {
|
||||
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 debridTorrent.Status == "downloaded" || !utils.Contains(downloadingStatuses, debridTorrent.Status) {
|
||||
break
|
||||
}
|
||||
if !utils.Contains(client.GetDownloadingStatus(), debridTorrent.Status) {
|
||||
break
|
||||
}
|
||||
time.Sleep(time.Duration(q.RefreshInterval) * time.Second)
|
||||
}
|
||||
var torrentSymlinkPath string
|
||||
var err error
|
||||
debridTorrent.Arr = arr
|
||||
|
||||
// Check if debrid supports webdav by checking cache
|
||||
if isSymlink {
|
||||
timer := time.Now()
|
||||
cache, useWebdav := svc.Debrid.Caches[debridTorrent.Debrid]
|
||||
if useWebdav {
|
||||
q.logger.Info().Msgf("Using internal webdav for %s", debridTorrent.Debrid)
|
||||
|
||||
// Use webdav to download the file
|
||||
|
||||
if err := cache.AddTorrent(debridTorrent); err != nil {
|
||||
q.logger.Error().Msgf("Error adding torrent to cache: %v", err)
|
||||
q.MarkAsFailed(torrent)
|
||||
return
|
||||
}
|
||||
|
||||
rclonePath := filepath.Join(debridTorrent.MountPath, cache.GetTorrentFolder(debridTorrent)) // /mnt/remote/realdebrid/MyTVShow
|
||||
torrentFolderNoExt := utils.RemoveExtension(debridTorrent.Name)
|
||||
torrentSymlinkPath, err = q.createSymlinksWebdav(debridTorrent, rclonePath, torrentFolderNoExt) // /mnt/symlinks/{category}/MyTVShow/
|
||||
|
||||
} else {
|
||||
// User is using either zurg or debrid webdav
|
||||
torrentSymlinkPath, err = q.ProcessSymlink(torrent) // /mnt/symlinks/{category}/MyTVShow/
|
||||
}
|
||||
q.logger.Info().Msgf("Adding %s took %s", debridTorrent.Name, time.Since(timer))
|
||||
} else {
|
||||
torrentSymlinkPath, err = q.ProcessManualFile(torrent)
|
||||
}
|
||||
if err != nil {
|
||||
q.MarkAsFailed(torrent)
|
||||
go func() {
|
||||
_ = client.DeleteTorrent(debridTorrent.Id)
|
||||
}()
|
||||
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)
|
||||
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) UpdateTorrentMin(t *Torrent, debridTorrent *debridTypes.Torrent) *Torrent {
|
||||
if debridTorrent == nil {
|
||||
return t
|
||||
}
|
||||
|
||||
addedOn, err := time.Parse(time.RFC3339, debridTorrent.Added)
|
||||
if err != nil {
|
||||
addedOn = time.Now()
|
||||
}
|
||||
totalSize := debridTorrent.Bytes
|
||||
progress := (cmp.Or(debridTorrent.Progress, 0.0)) / 100.0
|
||||
sizeCompleted := int64(float64(totalSize) * progress)
|
||||
|
||||
var speed int64
|
||||
if debridTorrent.Speed != 0 {
|
||||
speed = debridTorrent.Speed
|
||||
}
|
||||
var eta int
|
||||
if speed != 0 {
|
||||
eta = int((totalSize - sizeCompleted) / speed)
|
||||
}
|
||||
t.ID = debridTorrent.Id
|
||||
t.Name = debridTorrent.Name
|
||||
t.AddedOn = addedOn.Unix()
|
||||
t.DebridTorrent = debridTorrent
|
||||
t.Debrid = debridTorrent.Debrid
|
||||
t.Size = totalSize
|
||||
t.Completed = sizeCompleted
|
||||
t.Downloaded = sizeCompleted
|
||||
t.DownloadedSession = sizeCompleted
|
||||
t.Uploaded = sizeCompleted
|
||||
t.UploadedSession = sizeCompleted
|
||||
t.AmountLeft = totalSize - sizeCompleted
|
||||
t.Progress = progress
|
||||
t.Eta = eta
|
||||
t.Dlspeed = speed
|
||||
t.Upspeed = speed
|
||||
t.SavePath = filepath.Join(q.DownloadFolder, t.Category) + string(os.PathSeparator)
|
||||
t.ContentPath = filepath.Join(t.SavePath, t.Name) + string(os.PathSeparator)
|
||||
return t
|
||||
}
|
||||
|
||||
func (q *QBit) UpdateTorrent(t *Torrent, debridTorrent *debridTypes.Torrent) *Torrent {
|
||||
if debridTorrent == nil {
|
||||
return t
|
||||
}
|
||||
|
||||
if debridClient := service.GetDebrid().GetClient(debridTorrent.Debrid); debridClient != nil {
|
||||
if debridTorrent.Status != "downloaded" {
|
||||
_ = debridClient.UpdateTorrent(debridTorrent)
|
||||
}
|
||||
}
|
||||
t = q.UpdateTorrentMin(t, debridTorrent)
|
||||
t.ContentPath = t.TorrentPath + string(os.PathSeparator)
|
||||
|
||||
if t.IsReady() {
|
||||
t.State = "pausedUP"
|
||||
q.Storage.Update(t)
|
||||
return t
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
if t.IsReady() {
|
||||
t.State = "pausedUP"
|
||||
q.Storage.Update(t)
|
||||
return t
|
||||
}
|
||||
updatedT := q.UpdateTorrent(t, debridTorrent)
|
||||
t = updatedT
|
||||
|
||||
case <-time.After(10 * time.Minute): // Add a timeout
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) ResumeTorrent(t *Torrent) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) PauseTorrent(t *Torrent) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) RefreshTorrent(t *Torrent) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) GetTorrentProperties(t *Torrent) *TorrentProperties {
|
||||
return &TorrentProperties{
|
||||
AdditionDate: t.AddedOn,
|
||||
Comment: "Debrid Blackhole <https://github.com/sirrobot01/decypharr>",
|
||||
CreatedBy: "Debrid Blackhole <https://github.com/sirrobot01/decypharr>",
|
||||
CreationDate: t.AddedOn,
|
||||
DlLimit: -1,
|
||||
UpLimit: -1,
|
||||
DlSpeed: t.Dlspeed,
|
||||
UpSpeed: t.Upspeed,
|
||||
TotalSize: t.Size,
|
||||
TotalUploaded: t.Uploaded,
|
||||
TotalDownloaded: t.Downloaded,
|
||||
TotalUploadedSession: t.UploadedSession,
|
||||
TotalDownloadedSession: t.DownloadedSession,
|
||||
LastSeen: time.Now().Unix(),
|
||||
NbConnectionsLimit: 100,
|
||||
Peers: 0,
|
||||
PeersTotal: 2,
|
||||
SeedingTime: 1,
|
||||
Seeds: 100,
|
||||
ShareRatio: 100,
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) GetTorrentFiles(t *Torrent) []*TorrentFile {
|
||||
files := make([]*TorrentFile, 0)
|
||||
if t.DebridTorrent == nil {
|
||||
return files
|
||||
}
|
||||
for _, file := range t.DebridTorrent.Files {
|
||||
files = append(files, &TorrentFile{
|
||||
Name: file.Path,
|
||||
Size: file.Size,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (q *QBit) SetTorrentTags(t *Torrent, tags []string) bool {
|
||||
torrentTags := strings.Split(t.Tags, ",")
|
||||
for _, tag := range tags {
|
||||
if tag == "" {
|
||||
continue
|
||||
}
|
||||
if !utils.Contains(torrentTags, tag) {
|
||||
torrentTags = append(torrentTags, tag)
|
||||
}
|
||||
if !utils.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 !utils.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
|
||||
}
|
||||
442
pkg/qbit/types.go
Normal file
442
pkg/qbit/types.go
Normal file
@@ -0,0 +1,442 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"sync"
|
||||
)
|
||||
|
||||
type BuildInfo struct {
|
||||
Libtorrent string `json:"libtorrent"`
|
||||
Bitness int `json:"bitness"`
|
||||
Boost string `json:"boost"`
|
||||
Openssl string `json:"openssl"`
|
||||
Qt string `json:"qt"`
|
||||
Zlib string `json:"zlib"`
|
||||
}
|
||||
|
||||
type AppPreferences struct {
|
||||
AddTrackers string `json:"add_trackers"`
|
||||
AddTrackersEnabled bool `json:"add_trackers_enabled"`
|
||||
AltDlLimit int `json:"alt_dl_limit"`
|
||||
AltUpLimit int `json:"alt_up_limit"`
|
||||
AlternativeWebuiEnabled bool `json:"alternative_webui_enabled"`
|
||||
AlternativeWebuiPath string `json:"alternative_webui_path"`
|
||||
AnnounceIp string `json:"announce_ip"`
|
||||
AnnounceToAllTiers bool `json:"announce_to_all_tiers"`
|
||||
AnnounceToAllTrackers bool `json:"announce_to_all_trackers"`
|
||||
AnonymousMode bool `json:"anonymous_mode"`
|
||||
AsyncIoThreads int `json:"async_io_threads"`
|
||||
AutoDeleteMode int `json:"auto_delete_mode"`
|
||||
AutoTmmEnabled bool `json:"auto_tmm_enabled"`
|
||||
AutorunEnabled bool `json:"autorun_enabled"`
|
||||
AutorunProgram string `json:"autorun_program"`
|
||||
BannedIPs string `json:"banned_IPs"`
|
||||
BittorrentProtocol int `json:"bittorrent_protocol"`
|
||||
BypassAuthSubnetWhitelist string `json:"bypass_auth_subnet_whitelist"`
|
||||
BypassAuthSubnetWhitelistEnabled bool `json:"bypass_auth_subnet_whitelist_enabled"`
|
||||
BypassLocalAuth bool `json:"bypass_local_auth"`
|
||||
CategoryChangedTmmEnabled bool `json:"category_changed_tmm_enabled"`
|
||||
CheckingMemoryUse int `json:"checking_memory_use"`
|
||||
CreateSubfolderEnabled bool `json:"create_subfolder_enabled"`
|
||||
CurrentInterfaceAddress string `json:"current_interface_address"`
|
||||
CurrentNetworkInterface string `json:"current_network_interface"`
|
||||
Dht bool `json:"dht"`
|
||||
DiskCache int `json:"disk_cache"`
|
||||
DiskCacheTtl int `json:"disk_cache_ttl"`
|
||||
DlLimit int `json:"dl_limit"`
|
||||
DontCountSlowTorrents bool `json:"dont_count_slow_torrents"`
|
||||
DyndnsDomain string `json:"dyndns_domain"`
|
||||
DyndnsEnabled bool `json:"dyndns_enabled"`
|
||||
DyndnsPassword string `json:"dyndns_password"`
|
||||
DyndnsService int `json:"dyndns_service"`
|
||||
DyndnsUsername string `json:"dyndns_username"`
|
||||
EmbeddedTrackerPort int `json:"embedded_tracker_port"`
|
||||
EnableCoalesceReadWrite bool `json:"enable_coalesce_read_write"`
|
||||
EnableEmbeddedTracker bool `json:"enable_embedded_tracker"`
|
||||
EnableMultiConnectionsFromSameIp bool `json:"enable_multi_connections_from_same_ip"`
|
||||
EnableOsCache bool `json:"enable_os_cache"`
|
||||
EnablePieceExtentAffinity bool `json:"enable_piece_extent_affinity"`
|
||||
EnableSuperSeeding bool `json:"enable_super_seeding"`
|
||||
EnableUploadSuggestions bool `json:"enable_upload_suggestions"`
|
||||
Encryption int `json:"encryption"`
|
||||
ExportDir string `json:"export_dir"`
|
||||
ExportDirFin string `json:"export_dir_fin"`
|
||||
FilePoolSize int `json:"file_pool_size"`
|
||||
IncompleteFilesExt bool `json:"incomplete_files_ext"`
|
||||
IpFilterEnabled bool `json:"ip_filter_enabled"`
|
||||
IpFilterPath string `json:"ip_filter_path"`
|
||||
IpFilterTrackers bool `json:"ip_filter_trackers"`
|
||||
LimitLanPeers bool `json:"limit_lan_peers"`
|
||||
LimitTcpOverhead bool `json:"limit_tcp_overhead"`
|
||||
LimitUtpRate bool `json:"limit_utp_rate"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
Locale string `json:"locale"`
|
||||
Lsd bool `json:"lsd"`
|
||||
MailNotificationAuthEnabled bool `json:"mail_notification_auth_enabled"`
|
||||
MailNotificationEmail string `json:"mail_notification_email"`
|
||||
MailNotificationEnabled bool `json:"mail_notification_enabled"`
|
||||
MailNotificationPassword string `json:"mail_notification_password"`
|
||||
MailNotificationSender string `json:"mail_notification_sender"`
|
||||
MailNotificationSmtp string `json:"mail_notification_smtp"`
|
||||
MailNotificationSslEnabled bool `json:"mail_notification_ssl_enabled"`
|
||||
MailNotificationUsername string `json:"mail_notification_username"`
|
||||
MaxActiveDownloads int `json:"max_active_downloads"`
|
||||
MaxActiveTorrents int `json:"max_active_torrents"`
|
||||
MaxActiveUploads int `json:"max_active_uploads"`
|
||||
MaxConnec int `json:"max_connec"`
|
||||
MaxConnecPerTorrent int `json:"max_connec_per_torrent"`
|
||||
MaxRatio int `json:"max_ratio"`
|
||||
MaxRatioAct int `json:"max_ratio_act"`
|
||||
MaxRatioEnabled bool `json:"max_ratio_enabled"`
|
||||
MaxSeedingTime int `json:"max_seeding_time"`
|
||||
MaxSeedingTimeEnabled bool `json:"max_seeding_time_enabled"`
|
||||
MaxUploads int `json:"max_uploads"`
|
||||
MaxUploadsPerTorrent int `json:"max_uploads_per_torrent"`
|
||||
OutgoingPortsMax int `json:"outgoing_ports_max"`
|
||||
OutgoingPortsMin int `json:"outgoing_ports_min"`
|
||||
Pex bool `json:"pex"`
|
||||
PreallocateAll bool `json:"preallocate_all"`
|
||||
ProxyAuthEnabled bool `json:"proxy_auth_enabled"`
|
||||
ProxyIp string `json:"proxy_ip"`
|
||||
ProxyPassword string `json:"proxy_password"`
|
||||
ProxyPeerConnections bool `json:"proxy_peer_connections"`
|
||||
ProxyPort int `json:"proxy_port"`
|
||||
ProxyTorrentsOnly bool `json:"proxy_torrents_only"`
|
||||
ProxyType int `json:"proxy_type"`
|
||||
ProxyUsername string `json:"proxy_username"`
|
||||
QueueingEnabled bool `json:"queueing_enabled"`
|
||||
RandomPort bool `json:"random_port"`
|
||||
RecheckCompletedTorrents bool `json:"recheck_completed_torrents"`
|
||||
ResolvePeerCountries bool `json:"resolve_peer_countries"`
|
||||
RssAutoDownloadingEnabled bool `json:"rss_auto_downloading_enabled"`
|
||||
RssMaxArticlesPerFeed int `json:"rss_max_articles_per_feed"`
|
||||
RssProcessingEnabled bool `json:"rss_processing_enabled"`
|
||||
RssRefreshInterval int `json:"rss_refresh_interval"`
|
||||
SavePath string `json:"save_path"`
|
||||
SavePathChangedTmmEnabled bool `json:"save_path_changed_tmm_enabled"`
|
||||
SaveResumeDataInterval int `json:"save_resume_data_interval"`
|
||||
ScanDirs ScanDirs `json:"scan_dirs"`
|
||||
ScheduleFromHour int `json:"schedule_from_hour"`
|
||||
ScheduleFromMin int `json:"schedule_from_min"`
|
||||
ScheduleToHour int `json:"schedule_to_hour"`
|
||||
ScheduleToMin int `json:"schedule_to_min"`
|
||||
SchedulerDays int `json:"scheduler_days"`
|
||||
SchedulerEnabled bool `json:"scheduler_enabled"`
|
||||
SendBufferLowWatermark int `json:"send_buffer_low_watermark"`
|
||||
SendBufferWatermark int `json:"send_buffer_watermark"`
|
||||
SendBufferWatermarkFactor int `json:"send_buffer_watermark_factor"`
|
||||
SlowTorrentDlRateThreshold int `json:"slow_torrent_dl_rate_threshold"`
|
||||
SlowTorrentInactiveTimer int `json:"slow_torrent_inactive_timer"`
|
||||
SlowTorrentUlRateThreshold int `json:"slow_torrent_ul_rate_threshold"`
|
||||
SocketBacklogSize int `json:"socket_backlog_size"`
|
||||
StartPausedEnabled bool `json:"start_paused_enabled"`
|
||||
StopTrackerTimeout int `json:"stop_tracker_timeout"`
|
||||
TempPath string `json:"temp_path"`
|
||||
TempPathEnabled bool `json:"temp_path_enabled"`
|
||||
TorrentChangedTmmEnabled bool `json:"torrent_changed_tmm_enabled"`
|
||||
UpLimit int `json:"up_limit"`
|
||||
UploadChokingAlgorithm int `json:"upload_choking_algorithm"`
|
||||
UploadSlotsBehavior int `json:"upload_slots_behavior"`
|
||||
Upnp bool `json:"upnp"`
|
||||
UpnpLeaseDuration int `json:"upnp_lease_duration"`
|
||||
UseHttps bool `json:"use_https"`
|
||||
UtpTcpMixedMode int `json:"utp_tcp_mixed_mode"`
|
||||
WebUiAddress string `json:"web_ui_address"`
|
||||
WebUiBanDuration int `json:"web_ui_ban_duration"`
|
||||
WebUiClickjackingProtectionEnabled bool `json:"web_ui_clickjacking_protection_enabled"`
|
||||
WebUiCsrfProtectionEnabled bool `json:"web_ui_csrf_protection_enabled"`
|
||||
WebUiDomainList string `json:"web_ui_domain_list"`
|
||||
WebUiHostHeaderValidationEnabled bool `json:"web_ui_host_header_validation_enabled"`
|
||||
WebUiHttpsCertPath string `json:"web_ui_https_cert_path"`
|
||||
WebUiHttpsKeyPath string `json:"web_ui_https_key_path"`
|
||||
WebUiMaxAuthFailCount int `json:"web_ui_max_auth_fail_count"`
|
||||
WebUiPort int `json:"web_ui_port"`
|
||||
WebUiSecureCookieEnabled bool `json:"web_ui_secure_cookie_enabled"`
|
||||
WebUiSessionTimeout int `json:"web_ui_session_timeout"`
|
||||
WebUiUpnp bool `json:"web_ui_upnp"`
|
||||
WebUiUsername string `json:"web_ui_username"`
|
||||
WebUiPassword string `json:"web_ui_password"`
|
||||
SSLKey string `json:"ssl_key"`
|
||||
SSLCert string `json:"ssl_cert"`
|
||||
RSSDownloadRepack string `json:"rss_download_repack_proper_episodes"`
|
||||
RSSSmartEpisodeFilters string `json:"rss_smart_episode_filters"`
|
||||
WebUiUseCustomHttpHeaders bool `json:"web_ui_use_custom_http_headers"`
|
||||
WebUiUseCustomHttpHeadersEnabled bool `json:"web_ui_use_custom_http_headers_enabled"`
|
||||
}
|
||||
|
||||
type ScanDirs struct{}
|
||||
|
||||
type TorrentCategory struct {
|
||||
Name string `json:"name"`
|
||||
SavePath string `json:"savePath"`
|
||||
}
|
||||
|
||||
type Torrent struct {
|
||||
ID string `json:"id"`
|
||||
DebridTorrent *types.Torrent `json:"-"`
|
||||
Debrid string `json:"debrid"`
|
||||
TorrentPath string `json:"-"`
|
||||
|
||||
AddedOn int64 `json:"added_on,omitempty"`
|
||||
AmountLeft int64 `json:"amount_left"`
|
||||
AutoTmm bool `json:"auto_tmm"`
|
||||
Availability float64 `json:"availability,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Completed int64 `json:"completed"`
|
||||
CompletionOn int `json:"completion_on,omitempty"`
|
||||
ContentPath string `json:"content_path"`
|
||||
DlLimit int `json:"dl_limit"`
|
||||
Dlspeed 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 int `json:"max_ratio,omitempty"`
|
||||
MaxSeedingTime int `json:"max_seeding_time,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
NumComplete int `json:"num_complete,omitempty"`
|
||||
NumIncomplete int `json:"num_incomplete,omitempty"`
|
||||
NumLeechs int `json:"num_leechs,omitempty"`
|
||||
NumSeeds int `json:"num_seeds,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
Progress float64 `json:"progress"`
|
||||
Ratio int `json:"ratio,omitempty"`
|
||||
RatioLimit int `json:"ratio_limit,omitempty"`
|
||||
SavePath string `json:"save_path"`
|
||||
SeedingTimeLimit int `json:"seeding_time_limit,omitempty"`
|
||||
SeenComplete int64 `json:"seen_complete,omitempty"`
|
||||
SeqDl bool `json:"seq_dl"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
State string `json:"state,omitempty"`
|
||||
SuperSeeding bool `json:"super_seeding"`
|
||||
Tags string `json:"tags,omitempty"`
|
||||
TimeActive int `json:"time_active,omitempty"`
|
||||
TotalSize int64 `json:"total_size,omitempty"`
|
||||
Tracker string `json:"tracker,omitempty"`
|
||||
UpLimit int64 `json:"up_limit,omitempty"`
|
||||
Uploaded int64 `json:"uploaded,omitempty"`
|
||||
UploadedSession int64 `json:"uploaded_session,omitempty"`
|
||||
Upspeed int64 `json:"upspeed,omitempty"`
|
||||
Source string `json:"source,omitempty"`
|
||||
|
||||
Mu sync.Mutex `json:"-"`
|
||||
}
|
||||
|
||||
func (t *Torrent) IsReady() bool {
|
||||
return (t.AmountLeft <= 0 || t.Progress == 1) && 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 int `json:"dl_limit,omitempty"`
|
||||
DlSpeed int64 `json:"dl_speed,omitempty"`
|
||||
DlSpeedAvg int `json:"dl_speed_avg,omitempty"`
|
||||
Eta int `json:"eta,omitempty"`
|
||||
LastSeen int64 `json:"last_seen,omitempty"`
|
||||
NbConnections int `json:"nb_connections,omitempty"`
|
||||
NbConnectionsLimit int `json:"nb_connections_limit,omitempty"`
|
||||
Peers int `json:"peers,omitempty"`
|
||||
PeersTotal int `json:"peers_total,omitempty"`
|
||||
PieceSize int64 `json:"piece_size,omitempty"`
|
||||
PiecesHave int64 `json:"pieces_have,omitempty"`
|
||||
PiecesNum int64 `json:"pieces_num,omitempty"`
|
||||
Reannounce int `json:"reannounce,omitempty"`
|
||||
SavePath string `json:"save_path,omitempty"`
|
||||
SeedingTime int `json:"seeding_time,omitempty"`
|
||||
Seeds int `json:"seeds,omitempty"`
|
||||
SeedsTotal int `json:"seeds_total,omitempty"`
|
||||
ShareRatio int `json:"share_ratio,omitempty"`
|
||||
TimeElapsed int64 `json:"time_elapsed,omitempty"`
|
||||
TotalDownloaded int64 `json:"total_downloaded,omitempty"`
|
||||
TotalDownloadedSession int64 `json:"total_downloaded_session,omitempty"`
|
||||
TotalSize int64 `json:"total_size,omitempty"`
|
||||
TotalUploaded int64 `json:"total_uploaded,omitempty"`
|
||||
TotalUploadedSession int64 `json:"total_uploaded_session,omitempty"`
|
||||
TotalWasted int64 `json:"total_wasted,omitempty"`
|
||||
UpLimit int `json:"up_limit,omitempty"`
|
||||
UpSpeed int64 `json:"up_speed,omitempty"`
|
||||
UpSpeedAvg int `json:"up_speed_avg,omitempty"`
|
||||
}
|
||||
|
||||
type TorrentFile struct {
|
||||
Index int `json:"index,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Size int64 `json:"size,omitempty"`
|
||||
Progress int `json:"progress,omitempty"`
|
||||
Priority int `json:"priority,omitempty"`
|
||||
IsSeed bool `json:"is_seed,omitempty"`
|
||||
PieceRange []int `json:"piece_range,omitempty"`
|
||||
Availability float64 `json:"availability,omitempty"`
|
||||
}
|
||||
|
||||
func NewAppPreferences() *AppPreferences {
|
||||
preferences := &AppPreferences{
|
||||
AddTrackers: "",
|
||||
AddTrackersEnabled: false,
|
||||
AltDlLimit: 10240,
|
||||
AltUpLimit: 10240,
|
||||
AlternativeWebuiEnabled: false,
|
||||
AlternativeWebuiPath: "",
|
||||
AnnounceIp: "",
|
||||
AnnounceToAllTiers: true,
|
||||
AnnounceToAllTrackers: false,
|
||||
AnonymousMode: false,
|
||||
AsyncIoThreads: 4,
|
||||
AutoDeleteMode: 0,
|
||||
AutoTmmEnabled: false,
|
||||
AutorunEnabled: false,
|
||||
AutorunProgram: "",
|
||||
BannedIPs: "",
|
||||
BittorrentProtocol: 0,
|
||||
BypassAuthSubnetWhitelist: "",
|
||||
BypassAuthSubnetWhitelistEnabled: false,
|
||||
BypassLocalAuth: false,
|
||||
CategoryChangedTmmEnabled: false,
|
||||
CheckingMemoryUse: 32,
|
||||
CreateSubfolderEnabled: true,
|
||||
CurrentInterfaceAddress: "",
|
||||
CurrentNetworkInterface: "",
|
||||
Dht: true,
|
||||
DiskCache: -1,
|
||||
DiskCacheTtl: 60,
|
||||
DlLimit: 0,
|
||||
DontCountSlowTorrents: false,
|
||||
DyndnsDomain: "changeme.dyndns.org",
|
||||
DyndnsEnabled: false,
|
||||
DyndnsPassword: "",
|
||||
DyndnsService: 0,
|
||||
DyndnsUsername: "",
|
||||
EmbeddedTrackerPort: 9000,
|
||||
EnableCoalesceReadWrite: true,
|
||||
EnableEmbeddedTracker: false,
|
||||
EnableMultiConnectionsFromSameIp: false,
|
||||
EnableOsCache: true,
|
||||
EnablePieceExtentAffinity: false,
|
||||
EnableSuperSeeding: false,
|
||||
EnableUploadSuggestions: false,
|
||||
Encryption: 0,
|
||||
ExportDir: "",
|
||||
ExportDirFin: "",
|
||||
FilePoolSize: 40,
|
||||
IncompleteFilesExt: false,
|
||||
IpFilterEnabled: false,
|
||||
IpFilterPath: "",
|
||||
IpFilterTrackers: false,
|
||||
LimitLanPeers: true,
|
||||
LimitTcpOverhead: false,
|
||||
LimitUtpRate: true,
|
||||
ListenPort: 31193,
|
||||
Locale: "en",
|
||||
Lsd: true,
|
||||
MailNotificationAuthEnabled: false,
|
||||
MailNotificationEmail: "",
|
||||
MailNotificationEnabled: false,
|
||||
MailNotificationPassword: "",
|
||||
MailNotificationSender: "qBittorrentNotification@example.com",
|
||||
MailNotificationSmtp: "smtp.changeme.com",
|
||||
MailNotificationSslEnabled: false,
|
||||
MailNotificationUsername: "",
|
||||
MaxActiveDownloads: 3,
|
||||
MaxActiveTorrents: 5,
|
||||
MaxActiveUploads: 3,
|
||||
MaxConnec: 500,
|
||||
MaxConnecPerTorrent: 100,
|
||||
MaxRatio: -1,
|
||||
MaxRatioAct: 0,
|
||||
MaxRatioEnabled: false,
|
||||
MaxSeedingTime: -1,
|
||||
MaxSeedingTimeEnabled: false,
|
||||
MaxUploads: -1,
|
||||
MaxUploadsPerTorrent: -1,
|
||||
OutgoingPortsMax: 0,
|
||||
OutgoingPortsMin: 0,
|
||||
Pex: true,
|
||||
PreallocateAll: false,
|
||||
ProxyAuthEnabled: false,
|
||||
ProxyIp: "0.0.0.0",
|
||||
ProxyPassword: "",
|
||||
ProxyPeerConnections: false,
|
||||
ProxyPort: 8080,
|
||||
ProxyTorrentsOnly: false,
|
||||
ProxyType: 0,
|
||||
ProxyUsername: "",
|
||||
QueueingEnabled: false,
|
||||
RandomPort: false,
|
||||
RecheckCompletedTorrents: false,
|
||||
ResolvePeerCountries: true,
|
||||
RssAutoDownloadingEnabled: false,
|
||||
RssMaxArticlesPerFeed: 50,
|
||||
RssProcessingEnabled: false,
|
||||
RssRefreshInterval: 30,
|
||||
SavePathChangedTmmEnabled: false,
|
||||
SaveResumeDataInterval: 60,
|
||||
ScanDirs: ScanDirs{},
|
||||
ScheduleFromHour: 8,
|
||||
ScheduleFromMin: 0,
|
||||
ScheduleToHour: 20,
|
||||
ScheduleToMin: 0,
|
||||
SchedulerDays: 0,
|
||||
SchedulerEnabled: false,
|
||||
SendBufferLowWatermark: 10,
|
||||
SendBufferWatermark: 500,
|
||||
SendBufferWatermarkFactor: 50,
|
||||
SlowTorrentDlRateThreshold: 2,
|
||||
SlowTorrentInactiveTimer: 60,
|
||||
SlowTorrentUlRateThreshold: 2,
|
||||
SocketBacklogSize: 30,
|
||||
StartPausedEnabled: false,
|
||||
StopTrackerTimeout: 1,
|
||||
TempPathEnabled: false,
|
||||
TorrentChangedTmmEnabled: true,
|
||||
UpLimit: 0,
|
||||
UploadChokingAlgorithm: 1,
|
||||
UploadSlotsBehavior: 0,
|
||||
Upnp: true,
|
||||
UpnpLeaseDuration: 0,
|
||||
UseHttps: false,
|
||||
UtpTcpMixedMode: 0,
|
||||
WebUiAddress: "*",
|
||||
WebUiBanDuration: 3600,
|
||||
WebUiClickjackingProtectionEnabled: true,
|
||||
WebUiCsrfProtectionEnabled: true,
|
||||
WebUiDomainList: "*",
|
||||
WebUiHostHeaderValidationEnabled: true,
|
||||
WebUiHttpsCertPath: "",
|
||||
WebUiHttpsKeyPath: "",
|
||||
WebUiMaxAuthFailCount: 5,
|
||||
WebUiPort: 8080,
|
||||
WebUiSecureCookieEnabled: true,
|
||||
WebUiSessionTimeout: 3600,
|
||||
WebUiUpnp: false,
|
||||
|
||||
// Fields in the struct but not in the JSON (set to zero values):
|
||||
WebUiPassword: "",
|
||||
SSLKey: "",
|
||||
SSLCert: "",
|
||||
RSSDownloadRepack: "",
|
||||
RSSSmartEpisodeFilters: "",
|
||||
WebUiUseCustomHttpHeaders: false,
|
||||
WebUiUseCustomHttpHeadersEnabled: false,
|
||||
}
|
||||
return preferences
|
||||
}
|
||||
159
pkg/repair/clean.go
Normal file
159
pkg/repair/clean.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package repair
|
||||
|
||||
//func (r *Repair) clean(job *Job) error {
|
||||
// // Create a new error group
|
||||
// g, ctx := errgroup.WithContext(context.Background())
|
||||
//
|
||||
// uniqueItems := make(map[string]string)
|
||||
// mu := sync.Mutex{}
|
||||
//
|
||||
// // Limit concurrent goroutines
|
||||
// g.SetLimit(10)
|
||||
//
|
||||
// for _, a := range job.Arrs {
|
||||
// a := a // Capture range variable
|
||||
// g.Go(func() error {
|
||||
// // Check if context was canceled
|
||||
// select {
|
||||
// case <-ctx.Done():
|
||||
// return ctx.Err()
|
||||
// default:
|
||||
// }
|
||||
//
|
||||
// items, err := r.cleanArr(job, a, "")
|
||||
// if err != nil {
|
||||
// r.logger.Error().Err(err).Msgf("Error cleaning %s", a)
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// // Safely append the found items to the shared slice
|
||||
// if len(items) > 0 {
|
||||
// mu.Lock()
|
||||
// for k, v := range items {
|
||||
// uniqueItems[k] = v
|
||||
// }
|
||||
// mu.Unlock()
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// if err := g.Wait(); err != nil {
|
||||
// return err
|
||||
// }
|
||||
//
|
||||
// if len(uniqueItems) == 0 {
|
||||
// job.CompletedAt = time.Now()
|
||||
// job.Status = JobCompleted
|
||||
//
|
||||
// go func() {
|
||||
// if err := request.SendDiscordMessage("repair_clean_complete", "success", job.discordContext()); err != nil {
|
||||
// r.logger.Error().Msgf("Error sending discord message: %v", err)
|
||||
// }
|
||||
// }()
|
||||
//
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// cache := r.deb.Caches["realdebrid"]
|
||||
// if cache == nil {
|
||||
// return fmt.Errorf("cache not found")
|
||||
// }
|
||||
// torrents := cache.GetTorrents()
|
||||
//
|
||||
// dangling := make([]string, 0)
|
||||
// for _, t := range torrents {
|
||||
// if _, ok := uniqueItems[t.Name]; !ok {
|
||||
// dangling = append(dangling, t.Id)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// r.logger.Info().Msgf("Found %d delapitated items", len(dangling))
|
||||
//
|
||||
// if len(dangling) == 0 {
|
||||
// job.CompletedAt = time.Now()
|
||||
// job.Status = JobCompleted
|
||||
// return nil
|
||||
// }
|
||||
//
|
||||
// client := r.deb.Clients["realdebrid"]
|
||||
// if client == nil {
|
||||
// return fmt.Errorf("client not found")
|
||||
// }
|
||||
// for _, id := range dangling {
|
||||
// err := client.DeleteTorrent(id)
|
||||
// if err != nil {
|
||||
// return err
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// return nil
|
||||
//}
|
||||
//
|
||||
//func (r *Repair) cleanArr(j *Job, _arr string, tmdbId string) (map[string]string, error) {
|
||||
// uniqueItems := make(map[string]string)
|
||||
// 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 uniqueItems, err
|
||||
// }
|
||||
//
|
||||
// // Create a new error group
|
||||
// g, ctx := errgroup.WithContext(context.Background())
|
||||
//
|
||||
// mu := sync.Mutex{}
|
||||
//
|
||||
// // Limit concurrent goroutines
|
||||
// g.SetLimit(runtime.NumCPU() * 4)
|
||||
//
|
||||
// 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:
|
||||
// }
|
||||
//
|
||||
// u := r.getUniquePaths(m)
|
||||
// for k, v := range u {
|
||||
// mu.Lock()
|
||||
// uniqueItems[k] = v
|
||||
// mu.Unlock()
|
||||
// }
|
||||
// return nil
|
||||
// })
|
||||
// }
|
||||
//
|
||||
// if err := g.Wait(); err != nil {
|
||||
// return uniqueItems, err
|
||||
// }
|
||||
//
|
||||
// r.logger.Info().Msgf("Repair completed for %s. %d unique items", a.Name, len(uniqueItems))
|
||||
// return uniqueItems, nil
|
||||
//}
|
||||
|
||||
//func (r *Repair) getUniquePaths(media arr.Content) map[string]string {
|
||||
// // Use zurg setup to check file availability with zurg
|
||||
// // This reduces bandwidth usage significantly
|
||||
//
|
||||
// uniqueParents := make(map[string]string)
|
||||
// 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] = target
|
||||
// }
|
||||
// }
|
||||
// return uniqueParents
|
||||
//}
|
||||
84
pkg/repair/misc.go
Normal file
84
pkg/repair/misc.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package repair
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func collectFiles(media arr.Content) map[string][]arr.ContentFile {
|
||||
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)
|
||||
torrentNamePath := filepath.Clean(dir)
|
||||
// Set target path folder/file.mkv
|
||||
file.TargetPath = f
|
||||
uniqueParents[torrentNamePath] = append(uniqueParents[torrentNamePath], file)
|
||||
}
|
||||
}
|
||||
return uniqueParents
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user