Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f93d1a5913 | ||
|
|
2a4f09c06d | ||
|
|
b1b6353fb3 | ||
|
|
df7979c430 | ||
|
|
726f97e13c | ||
|
|
ab485adfc8 | ||
|
|
700d00b802 | ||
|
|
22dae9efad | ||
|
|
3f0870cd1c | ||
|
|
30b2db06e7 | ||
|
|
76f5b85313 | ||
|
|
85cd37f29b | ||
|
|
aff12c2e4b | ||
|
|
d76ca032ab | ||
|
|
8bb786c689 | ||
|
|
83058489b6 | ||
|
|
267cc2d32b | ||
|
|
eefe8a3901 | ||
|
|
618eb73067 | ||
|
|
f8667938b6 | ||
|
|
b0a698f15e | ||
|
|
2548c21e5b | ||
|
|
1b03ccefbb | ||
|
|
e3a249a9cc | ||
|
|
8696db42d2 | ||
|
|
742d8fb088 | ||
|
|
a0e9f7f553 | ||
|
|
4be4f6b293 | ||
|
|
6c8949b831 | ||
|
|
0dd1efb07c | ||
|
|
3aeb806033 | ||
|
|
7c8156eacf | ||
|
|
d8a963f77f | ||
|
|
27e7bc8f47 | ||
|
|
1d243dd12b | ||
|
|
b4efa22bfd | ||
|
|
6f9fafd7d8 | ||
|
|
eba24c9d63 | ||
|
|
c620ba3d56 | ||
|
|
fab3a7e4f7 | ||
|
|
01615cb51e | ||
|
|
cb63fc69f5 | ||
|
|
40755fbdde | ||
|
|
d0ae839617 | ||
|
|
ce972779c3 | ||
|
|
139249a1f3 | ||
|
|
a60d93677f | ||
|
|
9c31ad266e | ||
|
|
3d2fcf5656 | ||
|
|
fa6920f94a | ||
|
|
ab12610346 | ||
|
|
705de2d2bc | ||
|
|
87bf8d0574 | ||
|
|
d313ed0712 |
@@ -5,7 +5,7 @@ tmp_dir = "tmp"
|
||||
[build]
|
||||
args_bin = ["--config", "data/"]
|
||||
bin = "./tmp/main"
|
||||
cmd = "bash -c 'npm run build && 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 .'"
|
||||
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 = ["tmp", "vendor", "testdata", "data", "logs", "docs", "dist", "node_modules", ".ven"]
|
||||
exclude_file = []
|
||||
@@ -16,7 +16,7 @@ tmp_dir = "tmp"
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html", ".json", ".js", ".css"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
kill_delay = "1s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
@@ -24,8 +24,8 @@ tmp_dir = "tmp"
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
send_interrupt = true
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
|
||||
@@ -25,4 +25,9 @@ node_modules/
|
||||
# Build artifacts
|
||||
decypharr
|
||||
healthcheck
|
||||
*.exe
|
||||
*.exe
|
||||
.venv/
|
||||
data/**
|
||||
|
||||
.stignore
|
||||
.stfolder/**
|
||||
2
.github/workflows/deploy-docs.yml
vendored
@@ -24,5 +24,5 @@ jobs:
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
- run: pip install mkdocs-material
|
||||
- run: cd docs && pip install -r requirements.txt
|
||||
- run: cd docs && mkdocs gh-deploy --force
|
||||
5
.gitignore
vendored
@@ -14,4 +14,7 @@ logs/**
|
||||
auth.json
|
||||
.ven/
|
||||
.env
|
||||
node_modules/
|
||||
node_modules/
|
||||
.venv/
|
||||
.stignore
|
||||
.stfolder/**
|
||||
51
Dockerfile
@@ -29,40 +29,49 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go build -trimpath -ldflags="-w -s" \
|
||||
-o /healthcheck cmd/healthcheck/main.go
|
||||
|
||||
# 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 2: Final image
|
||||
FROM alpine:latest
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
ARG VERSION=0.0.0
|
||||
ARG CHANNEL=dev
|
||||
|
||||
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
|
||||
# Install dependencies including rclone
|
||||
RUN apk add --no-cache fuse3 ca-certificates su-exec shadow curl unzip && \
|
||||
echo "user_allow_other" >> /etc/fuse.conf && \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) ARCH=amd64 ;; \
|
||||
aarch64) ARCH=arm64 ;; \
|
||||
armv7l) ARCH=arm ;; \
|
||||
*) echo "Unsupported architecture: $(uname -m)" && exit 1 ;; \
|
||||
esac && \
|
||||
curl -O "https://downloads.rclone.org/rclone-current-linux-${ARCH}.zip" && \
|
||||
unzip "rclone-current-linux-${ARCH}.zip" && \
|
||||
cp rclone-*/rclone /usr/local/bin/ && \
|
||||
chmod +x /usr/local/bin/rclone && \
|
||||
rm -rf rclone-* && \
|
||||
apk del curl unzip
|
||||
|
||||
# Copy pre-made directory structure
|
||||
COPY --from=dirsetup --chown=nonroot:nonroot /app /app
|
||||
# Copy binaries and entrypoint
|
||||
COPY --from=builder /decypharr /usr/bin/decypharr
|
||||
COPY --from=builder /healthcheck /usr/bin/healthcheck
|
||||
COPY scripts/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
|
||||
# Metadata
|
||||
# Set environment variables
|
||||
ENV PUID=1000
|
||||
ENV PGID=1000
|
||||
ENV LOG_PATH=/app/logs
|
||||
|
||||
EXPOSE 8282
|
||||
VOLUME ["/app"]
|
||||
USER nonroot:nonroot
|
||||
|
||||
HEALTHCHECK --interval=10s --retries=10 CMD ["/usr/bin/healthcheck", "--config", "/app", "--basic"]
|
||||
|
||||
# Base healthcheck
|
||||
HEALTHCHECK --interval=3s --retries=10 CMD ["/usr/bin/healthcheck", "--config", "/app", "--basic"]
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["/usr/bin/decypharr", "--config", "/app"]
|
||||
35
README.md
@@ -12,9 +12,9 @@ Decypharr combines the power of QBittorrent with popular Debrid services to enha
|
||||
|
||||
- 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
|
||||
- Optional mounting of WebDAV to your system(using [Rclone](https://rclone.org/))
|
||||
- Repair Worker for missing files
|
||||
|
||||
## Supported Debrid Providers
|
||||
@@ -29,17 +29,22 @@ Decypharr combines the power of QBittorrent with popular Debrid services to enha
|
||||
### Docker (Recommended)
|
||||
|
||||
```yaml
|
||||
version: '3.7'
|
||||
services:
|
||||
decypharr:
|
||||
image: cy01/blackhole:latest # or cy01/blackhole:beta
|
||||
image: cy01/blackhole:latest
|
||||
container_name: decypharr
|
||||
ports:
|
||||
- "8282:8282" # qBittorrent
|
||||
- "8282:8282"
|
||||
volumes:
|
||||
- /mnt/:/mnt
|
||||
- /mnt/:/mnt:rshared
|
||||
- ./configs/:/app # config.json must be in this directory
|
||||
restart: unless-stopped
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse:rwm
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
```
|
||||
|
||||
## Documentation
|
||||
@@ -57,25 +62,7 @@ The documentation includes:
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
```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"
|
||||
}
|
||||
```
|
||||
You can configure Decypharr through the Web UI or by editing the `config.json` file directly.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -7,10 +7,10 @@ import (
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/pkg/qbit"
|
||||
"github.com/sirrobot01/decypharr/pkg/server"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"github.com/sirrobot01/decypharr/pkg/version"
|
||||
"github.com/sirrobot01/decypharr/pkg/web"
|
||||
"github.com/sirrobot01/decypharr/pkg/webdav"
|
||||
"github.com/sirrobot01/decypharr/pkg/wire"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime"
|
||||
@@ -40,6 +40,7 @@ func Start(ctx context.Context) error {
|
||||
svcCtx, cancelSvc := context.WithCancel(ctx)
|
||||
defer cancelSvc()
|
||||
|
||||
// Create the logger path if it doesn't exist
|
||||
for {
|
||||
cfg := config.Get()
|
||||
_log := logger.Default()
|
||||
@@ -73,6 +74,14 @@ func Start(ctx context.Context) error {
|
||||
}
|
||||
srv := server.New(handlers)
|
||||
|
||||
reset := func() {
|
||||
// Reset the store and services
|
||||
qb.Reset()
|
||||
wire.Reset()
|
||||
// refresh GC
|
||||
runtime.GC()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func(ctx context.Context) {
|
||||
if err := startServices(ctx, cancelSvc, wd, srv); err != nil {
|
||||
@@ -87,22 +96,18 @@ func Start(ctx context.Context) error {
|
||||
// graceful shutdown
|
||||
cancelSvc() // propagate to services
|
||||
<-done // wait for them to finish
|
||||
_log.Info().Msg("Decypharr has been stopped gracefully.")
|
||||
reset() // reset store and services
|
||||
return nil
|
||||
|
||||
case <-restartCh:
|
||||
cancelSvc() // tell existing services to shut down
|
||||
_log.Info().Msg("Restarting Decypharr...")
|
||||
<-done // wait for them to finish
|
||||
qb.Reset()
|
||||
store.Reset()
|
||||
|
||||
_log.Info().Msg("Decypharr has been restarted.")
|
||||
reset() // reset store and services
|
||||
// rebuild svcCtx off the original parent
|
||||
svcCtx, cancelSvc = context.WithCancel(ctx)
|
||||
runtime.GC()
|
||||
|
||||
config.Reload()
|
||||
store.Reset()
|
||||
// loop will restart services automatically
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,17 +149,18 @@ func startServices(ctx context.Context, cancelSvc context.CancelFunc, wd *webdav
|
||||
return srv.Start(ctx)
|
||||
})
|
||||
|
||||
// Start rclone RC server if enabled
|
||||
safeGo(func() error {
|
||||
arr := store.Get().Arr()
|
||||
if arr == nil {
|
||||
rcManager := wire.Get().RcloneManager()
|
||||
if rcManager == nil {
|
||||
return nil
|
||||
}
|
||||
return arr.StartSchedule(ctx)
|
||||
return rcManager.Start(ctx)
|
||||
})
|
||||
|
||||
if cfg := config.Get(); cfg.Repair.Enabled {
|
||||
safeGo(func() error {
|
||||
repair := store.Get().Repair()
|
||||
repair := wire.Get().Repair()
|
||||
if repair != nil {
|
||||
if err := repair.Start(ctx); err != nil {
|
||||
_log.Error().Err(err).Msg("repair failed")
|
||||
@@ -165,7 +171,8 @@ func startServices(ctx context.Context, cancelSvc context.CancelFunc, wd *webdav
|
||||
}
|
||||
|
||||
safeGo(func() error {
|
||||
return store.Get().StartQueueSchedule(ctx)
|
||||
wire.Get().StartWorkers(ctx)
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
|
||||
418
docs/docs/api-spec.yaml
Normal file
@@ -0,0 +1,418 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Decypharr API
|
||||
description: QbitTorrent with Debrid Support API
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: Decypharr
|
||||
url: https://github.com/sirrobot01/decypharr
|
||||
|
||||
servers:
|
||||
- url: /api
|
||||
description: API endpoints
|
||||
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
|
||||
paths:
|
||||
/arrs:
|
||||
get:
|
||||
summary: Get all configured Arrs
|
||||
description: Retrieve a list of all configured Arr applications (Sonarr, Radarr, etc.)
|
||||
tags:
|
||||
- Arrs
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully retrieved Arrs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Arr'
|
||||
|
||||
/add:
|
||||
post:
|
||||
summary: Add content for processing
|
||||
description: Add torrent files or magnet links for processing through debrid services
|
||||
tags:
|
||||
- Content
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
arr:
|
||||
type: string
|
||||
description: Name of the Arr application
|
||||
action:
|
||||
type: string
|
||||
description: Action to perform
|
||||
debrid:
|
||||
type: string
|
||||
description: Debrid service to use
|
||||
callbackUrl:
|
||||
type: string
|
||||
description: Optional callback URL
|
||||
downloadFolder:
|
||||
type: string
|
||||
description: Download folder path
|
||||
downloadUncached:
|
||||
type: boolean
|
||||
description: Whether to download uncached content
|
||||
urls:
|
||||
type: string
|
||||
description: Newline-separated URLs or magnet links
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
description: Torrent files to upload
|
||||
responses:
|
||||
'200':
|
||||
description: Content added successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ImportRequest'
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
/repair:
|
||||
post:
|
||||
summary: Repair media
|
||||
description: Start a repair process for specified media items
|
||||
tags:
|
||||
- Repair
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RepairRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Repair started or completed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
'400':
|
||||
description: Bad request
|
||||
'404':
|
||||
description: Arr not found
|
||||
'500':
|
||||
description: Internal server error
|
||||
|
||||
/repair/jobs:
|
||||
get:
|
||||
summary: Get repair jobs
|
||||
description: Retrieve all repair jobs
|
||||
tags:
|
||||
- Repair
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully retrieved repair jobs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RepairJob'
|
||||
delete:
|
||||
summary: Delete repair jobs
|
||||
description: Delete multiple repair jobs by IDs
|
||||
tags:
|
||||
- Repair
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- ids
|
||||
responses:
|
||||
'200':
|
||||
description: Jobs deleted successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
/repair/jobs/{id}/process:
|
||||
post:
|
||||
summary: Process repair job
|
||||
description: Process a specific repair job by ID
|
||||
tags:
|
||||
- Repair
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Job ID
|
||||
responses:
|
||||
'200':
|
||||
description: Job processed successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
/repair/jobs/{id}/stop:
|
||||
post:
|
||||
summary: Stop repair job
|
||||
description: Stop a running repair job by ID
|
||||
tags:
|
||||
- Repair
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Job ID
|
||||
responses:
|
||||
'200':
|
||||
description: Job stopped successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
'500':
|
||||
description: Internal server error
|
||||
|
||||
/torrents:
|
||||
get:
|
||||
summary: Get all torrents
|
||||
description: Retrieve all torrents sorted by added date
|
||||
tags:
|
||||
- Torrents
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully retrieved torrents
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Torrent'
|
||||
delete:
|
||||
summary: Delete multiple torrents
|
||||
description: Delete multiple torrents by hash list
|
||||
tags:
|
||||
- Torrents
|
||||
parameters:
|
||||
- name: hashes
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated list of torrent hashes
|
||||
- name: removeFromDebrid
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Whether to remove from debrid service
|
||||
responses:
|
||||
'200':
|
||||
description: Torrents deleted successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
/torrents/{category}/{hash}:
|
||||
delete:
|
||||
summary: Delete single torrent
|
||||
description: Delete a specific torrent by category and hash
|
||||
tags:
|
||||
- Torrents
|
||||
parameters:
|
||||
- name: category
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Torrent category
|
||||
- name: hash
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Torrent hash
|
||||
- name: removeFromDebrid
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Whether to remove from debrid service
|
||||
responses:
|
||||
'200':
|
||||
description: Torrent deleted successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: auth-session
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: token
|
||||
description: API token for authentication
|
||||
|
||||
schemas:
|
||||
Arr:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Name of the Arr application
|
||||
host:
|
||||
type: string
|
||||
description: Host URL of the Arr application
|
||||
token:
|
||||
type: string
|
||||
description: API token for the Arr application
|
||||
cleanup:
|
||||
type: boolean
|
||||
description: Whether to cleanup after processing
|
||||
skipRepair:
|
||||
type: boolean
|
||||
description: Whether to skip repair operations
|
||||
downloadUncached:
|
||||
type: boolean
|
||||
description: Whether to download uncached content
|
||||
selectedDebrid:
|
||||
type: string
|
||||
description: Selected debrid service
|
||||
source:
|
||||
type: string
|
||||
description: Source of the Arr configuration
|
||||
|
||||
ImportRequest:
|
||||
type: object
|
||||
properties:
|
||||
debridName:
|
||||
type: string
|
||||
description: Name of the debrid service
|
||||
downloadFolder:
|
||||
type: string
|
||||
description: Download folder path
|
||||
magnet:
|
||||
type: string
|
||||
description: Magnet link
|
||||
arr:
|
||||
$ref: '#/components/schemas/Arr'
|
||||
action:
|
||||
type: string
|
||||
description: Action to perform
|
||||
downloadUncached:
|
||||
type: boolean
|
||||
description: Whether to download uncached content
|
||||
callbackUrl:
|
||||
type: string
|
||||
description: Callback URL
|
||||
importType:
|
||||
type: string
|
||||
description: Type of import (API, etc.)
|
||||
|
||||
RepairRequest:
|
||||
type: object
|
||||
properties:
|
||||
arrName:
|
||||
type: string
|
||||
description: Name of the Arr application
|
||||
mediaIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: List of media IDs to repair
|
||||
autoProcess:
|
||||
type: boolean
|
||||
description: Whether to auto-process the repair
|
||||
async:
|
||||
type: boolean
|
||||
description: Whether to run repair asynchronously
|
||||
required:
|
||||
- arrName
|
||||
|
||||
RepairJob:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Job ID
|
||||
status:
|
||||
type: string
|
||||
description: Job status
|
||||
arrName:
|
||||
type: string
|
||||
description: Associated Arr application
|
||||
mediaIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Media IDs being repaired
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Job creation timestamp
|
||||
|
||||
Torrent:
|
||||
type: object
|
||||
properties:
|
||||
hash:
|
||||
type: string
|
||||
description: Torrent hash
|
||||
name:
|
||||
type: string
|
||||
description: Torrent name
|
||||
category:
|
||||
type: string
|
||||
description: Torrent category
|
||||
addedOn:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Date when torrent was added
|
||||
size:
|
||||
type: integer
|
||||
description: Torrent size in bytes
|
||||
progress:
|
||||
type: number
|
||||
format: float
|
||||
description: Download progress (0-1)
|
||||
status:
|
||||
type: string
|
||||
description: Torrent status
|
||||
|
||||
|
||||
tags:
|
||||
- name: Arrs
|
||||
description: Arr application management
|
||||
- name: Content
|
||||
description: Content addition and processing
|
||||
- name: Repair
|
||||
description: Media repair operations
|
||||
- name: Torrents
|
||||
description: Torrent management
|
||||
- name: Configuration
|
||||
description: Application configuration
|
||||
- name: Authentication
|
||||
description: API token management
|
||||
90
docs/docs/api.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# API Documentation
|
||||
|
||||
Decypharr provides a RESTful API for managing torrents, debrid services, and Arr integrations. The API requires authentication and all endpoints are prefixed with `/api`.
|
||||
|
||||
## Authentication
|
||||
|
||||
The API supports two authentication methods:
|
||||
|
||||
### 1. Session-based Authentication (Cookies)
|
||||
Log in through the web interface (`/login`) to establish an authenticated session. The session cookie (`auth-session`) will be automatically included in subsequent API requests from the same browser session.
|
||||
|
||||
### 2. API Token Authentication (Bearer Token)
|
||||
Use API tokens for programmatic access. Include the token in the `Authorization` header for each request:
|
||||
|
||||
- `Authorization: Bearer <your-token>`
|
||||
|
||||
## Interactive API Documentation
|
||||
|
||||
<swagger-ui src="api-spec.yaml"/>
|
||||
|
||||
## API Endpoints Overview
|
||||
|
||||
### Arrs Management
|
||||
- `GET /api/arrs` - Get all configured Arr applications (Sonarr, Radarr, etc.)
|
||||
|
||||
### Content Management
|
||||
- `POST /api/add` - Add torrent files or magnet links for processing through debrid services
|
||||
|
||||
### Repair Operations
|
||||
- `POST /api/repair` - Start repair process for media items
|
||||
- `GET /api/repair/jobs` - Get all repair jobs
|
||||
- `POST /api/repair/jobs/{id}/process` - Process a specific repair job
|
||||
- `POST /api/repair/jobs/{id}/stop` - Stop a running repair job
|
||||
- `DELETE /api/repair/jobs` - Delete multiple repair jobs
|
||||
|
||||
### Torrent Management
|
||||
- `GET /api/torrents` - Get all torrents
|
||||
- `DELETE /api/torrents/{category}/{hash}` - Delete a specific torrent
|
||||
- `DELETE /api/torrents/` - Delete multiple torrents
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Adding Content via API
|
||||
|
||||
#### Using API Token:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $API_TOKEN" -X POST http://localhost:8080/api/add \
|
||||
-F "arr=sonarr" \
|
||||
-F "debrid=realdebrid" \
|
||||
-F "urls=magnet:?xt=urn:btih:..." \
|
||||
-F "downloadUncached=true"
|
||||
-F "file=@/path/to/torrent/file.torrent"
|
||||
-F "callbackUrl=http://your.callback.url/endpoint"
|
||||
```
|
||||
|
||||
#### Using Session Cookies:
|
||||
```bash
|
||||
# Login first (this sets the session cookie)
|
||||
curl -c cookies.txt -X POST http://localhost:8080/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "your_username", "password": "your_password"}'
|
||||
|
||||
# Then use the session cookie for API calls
|
||||
curl -b cookies.txt -X POST http://localhost:8080/api/add \
|
||||
-F "arr=sonarr" \
|
||||
-F "debrid=realdebrid" \
|
||||
-F "urls=magnet:?xt=urn:btih:..." \
|
||||
-F "downloadUncached=true"
|
||||
```
|
||||
|
||||
### Getting Torrents
|
||||
|
||||
```bash
|
||||
# With API token
|
||||
curl -H "Authorization: Bearer $API_TOKEN" -X GET http://localhost:8080/api/torrents
|
||||
```
|
||||
|
||||
### Starting a Repair Job
|
||||
|
||||
```bash
|
||||
# With API token
|
||||
curl -H "Authorization: Bearer $API_TOKEN" -X POST http://localhost:8080/api/repair \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"arrName": "sonarr",
|
||||
"mediaIds": ["123", "456"],
|
||||
"autoProcess": true,
|
||||
"async": true
|
||||
}'
|
||||
```
|
||||
@@ -1,186 +0,0 @@
|
||||
# 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
|
||||
@@ -1,77 +0,0 @@
|
||||
# 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
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -1,131 +0,0 @@
|
||||
|
||||
# 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
|
||||
}
|
||||
```
|
||||
@@ -1,81 +0,0 @@
|
||||
# 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,
|
||||
"max_file_size": 0
|
||||
```
|
||||
|
||||
#### 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.
|
||||
@@ -1,43 +0,0 @@
|
||||
# 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"
|
||||
},
|
||||
"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).
|
||||
@@ -1,61 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"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/..."
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
[decypharr]
|
||||
type = webdav
|
||||
url = http://decypharr:8282/webdav/realdebrid
|
||||
vendor = other
|
||||
pacer_min_sleep = 0
|
||||
@@ -25,8 +25,10 @@ The Decypharr user interface provides:
|
||||
|
||||
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
|
||||
- [Repair Support](repair-worker.md): Identifies and fixes issues with your media files
|
||||
- WebDav Server: Provides direct access to your Debrid files
|
||||
- Mounting Support: Allows you to mount Debrid services using [rclone](https://rclone.org), making it easy to access your files directly from your system
|
||||
- Multiple Debrid Providers: Supports Real Debrid, Torbox, Debrid Link, and All Debrid, allowing you to choose the best service for your needs
|
||||
|
||||
## Supported Debrid Providers
|
||||
|
||||
@@ -36,5 +38,7 @@ Decypharr supports multiple Debrid providers:
|
||||
- Torbox
|
||||
- Debrid Link
|
||||
- All Debrid
|
||||
- Premiumize(Coming Soon)
|
||||
- Usenet(Coming Soon)
|
||||
|
||||
Each provider can be configured separately, allowing you to use one or multiple services simultaneously.
|
||||
@@ -15,27 +15,4 @@ The Repair Worker performs the following tasks:
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable and configure the Repair Worker, add the following to your `config.json`:
|
||||
|
||||
```json
|
||||
"repair": {
|
||||
"enabled": true,
|
||||
"interval": "12h",
|
||||
"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`).
|
||||
- `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
|
||||
You can enable and configure the Repair Worker in the Decypharr settings. It can be set to run at regular intervals, such as every 12 hours or daily.
|
||||
@@ -1,74 +0,0 @@
|
||||
# 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).
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
While Decypharr provides a Qbittorent API for integration with media management applications, it also allows you to manually download torrents directly through its interface. This guide will walk you through the process of downloading torrents using Decypharr.
|
||||
|
||||
- You can either use the Decypharr UI to add torrents manually or use its API to automate the process.
|
||||
- You can either use the Decypharr UI to add torrents manually or use its [API](../api.md) to automate the process.
|
||||
|
||||
## Manual Downloading
|
||||
|
||||

|
||||
To manually download a torrent using Decypharr, follow these steps:
|
||||
1. **Access the Download Page**: Navigate to the "Download" section in the Decypharr UI.
|
||||
1. Navigate to the "Download" section in the Decypharr UI.
|
||||
2. You can either upload torrent file(s) or paste magnet links directly into the input fields
|
||||
3. Select the action(defaults to Symlink)
|
||||
5. Add any additional options, such as:
|
||||
|
||||
4. Add any additional options, such as:
|
||||
- *Download Folder*: Specify the folder where the downloaded files will be saved.
|
||||
- *Arr Category*: Choose the category for the download, which helps in organizing files in your media management applications.
|
||||
- **Post Download Action**: Select what to do after the download completes:
|
||||
- **Create Symlink**: Create a symlink to the downloaded files in the mount folder(default)
|
||||
- **Download**: Download the file directly.
|
||||
- **No Action**: Do nothing after the download completes.
|
||||
- **Debrid Provider**: Choose which Debrid service to use for the download(if you have multiple)
|
||||
- **File Size Limits**: Set minimum and maximum file size limits if needed.
|
||||
- **Allowed File Types**: Specify which file types are allowed for download.
|
||||
- **Download Uncached**: If enabled, Decypharr will attempt to download uncached files from the Debrid service.
|
||||
|
||||
Note:
|
||||
- If you use an arr category, your download will go into **{download_folder}/{arr}**
|
||||
@@ -1,5 +1,4 @@
|
||||
# Guides for setting up Decypharr
|
||||
|
||||
|
||||
- [Setting up with Rclone](rclone.md)
|
||||
- [Manual Downloading with Decypharr](downloading.md)
|
||||
- [Manual Downloading with Decypharr](downloading.md)
|
||||
- [Internal Mounting](internal-mounting.md)
|
||||
85
docs/docs/guides/internal-mounting.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Internal Mounting
|
||||
|
||||
This guide explains how to use Decypharr's internal mounting feature to eliminate the need for external rclone setup.
|
||||
|
||||
## Overview
|
||||
|
||||

|
||||
|
||||
Instead of requiring users to install and configure rclone separately, Decypharr can now mount your WebDAV endpoints internally using rclone as a library dependency. This provides a seamless experience where files appear as regular filesystem paths without any external dependencies.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker users**: FUSE support may need to be enabled in the container depending on your Docker setup
|
||||
- **macOS users**: May need [macFUSE](https://osxfuse.github.io/) installed for mounting functionality
|
||||
- **Linux users**: FUSE should be available by default on most distributions
|
||||
- **Windows users**: Mounting functionality may be limited
|
||||
|
||||
### Configuration Options
|
||||
|
||||
You can set the options in the Web UI or directly in the configuration file:
|
||||
|
||||
#### Note:
|
||||
Check the Rclone documentation for more details on the available options: [Rclone Mount Options](https://rclone.org/commands/rclone_mount/).
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **WebDAV Server**: Decypharr starts its internal WebDAV server for enabled providers
|
||||
2. **Internal Mount**: Rclone is used internally to mount the WebDAV endpoint to a local filesystem path
|
||||
3. **File Access**: Your applications can access files using regular filesystem paths like `/mnt/decypharr/realdebrid/__all__/MyMovie/`
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Automatic Setup**: Mounting is handled automatically by Decypharr using internal rclone rcd
|
||||
- **Filesystem Access**: Files appear as regular directories and files
|
||||
- **Seamless Integration**: Works with existing media servers without changes
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
decypharr:
|
||||
image: sirrobot01/decypharr:latest
|
||||
container_name: decypharr
|
||||
ports:
|
||||
- "8282:8282"
|
||||
volumes:
|
||||
- ./config:/config
|
||||
- /mnt:/mnt:rshared # Important: use 'rshared' for mount propagation
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse:rwm
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
environment:
|
||||
- UMASK=002
|
||||
- PUID=1000 # Change to your user ID
|
||||
- PGID=1000 # Change to your group ID
|
||||
```
|
||||
|
||||
**Important Docker Notes:**
|
||||
- Mount volumes with `:rshared` to allow mount propagation
|
||||
- Include `/dev/fuse` device for FUSE mounting
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Mount Failures
|
||||
|
||||
If mounting fails, check:
|
||||
|
||||
1. **FUSE Installation**:
|
||||
- **macOS**: Install macFUSE from https://osxfuse.github.io/
|
||||
- **Linux**: Install fuse package (`apt install fuse` or `yum install fuse`)
|
||||
- **Docker**: Fuse is already included in the container, but ensure the host supports it
|
||||
2. **Permissions**: Ensure the application has sufficient privileges
|
||||
|
||||
### No Mount Methods Available
|
||||
|
||||
If you see "no mount method available" errors:
|
||||
|
||||
1. **Check Platform Support**: Some platforms have limited FUSE support
|
||||
2. **Install Dependencies**: Ensure FUSE libraries are installed
|
||||
3. **Use WebDAV Directly**: Access files via `http://localhost:8282/webdav/provider/`
|
||||
4. **External Mounting**: Use OS-native WebDAV mounting as fallback
|
||||
@@ -1,152 +0,0 @@
|
||||
# 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 a 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 = http://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": "rclone:5572"
|
||||
}
|
||||
],
|
||||
"qbittorrent": {
|
||||
"download_folder": "data/media/symlinks/",
|
||||
"refresh_interval": 10
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
- Check your current user and group IDs by running `id -u` and `id -g` in your terminal. You can use these values to set the `PUID` and `PGID` environment variables in the Docker Compose file.
|
||||
- You should also set `user` to your user ID and group ID in the Docker Compose file to ensure proper file permissions.
|
||||
|
||||
Create a `docker-compose.yml` file with the following content:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
decypharr:
|
||||
image: cy01/blackhole:latest
|
||||
container_name: decypharr
|
||||
user: "${PUID:-1000}:${PGID:-1000}"
|
||||
volumes:
|
||||
- /mnt/:/mnt:rslave
|
||||
- /opt/decypharr/:/app
|
||||
environment:
|
||||
- UMASK=002
|
||||
- PUID=1000 # Replace with your user ID
|
||||
- PGID=1000 # Replace with your group ID
|
||||
ports:
|
||||
- "8282:8282/tcp"
|
||||
restart: unless-stopped
|
||||
|
||||
rclone:
|
||||
image: rclone/rclone:latest
|
||||
container_name: rclone
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: UTC
|
||||
ports:
|
||||
- 5572:5572
|
||||
volumes:
|
||||
- /mnt/remote/realdebrid:/data:rshared
|
||||
- /opt/rclone/rclone.conf:/config/rclone/rclone.conf
|
||||
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 --dir-cache-time 10s --rc --rc-addr :5572 --rc-no-auth"
|
||||
```
|
||||
|
||||
#### Docker Notes
|
||||
|
||||
- Ensure that the `/mnt/` directory is mounted correctly to access your media files.
|
||||
- You can check your current user and group IDs and UMASK by running `id -a` and `umask` commands in your terminal.
|
||||
- You can adjust the `PUID` and `PGID` environment variables to match your user and group IDs for proper file permissions.
|
||||
- Also adding `--uid=$YOUR_PUID --gid=$YOUR_PGID` to the `rclone mount` command can help with permissions.
|
||||
- The `UMASK` environment variable can be set to control file permissions created by Decypharr.
|
||||
|
||||
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 = http://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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 293 KiB |
BIN
docs/docs/images/main-light.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 264 KiB |
BIN
docs/docs/images/settings/debrid.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
docs/docs/images/settings/qbittorent.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
docs/docs/images/settings/rclone.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
docs/docs/images/settings/repair.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
@@ -1,20 +1,19 @@
|
||||
# Decypharr
|
||||
|
||||

|
||||
{: .light-mode-image}
|
||||
{: .dark-mode-image}
|
||||
|
||||
**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.
|
||||
**TLDR**; Decypharr is a self-hosted, open-source download client that integrates with multiple Debrid services. It provides a user-friendly interface for managing files 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
|
||||
- WebDAV server support for each Debrid provider with an optional mounting feature(using [rclone](https://rclone.org))
|
||||
- Repair Worker for missing files, symlinks etc
|
||||
|
||||
## Supported Debrid Providers
|
||||
|
||||
@@ -18,7 +18,6 @@ You can use either Docker Hub or GitHub Container Registry to pull the image:
|
||||
- `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
|
||||
@@ -31,12 +30,13 @@ Run the Docker container:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name decypharr \
|
||||
--restart unless-stopped \
|
||||
-p 8282:8282 \
|
||||
-v /mnt/:/mnt \
|
||||
-v /mnt/:/mnt:rshared \
|
||||
-v ./config/:/app \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e UMASK=002 \
|
||||
--device /dev/fuse:/dev/fuse:rwm \
|
||||
--cap-add SYS_ADMIN \
|
||||
--security-opt apparmor:unconfined \
|
||||
cy01/blackhole:latest
|
||||
```
|
||||
|
||||
@@ -52,10 +52,15 @@ services:
|
||||
ports:
|
||||
- "8282:8282"
|
||||
volumes:
|
||||
- /mnt/:/mnt:rslave # Mount your media directory
|
||||
- ./config/:/app # config.json must be in this directory
|
||||
- QBIT_PORT=8282 # qBittorrent Port (optional)
|
||||
- /mnt/:/mnt:rshared
|
||||
- ./config/:/app
|
||||
restart: unless-stopped
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse:rwm
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
```
|
||||
|
||||
Run the Docker Compose setup:
|
||||
@@ -67,7 +72,7 @@ docker-compose up -d
|
||||
## Binary Installation
|
||||
If you prefer not to use Docker, you can download and run the binary directly.
|
||||
|
||||
Download your OS-specific release from the [releases page](https://github.com/sirrobot01/decypharr/releases).
|
||||
Download your OS-specific release from the [release page](https://github.com/sirrobot01/decypharr/releases).
|
||||
Create a configuration file (see Configuration)
|
||||
Run the binary:
|
||||
|
||||
@@ -76,44 +81,15 @@ 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"
|
||||
}
|
||||
```
|
||||
|
||||
### Notes for Docker Users
|
||||
|
||||
- Ensure that the `/mnt/` directory is mounted correctly to access your media files.
|
||||
- The `./config/` directory should contain your `config.json` file.
|
||||
- You can adjust the `PUID` and `PGID` environment variables to match your user and group IDs for proper file permissions.
|
||||
- The `UMASK` environment variable can be set to control file permissions created by Decypharr.
|
||||
|
||||
##### Health Checks
|
||||
- Health checks are disabled by default. You can enable them by adding a `healthcheck` section in your `docker-compose.yml` file.
|
||||
- Health checks checks for availability of several parts of the application;
|
||||
- Health checks the availability of several parts of the application;
|
||||
- The main web interface
|
||||
- The qBittorrent API
|
||||
- The WebDAV server (if enabled). You should disable health checks for the initial indexes as they can take a long time to complete.
|
||||
@@ -125,7 +101,7 @@ services:
|
||||
...
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/bin/healthcheck", "--config", "/app/"]
|
||||
interval: 5s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
24
docs/docs/styles/styles.css
Normal file
@@ -0,0 +1,24 @@
|
||||
/* Light mode image - visible by default */
|
||||
.light-mode-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dark mode image - hidden by default */
|
||||
.dark-mode-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* When dark theme (slate) is active */
|
||||
[data-md-color-scheme="slate"] .light-mode-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .dark-mode-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Optional: smooth transition */
|
||||
.light-mode-image,
|
||||
.dark-mode-image {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
@@ -2,15 +2,35 @@
|
||||
|
||||
This guide will help you get started with Decypharr after installation.
|
||||
|
||||
## Basic Setup
|
||||
After installing Decypharr, you can access the web interface at `http://localhost:8282` or your configured host/port.
|
||||
|
||||
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.)
|
||||
### Initial Configuration
|
||||
If it's the first time you're accessing the UI, you will be prompted to set up your credentials. You can skip this step if you don't want to enable authentication. If you choose to set up credentials, enter a username and password confirm password, then click **Save**. You will be redirected to the settings page.
|
||||
|
||||
## Connecting to Sonarr/Radarr
|
||||
### Debrid Configuration
|
||||

|
||||
- Click on **Debrid** in the tab
|
||||
- Add your desired Debrid services (Real Debrid, Torbox, Debrid Link, All Debrid) by entering the required API keys or tokens.
|
||||
- Set the **Mount/Rclone Folder**. This is where decypharr will look for added torrents to symlink them to your media library.
|
||||
- If you're using internal webdav, do not forget the `/__all__` suffix
|
||||
- Enable WebDAV
|
||||
- You can leave the remaining settings as default for now.
|
||||
|
||||
### Qbittorent Configuration
|
||||

|
||||
|
||||
- Click on **Qbittorrent** in the tab
|
||||
- Set the **Download Folder** to where you want Decypharr to save downloaded files. These files will be symlinked to the mount folder you configured earlier.
|
||||
You can leave the remaining settings as default for now.
|
||||
|
||||
### Arrs Configuration
|
||||
|
||||
You can skip Arr configuration for now. Decypharr will auto-add them when you connect to Sonarr or Radarr later.
|
||||
|
||||
|
||||
#### 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**
|
||||
@@ -18,22 +38,38 @@ To connect Decypharr to your Sonarr or Radarr instance:
|
||||
- **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)
|
||||
- **Password**: `sonarr_token` (your Arr API token, you can get this from Sonarr/Radarr settings)
|
||||
- **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
|
||||
### Rclone Configuration
|
||||
|
||||
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
|
||||
If you want Decypharr to automatically mount WebDAV folders using Rclone, you need to set up Rclone first:
|
||||
|
||||
Access the UI at `http://localhost:8282` or your configured host/port.
|
||||
If you're using Docker, the rclone binary is already included in the container. If you're running Decypharr directly, make sure Rclone is installed on your system.
|
||||
|
||||
Enable **Mount**
|
||||
- **Global Mount Path**: Set the path where you want to mount the WebDAV folders (e.g., `/mnt/remote`). Decypharr will create subfolders for each Debrid service. For example, if you set `/mnt/remote`, it will create `/mnt/remote/realdebrid`, `/mnt/remote/torbox`, etc. This should be the grandparent of your mount folder set in the Debrid configuration.
|
||||
- **User ID**: Set the user ID for Rclone mounts (default is gotten from the environment variable `PUID`).
|
||||
- **Group ID**: Set the group ID for Rclone mounts (default is gotten from the environment variable `PGID`).
|
||||
- **Buffer Size**: Set the buffer size for Rclone mounts.
|
||||
|
||||
You should set other options based on your use case. If you don't know what you're doing, leave it as defaults. Checkout the [Rclone documentation](https://rclone.org/commands/rclone_mount/) for more details.
|
||||
|
||||
### Repair Configuration
|
||||
|
||||

|
||||
|
||||
Repair is an optional feature that allows you to fix missing files, symlinks, and other issues in your media library.
|
||||
- Click on **Repair** in the tab
|
||||
- Enable **Scheduled Repair** if you want Decypharr to automatically check for missing files at your specified interval.
|
||||
- Set the **Repair Interval** to how often you want Decypharr to check for missing files (e.g 1h, 6h, 12h, 24h, you can also use cron syntax like `0 0 * * *` for daily checks).
|
||||
- Enable **WebDav**(You shoukd enable this, if you enabled WebDav in Debrid configuration)
|
||||
- **Auto Process**: Enable this if you want Decypharr to automatically process repair jobs when they are done. This could delete the original files, symlinks, be wary!!!
|
||||
- **Worker Threads**: Set the number of worker threads for processing repair jobs. More threads can speed up the process but may consume more resources.
|
||||
@@ -6,6 +6,9 @@ repo_name: sirrobot01/decypharr
|
||||
edit_uri: blob/main/docs
|
||||
|
||||
|
||||
extra_css:
|
||||
- styles/styles.css
|
||||
|
||||
theme:
|
||||
name: material
|
||||
logo: images/logo.png
|
||||
@@ -59,22 +62,17 @@ 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
|
||||
- API Documentation: api.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
|
||||
- Manual Downloading: guides/downloading.md
|
||||
- Internal Mounting: guides/internal-mounting.md
|
||||
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- tags
|
||||
- tags
|
||||
- swagger-ui-tag
|
||||
3
docs/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
mkdocs==1.6.1
|
||||
mkdocs-material==9.6.16
|
||||
mkdocs-swagger-ui-tag==0.6.10
|
||||
17
go.mod
@@ -7,17 +7,18 @@ toolchain go1.24.3
|
||||
require (
|
||||
github.com/anacrolix/torrent v1.55.0
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/chi/v5 v5.2.2
|
||||
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/puzpuzpuz/xsync/v4 v4.1.0
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/stanNthe5/stringbuf v0.0.3
|
||||
go.uber.org/ratelimit v0.3.1
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/sync v0.12.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/net v0.41.0
|
||||
golang.org/x/sync v0.15.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
@@ -27,13 +28,13 @@ require (
|
||||
github.com/benbjohnson/clock v1.3.0 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/huandu/xstrings v1.3.2 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // 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
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
||||
|
||||
33
go.sum
@@ -70,8 +70,8 @@ github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod
|
||||
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-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
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=
|
||||
@@ -102,8 +102,8 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
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=
|
||||
@@ -143,8 +143,9 @@ 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-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
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=
|
||||
@@ -185,11 +186,13 @@ github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R
|
||||
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
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/puzpuzpuz/xsync/v4 v4.1.0 h1:x9eHRl4QhZFIPJ17yl4KKW9xLyVWbb3/Yq4SXpjF71U=
|
||||
github.com/puzpuzpuz/xsync/v4 v4.1.0/go.mod h1:VJDmTCJMBt8igNxnkQd86r+8KUeN1quSfNKu5bLYFQo=
|
||||
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4=
|
||||
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/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
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=
|
||||
@@ -226,8 +229,8 @@ go.uber.org/ratelimit v0.3.1 h1:K4qVE+byfv/B3tC+4nYWP7v/6SimcO7HzHekoMNBma0=
|
||||
go.uber.org/ratelimit v0.3.1/go.mod h1:6euWsTB6U/Nb3X++xEUXA8ciPJvr19Q/0h1+oDcJhRk=
|
||||
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/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
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=
|
||||
@@ -243,8 +246,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.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
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=
|
||||
@@ -252,8 +255,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/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.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=
|
||||
@@ -268,8 +271,8 @@ golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
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/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
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/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
19
internal/config/auth.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package config
|
||||
|
||||
import "golang.org/x/crypto/bcrypt"
|
||||
|
||||
func VerifyAuth(username, password string) bool {
|
||||
// If you're storing hashed password, use bcrypt to compare
|
||||
if username == "" {
|
||||
return false
|
||||
}
|
||||
auth := Get().GetAuth()
|
||||
if auth == nil {
|
||||
return false
|
||||
}
|
||||
if username != auth.Username {
|
||||
return false
|
||||
}
|
||||
err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password))
|
||||
return err == nil
|
||||
}
|
||||
@@ -2,6 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -30,6 +32,7 @@ type Debrid struct {
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
DownloadAPIKeys []string `json:"download_api_keys,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
RcloneMountPath string `json:"rclone_mount_path,omitempty"` // Custom rclone mount path for this debrid service
|
||||
DownloadUncached bool `json:"download_uncached,omitempty"`
|
||||
CheckCached bool `json:"check_cached,omitempty"`
|
||||
RateLimit string `json:"rate_limit,omitempty"` // 200/minute or 10/second
|
||||
@@ -39,6 +42,7 @@ type Debrid struct {
|
||||
UnpackRar bool `json:"unpack_rar,omitempty"`
|
||||
AddSamples bool `json:"add_samples,omitempty"`
|
||||
MinimumFreeSlot int `json:"minimum_free_slot,omitempty"` // Minimum active pots to use this debrid
|
||||
Limit int `json:"limit,omitempty"` // Maximum number of total torrents
|
||||
|
||||
UseWebDav bool `json:"use_webdav,omitempty"`
|
||||
WebDav
|
||||
@@ -80,6 +84,51 @@ type Repair struct {
|
||||
type Auth struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
APIToken string `json:"api_token,omitempty"`
|
||||
}
|
||||
|
||||
type Rclone struct {
|
||||
// Global mount folder where all providers will be mounted as subfolders
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
MountPath string `json:"mount_path,omitempty"`
|
||||
RcPort string `json:"rc_port,omitempty"`
|
||||
|
||||
// Cache settings
|
||||
CacheDir string `json:"cache_dir,omitempty"`
|
||||
|
||||
// VFS settings
|
||||
VfsCacheMode string `json:"vfs_cache_mode,omitempty"` // off, minimal, writes, full
|
||||
VfsCacheMaxAge string `json:"vfs_cache_max_age,omitempty"` // Maximum age of objects in the cache (default 1h)
|
||||
VfsDiskSpaceTotal string `json:"vfs_disk_space_total,omitempty"` // Total disk space available for the cache (default off)
|
||||
VfsCacheMaxSize string `json:"vfs_cache_max_size,omitempty"` // Maximum size of the cache (default off)
|
||||
VfsCachePollInterval string `json:"vfs_cache_poll_interval,omitempty"` // How often to poll for changes (default 1m)
|
||||
VfsReadChunkSize string `json:"vfs_read_chunk_size,omitempty"` // Read chunk size (default 128M)
|
||||
VfsReadChunkSizeLimit string `json:"vfs_read_chunk_size_limit,omitempty"` // Max chunk size (default off)
|
||||
VfsReadAhead string `json:"vfs_read_ahead,omitempty"` // read ahead size
|
||||
BufferSize string `json:"buffer_size,omitempty"` // Buffer size for reading files (default 16M)
|
||||
BwLimit string `json:"bw_limit,omitempty"` // Bandwidth limit (default off)
|
||||
|
||||
VfsCacheMinFreeSpace string `json:"vfs_cache_min_free_space,omitempty"`
|
||||
VfsFastFingerprint bool `json:"vfs_fast_fingerprint,omitempty"`
|
||||
VfsReadChunkStreams int `json:"vfs_read_chunk_streams,omitempty"`
|
||||
AsyncRead *bool `json:"async_read,omitempty"` // Use async read for files
|
||||
Transfers int `json:"transfers,omitempty"` // Number of transfers to use (default 4)
|
||||
UseMmap bool `json:"use_mmap,omitempty"`
|
||||
|
||||
// File system settings
|
||||
UID uint32 `json:"uid,omitempty"` // User ID for mounted files
|
||||
GID uint32 `json:"gid,omitempty"` // Group ID for mounted files
|
||||
Umask string `json:"umask,omitempty"`
|
||||
|
||||
// Timeout settings
|
||||
AttrTimeout string `json:"attr_timeout,omitempty"` // Attribute cache timeout (default 1s)
|
||||
DirCacheTime string `json:"dir_cache_time,omitempty"` // Directory cache time (default 5m)
|
||||
|
||||
// Performance settings
|
||||
NoModTime bool `json:"no_modtime,omitempty"` // Don't read/write modification time
|
||||
NoChecksum bool `json:"no_checksum,omitempty"` // Don't checksum files on upload
|
||||
|
||||
LogLevel string `json:"log_level,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@@ -94,6 +143,7 @@ type Config struct {
|
||||
Arrs []Arr `json:"arrs,omitempty"`
|
||||
Repair Repair `json:"repair,omitempty"`
|
||||
WebDav WebDav `json:"webdav,omitempty"`
|
||||
Rclone Rclone `json:"rclone,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)
|
||||
@@ -102,6 +152,8 @@ type Config struct {
|
||||
Auth *Auth `json:"-"`
|
||||
DiscordWebhook string `json:"discord_webhook_url,omitempty"`
|
||||
RemoveStalledAfter string `json:"remove_stalled_after,omitzero"`
|
||||
CallbackURL string `json:"callback_url,omitempty"`
|
||||
EnableWebdavAuth bool `json:"enable_webdav_auth,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Config) JsonFile() string {
|
||||
@@ -197,6 +249,15 @@ func ValidateConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateAPIToken creates a new random API token
|
||||
func generateAPIToken() (string, error) {
|
||||
bytes := make([]byte, 32) // 256-bit token
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func SetConfigPath(path string) {
|
||||
configPath = path
|
||||
}
|
||||
@@ -249,6 +310,10 @@ func (c *Config) IsSizeAllowed(size int64) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (c *Config) SecretKey() string {
|
||||
return cmp.Or(os.Getenv("DECYPHARR_SECRET_KEY"), "\"wqj(v%lj*!-+kf@4&i95rhh_!5_px5qnuwqbr%cjrvrozz_r*(\"")
|
||||
}
|
||||
|
||||
func (c *Config) GetAuth() *Auth {
|
||||
if !c.UseAuth {
|
||||
return nil
|
||||
@@ -274,15 +339,12 @@ func (c *Config) SaveAuth(auth *Auth) error {
|
||||
return os.WriteFile(c.AuthFile(), data, 0644)
|
||||
}
|
||||
|
||||
func (c *Config) NeedsSetup() error {
|
||||
func (c *Config) CheckSetup() error {
|
||||
return ValidateConfig(c)
|
||||
}
|
||||
|
||||
func (c *Config) NeedsAuth() bool {
|
||||
if c.UseAuth {
|
||||
return c.GetAuth().Username == ""
|
||||
}
|
||||
return false
|
||||
return c.UseAuth && (c.Auth == nil || c.Auth.Username == "" || c.Auth.Password == "")
|
||||
}
|
||||
|
||||
func (c *Config) updateDebrid(d Debrid) Debrid {
|
||||
@@ -304,7 +366,7 @@ func (c *Config) updateDebrid(d Debrid) Debrid {
|
||||
}
|
||||
|
||||
if d.TorrentsRefreshInterval == "" {
|
||||
d.TorrentsRefreshInterval = cmp.Or(c.WebDav.TorrentsRefreshInterval, "15s") // 15 seconds
|
||||
d.TorrentsRefreshInterval = cmp.Or(c.WebDav.TorrentsRefreshInterval, "45s") // 45 seconds
|
||||
}
|
||||
if d.WebDav.DownloadLinksRefreshInterval == "" {
|
||||
d.DownloadLinksRefreshInterval = cmp.Or(c.WebDav.DownloadLinksRefreshInterval, "40m") // 40 minutes
|
||||
@@ -365,8 +427,50 @@ func (c *Config) setDefaults() {
|
||||
c.Repair.Strategy = RepairStrategyPerTorrent
|
||||
}
|
||||
|
||||
// Rclone defaults
|
||||
if c.Rclone.Enabled {
|
||||
c.Rclone.RcPort = cmp.Or(c.Rclone.RcPort, "5572")
|
||||
if c.Rclone.AsyncRead == nil {
|
||||
_asyncTrue := true
|
||||
c.Rclone.AsyncRead = &_asyncTrue
|
||||
}
|
||||
c.Rclone.VfsCacheMode = cmp.Or(c.Rclone.VfsCacheMode, "off")
|
||||
if c.Rclone.UID == 0 {
|
||||
c.Rclone.UID = uint32(os.Getuid())
|
||||
}
|
||||
if c.Rclone.GID == 0 {
|
||||
if runtime.GOOS == "windows" {
|
||||
// On Windows, we use the current user's SID as GID
|
||||
c.Rclone.GID = uint32(os.Getuid()) // Windows does not have GID, using UID instead
|
||||
} else {
|
||||
c.Rclone.GID = uint32(os.Getgid())
|
||||
}
|
||||
}
|
||||
if c.Rclone.Transfers == 0 {
|
||||
c.Rclone.Transfers = 4 // Default number of transfers
|
||||
}
|
||||
if c.Rclone.VfsCacheMode != "off" {
|
||||
c.Rclone.VfsCachePollInterval = cmp.Or(c.Rclone.VfsCachePollInterval, "1m") // Clean cache every minute
|
||||
}
|
||||
c.Rclone.DirCacheTime = cmp.Or(c.Rclone.DirCacheTime, "5m")
|
||||
c.Rclone.LogLevel = cmp.Or(c.Rclone.LogLevel, "INFO")
|
||||
}
|
||||
// Load the auth file
|
||||
c.Auth = c.GetAuth()
|
||||
|
||||
// Generate API token if auth is enabled and no token exists
|
||||
if c.UseAuth {
|
||||
if c.Auth == nil {
|
||||
c.Auth = &Auth{}
|
||||
}
|
||||
if c.Auth.APIToken == "" {
|
||||
if token, err := generateAPIToken(); err == nil {
|
||||
c.Auth.APIToken = token
|
||||
// Save the updated auth config
|
||||
_ = c.SaveAuth(c.Auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
|
||||
@@ -24,7 +24,7 @@ func (c *Config) IsAllowedFile(filename string) bool {
|
||||
}
|
||||
|
||||
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", ",")
|
||||
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,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
|
||||
|
||||
@@ -26,7 +26,7 @@ func GetLogPath() string {
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(logsDir, "decypharr.log")
|
||||
return logsDir
|
||||
}
|
||||
|
||||
func New(prefix string) zerolog.Logger {
|
||||
@@ -34,7 +34,7 @@ func New(prefix string) zerolog.Logger {
|
||||
level := config.Get().LogLevel
|
||||
|
||||
rotatingLogFile := &lumberjack.Logger{
|
||||
Filename: GetLogPath(),
|
||||
Filename: filepath.Join(GetLogPath(), "decypharr.log"),
|
||||
MaxSize: 10,
|
||||
MaxAge: 15,
|
||||
Compress: true,
|
||||
|
||||
@@ -45,6 +45,8 @@ func getDiscordHeader(event string) string {
|
||||
return "[Decypharr] Repair Completed, Awaiting action"
|
||||
case "repair_complete":
|
||||
return "[Decypharr] Repair Complete"
|
||||
case "repair_cancelled":
|
||||
return "[Decypharr] Repair Cancelled"
|
||||
default:
|
||||
// split the event string and capitalize the first letter of each word
|
||||
evs := strings.Split(event, "_")
|
||||
|
||||
@@ -7,10 +7,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"go.uber.org/ratelimit"
|
||||
"golang.org/x/net/proxy"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
@@ -20,6 +16,11 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"go.uber.org/ratelimit"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
func JoinURL(base string, paths ...string) (string, error) {
|
||||
@@ -298,40 +299,7 @@ func New(options ...ClientOption) *Client {
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
SetProxy(transport, client.proxy)
|
||||
|
||||
// Set the transport to the client
|
||||
client.client.Transport = transport
|
||||
@@ -417,3 +385,80 @@ func isRetryableError(err error) bool {
|
||||
// Not a retryable error
|
||||
return false
|
||||
}
|
||||
|
||||
func SetProxy(transport *http.Transport, proxyURL string) {
|
||||
if proxyURL != "" {
|
||||
if strings.HasPrefix(proxyURL, "socks5://") {
|
||||
// Handle SOCKS5 proxy
|
||||
socksURL, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return
|
||||
} 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 {
|
||||
return
|
||||
} else {
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_proxy, err := url.Parse(proxyURL)
|
||||
if err != nil {
|
||||
return
|
||||
} else {
|
||||
transport.Proxy = http.ProxyURL(_proxy)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
transport.Proxy = http.ProxyFromEnvironment
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ValidateURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
return fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
|
||||
// Try parsing as full URL first
|
||||
u, err := url.Parse(urlStr)
|
||||
if err == nil && u.Scheme != "" && u.Host != "" {
|
||||
// It's a full URL, validate scheme
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("URL scheme must be http or https")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a host:port format (no scheme)
|
||||
if strings.Contains(urlStr, ":") && !strings.Contains(urlStr, "://") {
|
||||
// Try parsing with http:// prefix
|
||||
testURL := "http://" + urlStr
|
||||
u, err := url.Parse(testURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid host:port format: %w", err)
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("host is required in host:port format")
|
||||
}
|
||||
|
||||
// Validate port number
|
||||
if u.Port() == "" {
|
||||
return fmt.Errorf("port is required in host:port format")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid URL format: %s", urlStr)
|
||||
}
|
||||
|
||||
@@ -84,3 +84,54 @@ func readSmallChunks(file *os.File, startPos int64, totalToRead int, chunkSize i
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnsureDir(dirPath string) error {
|
||||
if dirPath == "" {
|
||||
return fmt.Errorf("directory path is empty")
|
||||
}
|
||||
_, err := os.Stat(dirPath)
|
||||
if os.IsNotExist(err) {
|
||||
// Directory does not exist, create it
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func FormatSize(bytes int64) string {
|
||||
const (
|
||||
KB = 1024
|
||||
MB = 1024 * KB
|
||||
GB = 1024 * MB
|
||||
TB = 1024 * GB
|
||||
)
|
||||
|
||||
var size float64
|
||||
var unit string
|
||||
|
||||
switch {
|
||||
case bytes >= TB:
|
||||
size = float64(bytes) / TB
|
||||
unit = "TB"
|
||||
case bytes >= GB:
|
||||
size = float64(bytes) / GB
|
||||
unit = "GB"
|
||||
case bytes >= MB:
|
||||
size = float64(bytes) / MB
|
||||
unit = "MB"
|
||||
case bytes >= KB:
|
||||
size = float64(bytes) / KB
|
||||
unit = "KB"
|
||||
default:
|
||||
size = float64(bytes)
|
||||
unit = "bytes"
|
||||
}
|
||||
|
||||
// Format to 2 decimal places for larger units, no decimals for bytes
|
||||
if unit == "bytes" {
|
||||
return fmt.Sprintf("%.0f %s", size, unit)
|
||||
}
|
||||
return fmt.Sprintf("%.2f %s", size, unit)
|
||||
}
|
||||
|
||||
@@ -22,3 +22,15 @@ func Contains(slice []string, value string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func Mask(text string) string {
|
||||
res := ""
|
||||
if len(text) > 12 {
|
||||
res = text[:8] + "****" + text[len(text)-4:]
|
||||
} else if len(text) > 8 {
|
||||
res = text[:4] + "****" + text[len(text)-2:]
|
||||
} else {
|
||||
res = "****"
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
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)$"
|
||||
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|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?)`
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/robfig/cron/v3"
|
||||
@@ -10,25 +9,6 @@ import (
|
||||
"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
|
||||
|
||||
120
pkg/arr/arr.go
@@ -2,19 +2,21 @@ package arr
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"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"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
)
|
||||
|
||||
// Type is a type of arr
|
||||
@@ -32,12 +34,14 @@ const (
|
||||
Radarr Type = "radarr"
|
||||
Lidarr Type = "lidarr"
|
||||
Readarr Type = "readarr"
|
||||
Others Type = "others"
|
||||
)
|
||||
|
||||
type Arr struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Token string `json:"token"`
|
||||
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"`
|
||||
@@ -109,7 +113,11 @@ func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Respo
|
||||
|
||||
func (a *Arr) Validate() error {
|
||||
if a.Token == "" || a.Host == "" {
|
||||
return fmt.Errorf("arr not configured: %s", a.Name)
|
||||
return fmt.Errorf("arr not configured")
|
||||
}
|
||||
|
||||
if request.ValidateURL(a.Host) != nil {
|
||||
return fmt.Errorf("invalid arr host URL")
|
||||
}
|
||||
resp, err := a.Request("GET", "/api/v3/health", nil)
|
||||
if err != nil {
|
||||
@@ -146,7 +154,7 @@ func InferType(host, name string) Type {
|
||||
case strings.Contains(host, "readarr") || strings.Contains(name, "readarr"):
|
||||
return Readarr
|
||||
default:
|
||||
return ""
|
||||
return Others
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +165,11 @@ func NewStorage() *Storage {
|
||||
continue // Skip if host or token is not set
|
||||
}
|
||||
name := a.Name
|
||||
arrs[name] = New(name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid, a.Source)
|
||||
as := New(name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid, a.Source)
|
||||
if request.ValidateURL(as.Host) != nil {
|
||||
continue
|
||||
}
|
||||
arrs[a.Name] = as
|
||||
}
|
||||
return &Storage{
|
||||
Arrs: arrs,
|
||||
@@ -171,6 +183,11 @@ func (s *Storage) AddOrUpdate(arr *Arr) {
|
||||
if arr.Host == "" || arr.Token == "" || arr.Name == "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Check the host URL
|
||||
if request.ValidateURL(arr.Host) != nil {
|
||||
return
|
||||
}
|
||||
s.Arrs[arr.Name] = arr
|
||||
}
|
||||
|
||||
@@ -190,7 +207,88 @@ func (s *Storage) GetAll() []*Arr {
|
||||
return arrs
|
||||
}
|
||||
|
||||
func (s *Storage) StartSchedule(ctx context.Context) error {
|
||||
func (s *Storage) SyncToConfig() []config.Arr {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
cfg := config.Get()
|
||||
arrConfigs := make(map[string]config.Arr)
|
||||
for _, a := range cfg.Arrs {
|
||||
if a.Host == "" || a.Token == "" {
|
||||
continue // Skip empty arrs
|
||||
}
|
||||
arrConfigs[a.Name] = a
|
||||
}
|
||||
|
||||
for name, arr := range s.Arrs {
|
||||
exists, ok := arrConfigs[name]
|
||||
if ok {
|
||||
// Update existing arr config
|
||||
// Check if the host URL is valid
|
||||
if request.ValidateURL(arr.Host) == nil {
|
||||
exists.Host = arr.Host
|
||||
}
|
||||
exists.Token = cmp.Or(exists.Token, arr.Token)
|
||||
exists.Cleanup = arr.Cleanup
|
||||
exists.SkipRepair = arr.SkipRepair
|
||||
exists.DownloadUncached = arr.DownloadUncached
|
||||
exists.SelectedDebrid = arr.SelectedDebrid
|
||||
arrConfigs[name] = exists
|
||||
} else {
|
||||
// Add new arr config
|
||||
arrConfigs[name] = config.Arr{
|
||||
Name: arr.Name,
|
||||
Host: arr.Host,
|
||||
Token: arr.Token,
|
||||
Cleanup: arr.Cleanup,
|
||||
SkipRepair: arr.SkipRepair,
|
||||
DownloadUncached: arr.DownloadUncached,
|
||||
SelectedDebrid: arr.SelectedDebrid,
|
||||
Source: arr.Source,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Convert map to slice
|
||||
arrs := make([]config.Arr, 0, len(arrConfigs))
|
||||
for _, a := range arrConfigs {
|
||||
arrs = append(arrs, a)
|
||||
}
|
||||
return arrs
|
||||
}
|
||||
|
||||
func (s *Storage) SyncFromConfig(arrs []config.Arr) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
arrConfigs := make(map[string]*Arr)
|
||||
for _, a := range arrs {
|
||||
arrConfigs[a.Name] = New(a.Name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid, a.Source)
|
||||
}
|
||||
|
||||
// Add or update arrs from config
|
||||
for name, arr := range s.Arrs {
|
||||
if ac, ok := arrConfigs[name]; ok {
|
||||
// Update existing arr
|
||||
// is the host URL valid?
|
||||
if request.ValidateURL(ac.Host) == nil {
|
||||
ac.Host = arr.Host
|
||||
}
|
||||
ac.Token = cmp.Or(ac.Token, arr.Token)
|
||||
ac.Cleanup = arr.Cleanup
|
||||
ac.SkipRepair = arr.SkipRepair
|
||||
ac.DownloadUncached = arr.DownloadUncached
|
||||
ac.SelectedDebrid = arr.SelectedDebrid
|
||||
ac.Source = arr.Source
|
||||
arrConfigs[name] = ac
|
||||
} else {
|
||||
arrConfigs[name] = arr
|
||||
}
|
||||
}
|
||||
|
||||
// Replace the arrs map
|
||||
s.Arrs = arrConfigs
|
||||
|
||||
}
|
||||
|
||||
func (s *Storage) StartWorker(ctx context.Context) error {
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
|
||||
|
||||
@@ -234,6 +234,35 @@ func (a *Arr) searchRadarr(files []ContentFile) error {
|
||||
}
|
||||
|
||||
func (a *Arr) SearchMissing(files []ContentFile) error {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
return a.batchSearchMissing(files)
|
||||
}
|
||||
|
||||
func (a *Arr) batchSearchMissing(files []ContentFile) error {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
BatchSize := 50
|
||||
// Batch search for missing files
|
||||
if len(files) > BatchSize {
|
||||
for i := 0; i < len(files); i += BatchSize {
|
||||
end := i + BatchSize
|
||||
if end > len(files) {
|
||||
end = len(files)
|
||||
}
|
||||
if err := a.searchMissing(files[i:end]); err != nil {
|
||||
// continue searching the rest of the files
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return a.searchMissing(files)
|
||||
}
|
||||
|
||||
func (a *Arr) searchMissing(files []ContentFile) error {
|
||||
switch a.Type {
|
||||
case Sonarr:
|
||||
return a.searchSonarr(files)
|
||||
@@ -245,6 +274,28 @@ func (a *Arr) SearchMissing(files []ContentFile) error {
|
||||
}
|
||||
|
||||
func (a *Arr) DeleteFiles(files []ContentFile) error {
|
||||
if len(files) == 0 {
|
||||
return nil
|
||||
}
|
||||
BatchSize := 50
|
||||
// Batch delete files
|
||||
if len(files) > BatchSize {
|
||||
for i := 0; i < len(files); i += BatchSize {
|
||||
end := i + BatchSize
|
||||
if end > len(files) {
|
||||
end = len(files)
|
||||
}
|
||||
if err := a.batchDeleteFiles(files[i:end]); err != nil {
|
||||
// continue deleting the rest of the files
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return a.batchDeleteFiles(files)
|
||||
}
|
||||
|
||||
func (a *Arr) batchDeleteFiles(files []ContentFile) error {
|
||||
ids := make([]int, 0)
|
||||
for _, f := range files {
|
||||
ids = append(ids, f.FileId)
|
||||
|
||||
@@ -133,7 +133,7 @@ func (a *Arr) CleanupQueue() error {
|
||||
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") {
|
||||
if strings.Contains(strings.Join(m.Messages, " "), "No files found are eligible") {
|
||||
isMessedUp = true
|
||||
break
|
||||
}
|
||||
|
||||
119
pkg/debrid/account/account.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
|
||||
type Account struct {
|
||||
Debrid string `json:"debrid"` // The debrid service name, e.g. "realdebrid"
|
||||
links *xsync.Map[string, types.DownloadLink] // key is the sliced file link
|
||||
Index int `json:"index"` // The index of the account in the config
|
||||
Disabled atomic.Bool `json:"disabled"`
|
||||
Token string `json:"token"`
|
||||
TrafficUsed atomic.Int64 `json:"traffic_used"` // Traffic used in bytes
|
||||
Username string `json:"username"` // Username for the account
|
||||
httpClient *request.Client
|
||||
|
||||
// Account reactivation tracking
|
||||
DisableCount atomic.Int32 `json:"disable_count"`
|
||||
}
|
||||
|
||||
func (a *Account) Equals(other *Account) bool {
|
||||
if other == nil {
|
||||
return false
|
||||
}
|
||||
return a.Token == other.Token && a.Debrid == other.Debrid
|
||||
}
|
||||
|
||||
func (a *Account) Client() *request.Client {
|
||||
return a.httpClient
|
||||
}
|
||||
|
||||
// slice download link
|
||||
func (a *Account) sliceFileLink(fileLink string) string {
|
||||
if a.Debrid != "realdebrid" {
|
||||
return fileLink
|
||||
}
|
||||
if len(fileLink) < 39 {
|
||||
return fileLink
|
||||
}
|
||||
return fileLink[0:39]
|
||||
}
|
||||
|
||||
func (a *Account) GetDownloadLink(fileLink string) (types.DownloadLink, error) {
|
||||
slicedLink := a.sliceFileLink(fileLink)
|
||||
dl, ok := a.links.Load(slicedLink)
|
||||
if !ok {
|
||||
return types.DownloadLink{}, types.ErrDownloadLinkNotFound
|
||||
}
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (a *Account) StoreDownloadLink(dl types.DownloadLink) {
|
||||
slicedLink := a.sliceFileLink(dl.Link)
|
||||
a.links.Store(slicedLink, dl)
|
||||
}
|
||||
func (a *Account) DeleteDownloadLink(fileLink string) {
|
||||
slicedLink := a.sliceFileLink(fileLink)
|
||||
a.links.Delete(slicedLink)
|
||||
}
|
||||
func (a *Account) ClearDownloadLinks() {
|
||||
a.links.Clear()
|
||||
}
|
||||
func (a *Account) DownloadLinksCount() int {
|
||||
return a.links.Size()
|
||||
}
|
||||
func (a *Account) StoreDownloadLinks(dls map[string]*types.DownloadLink) {
|
||||
for _, dl := range dls {
|
||||
a.StoreDownloadLink(*dl)
|
||||
}
|
||||
}
|
||||
|
||||
// MarkDisabled marks the account as disabled and increments the disable count
|
||||
func (a *Account) MarkDisabled() {
|
||||
a.Disabled.Store(true)
|
||||
a.DisableCount.Add(1)
|
||||
}
|
||||
|
||||
func (a *Account) Reset() {
|
||||
a.DisableCount.Store(0)
|
||||
a.Disabled.Store(false)
|
||||
}
|
||||
|
||||
func (a *Account) CheckBandwidth() error {
|
||||
// Get a one of the download links to check if the account is still valid
|
||||
downloadLink := ""
|
||||
a.links.Range(func(key string, dl types.DownloadLink) bool {
|
||||
if dl.DownloadLink != "" {
|
||||
downloadLink = dl.DownloadLink
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if downloadLink == "" {
|
||||
return fmt.Errorf("no download link found")
|
||||
}
|
||||
|
||||
// Let's check the download link status
|
||||
req, err := http.NewRequest(http.MethodGet, downloadLink, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Use a simple client
|
||||
client := http.DefaultClient
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusPartialContent {
|
||||
return fmt.Errorf("account check failed with status code %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
239
pkg/debrid/account/manager.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"go.uber.org/ratelimit"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxDisableCount = 3
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
debrid string
|
||||
current atomic.Pointer[Account]
|
||||
accounts *xsync.Map[string, *Account]
|
||||
logger zerolog.Logger
|
||||
}
|
||||
|
||||
func NewManager(debridConf config.Debrid, downloadRL ratelimit.Limiter, logger zerolog.Logger) *Manager {
|
||||
m := &Manager{
|
||||
debrid: debridConf.Name,
|
||||
accounts: xsync.NewMap[string, *Account](),
|
||||
logger: logger,
|
||||
}
|
||||
|
||||
var firstAccount *Account
|
||||
for idx, token := range debridConf.DownloadAPIKeys {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", token),
|
||||
}
|
||||
account := &Account{
|
||||
Debrid: debridConf.Name,
|
||||
Token: token,
|
||||
Index: idx,
|
||||
links: xsync.NewMap[string, types.DownloadLink](),
|
||||
httpClient: request.New(
|
||||
request.WithRateLimiter(downloadRL),
|
||||
request.WithLogger(logger),
|
||||
request.WithHeaders(headers),
|
||||
request.WithMaxRetries(3),
|
||||
request.WithRetryableStatus(429, 447, 502),
|
||||
request.WithProxy(debridConf.Proxy),
|
||||
),
|
||||
}
|
||||
m.accounts.Store(token, account)
|
||||
if firstAccount == nil {
|
||||
firstAccount = account
|
||||
}
|
||||
}
|
||||
m.current.Store(firstAccount)
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) Active() []*Account {
|
||||
activeAccounts := make([]*Account, 0)
|
||||
m.accounts.Range(func(key string, acc *Account) bool {
|
||||
if !acc.Disabled.Load() {
|
||||
activeAccounts = append(activeAccounts, acc)
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
slices.SortFunc(activeAccounts, func(i, j *Account) int {
|
||||
return i.Index - j.Index
|
||||
})
|
||||
return activeAccounts
|
||||
}
|
||||
|
||||
func (m *Manager) All() []*Account {
|
||||
allAccounts := make([]*Account, 0)
|
||||
m.accounts.Range(func(key string, acc *Account) bool {
|
||||
allAccounts = append(allAccounts, acc)
|
||||
return true
|
||||
})
|
||||
|
||||
slices.SortFunc(allAccounts, func(i, j *Account) int {
|
||||
return i.Index - j.Index
|
||||
})
|
||||
return allAccounts
|
||||
}
|
||||
|
||||
func (m *Manager) Current() *Account {
|
||||
// Fast path - most common case
|
||||
current := m.current.Load()
|
||||
if current != nil && !current.Disabled.Load() {
|
||||
return current
|
||||
}
|
||||
|
||||
// Slow path - find new current account
|
||||
activeAccounts := m.Active()
|
||||
if len(activeAccounts) == 0 {
|
||||
// No active accounts left, try to use disabled ones
|
||||
m.logger.Warn().Str("debrid", m.debrid).Msg("No active accounts available, all accounts are disabled")
|
||||
allAccounts := m.All()
|
||||
if len(allAccounts) == 0 {
|
||||
m.logger.Error().Str("debrid", m.debrid).Msg("No accounts configured")
|
||||
m.current.Store(nil)
|
||||
return nil
|
||||
}
|
||||
m.current.Store(allAccounts[0])
|
||||
return allAccounts[0]
|
||||
}
|
||||
|
||||
newCurrent := activeAccounts[0]
|
||||
m.current.Store(newCurrent)
|
||||
return newCurrent
|
||||
}
|
||||
|
||||
func (m *Manager) Disable(account *Account) {
|
||||
if account == nil {
|
||||
return
|
||||
}
|
||||
|
||||
account.MarkDisabled()
|
||||
|
||||
// If we're disabling the current account, it will be replaced
|
||||
// on the next Current() call - no need to proactively update
|
||||
current := m.current.Load()
|
||||
if current != nil && current.Token == account.Token {
|
||||
// Optional: immediately find replacement
|
||||
activeAccounts := m.Active()
|
||||
if len(activeAccounts) > 0 {
|
||||
m.current.Store(activeAccounts[0])
|
||||
} else {
|
||||
m.current.Store(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Reset() {
|
||||
m.accounts.Range(func(key string, acc *Account) bool {
|
||||
acc.Reset()
|
||||
return true
|
||||
})
|
||||
|
||||
// Set current to first active account
|
||||
activeAccounts := m.Active()
|
||||
if len(activeAccounts) > 0 {
|
||||
m.current.Store(activeAccounts[0])
|
||||
} else {
|
||||
m.current.Store(nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetAccount(token string) (*Account, error) {
|
||||
if token == "" {
|
||||
return nil, fmt.Errorf("token cannot be empty")
|
||||
}
|
||||
acc, ok := m.accounts.Load(token)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("account not found for token")
|
||||
}
|
||||
return acc, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetDownloadLink(fileLink string) (types.DownloadLink, error) {
|
||||
current := m.Current()
|
||||
if current == nil {
|
||||
return types.DownloadLink{}, fmt.Errorf("no active account for debrid service %s", m.debrid)
|
||||
}
|
||||
return current.GetDownloadLink(fileLink)
|
||||
}
|
||||
|
||||
func (m *Manager) GetAccountFromDownloadLink(downloadLink types.DownloadLink) (*Account, error) {
|
||||
if downloadLink.Link == "" {
|
||||
return nil, fmt.Errorf("cannot get account from empty download link")
|
||||
}
|
||||
if downloadLink.Token == "" {
|
||||
return nil, fmt.Errorf("cannot get account from download link without token")
|
||||
}
|
||||
return m.GetAccount(downloadLink.Token)
|
||||
}
|
||||
|
||||
func (m *Manager) StoreDownloadLink(downloadLink types.DownloadLink) {
|
||||
if downloadLink.Link == "" || downloadLink.Token == "" {
|
||||
return
|
||||
}
|
||||
account, err := m.GetAccount(downloadLink.Token)
|
||||
if err != nil || account == nil {
|
||||
return
|
||||
}
|
||||
account.StoreDownloadLink(downloadLink)
|
||||
}
|
||||
|
||||
func (m *Manager) Stats() []map[string]any {
|
||||
stats := make([]map[string]any, 0)
|
||||
|
||||
for _, acc := range m.All() {
|
||||
maskedToken := utils.Mask(acc.Token)
|
||||
accountDetail := map[string]any{
|
||||
"in_use": acc.Equals(m.Current()),
|
||||
"order": acc.Index,
|
||||
"disabled": acc.Disabled.Load(),
|
||||
"token_masked": maskedToken,
|
||||
"username": acc.Username,
|
||||
"traffic_used": acc.TrafficUsed.Load(),
|
||||
"links_count": acc.DownloadLinksCount(),
|
||||
"debrid": acc.Debrid,
|
||||
}
|
||||
stats = append(stats, accountDetail)
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
func (m *Manager) CheckAndResetBandwidth() {
|
||||
found := false
|
||||
m.accounts.Range(func(key string, acc *Account) bool {
|
||||
if acc.Disabled.Load() && acc.DisableCount.Load() < MaxDisableCount {
|
||||
if err := acc.CheckBandwidth(); err == nil {
|
||||
acc.Disabled.Store(false)
|
||||
found = true
|
||||
m.logger.Info().Str("debrid", m.debrid).Str("token", utils.Mask(acc.Token)).Msg("Re-activated disabled account")
|
||||
} else {
|
||||
m.logger.Debug().Err(err).Str("debrid", m.debrid).Str("token", utils.Mask(acc.Token)).Msg("Account still disabled")
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if found {
|
||||
// If we re-activated any account, reset current to first active
|
||||
activeAccounts := m.Active()
|
||||
if len(activeAccounts) > 0 {
|
||||
m.current.Store(activeAccounts[0])
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
31
pkg/debrid/common/interface.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/account"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
SubmitMagnet(tr *types.Torrent) (*types.Torrent, error)
|
||||
CheckStatus(tr *types.Torrent) (*types.Torrent, error)
|
||||
GetFileDownloadLinks(tr *types.Torrent) error
|
||||
GetDownloadLink(tr *types.Torrent, file *types.File) (types.DownloadLink, error)
|
||||
DeleteTorrent(torrentId string) error
|
||||
IsAvailable(infohashes []string) map[string]bool
|
||||
GetDownloadUncached() bool
|
||||
UpdateTorrent(torrent *types.Torrent) error
|
||||
GetTorrent(torrentId string) (*types.Torrent, error)
|
||||
GetTorrents() ([]*types.Torrent, error)
|
||||
Name() string
|
||||
Logger() zerolog.Logger
|
||||
GetDownloadingStatus() []string
|
||||
RefreshDownloadLinks() error
|
||||
CheckLink(link string) error
|
||||
GetMountPath() string
|
||||
AccountManager() *account.Manager // Returns the active download account/token
|
||||
GetProfile() (*types.Profile, error)
|
||||
GetAvailableSlots() (int, error)
|
||||
SyncAccounts() error // Updates each accounts details(like traffic, username, etc.)
|
||||
DeleteDownloadLink(account *account.Account, downloadLink types.DownloadLink) error
|
||||
}
|
||||
@@ -1,58 +1,83 @@
|
||||
package debrid
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"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/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/common"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/alldebrid"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/debrid_link"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/debridlink"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/realdebrid"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/torbox"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/store"
|
||||
debridStore "github.com/sirrobot01/decypharr/pkg/debrid/store"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"sync"
|
||||
"github.com/sirrobot01/decypharr/pkg/rclone"
|
||||
"go.uber.org/ratelimit"
|
||||
)
|
||||
|
||||
type Debrid struct {
|
||||
cache *store.Cache // Could be nil if not using WebDAV
|
||||
client types.Client // HTTP client for making requests to the debrid service
|
||||
cache *debridStore.Cache // Could be nil if not using WebDAV
|
||||
client common.Client // HTTP client for making requests to the debrid service
|
||||
}
|
||||
|
||||
func (de *Debrid) Client() types.Client {
|
||||
func (de *Debrid) Client() common.Client {
|
||||
return de.client
|
||||
}
|
||||
|
||||
func (de *Debrid) Cache() *store.Cache {
|
||||
func (de *Debrid) Cache() *debridStore.Cache {
|
||||
return de.cache
|
||||
}
|
||||
|
||||
func (de *Debrid) Reset() {
|
||||
if de.cache != nil {
|
||||
de.cache.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
debrids map[string]*Debrid
|
||||
mu sync.RWMutex
|
||||
lastUsed string
|
||||
}
|
||||
|
||||
func NewStorage() *Storage {
|
||||
func NewStorage(rcManager *rclone.Manager) *Storage {
|
||||
cfg := config.Get()
|
||||
|
||||
_logger := logger.Default()
|
||||
|
||||
debrids := make(map[string]*Debrid)
|
||||
|
||||
bindAddress := cfg.BindAddress
|
||||
if bindAddress == "" {
|
||||
bindAddress = "localhost"
|
||||
}
|
||||
webdavUrl := fmt.Sprintf("http://%s:%s%s/webdav", bindAddress, cfg.Port, cfg.URLBase)
|
||||
|
||||
for _, dc := range cfg.Debrids {
|
||||
client, err := createDebridClient(dc)
|
||||
if err != nil {
|
||||
_logger.Error().Err(err).Str("Debrid", dc.Name).Msg("failed to connect to debrid client")
|
||||
continue
|
||||
}
|
||||
var cache *store.Cache
|
||||
var (
|
||||
cache *debridStore.Cache
|
||||
mounter *rclone.Mount
|
||||
)
|
||||
_log := client.Logger()
|
||||
if dc.UseWebDav {
|
||||
cache = store.NewDebridCache(dc, client)
|
||||
if cfg.Rclone.Enabled && rcManager != nil {
|
||||
mounter = rclone.NewMount(dc.Name, dc.RcloneMountPath, webdavUrl, rcManager)
|
||||
}
|
||||
cache = debridStore.NewDebridCache(dc, client, mounter)
|
||||
_log.Info().Msg("Debrid Service started with WebDAV")
|
||||
} else {
|
||||
_log.Info().Msg("Debrid Service started")
|
||||
@@ -79,6 +104,90 @@ func (d *Storage) Debrid(name string) *Debrid {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Storage) StartWorker(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
// Start syncAccounts worker
|
||||
go d.syncAccountsWorker(ctx)
|
||||
|
||||
// Start bandwidth reset worker
|
||||
go d.checkBandwidthWorker(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Storage) checkBandwidthWorker(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
ticker := time.NewTicker(30 * time.Minute)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
d.checkAccountBandwidth()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (d *Storage) checkAccountBandwidth() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
for _, debrid := range d.debrids {
|
||||
if debrid == nil || debrid.client == nil {
|
||||
continue
|
||||
}
|
||||
accountManager := debrid.client.AccountManager()
|
||||
if accountManager == nil {
|
||||
continue
|
||||
}
|
||||
accountManager.CheckAndResetBandwidth()
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Storage) syncAccountsWorker(ctx context.Context) {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
_ = d.syncAccounts()
|
||||
ticker := time.NewTicker(5 * time.Minute)
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
_ = d.syncAccounts()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
}
|
||||
|
||||
func (d *Storage) syncAccounts() error {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
for name, debrid := range d.debrids {
|
||||
if debrid == nil || debrid.client == nil {
|
||||
continue
|
||||
}
|
||||
_log := debrid.client.Logger()
|
||||
if err := debrid.client.SyncAccounts(); err != nil {
|
||||
_log.Error().Err(err).Msgf("Failed to sync account for %s", name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Storage) Debrids() map[string]*Debrid {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
@@ -91,7 +200,7 @@ func (d *Storage) Debrids() map[string]*Debrid {
|
||||
return debridsCopy
|
||||
}
|
||||
|
||||
func (d *Storage) Client(name string) types.Client {
|
||||
func (d *Storage) Client(name string) common.Client {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
if client, exists := d.debrids[name]; exists {
|
||||
@@ -102,15 +211,24 @@ func (d *Storage) Client(name string) types.Client {
|
||||
|
||||
func (d *Storage) Reset() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// Reset all debrid clients and caches
|
||||
for _, debrid := range d.debrids {
|
||||
if debrid != nil {
|
||||
debrid.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize the debrids map
|
||||
d.debrids = make(map[string]*Debrid)
|
||||
d.mu.Unlock()
|
||||
d.lastUsed = ""
|
||||
}
|
||||
|
||||
func (d *Storage) Clients() map[string]types.Client {
|
||||
func (d *Storage) Clients() map[string]common.Client {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
clientsCopy := make(map[string]types.Client)
|
||||
clientsCopy := make(map[string]common.Client)
|
||||
for name, debrid := range d.debrids {
|
||||
if debrid != nil && debrid.client != nil {
|
||||
clientsCopy[name] = debrid.client
|
||||
@@ -119,10 +237,10 @@ func (d *Storage) Clients() map[string]types.Client {
|
||||
return clientsCopy
|
||||
}
|
||||
|
||||
func (d *Storage) Caches() map[string]*store.Cache {
|
||||
func (d *Storage) Caches() map[string]*debridStore.Cache {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
cachesCopy := make(map[string]*store.Cache)
|
||||
cachesCopy := make(map[string]*debridStore.Cache)
|
||||
for name, debrid := range d.debrids {
|
||||
if debrid != nil && debrid.cache != nil {
|
||||
cachesCopy[name] = debrid.cache
|
||||
@@ -131,10 +249,10 @@ func (d *Storage) Caches() map[string]*store.Cache {
|
||||
return cachesCopy
|
||||
}
|
||||
|
||||
func (d *Storage) FilterClients(filter func(types.Client) bool) map[string]types.Client {
|
||||
func (d *Storage) FilterClients(filter func(common.Client) bool) map[string]common.Client {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
filteredClients := make(map[string]types.Client)
|
||||
filteredClients := make(map[string]common.Client)
|
||||
for name, client := range d.debrids {
|
||||
if client != nil && filter(client.client) {
|
||||
filteredClients[name] = client.client
|
||||
@@ -143,18 +261,28 @@ func (d *Storage) FilterClients(filter func(types.Client) bool) map[string]types
|
||||
return filteredClients
|
||||
}
|
||||
|
||||
func createDebridClient(dc config.Debrid) (types.Client, error) {
|
||||
func createDebridClient(dc config.Debrid) (common.Client, error) {
|
||||
rateLimits := map[string]ratelimit.Limiter{}
|
||||
|
||||
mainRL := request.ParseRateLimit(dc.RateLimit)
|
||||
repairRL := request.ParseRateLimit(cmp.Or(dc.RepairRateLimit, dc.RateLimit))
|
||||
downloadRL := request.ParseRateLimit(cmp.Or(dc.DownloadRateLimit, dc.RateLimit))
|
||||
|
||||
rateLimits["main"] = mainRL
|
||||
rateLimits["repair"] = repairRL
|
||||
rateLimits["download"] = downloadRL
|
||||
|
||||
switch dc.Name {
|
||||
case "realdebrid":
|
||||
return realdebrid.New(dc)
|
||||
return realdebrid.New(dc, rateLimits)
|
||||
case "torbox":
|
||||
return torbox.New(dc)
|
||||
return torbox.New(dc, rateLimits)
|
||||
case "debridlink":
|
||||
return debrid_link.New(dc)
|
||||
return debridlink.New(dc, rateLimits)
|
||||
case "alldebrid":
|
||||
return alldebrid.New(dc)
|
||||
return alldebrid.New(dc, rateLimits)
|
||||
default:
|
||||
return realdebrid.New(dc)
|
||||
return realdebrid.New(dc, rateLimits)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,7 +297,7 @@ func Process(ctx context.Context, store *Storage, selectedDebrid string, magnet
|
||||
Files: make(map[string]types.File),
|
||||
}
|
||||
|
||||
clients := store.FilterClients(func(c types.Client) bool {
|
||||
clients := store.FilterClients(func(c common.Client) bool {
|
||||
if selectedDebrid != "" && c.Name() != selectedDebrid {
|
||||
return false
|
||||
}
|
||||
@@ -184,16 +312,12 @@ func Process(ctx context.Context, store *Storage, selectedDebrid string, magnet
|
||||
|
||||
// Override first, arr second, debrid third
|
||||
|
||||
if overrideDownloadUncached {
|
||||
debridTorrent.DownloadUncached = true
|
||||
} else if a.DownloadUncached != nil {
|
||||
if !overrideDownloadUncached && a.DownloadUncached != nil {
|
||||
// Arr cached is set
|
||||
debridTorrent.DownloadUncached = *a.DownloadUncached
|
||||
} else {
|
||||
debridTorrent.DownloadUncached = false
|
||||
overrideDownloadUncached = *a.DownloadUncached
|
||||
}
|
||||
|
||||
for index, db := range clients {
|
||||
for _, db := range clients {
|
||||
_logger := db.Logger()
|
||||
_logger.Info().
|
||||
Str("Debrid", db.Name()).
|
||||
@@ -203,8 +327,9 @@ func Process(ctx context.Context, store *Storage, selectedDebrid string, magnet
|
||||
Str("Action", action).
|
||||
Msg("Processing torrent")
|
||||
|
||||
if !overrideDownloadUncached && a.DownloadUncached == nil {
|
||||
debridTorrent.DownloadUncached = db.GetDownloadUncached()
|
||||
// If debrid.DownloadUnached is true, it overrides everything
|
||||
if db.GetDownloadUncached() || overrideDownloadUncached {
|
||||
debridTorrent.DownloadUncached = true
|
||||
}
|
||||
|
||||
dbt, err := db.SubmitMagnet(debridTorrent)
|
||||
@@ -214,7 +339,7 @@ func Process(ctx context.Context, store *Storage, selectedDebrid string, magnet
|
||||
}
|
||||
dbt.Arr = a
|
||||
_logger.Info().Str("id", dbt.Id).Msgf("Torrent: %s submitted to %s", dbt.Name, db.Name())
|
||||
store.lastUsed = index
|
||||
store.lastUsed = db.Name()
|
||||
|
||||
torrent, err := db.CheckStatus(dbt)
|
||||
if err != nil && torrent != nil && torrent.Id != "" {
|
||||
|
||||
@@ -3,28 +3,32 @@ 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"
|
||||
|
||||
"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/account"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"go.uber.org/ratelimit"
|
||||
)
|
||||
|
||||
type AllDebrid struct {
|
||||
name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
accounts *types.Accounts
|
||||
accountsManager *account.Manager
|
||||
autoExpiresLinksAfter time.Duration
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
Profile *types.Profile `json:"profile"`
|
||||
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
@@ -33,12 +37,7 @@ type AllDebrid struct {
|
||||
minimumFreeSlot int
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetProfile() (*types.Profile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*AllDebrid, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*AllDebrid, error) {
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
@@ -47,7 +46,7 @@ func New(dc config.Debrid) (*AllDebrid, error) {
|
||||
client := request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithLogger(_log),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithRateLimiter(ratelimits["main"]),
|
||||
request.WithProxy(dc.Proxy),
|
||||
)
|
||||
|
||||
@@ -59,7 +58,7 @@ func New(dc config.Debrid) (*AllDebrid, error) {
|
||||
name: "alldebrid",
|
||||
Host: "http://api.alldebrid.com/v4.1",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: types.NewAccounts(dc),
|
||||
accountsManager: account.NewManager(dc, ratelimits["download"], _log),
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
autoExpiresLinksAfter: autoExpiresLinksAfter,
|
||||
client: client,
|
||||
@@ -105,11 +104,12 @@ func (ad *AllDebrid) SubmitMagnet(torrent *types.Torrent) (*types.Torrent, error
|
||||
}
|
||||
magnets := data.Data.Magnets
|
||||
if len(magnets) == 0 {
|
||||
return nil, fmt.Errorf("error adding torrent")
|
||||
return nil, fmt.Errorf("error adding torrent. No magnets returned")
|
||||
}
|
||||
magnet := magnets[0]
|
||||
torrentId := strconv.Itoa(magnet.ID)
|
||||
torrent.Id = torrentId
|
||||
torrent.Added = time.Now().Format(time.RFC3339)
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
@@ -296,7 +296,7 @@ func (ad *AllDebrid) DeleteTorrent(torrentId string) error {
|
||||
|
||||
func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
filesCh := make(chan types.File, len(t.Files))
|
||||
linksCh := make(chan *types.DownloadLink, len(t.Files))
|
||||
linksCh := make(chan types.DownloadLink, len(t.Files))
|
||||
errCh := make(chan error, len(t.Files))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -309,10 +309,6 @@ func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if link == nil {
|
||||
errCh <- fmt.Errorf("download link is empty")
|
||||
return
|
||||
}
|
||||
linksCh <- link
|
||||
file.DownloadLink = link
|
||||
filesCh <- file
|
||||
@@ -330,17 +326,14 @@ func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
}
|
||||
|
||||
// Collect download links
|
||||
links := make(map[string]*types.DownloadLink, len(t.Files))
|
||||
links := make(map[string]types.DownloadLink, len(t.Files))
|
||||
|
||||
for link := range linksCh {
|
||||
if link == nil {
|
||||
if link.Empty() {
|
||||
continue
|
||||
}
|
||||
links[link.Link] = link
|
||||
}
|
||||
// Update the files with download links
|
||||
ad.accounts.SetDownloadLinks(links)
|
||||
|
||||
// Check for errors
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
@@ -352,7 +345,7 @@ func (ad *AllDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
|
||||
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)
|
||||
@@ -360,22 +353,23 @@ func (ad *AllDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := ad.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
var data DownloadLink
|
||||
if err = json.Unmarshal(resp, &data); err != nil {
|
||||
return nil, err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
if data.Error != nil {
|
||||
return nil, fmt.Errorf("error getting download link: %s", data.Error.Message)
|
||||
return types.DownloadLink{}, 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{}, fmt.Errorf("download link is empty")
|
||||
}
|
||||
now := time.Now()
|
||||
return &types.DownloadLink{
|
||||
dl := types.DownloadLink{
|
||||
Token: ad.APIKey,
|
||||
Link: file.Link,
|
||||
DownloadLink: link,
|
||||
Id: data.Data.Id,
|
||||
@@ -383,7 +377,10 @@ func (ad *AllDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types
|
||||
Filename: file.Name,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(ad.autoExpiresLinksAfter),
|
||||
}, nil
|
||||
}
|
||||
// Set the download link in the account
|
||||
ad.accountsManager.StoreDownloadLink(dl)
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
@@ -419,8 +416,8 @@ func (ad *AllDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
return torrents, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloadLinks() (map[string]*types.DownloadLink, error) {
|
||||
return nil, nil
|
||||
func (ad *AllDebrid) RefreshDownloadLinks() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetDownloadingStatus() []string {
|
||||
@@ -439,16 +436,69 @@ func (ad *AllDebrid) GetMountPath() string {
|
||||
return ad.MountPath
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) DeleteDownloadLink(linkId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetAvailableSlots() (int, error) {
|
||||
// This function is a placeholder for AllDebrid
|
||||
//TODO: Implement the logic to check available slots for AllDebrid
|
||||
return 0, fmt.Errorf("GetAvailableSlots not implemented for AllDebrid")
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) Accounts() *types.Accounts {
|
||||
return ad.accounts
|
||||
func (ad *AllDebrid) GetProfile() (*types.Profile, error) {
|
||||
if ad.Profile != nil {
|
||||
return ad.Profile, nil
|
||||
}
|
||||
url := fmt.Sprintf("%s/user", ad.Host)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := ad.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res UserProfileResponse
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
ad.logger.Error().Err(err).Msgf("Error unmarshalling user profile")
|
||||
return nil, err
|
||||
}
|
||||
if res.Status != "success" {
|
||||
message := "unknown error"
|
||||
if res.Error != nil {
|
||||
message = res.Error.Message
|
||||
}
|
||||
return nil, fmt.Errorf("error getting user profile: %s", message)
|
||||
}
|
||||
userData := res.Data.User
|
||||
expiration := time.Unix(userData.PremiumUntil, 0)
|
||||
profile := &types.Profile{
|
||||
Id: 1,
|
||||
Name: ad.name,
|
||||
Username: userData.Username,
|
||||
Email: userData.Email,
|
||||
Points: userData.FidelityPoints,
|
||||
Premium: userData.PremiumUntil,
|
||||
Expiration: expiration,
|
||||
}
|
||||
if userData.IsPremium {
|
||||
profile.Type = "premium"
|
||||
} else if userData.IsTrial {
|
||||
profile.Type = "trial"
|
||||
} else {
|
||||
profile.Type = "free"
|
||||
}
|
||||
ad.Profile = profile
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) AccountManager() *account.Manager {
|
||||
return ad.accountsManager
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) SyncAccounts() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) DeleteDownloadLink(account *account.Account, downloadLink types.DownloadLink) error {
|
||||
account.DeleteDownloadLink(downloadLink.Link)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -112,3 +112,22 @@ func (m *Magnets) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
return fmt.Errorf("magnets: unsupported JSON format")
|
||||
}
|
||||
|
||||
type UserProfileResponse struct {
|
||||
Status string `json:"status"`
|
||||
Error *errorResponse `json:"error"`
|
||||
Data struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
IsPremium bool `json:"isPremium"`
|
||||
IsSubscribed bool `json:"isSubscribed"`
|
||||
IsTrial bool `json:"isTrial"`
|
||||
PremiumUntil int64 `json:"premiumUntil"`
|
||||
Lang string `json:"lang"`
|
||||
FidelityPoints int `json:"fidelityPoints"`
|
||||
LimitedHostersQuotas map[string]int `json:"limitedHostersQuotas"`
|
||||
Notifications []string `json:"notifications"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
package debrid_link
|
||||
package debridlink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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/account"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"time"
|
||||
"go.uber.org/ratelimit"
|
||||
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -20,7 +23,7 @@ type DebridLink struct {
|
||||
name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
accounts *types.Accounts
|
||||
accountsManager *account.Manager
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
|
||||
@@ -30,11 +33,11 @@ type DebridLink struct {
|
||||
logger zerolog.Logger
|
||||
checkCached bool
|
||||
addSamples bool
|
||||
|
||||
Profile *types.Profile `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*DebridLink, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
|
||||
func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*DebridLink, error) {
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
"Content-Type": "application/json",
|
||||
@@ -43,7 +46,7 @@ func New(dc config.Debrid) (*DebridLink, error) {
|
||||
client := request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithLogger(_log),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithRateLimiter(ratelimits["main"]),
|
||||
request.WithProxy(dc.Proxy),
|
||||
)
|
||||
|
||||
@@ -55,7 +58,7 @@ func New(dc config.Debrid) (*DebridLink, error) {
|
||||
name: "debridlink",
|
||||
Host: "https://debrid-link.com/api/v2",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: types.NewAccounts(dc),
|
||||
accountsManager: account.NewManager(dc, ratelimits["download"], _log),
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
autoExpiresLinksAfter: autoExpiresLinksAfter,
|
||||
client: client,
|
||||
@@ -66,10 +69,6 @@ func New(dc config.Debrid) (*DebridLink, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetProfile() (*types.Profile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) Name() string {
|
||||
return dl.name
|
||||
}
|
||||
@@ -223,7 +222,6 @@ func (dl *DebridLink) UpdateTorrent(t *types.Torrent) error {
|
||||
t.OriginalFilename = name
|
||||
t.Added = time.Unix(data.Created, 0).Format(time.RFC3339)
|
||||
cfg := config.Get()
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
now := time.Now()
|
||||
for _, f := range data.Files {
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
@@ -237,19 +235,19 @@ func (dl *DebridLink) UpdateTorrent(t *types.Torrent) error {
|
||||
Path: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
}
|
||||
link := &types.DownloadLink{
|
||||
link := types.DownloadLink{
|
||||
Token: dl.APIKey,
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(dl.autoExpiresLinksAfter),
|
||||
}
|
||||
links[file.Link] = link
|
||||
file.DownloadLink = link
|
||||
t.Files[f.Name] = file
|
||||
dl.accountsManager.StoreDownloadLink(link)
|
||||
}
|
||||
|
||||
dl.accounts.SetDownloadLinks(links)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -286,8 +284,6 @@ func (dl *DebridLink) SubmitMagnet(t *types.Torrent) (*types.Torrent, error) {
|
||||
t.MountPath = dl.MountPath
|
||||
t.Debrid = dl.name
|
||||
t.Added = time.Unix(data.Created, 0).Format(time.RFC3339)
|
||||
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
now := time.Now()
|
||||
for _, f := range data.Files {
|
||||
file := types.File{
|
||||
@@ -299,20 +295,19 @@ func (dl *DebridLink) SubmitMagnet(t *types.Torrent) (*types.Torrent, error) {
|
||||
Link: f.DownloadURL,
|
||||
Generated: now,
|
||||
}
|
||||
link := &types.DownloadLink{
|
||||
link := types.DownloadLink{
|
||||
Token: dl.APIKey,
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(dl.autoExpiresLinksAfter),
|
||||
}
|
||||
links[file.Link] = link
|
||||
file.DownloadLink = link
|
||||
t.Files[f.Name] = file
|
||||
dl.accountsManager.StoreDownloadLink(link)
|
||||
}
|
||||
|
||||
dl.accounts.SetDownloadLinks(links)
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -355,12 +350,12 @@ func (dl *DebridLink) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetDownloadLinks() (map[string]*types.DownloadLink, error) {
|
||||
return nil, nil
|
||||
func (dl *DebridLink) RefreshDownloadLinks() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
|
||||
return dl.accounts.GetDownloadLink(file.Link)
|
||||
func (dl *DebridLink) GetDownloadLink(t *types.Torrent, file *types.File) (types.DownloadLink, error) {
|
||||
return dl.accountsManager.GetDownloadLink(file.Link)
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetDownloadingStatus() []string {
|
||||
@@ -405,7 +400,6 @@ func (dl *DebridLink) getTorrents(page, perPage int) ([]*types.Torrent, error) {
|
||||
}
|
||||
|
||||
data := *res.Value
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
|
||||
if len(data) == 0 {
|
||||
return torrents, nil
|
||||
@@ -441,20 +435,20 @@ func (dl *DebridLink) getTorrents(page, perPage int) ([]*types.Torrent, error) {
|
||||
Path: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
}
|
||||
link := &types.DownloadLink{
|
||||
link := types.DownloadLink{
|
||||
Token: dl.APIKey,
|
||||
Filename: f.Name,
|
||||
Link: f.DownloadURL,
|
||||
DownloadLink: f.DownloadURL,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(dl.autoExpiresLinksAfter),
|
||||
}
|
||||
links[file.Link] = link
|
||||
file.DownloadLink = link
|
||||
torrent.Files[f.Name] = file
|
||||
dl.accountsManager.StoreDownloadLink(link)
|
||||
}
|
||||
torrents = append(torrents, torrent)
|
||||
}
|
||||
dl.accounts.SetDownloadLinks(links)
|
||||
|
||||
return torrents, nil
|
||||
}
|
||||
@@ -467,15 +461,65 @@ func (dl *DebridLink) GetMountPath() string {
|
||||
return dl.MountPath
|
||||
}
|
||||
|
||||
func (dl *DebridLink) DeleteDownloadLink(linkId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetAvailableSlots() (int, error) {
|
||||
//TODO: Implement the logic to check available slots for DebridLink
|
||||
return 0, fmt.Errorf("GetAvailableSlots not implemented for DebridLink")
|
||||
}
|
||||
|
||||
func (dl *DebridLink) Accounts() *types.Accounts {
|
||||
return dl.accounts
|
||||
func (dl *DebridLink) GetProfile() (*types.Profile, error) {
|
||||
if dl.Profile != nil {
|
||||
return dl.Profile, nil
|
||||
}
|
||||
url := fmt.Sprintf("%s/account/infos", dl.Host)
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := dl.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var res UserInfo
|
||||
err = json.Unmarshal(resp, &res)
|
||||
if err != nil {
|
||||
dl.logger.Error().Err(err).Msgf("Error unmarshalling user info")
|
||||
return nil, err
|
||||
}
|
||||
if !res.Success || res.Value == nil {
|
||||
return nil, fmt.Errorf("error getting user info")
|
||||
}
|
||||
data := *res.Value
|
||||
expiration := time.Unix(data.PremiumLeft, 0)
|
||||
profile := &types.Profile{
|
||||
Id: 1,
|
||||
Username: data.Username,
|
||||
Name: dl.name,
|
||||
Email: data.Email,
|
||||
Points: data.Points,
|
||||
Premium: data.PremiumLeft,
|
||||
Expiration: expiration,
|
||||
}
|
||||
if expiration.IsZero() {
|
||||
profile.Expiration = time.Now().AddDate(1, 0, 0) // Default to 1 year if no expiration
|
||||
}
|
||||
if data.PremiumLeft > 0 {
|
||||
profile.Type = "premium"
|
||||
} else {
|
||||
profile.Type = "free"
|
||||
}
|
||||
dl.Profile = profile
|
||||
return profile, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) AccountManager() *account.Manager {
|
||||
return dl.accountsManager
|
||||
}
|
||||
|
||||
func (dl *DebridLink) SyncAccounts() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) DeleteDownloadLink(account *account.Account, downloadLink types.DownloadLink) error {
|
||||
account.DeleteDownloadLink(downloadLink.Link)
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package debrid_link
|
||||
package debridlink
|
||||
|
||||
type APIResponse[T any] struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -43,3 +43,12 @@ type _torrentInfo struct {
|
||||
type torrentInfo APIResponse[[]_torrentInfo]
|
||||
|
||||
type SubmitTorrentInfo APIResponse[_torrentInfo]
|
||||
|
||||
type UserInfo APIResponse[struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
AccountType int `json:"accountType"`
|
||||
PremiumLeft int64 `json:"premiumLeft"`
|
||||
Points int `json:"pts"`
|
||||
Trafficshare int `json:"trafficshare"`
|
||||
}]
|
||||
@@ -2,11 +2,9 @@ package realdebrid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"io"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
@@ -16,6 +14,10 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/account"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"go.uber.org/ratelimit"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
@@ -28,12 +30,11 @@ type RealDebrid struct {
|
||||
name string
|
||||
Host string `json:"host"`
|
||||
|
||||
APIKey string
|
||||
accounts *types.Accounts
|
||||
APIKey string
|
||||
accountsManager *account.Manager
|
||||
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
downloadClient *request.Client
|
||||
repairClient *request.Client
|
||||
autoExpiresLinksAfter time.Duration
|
||||
|
||||
@@ -46,13 +47,10 @@ type RealDebrid struct {
|
||||
addSamples bool
|
||||
Profile *types.Profile
|
||||
minimumFreeSlot int // Minimum number of active pots to maintain (used for cached stuffs, etc.)
|
||||
|
||||
limit int
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*RealDebrid, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
repairRl := request.ParseRateLimit(cmp.Or(dc.RepairRateLimit, dc.RateLimit))
|
||||
downloadRl := request.ParseRateLimit(cmp.Or(dc.DownloadRateLimit, dc.RateLimit))
|
||||
func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*RealDebrid, error) {
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
@@ -68,27 +66,20 @@ func New(dc config.Debrid) (*RealDebrid, error) {
|
||||
name: "realdebrid",
|
||||
Host: "https://api.real-debrid.com/rest/1.0",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: types.NewAccounts(dc),
|
||||
accountsManager: account.NewManager(dc, ratelimits["download"], _log),
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
autoExpiresLinksAfter: autoExpiresLinksAfter,
|
||||
UnpackRar: dc.UnpackRar,
|
||||
client: request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithRateLimiter(ratelimits["main"]),
|
||||
request.WithLogger(_log),
|
||||
request.WithMaxRetries(10),
|
||||
request.WithRetryableStatus(429, 502),
|
||||
request.WithProxy(dc.Proxy),
|
||||
),
|
||||
downloadClient: request.New(
|
||||
request.WithRateLimiter(downloadRl),
|
||||
request.WithLogger(_log),
|
||||
request.WithMaxRetries(10),
|
||||
request.WithRetryableStatus(429, 447, 502),
|
||||
request.WithProxy(dc.Proxy),
|
||||
),
|
||||
repairClient: request.New(
|
||||
request.WithRateLimiter(repairRl),
|
||||
request.WithRateLimiter(ratelimits["repair"]),
|
||||
request.WithHeaders(headers),
|
||||
request.WithLogger(_log),
|
||||
request.WithMaxRetries(4),
|
||||
@@ -101,6 +92,7 @@ func New(dc config.Debrid) (*RealDebrid, error) {
|
||||
checkCached: dc.CheckCached,
|
||||
addSamples: dc.AddSamples,
|
||||
minimumFreeSlot: dc.MinimumFreeSlot,
|
||||
limit: dc.Limit,
|
||||
}
|
||||
|
||||
if _, err := r.GetProfile(); err != nil {
|
||||
@@ -160,6 +152,23 @@ func (r *RealDebrid) getSelectedFiles(t *types.Torrent, data torrentInfo) (map[s
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) handleRarFallback(t *types.Torrent, data torrentInfo) (map[string]types.File, error) {
|
||||
files := make(map[string]types.File)
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: "0",
|
||||
Name: t.Name + ".rar",
|
||||
Size: data.Bytes,
|
||||
IsRar: true,
|
||||
ByteRange: nil,
|
||||
Path: t.Name + ".rar",
|
||||
Link: data.Links[0],
|
||||
Generated: time.Now(),
|
||||
}
|
||||
files[file.Name] = file
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// handleRarArchive processes RAR archives with multiple files
|
||||
func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, selectedFiles []types.File) (map[string]types.File, error) {
|
||||
// This will block if 2 RAR operations are already in progress
|
||||
@@ -171,21 +180,8 @@ func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, select
|
||||
files := make(map[string]types.File)
|
||||
|
||||
if !r.UnpackRar {
|
||||
r.logger.Debug().Msgf("RAR file detected, but unpacking is disabled: %s", t.Name)
|
||||
// Create a single file representing the RAR archive
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: "0",
|
||||
Name: t.Name + ".rar",
|
||||
Size: 0,
|
||||
IsRar: true,
|
||||
ByteRange: nil,
|
||||
Path: t.Name + ".rar",
|
||||
Link: data.Links[0],
|
||||
Generated: time.Now(),
|
||||
}
|
||||
files[file.Name] = file
|
||||
return files, nil
|
||||
r.logger.Debug().Msgf("RAR file detected, but unpacking is disabled: %s. Falling back to single file representation.", t.Name)
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
|
||||
r.logger.Info().Msgf("RAR file detected, unpacking: %s", t.Name)
|
||||
@@ -193,20 +189,23 @@ func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, select
|
||||
downloadLinkObj, err := r.GetDownloadLink(t, linkFile)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get download link for RAR file: %w", err)
|
||||
r.logger.Debug().Err(err).Msgf("Error getting download link for RAR file: %s. Falling back to single file representation.", t.Name)
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
|
||||
dlLink := downloadLinkObj.DownloadLink
|
||||
reader, err := rar.NewReader(dlLink)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create RAR reader: %w", err)
|
||||
r.logger.Debug().Err(err).Msgf("Error creating RAR reader for %s. Falling back to single file representation.", t.Name)
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
|
||||
rarFiles, err := reader.GetFiles()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read RAR files: %w", err)
|
||||
r.logger.Debug().Err(err).Msgf("Error reading RAR files for %s. Falling back to single file representation.", t.Name)
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
|
||||
// Create lookup map for faster matching
|
||||
@@ -231,7 +230,11 @@ func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, select
|
||||
r.logger.Warn().Msgf("RAR file %s not found in torrent files", rarFile.Name())
|
||||
}
|
||||
}
|
||||
|
||||
if len(files) == 0 {
|
||||
r.logger.Warn().Msgf("No valid files found in RAR archive for torrent: %s", t.Name)
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
r.logger.Info().Msgf("Unpacked RAR archive for torrent: %s with %d files", t.Name, len(files))
|
||||
return files, nil
|
||||
}
|
||||
|
||||
@@ -344,21 +347,17 @@ func (r *RealDebrid) addTorrent(t *types.Torrent) (*types.Torrent, error) {
|
||||
if resp.StatusCode == 509 {
|
||||
return nil, utils.TooManyActiveDownloadsError
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
if err = json.Unmarshal(bodyBytes, &data); err != nil {
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Id = data.Id
|
||||
t.Debrid = r.name
|
||||
t.MountPath = r.MountPath
|
||||
t.Added = time.Now().Format(time.RFC3339)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -373,6 +372,7 @@ func (r *RealDebrid) addMagnet(t *types.Torrent) (*types.Torrent, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
// Handle multiple_downloads
|
||||
|
||||
@@ -380,20 +380,16 @@ func (r *RealDebrid) addMagnet(t *types.Torrent) (*types.Torrent, error) {
|
||||
return nil, utils.TooManyActiveDownloadsError
|
||||
}
|
||||
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
bodyBytes, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response body: %w", err)
|
||||
}
|
||||
if err = json.Unmarshal(bodyBytes, &data); err != nil {
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Id = data.Id
|
||||
t.Debrid = r.name
|
||||
t.MountPath = r.MountPath
|
||||
t.Added = time.Now().Format(time.RFC3339)
|
||||
return t, nil
|
||||
}
|
||||
|
||||
@@ -405,19 +401,15 @@ func (r *RealDebrid) GetTorrent(torrentId string) (*types.Torrent, error) {
|
||||
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 {
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, utils.TorrentNotFoundError
|
||||
}
|
||||
return nil, fmt.Errorf("realdebrid API error: Status: %d || Body: %s", resp.StatusCode, string(bodyBytes))
|
||||
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 {
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t := &types.Torrent{
|
||||
@@ -448,19 +440,15 @@ func (r *RealDebrid) UpdateTorrent(t *types.Torrent) error {
|
||||
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 utils.TorrentNotFoundError
|
||||
}
|
||||
bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||||
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 {
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
t.Name = data.Filename
|
||||
@@ -475,7 +463,6 @@ func (r *RealDebrid) UpdateTorrent(t *types.Torrent) error {
|
||||
t.Links = data.Links
|
||||
t.MountPath = r.MountPath
|
||||
t.Debrid = r.name
|
||||
t.Added = data.Added
|
||||
t.Files, _ = r.getSelectedFiles(t, data) // Get selected files
|
||||
|
||||
return nil
|
||||
@@ -507,10 +494,11 @@ func (r *RealDebrid) CheckStatus(t *types.Torrent) (*types.Torrent, error) {
|
||||
t.Status = status
|
||||
t.Debrid = r.name
|
||||
t.MountPath = r.MountPath
|
||||
t.Added = data.Added
|
||||
if status == "waiting_files_selection" {
|
||||
t.Files = r.getTorrentFiles(t, data)
|
||||
if len(t.Files) == 0 {
|
||||
return t, fmt.Errorf("no video files found")
|
||||
return t, fmt.Errorf("no valid files found")
|
||||
}
|
||||
filesId := make([]string, 0)
|
||||
for _, f := range t.Files {
|
||||
@@ -567,7 +555,7 @@ func (r *RealDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
var firstErr error
|
||||
|
||||
files := make(map[string]types.File)
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
links := make(map[string]types.DownloadLink)
|
||||
|
||||
_files := t.GetFiles()
|
||||
wg.Add(len(_files))
|
||||
@@ -585,7 +573,7 @@ func (r *RealDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
mu.Unlock()
|
||||
return
|
||||
}
|
||||
if link == nil {
|
||||
if link.Empty() {
|
||||
mu.Lock()
|
||||
if firstErr == nil {
|
||||
firstErr = fmt.Errorf("realdebrid API error: download link not found for file %s", file.Name)
|
||||
@@ -595,7 +583,6 @@ func (r *RealDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
}
|
||||
|
||||
file.DownloadLink = link
|
||||
|
||||
mu.Lock()
|
||||
files[file.Name] = file
|
||||
links[link.Link] = link
|
||||
@@ -610,7 +597,6 @@ func (r *RealDebrid) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
}
|
||||
|
||||
// Add links to cache
|
||||
r.accounts.SetDownloadLinks(links)
|
||||
t.Files = files
|
||||
return nil
|
||||
}
|
||||
@@ -631,8 +617,9 @@ func (r *RealDebrid) CheckLink(link string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) _getDownloadLink(file *types.File) (*types.DownloadLink, error) {
|
||||
func (r *RealDebrid) getDownloadLink(account *account.Account, file *types.File) (types.DownloadLink, error) {
|
||||
url := fmt.Sprintf("%s/unrestrict/link/", r.Host)
|
||||
emptyLink := types.DownloadLink{}
|
||||
_link := file.Link
|
||||
if strings.HasPrefix(file.Link, "https://real-debrid.com/d/") && len(file.Link) > 39 {
|
||||
_link = file.Link[0:39]
|
||||
@@ -641,70 +628,56 @@ func (r *RealDebrid) _getDownloadLink(file *types.File) (*types.DownloadLink, er
|
||||
"link": {_link},
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodPost, url, strings.NewReader(payload.Encode()))
|
||||
resp, err := r.downloadClient.Do(req)
|
||||
resp, err := account.Client().Do(req)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return emptyLink, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
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))
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return emptyLink, fmt.Errorf("error unmarshalling %d || %s", resp.StatusCode, err)
|
||||
}
|
||||
switch data.ErrorCode {
|
||||
case 19:
|
||||
return nil, utils.HosterUnavailableError // File has been removed
|
||||
case 23:
|
||||
return nil, utils.TrafficExceededError
|
||||
case 24:
|
||||
return nil, utils.HosterUnavailableError // Link has been nerfed
|
||||
case 34:
|
||||
return nil, utils.TrafficExceededError // traffic exceeded
|
||||
case 35:
|
||||
return nil, utils.HosterUnavailableError
|
||||
case 36:
|
||||
return nil, utils.TrafficExceededError // traffic exceeded
|
||||
case 19, 24, 35:
|
||||
return emptyLink, utils.HosterUnavailableError // File has been removed
|
||||
case 23, 34, 36:
|
||||
return emptyLink, utils.TrafficExceededError
|
||||
default:
|
||||
return nil, fmt.Errorf("realdebrid API error: Status: %d || Code: %d", resp.StatusCode, data.ErrorCode)
|
||||
return emptyLink, 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 err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return emptyLink, 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 emptyLink, fmt.Errorf("realdebrid API error: download link not found")
|
||||
}
|
||||
now := time.Now()
|
||||
return &types.DownloadLink{
|
||||
dl := types.DownloadLink{
|
||||
Token: account.Token,
|
||||
Filename: data.Filename,
|
||||
Size: data.Filesize,
|
||||
Link: data.Link,
|
||||
DownloadLink: data.Download,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(r.autoExpiresLinksAfter),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Store the link in the account
|
||||
account.StoreDownloadLink(dl)
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
|
||||
|
||||
accounts := r.accounts.All()
|
||||
|
||||
for _, account := range accounts {
|
||||
r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", account.Token))
|
||||
downloadLink, err := r._getDownloadLink(file)
|
||||
|
||||
func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (types.DownloadLink, error) {
|
||||
accounts := r.accountsManager.Active()
|
||||
for _, _account := range accounts {
|
||||
downloadLink, err := r.getDownloadLink(_account, file)
|
||||
if err == nil {
|
||||
return downloadLink, nil
|
||||
}
|
||||
@@ -715,16 +688,16 @@ func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types
|
||||
retries = 5
|
||||
} else {
|
||||
// If the error is not traffic exceeded, return the error
|
||||
return nil, err
|
||||
return downloadLink, err
|
||||
}
|
||||
backOff := 1 * time.Second
|
||||
for retries > 0 {
|
||||
downloadLink, err = r._getDownloadLink(file)
|
||||
downloadLink, err = r.getDownloadLink(_account, file)
|
||||
if err == nil {
|
||||
return downloadLink, nil
|
||||
}
|
||||
if !errors.Is(err, utils.TrafficExceededError) {
|
||||
return nil, err
|
||||
return downloadLink, err
|
||||
}
|
||||
// Add a delay before retrying
|
||||
time.Sleep(backOff)
|
||||
@@ -732,7 +705,7 @@ func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types
|
||||
retries--
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("realdebrid API error: download link not found")
|
||||
return types.DownloadLink{}, fmt.Errorf("realdebrid API error: used all active accounts")
|
||||
}
|
||||
|
||||
func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent, error) {
|
||||
@@ -758,14 +731,10 @@ func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent,
|
||||
}
|
||||
|
||||
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
|
||||
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return 0, nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
filenames := map[string]struct{}{}
|
||||
for _, t := range data {
|
||||
@@ -794,6 +763,10 @@ func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent,
|
||||
|
||||
func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
limit := 5000
|
||||
if r.limit != 0 {
|
||||
limit = r.limit
|
||||
}
|
||||
hardLimit := r.limit
|
||||
|
||||
// Get first batch and total count
|
||||
allTorrents := make([]*types.Torrent, 0)
|
||||
@@ -812,6 +785,10 @@ func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
}
|
||||
allTorrents = append(allTorrents, torrents...)
|
||||
offset += totalTorrents
|
||||
if hardLimit != 0 && len(allTorrents) >= hardLimit {
|
||||
// If hard limit is set, stop fetching more torrents
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if fetchError != nil {
|
||||
@@ -821,49 +798,47 @@ func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
return allTorrents, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetDownloadLinks() (map[string]*types.DownloadLink, error) {
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
offset := 0
|
||||
limit := 1000
|
||||
func (r *RealDebrid) RefreshDownloadLinks() error {
|
||||
accounts := r.accountsManager.All()
|
||||
|
||||
accounts := r.accounts.All()
|
||||
|
||||
if len(accounts) < 1 {
|
||||
// No active download keys. It's likely that the key has reached bandwidth limit
|
||||
return links, fmt.Errorf("no active download keys")
|
||||
}
|
||||
activeAccount := accounts[0]
|
||||
r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", activeAccount.Token))
|
||||
for {
|
||||
dl, err := r._getDownloads(offset, limit)
|
||||
if err != nil {
|
||||
break
|
||||
for _, _account := range accounts {
|
||||
if _account == nil || _account.Token == "" {
|
||||
continue
|
||||
}
|
||||
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
|
||||
offset := 0
|
||||
limit := 1000
|
||||
links := make(map[string]*types.DownloadLink)
|
||||
for {
|
||||
dl, err := r.getDownloadLinks(_account, offset, limit)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
links[d.Link] = &d
|
||||
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)
|
||||
}
|
||||
|
||||
offset += len(dl)
|
||||
_account.StoreDownloadLinks(links)
|
||||
}
|
||||
|
||||
return links, nil
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) _getDownloads(offset int, limit int) ([]types.DownloadLink, error) {
|
||||
func (r *RealDebrid) getDownloadLinks(account *account.Account, 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)
|
||||
resp, err := account.Client().MakeRequest(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -874,6 +849,7 @@ func (r *RealDebrid) _getDownloads(offset int, limit int) ([]types.DownloadLink,
|
||||
links := make([]types.DownloadLink, 0)
|
||||
for _, d := range data {
|
||||
links = append(links, types.DownloadLink{
|
||||
Token: account.Token,
|
||||
Filename: d.Filename,
|
||||
Size: d.Filesize,
|
||||
Link: d.Link,
|
||||
@@ -899,15 +875,6 @@ func (r *RealDebrid) GetMountPath() string {
|
||||
return r.MountPath
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetProfile() (*types.Profile, error) {
|
||||
if r.Profile != nil {
|
||||
return r.Profile, nil
|
||||
@@ -924,6 +891,7 @@ func (r *RealDebrid) GetProfile() (*types.Profile, error) {
|
||||
return nil, err
|
||||
}
|
||||
profile := &types.Profile{
|
||||
Name: r.name,
|
||||
Id: data.Id,
|
||||
Username: data.Username,
|
||||
Email: data.Email,
|
||||
@@ -937,8 +905,7 @@ func (r *RealDebrid) GetProfile() (*types.Profile, error) {
|
||||
}
|
||||
|
||||
func (r *RealDebrid) GetAvailableSlots() (int, error) {
|
||||
url := fmt.Sprintf("%s/torrents/activeCount", r.Host)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req, _ := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/torrents/activeCount", r.Host), nil)
|
||||
resp, err := r.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
return 0, nil
|
||||
@@ -950,6 +917,89 @@ func (r *RealDebrid) GetAvailableSlots() (int, error) {
|
||||
return data.TotalSlots - data.ActiveSlots - r.minimumFreeSlot, nil // Ensure we maintain minimum active pots
|
||||
}
|
||||
|
||||
func (r *RealDebrid) Accounts() *types.Accounts {
|
||||
return r.accounts
|
||||
func (r *RealDebrid) AccountManager() *account.Manager {
|
||||
return r.accountsManager
|
||||
}
|
||||
|
||||
func (r *RealDebrid) SyncAccounts() error {
|
||||
// Sync accounts with the current configuration
|
||||
if len(r.accountsManager.Active()) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, _account := range r.accountsManager.All() {
|
||||
if err := r.syncAccount(_account); err != nil {
|
||||
r.logger.Error().Err(err).Msgf("Error syncing account %s", _account.Username)
|
||||
continue // Skip this account and continue with the next
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) syncAccount(account *account.Account) error {
|
||||
if account.Token == "" {
|
||||
return fmt.Errorf("account %s has no token", account.Username)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/user", r.Host), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating request for account %s: %w", account.Username, err)
|
||||
}
|
||||
resp, err := account.Client().Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking account %s: %w", account.Username, err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
resp.Body.Close()
|
||||
return fmt.Errorf("account %s is not valid, status code: %d", account.Username, resp.StatusCode)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var profile profileResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||
return fmt.Errorf("error decoding profile for account %s: %w", account.Username, err)
|
||||
}
|
||||
account.Username = profile.Username
|
||||
|
||||
// Get traffic usage
|
||||
trafficReq, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/traffic/details", r.Host), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error creating request for traffic details for account %s: %w", account.Username, err)
|
||||
}
|
||||
trafficResp, err := account.Client().Do(trafficReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking traffic for account %s: %w", account.Username, err)
|
||||
}
|
||||
if trafficResp.StatusCode != http.StatusOK {
|
||||
trafficResp.Body.Close()
|
||||
return fmt.Errorf("error checking traffic for account %s, status code: %d", account.Username, trafficResp.StatusCode)
|
||||
}
|
||||
|
||||
defer trafficResp.Body.Close()
|
||||
var trafficData TrafficResponse
|
||||
if err := json.NewDecoder(trafficResp.Body).Decode(&trafficData); err != nil {
|
||||
// Skip logging traffic error
|
||||
account.TrafficUsed.Store(0)
|
||||
} else {
|
||||
today := time.Now().Format(time.DateOnly)
|
||||
if todayData, exists := trafficData[today]; exists {
|
||||
account.TrafficUsed.Store(todayData.Bytes)
|
||||
}
|
||||
}
|
||||
//r.accountsManager.Update(account)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) DeleteDownloadLink(account *account.Account, downloadLink types.DownloadLink) error {
|
||||
url := fmt.Sprintf("%s/downloads/delete/%s", r.Host, downloadLink.Id)
|
||||
req, _ := http.NewRequest(http.MethodDelete, url, nil)
|
||||
resp, err := account.Client().Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(Body io.ReadCloser) {
|
||||
_ = Body.Close()
|
||||
}(resp.Body)
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
return fmt.Errorf("realdebrid API error: %d", resp.StatusCode)
|
||||
}
|
||||
account.DeleteDownloadLink(downloadLink.Link)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -144,11 +144,11 @@ type profileResponse struct {
|
||||
Id int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Points int64 `json:"points"`
|
||||
Points int `json:"points"`
|
||||
Locale string `json:"locale"`
|
||||
Avatar string `json:"avatar"`
|
||||
Type string `json:"type"`
|
||||
Premium int `json:"premium"`
|
||||
Premium int64 `json:"premium"`
|
||||
Expiration time.Time `json:"expiration"`
|
||||
}
|
||||
|
||||
@@ -156,3 +156,10 @@ type AvailableSlotsResponse struct {
|
||||
ActiveSlots int `json:"nb"`
|
||||
TotalSlots int `json:"limit"`
|
||||
}
|
||||
|
||||
type hostData struct {
|
||||
Host map[string]int64 `json:"host"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type TrafficResponse map[string]hostData
|
||||
|
||||
@@ -20,15 +20,17 @@ import (
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/account"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"github.com/sirrobot01/decypharr/pkg/version"
|
||||
"go.uber.org/ratelimit"
|
||||
)
|
||||
|
||||
type Torbox struct {
|
||||
name string
|
||||
Host string `json:"host"`
|
||||
APIKey string
|
||||
accounts *types.Accounts
|
||||
accountsManager *account.Manager
|
||||
autoExpiresLinksAfter time.Duration
|
||||
|
||||
DownloadUncached bool
|
||||
@@ -40,12 +42,7 @@ type Torbox struct {
|
||||
addSamples bool
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetProfile() (*types.Profile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*Torbox, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
func New(dc config.Debrid, ratelimits map[string]ratelimit.Limiter) (*Torbox, error) {
|
||||
|
||||
headers := map[string]string{
|
||||
"Authorization": fmt.Sprintf("Bearer %s", dc.APIKey),
|
||||
@@ -54,7 +51,7 @@ func New(dc config.Debrid) (*Torbox, error) {
|
||||
_log := logger.New(dc.Name)
|
||||
client := request.New(
|
||||
request.WithHeaders(headers),
|
||||
request.WithRateLimiter(rl),
|
||||
request.WithRateLimiter(ratelimits["main"]),
|
||||
request.WithLogger(_log),
|
||||
request.WithProxy(dc.Proxy),
|
||||
)
|
||||
@@ -67,7 +64,7 @@ func New(dc config.Debrid) (*Torbox, error) {
|
||||
name: "torbox",
|
||||
Host: "https://api.torbox.app/v1",
|
||||
APIKey: dc.APIKey,
|
||||
accounts: types.NewAccounts(dc),
|
||||
accountsManager: account.NewManager(dc, ratelimits["download"], _log),
|
||||
DownloadUncached: dc.DownloadUncached,
|
||||
autoExpiresLinksAfter: autoExpiresLinksAfter,
|
||||
client: client,
|
||||
@@ -142,6 +139,9 @@ func (tb *Torbox) SubmitMagnet(torrent *types.Torrent) (*types.Torrent, error) {
|
||||
payload := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(payload)
|
||||
_ = writer.WriteField("magnet", torrent.Magnet.Link)
|
||||
if !torrent.DownloadUncached {
|
||||
_ = writer.WriteField("add_only_if_cached", "true")
|
||||
}
|
||||
err := writer.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -165,6 +165,7 @@ func (tb *Torbox) SubmitMagnet(torrent *types.Torrent) (*types.Torrent, error) {
|
||||
torrent.Id = torrentId
|
||||
torrent.MountPath = tb.MountPath
|
||||
torrent.Debrid = tb.name
|
||||
torrent.Added = time.Now().Format(time.RFC3339)
|
||||
|
||||
return torrent, nil
|
||||
}
|
||||
@@ -407,7 +408,7 @@ func (tb *Torbox) DeleteTorrent(torrentId string) error {
|
||||
|
||||
func (tb *Torbox) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
filesCh := make(chan types.File, len(t.Files))
|
||||
linkCh := make(chan *types.DownloadLink)
|
||||
linkCh := make(chan types.DownloadLink)
|
||||
errCh := make(chan error, len(t.Files))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
@@ -420,7 +421,7 @@ func (tb *Torbox) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
errCh <- err
|
||||
return
|
||||
}
|
||||
if link != nil {
|
||||
if link.DownloadLink != "" {
|
||||
linkCh <- link
|
||||
file.DownloadLink = link
|
||||
}
|
||||
@@ -440,13 +441,6 @@ func (tb *Torbox) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
files[file.Name] = file
|
||||
}
|
||||
|
||||
// Collect download links
|
||||
for link := range linkCh {
|
||||
if link != nil {
|
||||
tb.accounts.SetDownloadLink(link.Link, link)
|
||||
}
|
||||
}
|
||||
|
||||
// Check for errors
|
||||
for err := range errCh {
|
||||
if err != nil {
|
||||
@@ -458,7 +452,7 @@ func (tb *Torbox) GetFileDownloadLinks(t *types.Torrent) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
|
||||
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)
|
||||
@@ -474,7 +468,7 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
Str("torrent_id", t.Id).
|
||||
Str("file_id", file.Id).
|
||||
Msg("Failed to make request to Torbox API")
|
||||
return nil, err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
var data DownloadLinksResponse
|
||||
@@ -484,7 +478,7 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
Str("torrent_id", t.Id).
|
||||
Str("file_id", file.Id).
|
||||
Msg("Failed to unmarshal Torbox API response")
|
||||
return nil, err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
if data.Data == nil {
|
||||
@@ -495,7 +489,7 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
Interface("error", data.Error).
|
||||
Str("detail", data.Detail).
|
||||
Msg("Torbox API returned no data")
|
||||
return nil, fmt.Errorf("error getting download links")
|
||||
return types.DownloadLink{}, fmt.Errorf("error getting download links")
|
||||
}
|
||||
|
||||
link := *data.Data
|
||||
@@ -504,11 +498,12 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
Str("torrent_id", t.Id).
|
||||
Str("file_id", file.Id).
|
||||
Msg("Torbox API returned empty download link")
|
||||
return nil, fmt.Errorf("error getting download links")
|
||||
return types.DownloadLink{}, fmt.Errorf("error getting download links")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
downloadLink := &types.DownloadLink{
|
||||
dl := types.DownloadLink{
|
||||
Token: tb.APIKey,
|
||||
Link: file.Link,
|
||||
DownloadLink: link,
|
||||
Id: file.Id,
|
||||
@@ -516,7 +511,9 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
|
||||
ExpiresAt: now.Add(tb.autoExpiresLinksAfter),
|
||||
}
|
||||
|
||||
return downloadLink, nil
|
||||
tb.accountsManager.StoreDownloadLink(dl)
|
||||
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadingStatus() []string {
|
||||
@@ -524,7 +521,25 @@ func (tb *Torbox) GetDownloadingStatus() []string {
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetTorrents() ([]*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/api/torrents/mylist", tb.Host)
|
||||
offset := 0
|
||||
allTorrents := make([]*types.Torrent, 0)
|
||||
|
||||
for {
|
||||
torrents, err := tb.getTorrents(offset)
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
if len(torrents) == 0 {
|
||||
break
|
||||
}
|
||||
allTorrents = append(allTorrents, torrents...)
|
||||
offset += len(torrents)
|
||||
}
|
||||
return allTorrents, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) getTorrents(offset int) ([]*types.Torrent, error) {
|
||||
url := fmt.Sprintf("%s/api/torrents/mylist?offset=%d", tb.Host, offset)
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := tb.client.MakeRequest(req)
|
||||
if err != nil {
|
||||
@@ -611,8 +626,8 @@ func (tb *Torbox) GetDownloadUncached() bool {
|
||||
return tb.DownloadUncached
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadLinks() (map[string]*types.DownloadLink, error) {
|
||||
return nil, nil
|
||||
func (tb *Torbox) RefreshDownloadLinks() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) CheckLink(link string) error {
|
||||
@@ -623,15 +638,24 @@ func (tb *Torbox) GetMountPath() string {
|
||||
return tb.MountPath
|
||||
}
|
||||
|
||||
func (tb *Torbox) DeleteDownloadLink(linkId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetAvailableSlots() (int, error) {
|
||||
//TODO: Implement the logic to check available slots for Torbox
|
||||
return 0, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (tb *Torbox) Accounts() *types.Accounts {
|
||||
return tb.accounts
|
||||
func (tb *Torbox) GetProfile() (*types.Profile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) AccountManager() *account.Manager {
|
||||
return tb.accountsManager
|
||||
}
|
||||
|
||||
func (tb *Torbox) SyncAccounts() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) DeleteDownloadLink(account *account.Account, downloadLink types.DownloadLink) error {
|
||||
account.DeleteDownloadLink(downloadLink.Link)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -4,8 +4,10 @@ import (
|
||||
"bufio"
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -16,7 +18,12 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/common"
|
||||
"github.com/sirrobot01/decypharr/pkg/rclone"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"golang.org/x/sync/singleflight"
|
||||
|
||||
"encoding/json"
|
||||
_ "time/tzdata"
|
||||
@@ -71,18 +78,18 @@ type RepairRequest struct {
|
||||
|
||||
type Cache struct {
|
||||
dir string
|
||||
client types.Client
|
||||
client common.Client
|
||||
logger zerolog.Logger
|
||||
|
||||
torrents *torrentCache
|
||||
invalidDownloadLinks sync.Map
|
||||
folderNaming WebDavFolderNaming
|
||||
torrents *torrentCache
|
||||
folderNaming WebDavFolderNaming
|
||||
|
||||
listingDebouncer *utils.Debouncer[bool]
|
||||
// monitors
|
||||
repairRequest sync.Map
|
||||
failedToReinsert sync.Map
|
||||
downloadLinkRequests sync.Map
|
||||
invalidDownloadLinks *xsync.Map[string, string]
|
||||
repairRequest *xsync.Map[string, *reInsertRequest]
|
||||
failedToReinsert *xsync.Map[string, struct{}]
|
||||
failedLinksCounter *xsync.Map[string, atomic.Int32] // link -> counter
|
||||
|
||||
// repair
|
||||
repairChan chan RepairRequest
|
||||
@@ -106,9 +113,12 @@ type Cache struct {
|
||||
|
||||
config config.Debrid
|
||||
customFolders []string
|
||||
mounter *rclone.Mount
|
||||
downloadSG singleflight.Group
|
||||
streamClient *http.Client
|
||||
}
|
||||
|
||||
func NewDebridCache(dc config.Debrid, client types.Client) *Cache {
|
||||
func NewDebridCache(dc config.Debrid, client common.Client, mounter *rclone.Mount) *Cache {
|
||||
cfg := config.Get()
|
||||
cet, err := time.LoadLocation("CET")
|
||||
if err != nil {
|
||||
@@ -120,9 +130,13 @@ func NewDebridCache(dc config.Debrid, client types.Client) *Cache {
|
||||
cetSc, err := gocron.NewScheduler(gocron.WithLocation(cet))
|
||||
if err != nil {
|
||||
// If we can't create a CET scheduler, fallback to local time
|
||||
cetSc, _ = gocron.NewScheduler(gocron.WithLocation(time.Local))
|
||||
cetSc, _ = gocron.NewScheduler(gocron.WithLocation(time.Local), gocron.WithGlobalJobOptions(
|
||||
gocron.WithTags("decypharr-"+dc.Name)))
|
||||
}
|
||||
scheduler, err := gocron.NewScheduler(gocron.WithLocation(time.Local))
|
||||
scheduler, err := gocron.NewScheduler(
|
||||
gocron.WithLocation(time.Local),
|
||||
gocron.WithGlobalJobOptions(
|
||||
gocron.WithTags("decypharr-"+dc.Name)))
|
||||
if err != nil {
|
||||
// If we can't create a local scheduler, fallback to CET
|
||||
scheduler = cetSc
|
||||
@@ -147,6 +161,21 @@ func NewDebridCache(dc config.Debrid, client types.Client) *Cache {
|
||||
|
||||
}
|
||||
_log := logger.New(fmt.Sprintf("%s-webdav", client.Name()))
|
||||
transport := &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
ResponseHeaderTimeout: 60 * time.Second,
|
||||
MaxIdleConns: 100,
|
||||
MaxIdleConnsPerHost: 20,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
DisableKeepAlives: false,
|
||||
ForceAttemptHTTP2: false,
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: transport,
|
||||
Timeout: 0,
|
||||
}
|
||||
|
||||
c := &Cache{
|
||||
dir: filepath.Join(cfg.Path, "cache", dc.Name), // path to save cache files
|
||||
|
||||
@@ -163,8 +192,15 @@ func NewDebridCache(dc config.Debrid, client types.Client) *Cache {
|
||||
|
||||
config: dc,
|
||||
customFolders: customFolders,
|
||||
mounter: mounter,
|
||||
|
||||
ready: make(chan struct{}),
|
||||
ready: make(chan struct{}),
|
||||
invalidDownloadLinks: xsync.NewMap[string, string](),
|
||||
repairRequest: xsync.NewMap[string, *reInsertRequest](),
|
||||
failedToReinsert: xsync.NewMap[string, struct{}](),
|
||||
failedLinksCounter: xsync.NewMap[string, atomic.Int32](),
|
||||
streamClient: httpClient,
|
||||
repairChan: make(chan RepairRequest, 100), // Initialize the repair channel, max 100 requests buffered
|
||||
}
|
||||
|
||||
c.listingDebouncer = utils.NewDebouncer[bool](100*time.Millisecond, func(refreshRclone bool) {
|
||||
@@ -186,28 +222,36 @@ func (c *Cache) StreamWithRclone() bool {
|
||||
// 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")
|
||||
// Unmount first
|
||||
if c.mounter != nil && c.mounter.IsMounted() {
|
||||
if err := c.mounter.Unmount(); err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to unmount %s", c.config.Name)
|
||||
} else {
|
||||
c.logger.Info().Msgf("Unmounted %s", c.config.Name)
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Shutdown the scheduler (this will stop all 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)
|
||||
if c.repairChan != nil {
|
||||
close(c.repairChan)
|
||||
}
|
||||
|
||||
// 1. Reset torrent storage
|
||||
c.torrents.reset()
|
||||
|
||||
// 3. Clear any sync.Maps
|
||||
c.invalidDownloadLinks = sync.Map{}
|
||||
c.repairRequest = sync.Map{}
|
||||
c.failedToReinsert = sync.Map{}
|
||||
c.downloadLinkRequests = sync.Map{}
|
||||
c.invalidDownloadLinks = xsync.NewMap[string, string]()
|
||||
c.repairRequest = xsync.NewMap[string, *reInsertRequest]()
|
||||
c.failedToReinsert = xsync.NewMap[string, struct{}]()
|
||||
|
||||
// 5. Rebuild the listing debouncer
|
||||
c.listingDebouncer = utils.NewDebouncer[bool](
|
||||
@@ -219,6 +263,9 @@ func (c *Cache) Reset() {
|
||||
|
||||
// 6. Reset repair channel so the next Start() can spin it up
|
||||
c.repairChan = make(chan RepairRequest, 100)
|
||||
|
||||
// Reset the ready channel
|
||||
c.ready = make(chan struct{})
|
||||
}
|
||||
|
||||
func (c *Cache) Start(ctx context.Context) error {
|
||||
@@ -237,12 +284,6 @@ func (c *Cache) Start(ctx context.Context) error {
|
||||
|
||||
// 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) // Initialize the repair channel, max 100 requests buffered
|
||||
go c.repairWorker(ctx)
|
||||
|
||||
cfg := config.Get()
|
||||
@@ -250,10 +291,13 @@ func (c *Cache) Start(ctx context.Context) error {
|
||||
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()
|
||||
|
||||
if c.mounter != nil {
|
||||
if err := c.mounter.Mount(ctx); err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to mount %s", c.config.Name)
|
||||
}
|
||||
} else {
|
||||
c.logger.Warn().Msgf("Mounting is disabled for %s", c.config.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -515,7 +559,7 @@ func (c *Cache) setTorrent(t CachedTorrent, callback func(torrent CachedTorrent)
|
||||
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.torrents.set(torrentName, t)
|
||||
go c.SaveTorrent(t)
|
||||
if callback != nil {
|
||||
go callback(updatedTorrent)
|
||||
@@ -531,7 +575,7 @@ func (c *Cache) setTorrents(torrents map[string]CachedTorrent, callback func())
|
||||
mergedFiles := mergeFiles(o, updatedTorrent)
|
||||
updatedTorrent.Files = mergedFiles
|
||||
}
|
||||
c.torrents.set(torrentName, t, updatedTorrent)
|
||||
c.torrents.set(torrentName, t)
|
||||
}
|
||||
c.SaveTorrents()
|
||||
if callback != nil {
|
||||
@@ -688,7 +732,7 @@ func (c *Cache) ProcessTorrent(t *types.Torrent) error {
|
||||
Str("torrent_id", t.Id).
|
||||
Str("torrent_name", t.Name).
|
||||
Int("total_files", len(t.Files)).
|
||||
Msg("Torrent still not complete after refresh")
|
||||
Msg("Torrent still not complete after refresh, marking as bad")
|
||||
} else {
|
||||
|
||||
addedOn, err := time.Parse(time.RFC3339, t.Added)
|
||||
@@ -731,7 +775,7 @@ func (c *Cache) Add(t *types.Torrent) error {
|
||||
|
||||
}
|
||||
|
||||
func (c *Cache) Client() types.Client {
|
||||
func (c *Cache) Client() common.Client {
|
||||
return c.client
|
||||
}
|
||||
|
||||
@@ -881,3 +925,7 @@ func (c *Cache) RemoveFile(torrentId string, filename string) error {
|
||||
func (c *Cache) Logger() zerolog.Logger {
|
||||
return c.logger
|
||||
}
|
||||
|
||||
func (c *Cache) GetConfig() config.Debrid {
|
||||
return c.config
|
||||
}
|
||||
|
||||
@@ -3,87 +3,72 @@ package store
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
|
||||
type downloadLinkRequest struct {
|
||||
result string
|
||||
err error
|
||||
done chan struct{}
|
||||
}
|
||||
const (
|
||||
MaxLinkFailures = 10
|
||||
)
|
||||
|
||||
func newDownloadLinkRequest() *downloadLinkRequest {
|
||||
return &downloadLinkRequest{
|
||||
done: make(chan struct{}),
|
||||
func (c *Cache) GetDownloadLink(torrentName, filename, fileLink string) (types.DownloadLink, error) {
|
||||
// Check
|
||||
counter, ok := c.failedLinksCounter.Load(fileLink)
|
||||
if ok && counter.Load() >= MaxLinkFailures {
|
||||
return types.DownloadLink{}, fmt.Errorf("file link %s has failed %d times, not retrying", fileLink, counter.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func (r *downloadLinkRequest) Complete(result string, err error) {
|
||||
r.result = result
|
||||
r.err = err
|
||||
close(r.done)
|
||||
}
|
||||
// Use singleflight to deduplicate concurrent requests
|
||||
v, err, _ := c.downloadSG.Do(fileLink, func() (interface{}, error) {
|
||||
// Double-check cache inside singleflight (another goroutine might have filled it)
|
||||
if dl, err := c.checkDownloadLink(fileLink); err == nil && !dl.Empty() {
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (r *downloadLinkRequest) Wait() (string, error) {
|
||||
<-r.done
|
||||
return r.result, r.err
|
||||
}
|
||||
// Fetch the download link
|
||||
dl, err := c.fetchDownloadLink(torrentName, filename, fileLink)
|
||||
if err != nil {
|
||||
c.downloadSG.Forget(fileLink)
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
if dl.Empty() {
|
||||
c.downloadSG.Forget(fileLink)
|
||||
err = fmt.Errorf("download link is empty for %s in torrent %s", filename, torrentName)
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
func (c *Cache) GetDownloadLink(torrentName, filename, fileLink string) (string, error) {
|
||||
// Check link cache
|
||||
if dl, err := c.checkDownloadLink(fileLink); dl != "" && err == nil {
|
||||
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)
|
||||
|
||||
dl, err := c.fetchDownloadLink(torrentName, filename, fileLink)
|
||||
if err != nil {
|
||||
req.Complete("", err)
|
||||
c.downloadLinkRequests.Delete(fileLink)
|
||||
return "", err
|
||||
return types.DownloadLink{}, err
|
||||
}
|
||||
|
||||
if dl == nil || dl.DownloadLink == "" {
|
||||
err = fmt.Errorf("download link is empty for %s in torrent %s", filename, torrentName)
|
||||
req.Complete("", err)
|
||||
c.downloadLinkRequests.Delete(fileLink)
|
||||
return "", err
|
||||
}
|
||||
req.Complete(dl.DownloadLink, err)
|
||||
c.downloadLinkRequests.Delete(fileLink)
|
||||
return dl.DownloadLink, err
|
||||
return v.(types.DownloadLink), nil
|
||||
}
|
||||
|
||||
func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (*types.DownloadLink, error) {
|
||||
func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (types.DownloadLink, error) {
|
||||
emptyDownloadLink := types.DownloadLink{}
|
||||
ct := c.GetTorrentByName(torrentName)
|
||||
if ct == nil {
|
||||
return nil, fmt.Errorf("torrent not found")
|
||||
return emptyDownloadLink, fmt.Errorf("torrent not found")
|
||||
}
|
||||
file, ok := ct.GetFile(filename)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %s not found in torrent %s", filename, torrentName)
|
||||
return emptyDownloadLink, fmt.Errorf("file %s not found in torrent %s", filename, torrentName)
|
||||
}
|
||||
|
||||
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 nil, fmt.Errorf("failed to refresh torrent")
|
||||
return emptyDownloadLink, fmt.Errorf("failed to refresh torrent")
|
||||
} else {
|
||||
file, ok = ct.GetFile(filename)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %s not found in refreshed torrent %s", filename, torrentName)
|
||||
return emptyDownloadLink, fmt.Errorf("file %s not found in refreshed torrent %s", filename, torrentName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -93,56 +78,53 @@ func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (*type
|
||||
// Try to reinsert the torrent?
|
||||
newCt, err := c.reInsertTorrent(ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reinsert torrent. %w", err)
|
||||
return emptyDownloadLink, fmt.Errorf("failed to reinsert torrent. %w", err)
|
||||
}
|
||||
ct = newCt
|
||||
file, ok = ct.GetFile(filename)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||
return emptyDownloadLink, fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||
}
|
||||
}
|
||||
|
||||
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, utils.HosterUnavailableError) {
|
||||
c.logger.Trace().
|
||||
Str("token", utils.Mask(downloadLink.Token)).
|
||||
Str("filename", filename).
|
||||
Str("torrent_id", ct.Id).
|
||||
Msg("Hoster unavailable, attempting to reinsert torrent")
|
||||
|
||||
newCt, err := c.reInsertTorrent(ct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reinsert torrent: %w", err)
|
||||
return emptyDownloadLink, fmt.Errorf("failed to reinsert torrent: %w", err)
|
||||
}
|
||||
ct = newCt
|
||||
file, ok = ct.GetFile(filename)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||
return emptyDownloadLink, fmt.Errorf("file %s not found in reinserted torrent %s", filename, torrentName)
|
||||
}
|
||||
// Retry getting the download link
|
||||
downloadLink, err = c.client.GetDownloadLink(ct.Torrent, &file)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("retry failed to get download link: %w", err)
|
||||
return emptyDownloadLink, fmt.Errorf("retry failed to get download link: %w", err)
|
||||
}
|
||||
if downloadLink == nil {
|
||||
return nil, fmt.Errorf("download link is empty after retry")
|
||||
if downloadLink.Empty() {
|
||||
return emptyDownloadLink, fmt.Errorf("download link is empty after retry")
|
||||
}
|
||||
return emptyDownloadLink, fmt.Errorf("download link is empty after retry")
|
||||
} else if errors.Is(err, utils.TrafficExceededError) {
|
||||
// This is likely a fair usage limit error
|
||||
return nil, err
|
||||
return emptyDownloadLink, err
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to get download link: %w", err)
|
||||
return emptyDownloadLink, fmt.Errorf("failed to get download link: %w", err)
|
||||
}
|
||||
}
|
||||
if downloadLink == nil {
|
||||
return nil, fmt.Errorf("download link is empty")
|
||||
if downloadLink.Empty() {
|
||||
return emptyDownloadLink, fmt.Errorf("download link is empty")
|
||||
}
|
||||
|
||||
// Set link to cache
|
||||
go c.client.Accounts().SetDownloadLink(fileLink, downloadLink)
|
||||
return downloadLink, nil
|
||||
}
|
||||
|
||||
@@ -153,34 +135,65 @@ func (c *Cache) GetFileDownloadLinks(t CachedTorrent) {
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) checkDownloadLink(link string) (string, error) {
|
||||
|
||||
dl, err := c.client.Accounts().GetDownloadLink(link)
|
||||
func (c *Cache) checkDownloadLink(link string) (types.DownloadLink, error) {
|
||||
dl, err := c.client.AccountManager().GetDownloadLink(link)
|
||||
if err != nil {
|
||||
return "", err
|
||||
return dl, err
|
||||
}
|
||||
if !c.downloadLinkIsInvalid(dl.DownloadLink) {
|
||||
return dl.DownloadLink, nil
|
||||
return dl, nil
|
||||
}
|
||||
return "", fmt.Errorf("download link not found for %s", link)
|
||||
return types.DownloadLink{}, fmt.Errorf("download link not found for %s", link)
|
||||
}
|
||||
|
||||
func (c *Cache) MarkDownloadLinkAsInvalid(link, downloadLink, reason string) {
|
||||
c.invalidDownloadLinks.Store(downloadLink, reason)
|
||||
func (c *Cache) IncrementFailedLinkCounter(link string) int32 {
|
||||
counter, _ := c.failedLinksCounter.LoadOrCompute(link, func() (atomic.Int32, bool) {
|
||||
return atomic.Int32{}, true
|
||||
})
|
||||
return counter.Add(1)
|
||||
}
|
||||
|
||||
func (c *Cache) MarkLinkAsInvalid(downloadLink types.DownloadLink, reason string) {
|
||||
// Increment file link error counter
|
||||
c.IncrementFailedLinkCounter(downloadLink.Link)
|
||||
|
||||
c.invalidDownloadLinks.Store(downloadLink.DownloadLink, reason)
|
||||
// Remove the download api key from active
|
||||
if reason == "bandwidth_exceeded" {
|
||||
// Disable the account
|
||||
_, account, err := c.client.Accounts().GetDownloadLinkWithAccount(link)
|
||||
accountManager := c.client.AccountManager()
|
||||
account, err := accountManager.GetAccount(downloadLink.Token)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Str("token", utils.Mask(downloadLink.Token)).Msg("Failed to get account to disable")
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
c.logger.Error().Str("token", utils.Mask(downloadLink.Token)).Msg("Account not found to disable")
|
||||
return
|
||||
}
|
||||
accountManager.Disable(account)
|
||||
} else if reason == "link_not_found" {
|
||||
// Let's try to delete the download link from the account, so we can fetch a new one next time
|
||||
accountManager := c.client.AccountManager()
|
||||
account, err := accountManager.GetAccount(downloadLink.Token)
|
||||
if err != nil {
|
||||
c.logger.Error().Err(err).Str("token", utils.Mask(downloadLink.Token)).Msg("Failed to get account to delete download link")
|
||||
return
|
||||
}
|
||||
if account == nil {
|
||||
c.logger.Error().Str("token", utils.Mask(downloadLink.Token)).Msg("Account not found to delete download link")
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.client.DeleteDownloadLink(account, downloadLink); err != nil {
|
||||
c.logger.Error().Err(err).Str("token", utils.Mask(downloadLink.Token)).Msg("Failed to delete download link from account")
|
||||
return
|
||||
}
|
||||
c.client.Accounts().Disable(account)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) downloadLinkIsInvalid(downloadLink string) bool {
|
||||
if reason, ok := c.invalidDownloadLinks.Load(downloadLink); ok {
|
||||
c.logger.Debug().Msgf("Download link %s is invalid: %s", downloadLink, reason)
|
||||
if _, ok := c.invalidDownloadLinks.Load(downloadLink); ok {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -196,5 +209,10 @@ func (c *Cache) GetDownloadByteRange(torrentName, filename string) (*[2]int64, e
|
||||
}
|
||||
|
||||
func (c *Cache) GetTotalActiveDownloadLinks() int {
|
||||
return c.client.Accounts().GetLinksCount()
|
||||
total := 0
|
||||
allAccounts := c.client.AccountManager().Active()
|
||||
for _, acc := range allAccounts {
|
||||
total += acc.DownloadLinksCount()
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"sort"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
|
||||
// MergeFiles merges the files from multiple torrents into a single map.
|
||||
|
||||
@@ -3,13 +3,14 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
|
||||
type fileInfo struct {
|
||||
@@ -120,17 +121,28 @@ func (c *Cache) refreshTorrents(ctx context.Context) {
|
||||
close(workChan)
|
||||
wg.Wait()
|
||||
|
||||
c.listingDebouncer.Call(false)
|
||||
c.listingDebouncer.Call(true)
|
||||
|
||||
c.logger.Debug().Msgf("Processed %d new torrents", counter)
|
||||
}
|
||||
|
||||
func (c *Cache) refreshRclone() error {
|
||||
cfg := c.config
|
||||
|
||||
if cfg.RcUrl == "" {
|
||||
return nil
|
||||
dirs := strings.FieldsFunc(cfg.RcRefreshDirs, func(r rune) bool {
|
||||
return r == ',' || r == '&'
|
||||
})
|
||||
if len(dirs) == 0 {
|
||||
dirs = []string{"__all__"}
|
||||
}
|
||||
if c.mounter != nil {
|
||||
return c.mounter.RefreshDir(dirs)
|
||||
} else {
|
||||
return c.refreshRcloneWithRC(dirs)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) refreshRcloneWithRC(dirs []string) error {
|
||||
cfg := c.config
|
||||
|
||||
if cfg.RcUrl == "" {
|
||||
return nil
|
||||
@@ -138,7 +150,7 @@ func (c *Cache) refreshRclone() error {
|
||||
|
||||
client := http.DefaultClient
|
||||
// Create form data
|
||||
data := c.buildRcloneRequestData()
|
||||
data := c.buildRcloneRequestData(dirs)
|
||||
|
||||
if err := c.sendRcloneRequest(client, "vfs/forget", data); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to send rclone vfs/forget request")
|
||||
@@ -151,16 +163,7 @@ func (c *Cache) refreshRclone() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) buildRcloneRequestData() string {
|
||||
cfg := c.config
|
||||
dirs := strings.FieldsFunc(cfg.RcRefreshDirs, func(r rune) bool {
|
||||
return r == ',' || r == '&'
|
||||
})
|
||||
|
||||
if len(dirs) == 0 {
|
||||
return "dir=__all__"
|
||||
}
|
||||
|
||||
func (c *Cache) buildRcloneRequestData(dirs []string) string {
|
||||
var data strings.Builder
|
||||
for index, dir := range dirs {
|
||||
if dir != "" {
|
||||
@@ -241,14 +244,10 @@ func (c *Cache) refreshDownloadLinks(ctx context.Context) {
|
||||
}
|
||||
defer c.downloadLinksRefreshMu.Unlock()
|
||||
|
||||
links, err := c.client.GetDownloadLinks()
|
||||
|
||||
if err != nil {
|
||||
if err := c.client.RefreshDownloadLinks(); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to get download links")
|
||||
return
|
||||
}
|
||||
|
||||
c.client.Accounts().SetDownloadLinks(links)
|
||||
|
||||
c.logger.Debug().Msgf("Refreshed download %d links", c.client.Accounts().GetLinksCount())
|
||||
c.logger.Debug().Msgf("Refreshed download links")
|
||||
}
|
||||
|
||||
@@ -4,11 +4,13 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/puzpuzpuz/xsync/v4"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type reInsertRequest struct {
|
||||
@@ -59,6 +61,8 @@ func (c *Cache) markAsSuccessfullyReinserted(torrentId string) {
|
||||
}
|
||||
}
|
||||
|
||||
// GetBrokenFiles checks the files in the torrent for broken links.
|
||||
// It also attempts to reinsert the torrent if any files are broken.
|
||||
func (c *Cache) GetBrokenFiles(t *CachedTorrent, filenames []string) []string {
|
||||
files := make(map[string]types.File)
|
||||
repairStrategy := config.Get().Repair.Strategy
|
||||
@@ -217,8 +221,7 @@ func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
|
||||
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)
|
||||
if req, inFlight := c.repairRequest.Load(oldID); inFlight {
|
||||
c.logger.Debug().Msgf("Waiting for existing reinsert request to complete for torrent %s", oldID)
|
||||
return req.Wait()
|
||||
}
|
||||
@@ -232,12 +235,13 @@ func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
|
||||
|
||||
// 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,
|
||||
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,
|
||||
DownloadUncached: false,
|
||||
}
|
||||
var err error
|
||||
newTorrent, err = c.client.SubmitMagnet(newTorrent)
|
||||
@@ -260,7 +264,7 @@ func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
|
||||
_ = c.client.DeleteTorrent(newTorrent.Id)
|
||||
}
|
||||
c.markAsFailedToReinsert(oldID)
|
||||
return ct, err
|
||||
return ct, fmt.Errorf("failed to check torrent: %w", err)
|
||||
}
|
||||
|
||||
// Update the torrent in the cache
|
||||
@@ -293,7 +297,7 @@ func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
|
||||
}
|
||||
}
|
||||
|
||||
req.Complete(ct, err)
|
||||
req.Complete(ct, nil)
|
||||
c.markAsSuccessfullyReinserted(oldID)
|
||||
|
||||
c.logger.Debug().Str("torrentId", torrent.Id).Msg("Torrent successfully reinserted")
|
||||
@@ -303,9 +307,8 @@ func (c *Cache) reInsertTorrent(ct *CachedTorrent) (*CachedTorrent, error) {
|
||||
|
||||
func (c *Cache) resetInvalidLinks(ctx context.Context) {
|
||||
c.logger.Debug().Msgf("Resetting accounts")
|
||||
c.invalidDownloadLinks = sync.Map{}
|
||||
c.client.Accounts().Reset() // Reset the active download keys
|
||||
|
||||
c.invalidDownloadLinks = xsync.NewMap[string, string]()
|
||||
c.client.AccountManager().Reset() // Reset the active download keys
|
||||
// Refresh the download links
|
||||
c.refreshDownloadLinks(ctx)
|
||||
}
|
||||
|
||||
236
pkg/debrid/store/stream.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxNetworkRetries = 5
|
||||
MaxLinkRetries = 10
|
||||
)
|
||||
|
||||
type StreamError struct {
|
||||
Err error
|
||||
Retryable bool
|
||||
LinkError bool // true if we should try a new link
|
||||
}
|
||||
|
||||
func (e StreamError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// isConnectionError checks if the error is related to connection issues
|
||||
func (c *Cache) isConnectionError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
errStr := err.Error()
|
||||
// Check for common connection errors
|
||||
if strings.Contains(errStr, "EOF") ||
|
||||
strings.Contains(errStr, "connection reset by peer") ||
|
||||
strings.Contains(errStr, "broken pipe") ||
|
||||
strings.Contains(errStr, "connection refused") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for net.Error types
|
||||
var netErr net.Error
|
||||
return errors.As(err, &netErr)
|
||||
}
|
||||
|
||||
func (c *Cache) Stream(ctx context.Context, start, end int64, linkFunc func() (types.DownloadLink, error)) (*http.Response, error) {
|
||||
|
||||
var lastErr error
|
||||
|
||||
downloadLink, err := linkFunc()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get download link: %w", err)
|
||||
}
|
||||
|
||||
// Outer loop: Link retries
|
||||
for retry := 0; retry < MaxLinkRetries; retry++ {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
resp, err := c.doRequest(ctx, downloadLink.DownloadLink, start, end)
|
||||
if err != nil {
|
||||
// Network/connection error
|
||||
lastErr = err
|
||||
c.logger.Trace().
|
||||
Int("retries", retry).
|
||||
Err(err).
|
||||
Msg("Network request failed, retrying")
|
||||
|
||||
// Backoff and continue network retry
|
||||
if retry < MaxLinkRetries {
|
||||
backoff := time.Duration(retry+1) * time.Second
|
||||
jitter := time.Duration(rand.Intn(1000)) * time.Millisecond
|
||||
select {
|
||||
case <-time.After(backoff + jitter):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
continue
|
||||
} else {
|
||||
return nil, fmt.Errorf("network request failed after retries: %w", lastErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Got response - check status
|
||||
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusPartialContent {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Bad status code - handle error
|
||||
streamErr := c.handleHTTPError(resp, downloadLink)
|
||||
resp.Body.Close()
|
||||
|
||||
if !streamErr.Retryable {
|
||||
return nil, streamErr // Fatal error
|
||||
}
|
||||
|
||||
if streamErr.LinkError {
|
||||
lastErr = streamErr
|
||||
// Try new link
|
||||
downloadLink, err = linkFunc()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get download link: %w", err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Retryable HTTP error (429, 503, 404 etc.) - retry network
|
||||
lastErr = streamErr
|
||||
c.logger.Trace().
|
||||
Err(lastErr).
|
||||
Str("downloadLink", downloadLink.DownloadLink).
|
||||
Str("link", downloadLink.Link).
|
||||
Int("retries", retry).
|
||||
Int("statusCode", resp.StatusCode).
|
||||
Msg("HTTP error, retrying")
|
||||
|
||||
if retry < MaxNetworkRetries-1 {
|
||||
backoff := time.Duration(retry+1) * time.Second
|
||||
jitter := time.Duration(rand.Intn(1000)) * time.Millisecond
|
||||
select {
|
||||
case <-time.After(backoff + jitter):
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("stream failed after %d link retries: %w", MaxLinkRetries, lastErr)
|
||||
}
|
||||
|
||||
func (c *Cache) StreamReader(ctx context.Context, start, end int64, linkFunc func() (types.DownloadLink, error)) (io.ReadCloser, error) {
|
||||
resp, err := c.Stream(ctx, start, end, linkFunc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate we got the expected content
|
||||
if resp.ContentLength == 0 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("received empty response")
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func (c *Cache) doRequest(ctx context.Context, url string, start, end int64) (*http.Response, error) {
|
||||
var lastErr error
|
||||
// Retry loop specifically for connection-level failures (EOF, reset, etc.)
|
||||
for connRetry := 0; connRetry < 3; connRetry++ {
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, StreamError{Err: err, Retryable: false}
|
||||
}
|
||||
|
||||
// Set range header
|
||||
if start > 0 || end > 0 {
|
||||
rangeHeader := fmt.Sprintf("bytes=%d-", start)
|
||||
if end > 0 {
|
||||
rangeHeader = fmt.Sprintf("bytes=%d-%d", start, end)
|
||||
}
|
||||
req.Header.Set("Range", rangeHeader)
|
||||
}
|
||||
|
||||
// Set optimized headers for streaming
|
||||
req.Header.Set("Connection", "keep-alive")
|
||||
req.Header.Set("Accept-Encoding", "identity") // Disable compression for streaming
|
||||
req.Header.Set("Cache-Control", "no-cache")
|
||||
|
||||
resp, err := c.streamClient.Do(req)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
|
||||
// Check if it's a connection error that we should retry
|
||||
if c.isConnectionError(err) && connRetry < 2 {
|
||||
// Brief backoff before retrying with fresh connection
|
||||
time.Sleep(time.Duration(connRetry+1) * 100 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
|
||||
return nil, StreamError{Err: err, Retryable: true}
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
return nil, StreamError{Err: fmt.Errorf("connection retry exhausted: %w", lastErr), Retryable: true}
|
||||
}
|
||||
|
||||
func (c *Cache) handleHTTPError(resp *http.Response, downloadLink types.DownloadLink) StreamError {
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNotFound:
|
||||
c.MarkLinkAsInvalid(downloadLink, "link_not_found")
|
||||
return StreamError{
|
||||
Err: errors.New("download link not found"),
|
||||
Retryable: true,
|
||||
LinkError: true,
|
||||
}
|
||||
|
||||
case http.StatusServiceUnavailable:
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
bodyStr := strings.ToLower(string(body))
|
||||
if strings.Contains(bodyStr, "bandwidth") || strings.Contains(bodyStr, "traffic") {
|
||||
c.MarkLinkAsInvalid(downloadLink, "bandwidth_exceeded")
|
||||
return StreamError{
|
||||
Err: errors.New("bandwidth limit exceeded"),
|
||||
Retryable: true,
|
||||
LinkError: true,
|
||||
}
|
||||
}
|
||||
fallthrough
|
||||
|
||||
case http.StatusTooManyRequests:
|
||||
return StreamError{
|
||||
Err: fmt.Errorf("HTTP %d: rate limited", resp.StatusCode),
|
||||
Retryable: true,
|
||||
LinkError: false,
|
||||
}
|
||||
|
||||
default:
|
||||
retryable := resp.StatusCode >= 500
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return StreamError{
|
||||
Err: fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body)),
|
||||
Retryable: retryable,
|
||||
LinkError: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,19 +40,27 @@ type directoryFilter struct {
|
||||
ageThreshold time.Duration // only for last_added
|
||||
}
|
||||
|
||||
type torrents struct {
|
||||
sync.RWMutex
|
||||
byID map[string]CachedTorrent
|
||||
byName map[string]CachedTorrent
|
||||
}
|
||||
|
||||
type folders struct {
|
||||
sync.RWMutex
|
||||
listing map[string][]os.FileInfo // folder name to file listing
|
||||
}
|
||||
|
||||
type CachedTorrentEntry struct {
|
||||
CachedTorrent
|
||||
deleted bool // Tombstone flag
|
||||
}
|
||||
|
||||
type torrentCache struct {
|
||||
torrents torrents
|
||||
mu sync.RWMutex
|
||||
torrents []CachedTorrentEntry // Changed to store entries with tombstone
|
||||
|
||||
// Lookup indices
|
||||
idIndex map[string]int
|
||||
nameIndex map[string]int
|
||||
|
||||
// Compaction tracking
|
||||
deletedCount atomic.Int32
|
||||
compactThreshold int // Trigger compaction when deletedCount exceeds this
|
||||
|
||||
listing atomic.Value
|
||||
folders folders
|
||||
@@ -69,12 +77,11 @@ type sortableFile struct {
|
||||
}
|
||||
|
||||
func newTorrentCache(dirFilters map[string][]directoryFilter) *torrentCache {
|
||||
|
||||
tc := &torrentCache{
|
||||
torrents: torrents{
|
||||
byID: make(map[string]CachedTorrent),
|
||||
byName: make(map[string]CachedTorrent),
|
||||
},
|
||||
torrents: []CachedTorrentEntry{},
|
||||
idIndex: make(map[string]int),
|
||||
nameIndex: make(map[string]int),
|
||||
compactThreshold: 100, // Compact when 100+ deleted entries
|
||||
folders: folders{
|
||||
listing: make(map[string][]os.FileInfo),
|
||||
},
|
||||
@@ -87,10 +94,12 @@ func newTorrentCache(dirFilters map[string][]directoryFilter) *torrentCache {
|
||||
}
|
||||
|
||||
func (tc *torrentCache) reset() {
|
||||
tc.torrents.Lock()
|
||||
tc.torrents.byID = make(map[string]CachedTorrent)
|
||||
tc.torrents.byName = make(map[string]CachedTorrent)
|
||||
tc.torrents.Unlock()
|
||||
tc.mu.Lock()
|
||||
tc.torrents = tc.torrents[:0] // Clear the slice
|
||||
tc.idIndex = make(map[string]int) // Reset the ID index
|
||||
tc.nameIndex = make(map[string]int) // Reset the name index
|
||||
tc.deletedCount.Store(0)
|
||||
tc.mu.Unlock()
|
||||
|
||||
// reset the sorted listing
|
||||
tc.sortNeeded.Store(false)
|
||||
@@ -103,62 +112,183 @@ func (tc *torrentCache) reset() {
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getByID(id string) (CachedTorrent, bool) {
|
||||
tc.torrents.RLock()
|
||||
defer tc.torrents.RUnlock()
|
||||
torrent, exists := tc.torrents.byID[id]
|
||||
return torrent, exists
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
|
||||
if index, exists := tc.idIndex[id]; exists && index < len(tc.torrents) {
|
||||
entry := tc.torrents[index]
|
||||
if !entry.deleted {
|
||||
return entry.CachedTorrent, true
|
||||
}
|
||||
}
|
||||
return CachedTorrent{}, false
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getByName(name string) (CachedTorrent, bool) {
|
||||
tc.torrents.RLock()
|
||||
defer tc.torrents.RUnlock()
|
||||
torrent, exists := tc.torrents.byName[name]
|
||||
return torrent, exists
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
|
||||
if index, exists := tc.nameIndex[name]; exists && index < len(tc.torrents) {
|
||||
entry := tc.torrents[index]
|
||||
if !entry.deleted {
|
||||
return entry.CachedTorrent, true
|
||||
}
|
||||
}
|
||||
return CachedTorrent{}, false
|
||||
}
|
||||
|
||||
func (tc *torrentCache) set(name string, torrent, newTorrent CachedTorrent) {
|
||||
tc.torrents.Lock()
|
||||
// Set the id first
|
||||
func (tc *torrentCache) set(name string, torrent CachedTorrent) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
tc.torrents.byName[name] = torrent
|
||||
tc.torrents.byID[torrent.Id] = torrent // This is the unadulterated torrent
|
||||
tc.torrents.Unlock()
|
||||
// Check if this torrent already exists (update case)
|
||||
if existingIndex, exists := tc.idIndex[torrent.Id]; exists && existingIndex < len(tc.torrents) {
|
||||
if !tc.torrents[existingIndex].deleted {
|
||||
// Update existing entry
|
||||
tc.torrents[existingIndex].CachedTorrent = torrent
|
||||
tc.sortNeeded.Store(true)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Add new torrent
|
||||
entry := CachedTorrentEntry{
|
||||
CachedTorrent: torrent,
|
||||
deleted: false,
|
||||
}
|
||||
|
||||
tc.torrents = append(tc.torrents, entry)
|
||||
index := len(tc.torrents) - 1
|
||||
|
||||
tc.idIndex[torrent.Id] = index
|
||||
tc.nameIndex[name] = index
|
||||
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)
|
||||
}
|
||||
func (tc *torrentCache) removeId(id string) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
// Slow path: need to sort
|
||||
tc.refreshListing()
|
||||
return tc.listing.Load().([]os.FileInfo)
|
||||
if index, exists := tc.idIndex[id]; exists && index < len(tc.torrents) {
|
||||
if !tc.torrents[index].deleted {
|
||||
// Mark as deleted (tombstone)
|
||||
tc.torrents[index].deleted = true
|
||||
tc.deletedCount.Add(1)
|
||||
|
||||
// Remove from indices
|
||||
delete(tc.idIndex, id)
|
||||
|
||||
// Find and remove from name index
|
||||
for name, idx := range tc.nameIndex {
|
||||
if idx == index {
|
||||
delete(tc.nameIndex, name)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
tc.sortNeeded.Store(true)
|
||||
|
||||
// Trigger compaction if threshold exceeded
|
||||
if tc.deletedCount.Load() > int32(tc.compactThreshold) {
|
||||
go tc.compact()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getFolderListing(folderName string) []os.FileInfo {
|
||||
tc.folders.RLock()
|
||||
defer tc.folders.RUnlock()
|
||||
if folderName == "" {
|
||||
return tc.getListing()
|
||||
func (tc *torrentCache) remove(name string) {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
if index, exists := tc.nameIndex[name]; exists && index < len(tc.torrents) {
|
||||
if !tc.torrents[index].deleted {
|
||||
// Mark as deleted (tombstone)
|
||||
torrentID := tc.torrents[index].CachedTorrent.Id
|
||||
tc.torrents[index].deleted = true
|
||||
tc.deletedCount.Add(1)
|
||||
|
||||
// Remove from indices
|
||||
delete(tc.nameIndex, name)
|
||||
delete(tc.idIndex, torrentID)
|
||||
|
||||
tc.sortNeeded.Store(true)
|
||||
|
||||
// Trigger compaction if threshold exceeded
|
||||
if tc.deletedCount.Load() > int32(tc.compactThreshold) {
|
||||
go tc.compact()
|
||||
}
|
||||
}
|
||||
}
|
||||
if folder, ok := tc.folders.listing[folderName]; ok {
|
||||
return folder
|
||||
}
|
||||
|
||||
// Compact removes tombstoned entries and rebuilds indices
|
||||
func (tc *torrentCache) compact() {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
|
||||
deletedCount := tc.deletedCount.Load()
|
||||
if deletedCount == 0 {
|
||||
return // Nothing to compact
|
||||
}
|
||||
// If folder not found, return empty slice
|
||||
return []os.FileInfo{}
|
||||
|
||||
// Create new slice with only non-deleted entries
|
||||
newTorrents := make([]CachedTorrentEntry, 0, len(tc.torrents)-int(deletedCount))
|
||||
newIdIndex := make(map[string]int, len(tc.idIndex))
|
||||
newNameIndex := make(map[string]int, len(tc.nameIndex))
|
||||
|
||||
// Copy non-deleted entries
|
||||
for oldIndex, entry := range tc.torrents {
|
||||
if !entry.deleted {
|
||||
newIndex := len(newTorrents)
|
||||
newTorrents = append(newTorrents, entry)
|
||||
|
||||
// Find the name for this torrent (reverse lookup)
|
||||
for name, nameIndex := range tc.nameIndex {
|
||||
if nameIndex == oldIndex {
|
||||
newNameIndex[name] = newIndex
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
newIdIndex[entry.CachedTorrent.Id] = newIndex
|
||||
}
|
||||
}
|
||||
|
||||
// Replace old data with compacted data
|
||||
tc.torrents = newTorrents
|
||||
tc.idIndex = newIdIndex
|
||||
tc.nameIndex = newNameIndex
|
||||
|
||||
tc.deletedCount.Store(0)
|
||||
tc.sortNeeded.Store(true)
|
||||
}
|
||||
|
||||
func (tc *torrentCache) ForceCompact() {
|
||||
tc.compact()
|
||||
}
|
||||
|
||||
func (tc *torrentCache) GetStats() (total, active, deleted int) {
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
|
||||
total = len(tc.torrents)
|
||||
deleted = int(tc.deletedCount.Load())
|
||||
active = total - deleted
|
||||
|
||||
return total, active, deleted
|
||||
}
|
||||
|
||||
func (tc *torrentCache) refreshListing() {
|
||||
|
||||
tc.torrents.RLock()
|
||||
all := make([]sortableFile, 0, len(tc.torrents.byName))
|
||||
for name, t := range tc.torrents.byName {
|
||||
all = append(all, sortableFile{t.Id, name, t.AddedOn, t.Bytes, t.Bad})
|
||||
tc.mu.RLock()
|
||||
all := make([]sortableFile, 0, len(tc.nameIndex))
|
||||
for name, index := range tc.nameIndex {
|
||||
if index < len(tc.torrents) && !tc.torrents[index].deleted {
|
||||
t := tc.torrents[index].CachedTorrent
|
||||
all = append(all, sortableFile{t.Id, name, t.AddedOn, t.Bytes, t.Bad})
|
||||
}
|
||||
}
|
||||
tc.sortNeeded.Store(false)
|
||||
tc.torrents.RUnlock()
|
||||
tc.mu.RUnlock()
|
||||
|
||||
sort.Slice(all, func(i, j int) bool {
|
||||
if all[i].name != all[j].name {
|
||||
@@ -234,8 +364,31 @@ func (tc *torrentCache) refreshListing() {
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func (tc *torrentCache) torrentMatchDirectory(filters []directoryFilter, file sortableFile, now time.Time) bool {
|
||||
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.folders.RLock()
|
||||
defer tc.folders.RUnlock()
|
||||
if folderName == "" {
|
||||
return tc.getListing()
|
||||
}
|
||||
if folder, ok := tc.folders.listing[folderName]; ok {
|
||||
return folder
|
||||
}
|
||||
// If folder not found, return empty slice
|
||||
return []os.FileInfo{}
|
||||
}
|
||||
|
||||
func (tc *torrentCache) torrentMatchDirectory(filters []directoryFilter, file sortableFile, now time.Time) bool {
|
||||
torrentName := strings.ToLower(file.name)
|
||||
for _, filter := range filters {
|
||||
matched := false
|
||||
@@ -278,51 +431,46 @@ func (tc *torrentCache) torrentMatchDirectory(filters []directoryFilter, file so
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getAll() map[string]CachedTorrent {
|
||||
tc.torrents.RLock()
|
||||
defer tc.torrents.RUnlock()
|
||||
result := make(map[string]CachedTorrent, len(tc.torrents.byID))
|
||||
for name, torrent := range tc.torrents.byID {
|
||||
result[name] = torrent
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
|
||||
result := make(map[string]CachedTorrent)
|
||||
for _, entry := range tc.torrents {
|
||||
if !entry.deleted {
|
||||
result[entry.CachedTorrent.Id] = entry.CachedTorrent
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getAllCount() int {
|
||||
tc.torrents.RLock()
|
||||
defer tc.torrents.RUnlock()
|
||||
return len(tc.torrents.byID)
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
return len(tc.torrents) - int(tc.deletedCount.Load())
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getAllByName() map[string]CachedTorrent {
|
||||
tc.torrents.RLock()
|
||||
defer tc.torrents.RUnlock()
|
||||
results := make(map[string]CachedTorrent, len(tc.torrents.byName))
|
||||
for name, torrent := range tc.torrents.byName {
|
||||
results[name] = torrent
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
|
||||
results := make(map[string]CachedTorrent, len(tc.nameIndex))
|
||||
for name, index := range tc.nameIndex {
|
||||
if index < len(tc.torrents) && !tc.torrents[index].deleted {
|
||||
results[name] = tc.torrents[index].CachedTorrent
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (tc *torrentCache) getIdMaps() map[string]struct{} {
|
||||
tc.torrents.RLock()
|
||||
defer tc.torrents.RUnlock()
|
||||
res := make(map[string]struct{}, len(tc.torrents.byID))
|
||||
for id := range tc.torrents.byID {
|
||||
res[id] = struct{}{}
|
||||
tc.mu.RLock()
|
||||
defer tc.mu.RUnlock()
|
||||
|
||||
res := make(map[string]struct{}, len(tc.idIndex))
|
||||
for id, index := range tc.idIndex {
|
||||
if index < len(tc.torrents) && !tc.torrents[index].deleted {
|
||||
res[id] = struct{}{}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (tc *torrentCache) removeId(id string) {
|
||||
tc.torrents.Lock()
|
||||
defer tc.torrents.Unlock()
|
||||
delete(tc.torrents.byID, id)
|
||||
tc.sortNeeded.Store(true)
|
||||
}
|
||||
|
||||
func (tc *torrentCache) remove(name string) {
|
||||
tc.torrents.Lock()
|
||||
defer tc.torrents.Unlock()
|
||||
delete(tc.torrents.byName, name)
|
||||
tc.sortNeeded.Store(true)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,17 @@ package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
)
|
||||
|
||||
func (c *Cache) StartSchedule(ctx context.Context) error {
|
||||
func (c *Cache) StartWorker(ctx context.Context) error {
|
||||
// For now, we just want to refresh the listing and download links
|
||||
|
||||
// Stop any existing jobs before starting new ones
|
||||
c.scheduler.RemoveByTags("decypharr-%s", c.GetConfig().Name)
|
||||
|
||||
// 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")
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Accounts struct {
|
||||
current *Account
|
||||
accounts []*Account
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewAccounts(debridConf config.Debrid) *Accounts {
|
||||
accounts := make([]*Account, 0)
|
||||
for idx, token := range debridConf.DownloadAPIKeys {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
account := newAccount(debridConf.Name, token, idx)
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
|
||||
var current *Account
|
||||
if len(accounts) > 0 {
|
||||
current = accounts[0]
|
||||
}
|
||||
return &Accounts{
|
||||
accounts: accounts,
|
||||
current: current,
|
||||
}
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Debrid string // e.g., "realdebrid", "torbox", etc.
|
||||
Order int
|
||||
Disabled bool
|
||||
Token string
|
||||
links map[string]*DownloadLink
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (a *Accounts) All() []*Account {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
activeAccounts := make([]*Account, 0)
|
||||
for _, acc := range a.accounts {
|
||||
if !acc.Disabled {
|
||||
activeAccounts = append(activeAccounts, acc)
|
||||
}
|
||||
}
|
||||
return activeAccounts
|
||||
}
|
||||
|
||||
func (a *Accounts) Current() *Account {
|
||||
a.mu.RLock()
|
||||
if a.current != nil {
|
||||
current := a.current
|
||||
a.mu.RUnlock()
|
||||
return current
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
// Double-check after acquiring write lock
|
||||
if a.current != nil {
|
||||
return a.current
|
||||
}
|
||||
|
||||
activeAccounts := make([]*Account, 0)
|
||||
for _, acc := range a.accounts {
|
||||
if !acc.Disabled {
|
||||
activeAccounts = append(activeAccounts, acc)
|
||||
}
|
||||
}
|
||||
|
||||
if len(activeAccounts) > 0 {
|
||||
a.current = activeAccounts[0]
|
||||
}
|
||||
return a.current
|
||||
}
|
||||
|
||||
func (a *Accounts) Disable(account *Account) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
account.disable()
|
||||
|
||||
if a.current == account {
|
||||
var newCurrent *Account
|
||||
for _, acc := range a.accounts {
|
||||
if !acc.Disabled {
|
||||
newCurrent = acc
|
||||
break
|
||||
}
|
||||
}
|
||||
a.current = newCurrent
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Accounts) Reset() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
for _, acc := range a.accounts {
|
||||
acc.resetDownloadLinks()
|
||||
acc.Disabled = false
|
||||
}
|
||||
if len(a.accounts) > 0 {
|
||||
a.current = a.accounts[0]
|
||||
} else {
|
||||
a.current = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Accounts) GetDownloadLink(fileLink string) (*DownloadLink, error) {
|
||||
if a.Current() == nil {
|
||||
return nil, NoActiveAccountsError
|
||||
}
|
||||
dl, ok := a.Current().getLink(fileLink)
|
||||
if !ok {
|
||||
return nil, NoDownloadLinkError
|
||||
}
|
||||
if dl.ExpiresAt.IsZero() || dl.ExpiresAt.Before(time.Now()) {
|
||||
return nil, DownloadLinkExpiredError
|
||||
}
|
||||
if dl.DownloadLink == "" {
|
||||
return nil, EmptyDownloadLinkError
|
||||
}
|
||||
return dl, nil
|
||||
}
|
||||
|
||||
func (a *Accounts) GetDownloadLinkWithAccount(fileLink string) (*DownloadLink, *Account, error) {
|
||||
currentAccount := a.Current()
|
||||
if currentAccount == nil {
|
||||
return nil, nil, NoActiveAccountsError
|
||||
}
|
||||
dl, ok := currentAccount.getLink(fileLink)
|
||||
if !ok {
|
||||
return nil, nil, NoDownloadLinkError
|
||||
}
|
||||
if dl.ExpiresAt.IsZero() || dl.ExpiresAt.Before(time.Now()) {
|
||||
return nil, currentAccount, DownloadLinkExpiredError
|
||||
}
|
||||
if dl.DownloadLink == "" {
|
||||
return nil, currentAccount, EmptyDownloadLinkError
|
||||
}
|
||||
return dl, currentAccount, nil
|
||||
}
|
||||
|
||||
func (a *Accounts) SetDownloadLink(fileLink string, dl *DownloadLink) {
|
||||
if a.Current() == nil {
|
||||
return
|
||||
}
|
||||
a.Current().setLink(fileLink, dl)
|
||||
}
|
||||
|
||||
func (a *Accounts) DeleteDownloadLink(fileLink string) {
|
||||
if a.Current() == nil {
|
||||
return
|
||||
}
|
||||
a.Current().deleteLink(fileLink)
|
||||
}
|
||||
|
||||
func (a *Accounts) GetLinksCount() int {
|
||||
if a.Current() == nil {
|
||||
return 0
|
||||
}
|
||||
return a.Current().LinksCount()
|
||||
}
|
||||
|
||||
func (a *Accounts) SetDownloadLinks(links map[string]*DownloadLink) {
|
||||
if a.Current() == nil {
|
||||
return
|
||||
}
|
||||
a.Current().setLinks(links)
|
||||
}
|
||||
|
||||
func newAccount(debridName, token string, index int) *Account {
|
||||
return &Account{
|
||||
Debrid: debridName,
|
||||
Token: token,
|
||||
Order: index,
|
||||
links: make(map[string]*DownloadLink),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Account) getLink(fileLink string) (*DownloadLink, bool) {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
dl, ok := a.links[a.sliceFileLink(fileLink)]
|
||||
return dl, ok
|
||||
}
|
||||
func (a *Account) setLink(fileLink string, dl *DownloadLink) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.links[a.sliceFileLink(fileLink)] = dl
|
||||
}
|
||||
func (a *Account) deleteLink(fileLink string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
|
||||
delete(a.links, a.sliceFileLink(fileLink))
|
||||
}
|
||||
func (a *Account) resetDownloadLinks() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
a.links = make(map[string]*DownloadLink)
|
||||
}
|
||||
func (a *Account) LinksCount() int {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return len(a.links)
|
||||
}
|
||||
|
||||
func (a *Account) disable() {
|
||||
a.Disabled = true
|
||||
}
|
||||
|
||||
func (a *Account) setLinks(links map[string]*DownloadLink) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
now := time.Now()
|
||||
for _, dl := range links {
|
||||
if !dl.ExpiresAt.IsZero() && dl.ExpiresAt.Before(now) {
|
||||
// Expired, continue
|
||||
continue
|
||||
}
|
||||
a.links[a.sliceFileLink(dl.Link)] = dl
|
||||
}
|
||||
}
|
||||
|
||||
// slice download link
|
||||
func (a *Account) sliceFileLink(fileLink string) string {
|
||||
if a.Debrid != "realdebrid" {
|
||||
return fileLink
|
||||
}
|
||||
if len(fileLink) < 39 {
|
||||
return fileLink
|
||||
}
|
||||
return fileLink[0:39]
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Client interface {
|
||||
SubmitMagnet(tr *Torrent) (*Torrent, error)
|
||||
CheckStatus(tr *Torrent) (*Torrent, error)
|
||||
GetFileDownloadLinks(tr *Torrent) error
|
||||
GetDownloadLink(tr *Torrent, file *File) (*DownloadLink, error)
|
||||
DeleteTorrent(torrentId string) error
|
||||
IsAvailable(infohashes []string) map[string]bool
|
||||
GetDownloadUncached() bool
|
||||
UpdateTorrent(torrent *Torrent) error
|
||||
GetTorrent(torrentId string) (*Torrent, error)
|
||||
GetTorrents() ([]*Torrent, error)
|
||||
Name() string
|
||||
Logger() zerolog.Logger
|
||||
GetDownloadingStatus() []string
|
||||
GetDownloadLinks() (map[string]*DownloadLink, error)
|
||||
CheckLink(link string) error
|
||||
GetMountPath() string
|
||||
Accounts() *Accounts // Returns the active download account/token
|
||||
DeleteDownloadLink(linkId string) error
|
||||
GetProfile() (*Profile, error)
|
||||
GetAvailableSlots() (int, error)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ var NoActiveAccountsError = &Error{
|
||||
Code: "no_active_accounts",
|
||||
}
|
||||
|
||||
var NoDownloadLinkError = &Error{
|
||||
var ErrDownloadLinkNotFound = &Error{
|
||||
Message: "No download link found",
|
||||
Code: "no_download_link",
|
||||
}
|
||||
|
||||
@@ -42,6 +42,38 @@ type Torrent struct {
|
||||
sync.Mutex
|
||||
}
|
||||
|
||||
func (t *Torrent) Copy() *Torrent {
|
||||
t.Lock()
|
||||
defer t.Unlock()
|
||||
|
||||
newFiles := make(map[string]File, len(t.Files))
|
||||
for k, v := range t.Files {
|
||||
newFiles[k] = v
|
||||
}
|
||||
|
||||
return &Torrent{
|
||||
Id: t.Id,
|
||||
InfoHash: t.InfoHash,
|
||||
Name: t.Name,
|
||||
Folder: t.Folder,
|
||||
Filename: t.Filename,
|
||||
OriginalFilename: t.OriginalFilename,
|
||||
Size: t.Size,
|
||||
Bytes: t.Bytes,
|
||||
Magnet: t.Magnet,
|
||||
Files: newFiles,
|
||||
Status: t.Status,
|
||||
Added: t.Added,
|
||||
Progress: t.Progress,
|
||||
Speed: t.Speed,
|
||||
Seeders: t.Seeders,
|
||||
Links: append([]string{}, t.Links...),
|
||||
MountPath: t.MountPath,
|
||||
Debrid: t.Debrid,
|
||||
Arr: t.Arr,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Torrent) GetSymlinkFolder(parent string) string {
|
||||
return filepath.Join(parent, t.Arr.Name, t.Folder)
|
||||
}
|
||||
@@ -84,18 +116,18 @@ func (t *Torrent) GetFiles() []File {
|
||||
}
|
||||
|
||||
type File struct {
|
||||
TorrentId string `json:"torrent_id"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsRar bool `json:"is_rar"`
|
||||
ByteRange *[2]int64 `json:"byte_range,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Link string `json:"link"`
|
||||
AccountId string `json:"account_id"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Deleted bool `json:"deleted"`
|
||||
DownloadLink *DownloadLink `json:"-"`
|
||||
TorrentId string `json:"torrent_id"`
|
||||
Id string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int64 `json:"size"`
|
||||
IsRar bool `json:"is_rar"`
|
||||
ByteRange *[2]int64 `json:"byte_range,omitempty"`
|
||||
Path string `json:"path"`
|
||||
Link string `json:"link"`
|
||||
AccountId string `json:"account_id"`
|
||||
Generated time.Time `json:"generated"`
|
||||
Deleted bool `json:"deleted"`
|
||||
DownloadLink DownloadLink `json:"-"`
|
||||
}
|
||||
|
||||
func (t *Torrent) Cleanup(remove bool) {
|
||||
@@ -114,22 +146,32 @@ type IngestData struct {
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type LibraryStats struct {
|
||||
Total int `json:"total"`
|
||||
Bad int `json:"bad"`
|
||||
ActiveLinks int `json:"active_links"`
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Profile *Profile `json:"profile"`
|
||||
Library LibraryStats `json:"library"`
|
||||
Accounts []map[string]any `json:"accounts"`
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
Id int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Points int64 `json:"points"`
|
||||
Points int `json:"points"`
|
||||
Type string `json:"type"`
|
||||
Premium int `json:"premium"`
|
||||
Premium int64 `json:"premium"`
|
||||
Expiration time.Time `json:"expiration"`
|
||||
|
||||
LibrarySize int `json:"library_size"`
|
||||
BadTorrents int `json:"bad_torrents"`
|
||||
ActiveLinks int `json:"active_links"`
|
||||
}
|
||||
|
||||
type DownloadLink struct {
|
||||
Debrid string `json:"debrid"`
|
||||
Token string `json:"token"`
|
||||
Filename string `json:"filename"`
|
||||
Link string `json:"link"`
|
||||
DownloadLink string `json:"download_link"`
|
||||
@@ -139,6 +181,17 @@ type DownloadLink struct {
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (d *DownloadLink) String() string {
|
||||
return d.DownloadLink
|
||||
func (dl *DownloadLink) Valid() error {
|
||||
if dl.Empty() {
|
||||
return EmptyDownloadLinkError
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (dl *DownloadLink) Empty() bool {
|
||||
return dl.DownloadLink == ""
|
||||
}
|
||||
|
||||
func (dl *DownloadLink) String() string {
|
||||
return dl.DownloadLink
|
||||
}
|
||||
|
||||
@@ -2,14 +2,16 @@ package qbit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/wire"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
@@ -20,45 +22,6 @@ const (
|
||||
arrKey contextKey = "arr"
|
||||
)
|
||||
|
||||
func validateServiceURL(urlStr string) error {
|
||||
if urlStr == "" {
|
||||
return fmt.Errorf("URL cannot be empty")
|
||||
}
|
||||
|
||||
// Try parsing as full URL first
|
||||
u, err := url.Parse(urlStr)
|
||||
if err == nil && u.Scheme != "" && u.Host != "" {
|
||||
// It's a full URL, validate scheme
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("URL scheme must be http or https")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a host:port format (no scheme)
|
||||
if strings.Contains(urlStr, ":") && !strings.Contains(urlStr, "://") {
|
||||
// Try parsing with http:// prefix
|
||||
testURL := "http://" + urlStr
|
||||
u, err := url.Parse(testURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid host:port format: %w", err)
|
||||
}
|
||||
|
||||
if u.Host == "" {
|
||||
return fmt.Errorf("host is required in host:port format")
|
||||
}
|
||||
|
||||
// Validate port number
|
||||
if u.Port() == "" {
|
||||
return fmt.Errorf("port is required in host:port format")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid URL format: %s", urlStr)
|
||||
}
|
||||
|
||||
func getCategory(ctx context.Context) string {
|
||||
if category, ok := ctx.Value(categoryKey).(string); ok {
|
||||
return category
|
||||
@@ -95,14 +58,22 @@ func decodeAuthHeader(header string) (string, string, error) {
|
||||
bearer := string(bytes)
|
||||
|
||||
colonIndex := strings.LastIndex(bearer, ":")
|
||||
host := bearer[:colonIndex]
|
||||
token := bearer[colonIndex+1:]
|
||||
username := bearer[:colonIndex]
|
||||
password := bearer[colonIndex+1:]
|
||||
|
||||
return host, token, nil
|
||||
if username == "" || password == "" {
|
||||
return username, password, fmt.Errorf("empty username or password")
|
||||
}
|
||||
|
||||
return strings.TrimSpace(username), strings.TrimSpace(password), nil
|
||||
}
|
||||
|
||||
func (q *QBit) categoryContext(next http.Handler) http.Handler {
|
||||
// Print full URL for debugging
|
||||
|
||||
// Try to get category from URL query first
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Print request method and URL
|
||||
category := strings.Trim(r.URL.Query().Get("category"), "")
|
||||
if category == "" {
|
||||
// Get from form
|
||||
@@ -125,38 +96,116 @@ func (q *QBit) categoryContext(next http.Handler) http.Handler {
|
||||
// Only a valid host and token will be added to the context/config. The rest are manual
|
||||
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 := getCategory(r.Context())
|
||||
arrs := store.Get().Arr()
|
||||
// Check if arr exists
|
||||
a := arrs.Get(category)
|
||||
if a == nil {
|
||||
// Arr is not configured, create a new one
|
||||
downloadUncached := false
|
||||
a = arr.New(category, "", "", false, false, &downloadUncached, "", "auto")
|
||||
}
|
||||
if err == nil {
|
||||
host = strings.TrimSpace(host)
|
||||
if host != "" {
|
||||
a.Host = host
|
||||
}
|
||||
token = strings.TrimSpace(token)
|
||||
if token != "" {
|
||||
a.Token = token
|
||||
}
|
||||
}
|
||||
a.Source = "auto"
|
||||
if err := validateServiceURL(a.Host); err != nil {
|
||||
// Return silently, no need to raise a problem. Just do not add the Arr to the context/config.json
|
||||
next.ServeHTTP(w, r)
|
||||
|
||||
username, password, err := getUsernameAndPassword(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
category := getCategory(r.Context())
|
||||
a, err := q.authenticate(category, username, password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
arrs.AddOrUpdate(a)
|
||||
ctx := context.WithValue(r.Context(), arrKey, a)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
func getUsernameAndPassword(r *http.Request) (string, string, error) {
|
||||
// Try to get from authorization header
|
||||
username, password, err := decodeAuthHeader(r.Header.Get("Authorization"))
|
||||
if err == nil && username != "" {
|
||||
return username, password, err
|
||||
}
|
||||
// Try to get from cookie
|
||||
sid, err := r.Cookie("sid")
|
||||
if err != nil {
|
||||
// try SID
|
||||
sid, err = r.Cookie("SID")
|
||||
}
|
||||
if err == nil {
|
||||
username, password, err = extractFromSID(sid.Value)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func (q *QBit) authenticate(category, username, password string) (*arr.Arr, error) {
|
||||
cfg := config.Get()
|
||||
arrs := wire.Get().Arr()
|
||||
// Check if arr exists
|
||||
a := arrs.Get(category)
|
||||
if a == nil {
|
||||
// Arr is not configured, create a new one
|
||||
downloadUncached := false
|
||||
a = arr.New(category, "", "", false, false, &downloadUncached, "", "auto")
|
||||
}
|
||||
a.Host = username
|
||||
a.Token = password
|
||||
arrValidated := false // This is a flag to indicate if arr validation was successful
|
||||
if (a.Host == "" || a.Token == "") && cfg.UseAuth {
|
||||
return nil, fmt.Errorf("unauthorized: Host and token are required for authentication(you've enabled authentication)")
|
||||
}
|
||||
if err := a.Validate(); err == nil {
|
||||
arrValidated = true
|
||||
}
|
||||
|
||||
if !arrValidated && cfg.UseAuth {
|
||||
// If arr validation failed, try to use user auth validation
|
||||
if !config.VerifyAuth(username, password) {
|
||||
return nil, fmt.Errorf("unauthorized: invalid credentials")
|
||||
}
|
||||
}
|
||||
a.Source = "auto"
|
||||
arrs.AddOrUpdate(a)
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func createSID(username, password string) string {
|
||||
// Create a verification hash
|
||||
cfg := config.Get()
|
||||
combined := fmt.Sprintf("%s|%s", username, password)
|
||||
hash := sha256.Sum256([]byte(combined + cfg.SecretKey()))
|
||||
hashStr := fmt.Sprintf("%x", hash)[:16] // First 16 chars
|
||||
// Base64 encode
|
||||
return base64.URLEncoding.EncodeToString([]byte(fmt.Sprintf("%s|%s", combined, hashStr)))
|
||||
}
|
||||
|
||||
func extractFromSID(sid string) (string, string, error) {
|
||||
// Decode base64
|
||||
decoded, err := base64.URLEncoding.DecodeString(sid)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid SID format")
|
||||
}
|
||||
|
||||
// Split into parts: username:password:hash
|
||||
parts := strings.Split(string(decoded), "|")
|
||||
if len(parts) != 3 {
|
||||
return "", "", fmt.Errorf("invalid SID structure")
|
||||
}
|
||||
|
||||
username := parts[0]
|
||||
password := parts[1]
|
||||
providedHash := parts[2]
|
||||
|
||||
// Verify hash
|
||||
cfg := config.Get()
|
||||
combined := fmt.Sprintf("%s|%s", username, password)
|
||||
expectedHash := sha256.Sum256([]byte(combined + cfg.SecretKey()))
|
||||
expectedHashStr := fmt.Sprintf("%x", expectedHash)[:16]
|
||||
|
||||
if providedHash != expectedHashStr {
|
||||
return "", "", fmt.Errorf("invalid SID signature")
|
||||
}
|
||||
|
||||
return username, password, nil
|
||||
}
|
||||
|
||||
func hashesContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_hashes := chi.URLParam(r, "hashes")
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
)
|
||||
|
||||
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
_arr := getArrFromContext(ctx)
|
||||
if _arr == nil {
|
||||
// Arr not in context, return OK
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
cfg := config.Get()
|
||||
username := r.FormValue("username")
|
||||
password := r.FormValue("password")
|
||||
a, err := q.authenticate(getCategory(ctx), username, password)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if err := _arr.Validate(); err != nil {
|
||||
q.logger.Error().Err(err).Msgf("Error validating arr")
|
||||
http.Error(w, "Invalid arr configuration", http.StatusBadRequest)
|
||||
if cfg.UseAuth {
|
||||
cookie := &http.Cookie{
|
||||
Name: "sid",
|
||||
Value: createSID(a.Host, a.Token),
|
||||
Path: "/",
|
||||
SameSite: http.SameSiteNoneMode,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
}
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"github.com/sirrobot01/decypharr/pkg/wire"
|
||||
)
|
||||
|
||||
type QBit struct {
|
||||
@@ -12,7 +12,7 @@ type QBit struct {
|
||||
Password string
|
||||
DownloadFolder string
|
||||
Categories []string
|
||||
storage *store.TorrentStorage
|
||||
storage *wire.TorrentStorage
|
||||
logger zerolog.Logger
|
||||
Tags []string
|
||||
}
|
||||
@@ -25,7 +25,7 @@ func New() *QBit {
|
||||
Password: cfg.Password,
|
||||
DownloadFolder: cfg.DownloadFolder,
|
||||
Categories: cfg.Categories,
|
||||
storage: store.Get().Torrents(),
|
||||
storage: wire.Get().Torrents(),
|
||||
logger: logger.New("qbit"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,50 @@
|
||||
package qbit
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
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(q.authContext)
|
||||
r.Use(hashesContext)
|
||||
|
||||
r.Get("/info", q.handleTorrentsInfo)
|
||||
r.Post("/info", q.handleTorrentsInfo)
|
||||
|
||||
r.Post("/add", q.handleTorrentsAdd)
|
||||
r.Post("/delete", q.handleTorrentsDelete)
|
||||
|
||||
r.Get("/categories", q.handleCategories)
|
||||
r.Post("/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)
|
||||
|
||||
// Create POST equivalents for pause, resume, recheck
|
||||
r.Post("/tags", q.handleGetTags)
|
||||
r.Post("/pause", q.handleTorrentsPause)
|
||||
r.Post("/resume", q.handleTorrentsResume)
|
||||
r.Post("/recheck", q.handleTorrentRecheck)
|
||||
r.Post("/properties", q.handleTorrentProperties)
|
||||
r.Post("/files", q.handleTorrentFiles)
|
||||
|
||||
})
|
||||
|
||||
r.Route("/app", func(r chi.Router) {
|
||||
|
||||
@@ -3,13 +3,14 @@ package qbit
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/wire"
|
||||
)
|
||||
|
||||
// All torrent-related helpers goes here
|
||||
@@ -18,9 +19,9 @@ func (q *QBit) addMagnet(ctx context.Context, url string, arr *arr.Arr, debrid s
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing magnet link: %w", err)
|
||||
}
|
||||
_store := store.Get()
|
||||
_store := wire.Get()
|
||||
|
||||
importReq := store.NewImportRequest(debrid, q.DownloadFolder, magnet, arr, action, false, "", store.ImportTypeQBitTorrent)
|
||||
importReq := wire.NewImportRequest(debrid, q.DownloadFolder, magnet, arr, action, false, "", wire.ImportTypeQBitTorrent, false)
|
||||
|
||||
err = _store.AddTorrent(ctx, importReq)
|
||||
if err != nil {
|
||||
@@ -37,8 +38,8 @@ func (q *QBit) addTorrent(ctx context.Context, fileHeader *multipart.FileHeader,
|
||||
if err != nil {
|
||||
return fmt.Errorf("error reading file: %s \n %w", fileHeader.Filename, err)
|
||||
}
|
||||
_store := store.Get()
|
||||
importReq := store.NewImportRequest(debrid, q.DownloadFolder, magnet, arr, action, false, "", store.ImportTypeQBitTorrent)
|
||||
_store := wire.Get()
|
||||
importReq := wire.NewImportRequest(debrid, q.DownloadFolder, magnet, arr, action, false, "", wire.ImportTypeQBitTorrent, false)
|
||||
err = _store.AddTorrent(ctx, importReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to process torrent: %w", err)
|
||||
@@ -46,19 +47,19 @@ func (q *QBit) addTorrent(ctx context.Context, fileHeader *multipart.FileHeader,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *QBit) ResumeTorrent(t *store.Torrent) bool {
|
||||
func (q *QBit) ResumeTorrent(t *wire.Torrent) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) PauseTorrent(t *store.Torrent) bool {
|
||||
func (q *QBit) PauseTorrent(t *wire.Torrent) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) RefreshTorrent(t *store.Torrent) bool {
|
||||
func (q *QBit) RefreshTorrent(t *wire.Torrent) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) GetTorrentProperties(t *store.Torrent) *TorrentProperties {
|
||||
func (q *QBit) GetTorrentProperties(t *wire.Torrent) *TorrentProperties {
|
||||
return &TorrentProperties{
|
||||
AdditionDate: t.AddedOn,
|
||||
Comment: "Debrid Blackhole <https://github.com/sirrobot01/decypharr>",
|
||||
@@ -83,7 +84,7 @@ func (q *QBit) GetTorrentProperties(t *store.Torrent) *TorrentProperties {
|
||||
}
|
||||
}
|
||||
|
||||
func (q *QBit) setTorrentTags(t *store.Torrent, tags []string) bool {
|
||||
func (q *QBit) setTorrentTags(t *wire.Torrent, tags []string) bool {
|
||||
torrentTags := strings.Split(t.Tags, ",")
|
||||
for _, tag := range tags {
|
||||
if tag == "" {
|
||||
@@ -101,7 +102,7 @@ func (q *QBit) setTorrentTags(t *store.Torrent, tags []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func (q *QBit) removeTorrentTags(t *store.Torrent, tags []string) bool {
|
||||
func (q *QBit) removeTorrentTags(t *wire.Torrent, tags []string) bool {
|
||||
torrentTags := strings.Split(t.Tags, ",")
|
||||
newTorrentTags := utils.RemoveItem(torrentTags, tags...)
|
||||
q.Tags = utils.RemoveItem(q.Tags, tags...)
|
||||
|
||||
@@ -191,7 +191,6 @@ func (f *HttpFile) ReadAt(p []byte, off int64) (n int, err error) {
|
||||
bytesRead, err := io.ReadFull(resp.Body, p)
|
||||
return bytesRead, err
|
||||
case http.StatusOK:
|
||||
// Some servers return the full content instead of partial
|
||||
fullData, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("%w: %v", ErrNetworkError, err)
|
||||
@@ -684,18 +683,3 @@ func (r *Reader) ExtractFile(file *File) ([]byte, error) {
|
||||
|
||||
return r.readBytes(file.DataOffset, int(file.CompressedSize))
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func max(a, b int) int {
|
||||
if a > b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
447
pkg/rclone/client.go
Normal file
@@ -0,0 +1,447 @@
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
)
|
||||
|
||||
// Mount creates a mount using the rclone RC API with retry logic
|
||||
func (m *Manager) Mount(mountPath, provider, webdavURL string) error {
|
||||
return m.mountWithRetry(mountPath, provider, webdavURL, 3)
|
||||
}
|
||||
|
||||
// mountWithRetry attempts to mount with retry logic
|
||||
func (m *Manager) mountWithRetry(mountPath, provider, webdavURL string, maxRetries int) error {
|
||||
if !m.IsReady() {
|
||||
if err := m.WaitForReady(30 * time.Second); err != nil {
|
||||
return fmt.Errorf("rclone RC server not ready: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for attempt := 0; attempt <= maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Wait before retry
|
||||
wait := time.Duration(attempt*2) * time.Second
|
||||
m.logger.Debug().
|
||||
Int("attempt", attempt).
|
||||
Str("provider", provider).
|
||||
Msg("Retrying mount operation")
|
||||
time.Sleep(wait)
|
||||
}
|
||||
|
||||
if err := m.performMount(mountPath, provider, webdavURL); err != nil {
|
||||
m.logger.Error().
|
||||
Err(err).
|
||||
Str("provider", provider).
|
||||
Int("attempt", attempt+1).
|
||||
Msg("Mount attempt failed")
|
||||
continue
|
||||
}
|
||||
|
||||
return nil // Success
|
||||
}
|
||||
return fmt.Errorf("mount failed for %s", provider)
|
||||
}
|
||||
|
||||
// performMount performs a single mount attempt
|
||||
func (m *Manager) performMount(mountPath, provider, webdavURL string) error {
|
||||
cfg := config.Get()
|
||||
|
||||
// Create mount directory
|
||||
if err := os.MkdirAll(mountPath, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create mount directory %s: %w", mountPath, err)
|
||||
}
|
||||
|
||||
// Check if already mounted
|
||||
m.mountsMutex.RLock()
|
||||
existingMount, exists := m.mounts[provider]
|
||||
m.mountsMutex.RUnlock()
|
||||
|
||||
if exists && existingMount.Mounted {
|
||||
m.logger.Info().Str("provider", provider).Str("path", mountPath).Msg("Already mounted")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clean up any stale mount first
|
||||
if exists && !existingMount.Mounted {
|
||||
err := m.forceUnmountPath(mountPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Create rclone config for this provider
|
||||
if err := m.createConfig(provider, webdavURL); err != nil {
|
||||
return fmt.Errorf("failed to create rclone config: %w", err)
|
||||
}
|
||||
|
||||
// Prepare mount arguments
|
||||
mountArgs := map[string]interface{}{
|
||||
"fs": fmt.Sprintf("%s:", provider),
|
||||
"mountPoint": mountPath,
|
||||
}
|
||||
mountOpt := map[string]interface{}{
|
||||
"AllowNonEmpty": true,
|
||||
"AllowOther": true,
|
||||
"DebugFUSE": false,
|
||||
"DeviceName": fmt.Sprintf("decypharr-%s", provider),
|
||||
"VolumeName": fmt.Sprintf("decypharr-%s", provider),
|
||||
}
|
||||
|
||||
if cfg.Rclone.AsyncRead != nil {
|
||||
mountOpt["AsyncRead"] = *cfg.Rclone.AsyncRead
|
||||
}
|
||||
|
||||
if cfg.Rclone.UseMmap {
|
||||
mountOpt["UseMmap"] = cfg.Rclone.UseMmap
|
||||
}
|
||||
|
||||
if cfg.Rclone.Transfers != 0 {
|
||||
mountOpt["Transfers"] = cfg.Rclone.Transfers
|
||||
}
|
||||
|
||||
configOpts := make(map[string]interface{})
|
||||
|
||||
if cfg.Rclone.BufferSize != "" {
|
||||
configOpts["BufferSize"] = cfg.Rclone.BufferSize
|
||||
}
|
||||
|
||||
if cfg.Rclone.BwLimit != "" {
|
||||
configOpts["BwLimit"] = cfg.Rclone.BwLimit
|
||||
}
|
||||
|
||||
if len(configOpts) > 0 {
|
||||
// Only add _config if there are options to set
|
||||
mountArgs["_config"] = configOpts
|
||||
}
|
||||
vfsOpt := map[string]interface{}{
|
||||
"CacheMode": cfg.Rclone.VfsCacheMode,
|
||||
"DirCacheTime": cfg.Rclone.DirCacheTime,
|
||||
}
|
||||
vfsOpt["PollInterval"] = 0 // Poll interval not supported for webdav, set to 0
|
||||
|
||||
// Add VFS options if caching is enabled
|
||||
if cfg.Rclone.VfsCacheMode != "off" {
|
||||
|
||||
if cfg.Rclone.VfsCacheMaxAge != "" {
|
||||
vfsOpt["CacheMaxAge"] = cfg.Rclone.VfsCacheMaxAge
|
||||
}
|
||||
if cfg.Rclone.VfsDiskSpaceTotal != "" {
|
||||
vfsOpt["DiskSpaceTotalSize"] = cfg.Rclone.VfsDiskSpaceTotal
|
||||
}
|
||||
if cfg.Rclone.VfsReadChunkSizeLimit != "" {
|
||||
vfsOpt["ChunkSizeLimit"] = cfg.Rclone.VfsReadChunkSizeLimit
|
||||
}
|
||||
|
||||
if cfg.Rclone.VfsCacheMaxSize != "" {
|
||||
vfsOpt["CacheMaxSize"] = cfg.Rclone.VfsCacheMaxSize
|
||||
}
|
||||
if cfg.Rclone.VfsCachePollInterval != "" {
|
||||
vfsOpt["CachePollInterval"] = cfg.Rclone.VfsCachePollInterval
|
||||
}
|
||||
if cfg.Rclone.VfsReadChunkSize != "" {
|
||||
vfsOpt["ChunkSize"] = cfg.Rclone.VfsReadChunkSize
|
||||
}
|
||||
if cfg.Rclone.VfsReadAhead != "" {
|
||||
vfsOpt["ReadAhead"] = cfg.Rclone.VfsReadAhead
|
||||
}
|
||||
|
||||
if cfg.Rclone.VfsCacheMinFreeSpace != "" {
|
||||
vfsOpt["CacheMinFreeSpace"] = cfg.Rclone.VfsCacheMinFreeSpace
|
||||
}
|
||||
|
||||
if cfg.Rclone.VfsFastFingerprint {
|
||||
vfsOpt["FastFingerprint"] = cfg.Rclone.VfsFastFingerprint
|
||||
}
|
||||
|
||||
if cfg.Rclone.VfsReadChunkStreams != 0 {
|
||||
vfsOpt["ChunkStreams"] = cfg.Rclone.VfsReadChunkStreams
|
||||
}
|
||||
|
||||
if cfg.Rclone.NoChecksum {
|
||||
vfsOpt["NoChecksum"] = cfg.Rclone.NoChecksum
|
||||
}
|
||||
if cfg.Rclone.NoModTime {
|
||||
vfsOpt["NoModTime"] = cfg.Rclone.NoModTime
|
||||
}
|
||||
}
|
||||
|
||||
// Add mount options based on configuration
|
||||
if cfg.Rclone.UID != 0 {
|
||||
vfsOpt["UID"] = cfg.Rclone.UID
|
||||
}
|
||||
if cfg.Rclone.GID != 0 {
|
||||
vfsOpt["GID"] = cfg.Rclone.GID
|
||||
}
|
||||
|
||||
if cfg.Rclone.Umask != "" {
|
||||
umask, err := strconv.ParseInt(cfg.Rclone.Umask, 8, 32)
|
||||
if err == nil {
|
||||
vfsOpt["Umask"] = uint32(umask)
|
||||
}
|
||||
}
|
||||
|
||||
if cfg.Rclone.AttrTimeout != "" {
|
||||
if attrTimeout, err := time.ParseDuration(cfg.Rclone.AttrTimeout); err == nil {
|
||||
mountOpt["AttrTimeout"] = attrTimeout.String()
|
||||
}
|
||||
}
|
||||
|
||||
mountArgs["vfsOpt"] = vfsOpt
|
||||
mountArgs["mountOpt"] = mountOpt
|
||||
// Make the mount request
|
||||
req := RCRequest{
|
||||
Command: "mount/mount",
|
||||
Args: mountArgs,
|
||||
}
|
||||
|
||||
_, err := m.makeRequest(req, true)
|
||||
if err != nil {
|
||||
// Clean up mount point on failure
|
||||
_ = m.forceUnmountPath(mountPath)
|
||||
return fmt.Errorf("failed to create mount for %s: %w", provider, err)
|
||||
}
|
||||
|
||||
// Store mount info
|
||||
mountInfo := &MountInfo{
|
||||
Provider: provider,
|
||||
LocalPath: mountPath,
|
||||
WebDAVURL: webdavURL,
|
||||
Mounted: true,
|
||||
MountedAt: time.Now().Format(time.RFC3339),
|
||||
ConfigName: provider,
|
||||
}
|
||||
|
||||
m.mountsMutex.Lock()
|
||||
m.mounts[provider] = mountInfo
|
||||
m.mountsMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unmount unmounts a specific provider
|
||||
func (m *Manager) Unmount(provider string) error {
|
||||
return m.unmount(provider)
|
||||
}
|
||||
|
||||
// unmount is the internal unmount function
|
||||
func (m *Manager) unmount(provider string) error {
|
||||
m.mountsMutex.RLock()
|
||||
mountInfo, exists := m.mounts[provider]
|
||||
m.mountsMutex.RUnlock()
|
||||
|
||||
if !exists || !mountInfo.Mounted {
|
||||
m.logger.Info().Str("provider", provider).Msg("Mount not found or already unmounted")
|
||||
return nil
|
||||
}
|
||||
|
||||
m.logger.Info().Str("provider", provider).Str("path", mountInfo.LocalPath).Msg("Unmounting")
|
||||
|
||||
// Try RC unmount first
|
||||
req := RCRequest{
|
||||
Command: "mount/unmount",
|
||||
Args: map[string]interface{}{
|
||||
"mountPoint": mountInfo.LocalPath,
|
||||
},
|
||||
}
|
||||
|
||||
var rcErr error
|
||||
if m.IsReady() {
|
||||
_, rcErr = m.makeRequest(req, true)
|
||||
}
|
||||
|
||||
// If RC unmount fails or server is not ready, try force unmount
|
||||
if rcErr != nil {
|
||||
m.logger.Warn().Err(rcErr).Str("provider", provider).Msg("RC unmount failed, trying force unmount")
|
||||
if err := m.forceUnmountPath(mountInfo.LocalPath); err != nil {
|
||||
m.logger.Error().Err(err).Str("provider", provider).Msg("Force unmount failed")
|
||||
// Don't return error here, update the state anyway
|
||||
}
|
||||
}
|
||||
|
||||
// Update mount info
|
||||
m.mountsMutex.Lock()
|
||||
if info, exists := m.mounts[provider]; exists {
|
||||
info.Mounted = false
|
||||
info.Error = ""
|
||||
if rcErr != nil {
|
||||
info.Error = rcErr.Error()
|
||||
}
|
||||
}
|
||||
m.mountsMutex.Unlock()
|
||||
|
||||
m.logger.Info().Str("provider", provider).Msg("Unmount completed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// UnmountAll unmounts all mounts
|
||||
func (m *Manager) UnmountAll() error {
|
||||
m.mountsMutex.RLock()
|
||||
providers := make([]string, 0, len(m.mounts))
|
||||
for provider, mount := range m.mounts {
|
||||
if mount.Mounted {
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
}
|
||||
m.mountsMutex.RUnlock()
|
||||
|
||||
var lastError error
|
||||
for _, provider := range providers {
|
||||
if err := m.unmount(provider); err != nil {
|
||||
lastError = err
|
||||
m.logger.Error().Err(err).Str("provider", provider).Msg("Failed to unmount")
|
||||
}
|
||||
}
|
||||
|
||||
return lastError
|
||||
}
|
||||
|
||||
// GetMountInfo returns information about a specific mount
|
||||
func (m *Manager) GetMountInfo(provider string) (*MountInfo, bool) {
|
||||
m.mountsMutex.RLock()
|
||||
defer m.mountsMutex.RUnlock()
|
||||
|
||||
info, exists := m.mounts[provider]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Create a copy to avoid race conditions
|
||||
mountInfo := *info
|
||||
return &mountInfo, true
|
||||
}
|
||||
|
||||
// GetAllMounts returns information about all mounts
|
||||
func (m *Manager) GetAllMounts() map[string]*MountInfo {
|
||||
m.mountsMutex.RLock()
|
||||
defer m.mountsMutex.RUnlock()
|
||||
|
||||
result := make(map[string]*MountInfo, len(m.mounts))
|
||||
for provider, info := range m.mounts {
|
||||
// Create a copy to avoid race conditions
|
||||
mountInfo := *info
|
||||
result[provider] = &mountInfo
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// IsMounted checks if a provider is mounted
|
||||
func (m *Manager) IsMounted(provider string) bool {
|
||||
info, exists := m.GetMountInfo(provider)
|
||||
return exists && info.Mounted
|
||||
}
|
||||
|
||||
// RefreshDir refreshes directories in the VFS cache
|
||||
func (m *Manager) RefreshDir(provider string, dirs []string) error {
|
||||
if !m.IsReady() {
|
||||
return fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
|
||||
mountInfo, exists := m.GetMountInfo(provider)
|
||||
if !exists || !mountInfo.Mounted {
|
||||
return fmt.Errorf("provider %s not mounted", provider)
|
||||
}
|
||||
|
||||
// If no specific directories provided, refresh root
|
||||
if len(dirs) == 0 {
|
||||
dirs = []string{"/"}
|
||||
}
|
||||
args := map[string]interface{}{
|
||||
"fs": fmt.Sprintf("%s:", provider),
|
||||
}
|
||||
for i, dir := range dirs {
|
||||
if dir != "" {
|
||||
if i == 0 {
|
||||
args["dir"] = dir
|
||||
} else {
|
||||
args[fmt.Sprintf("dir%d", i+1)] = dir
|
||||
}
|
||||
}
|
||||
}
|
||||
req := RCRequest{
|
||||
Command: "vfs/forget",
|
||||
Args: args,
|
||||
}
|
||||
|
||||
_, err := m.makeRequest(req, true)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).
|
||||
Str("provider", provider).
|
||||
Msg("Failed to refresh directory")
|
||||
return fmt.Errorf("failed to refresh directory %s for provider %s: %w", dirs, provider, err)
|
||||
}
|
||||
|
||||
req = RCRequest{
|
||||
Command: "vfs/refresh",
|
||||
Args: args,
|
||||
}
|
||||
|
||||
_, err = m.makeRequest(req, true)
|
||||
if err != nil {
|
||||
m.logger.Error().Err(err).
|
||||
Str("provider", provider).
|
||||
Msg("Failed to refresh directory")
|
||||
return fmt.Errorf("failed to refresh directory %s for provider %s: %w", dirs, provider, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// createConfig creates an rclone config entry for the provider
|
||||
func (m *Manager) createConfig(configName, webdavURL string) error {
|
||||
req := RCRequest{
|
||||
Command: "config/create",
|
||||
Args: map[string]interface{}{
|
||||
"name": configName,
|
||||
"type": "webdav",
|
||||
"parameters": map[string]interface{}{
|
||||
"url": webdavURL,
|
||||
"vendor": "other",
|
||||
"pacer_min_sleep": "0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := m.makeRequest(req, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create config %s: %w", configName, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// forceUnmountPath attempts to force unmount a path using system commands
|
||||
func (m *Manager) forceUnmountPath(mountPath string) error {
|
||||
methods := [][]string{
|
||||
{"umount", mountPath},
|
||||
{"umount", "-l", mountPath}, // lazy unmount
|
||||
{"fusermount", "-uz", mountPath},
|
||||
{"fusermount3", "-uz", mountPath},
|
||||
}
|
||||
|
||||
for _, method := range methods {
|
||||
if err := m.tryUnmountCommand(method...); err == nil {
|
||||
m.logger.Info().
|
||||
Strs("command", method).
|
||||
Str("path", mountPath).
|
||||
Msg("Successfully unmounted using system command")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("all force unmount attempts failed for %s", mountPath)
|
||||
}
|
||||
|
||||
// tryUnmountCommand tries to run an unmount command
|
||||
func (m *Manager) tryUnmountCommand(args ...string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("no command provided")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(m.ctx, args[0], args[1:]...)
|
||||
return cmd.Run()
|
||||
}
|
||||
140
pkg/rclone/health.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HealthCheck performs comprehensive health checks on the rclone system
|
||||
func (m *Manager) HealthCheck() error {
|
||||
if !m.serverStarted {
|
||||
return fmt.Errorf("rclone RC server is not started")
|
||||
}
|
||||
|
||||
if !m.IsReady() {
|
||||
return fmt.Errorf("rclone RC server is not ready")
|
||||
}
|
||||
|
||||
// Check if we can communicate with the server
|
||||
if !m.pingServer() {
|
||||
return fmt.Errorf("rclone RC server is not responding")
|
||||
}
|
||||
|
||||
// Check mounts health
|
||||
m.mountsMutex.RLock()
|
||||
unhealthyMounts := make([]string, 0)
|
||||
for provider, mount := range m.mounts {
|
||||
if mount.Mounted && !m.checkMountHealth(provider) {
|
||||
unhealthyMounts = append(unhealthyMounts, provider)
|
||||
}
|
||||
}
|
||||
m.mountsMutex.RUnlock()
|
||||
|
||||
if len(unhealthyMounts) > 0 {
|
||||
return fmt.Errorf("unhealthy mounts detected: %v", unhealthyMounts)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkMountHealth checks if a specific mount is healthy
|
||||
func (m *Manager) checkMountHealth(provider string) bool {
|
||||
// Try to list the root directory of the mount
|
||||
req := RCRequest{
|
||||
Command: "operations/list",
|
||||
Args: map[string]interface{}{
|
||||
"fs": fmt.Sprintf("%s:", provider),
|
||||
"remote": "",
|
||||
},
|
||||
}
|
||||
|
||||
_, err := m.makeRequest(req, true)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// RecoverMount attempts to recover a failed mount
|
||||
func (m *Manager) RecoverMount(provider string) error {
|
||||
m.mountsMutex.RLock()
|
||||
mountInfo, exists := m.mounts[provider]
|
||||
m.mountsMutex.RUnlock()
|
||||
|
||||
if !exists {
|
||||
return fmt.Errorf("mount for provider %s does not exist", provider)
|
||||
}
|
||||
|
||||
m.logger.Warn().Str("provider", provider).Msg("Attempting to recover mount")
|
||||
|
||||
// First try to unmount cleanly
|
||||
if err := m.unmount(provider); err != nil {
|
||||
m.logger.Error().Err(err).Str("provider", provider).Msg("Failed to unmount during recovery")
|
||||
}
|
||||
|
||||
// Wait a moment
|
||||
time.Sleep(1 * time.Second)
|
||||
|
||||
// Try to remount
|
||||
if err := m.Mount(mountInfo.LocalPath, provider, mountInfo.WebDAVURL); err != nil {
|
||||
return fmt.Errorf("failed to recover mount for %s: %w", provider, err)
|
||||
}
|
||||
|
||||
m.logger.Info().Str("provider", provider).Msg("Successfully recovered mount")
|
||||
return nil
|
||||
}
|
||||
|
||||
// MonitorMounts continuously monitors mount health and attempts recovery
|
||||
func (m *Manager) MonitorMounts(ctx context.Context) {
|
||||
if !m.serverStarted {
|
||||
return
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second) // Check every 30 seconds
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
m.logger.Debug().Msg("Mount monitoring stopped")
|
||||
return
|
||||
case <-ticker.C:
|
||||
m.performMountHealthCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// performMountHealthCheck checks and attempts to recover unhealthy mounts
|
||||
func (m *Manager) performMountHealthCheck() {
|
||||
if !m.IsReady() {
|
||||
return
|
||||
}
|
||||
|
||||
m.mountsMutex.RLock()
|
||||
providers := make([]string, 0, len(m.mounts))
|
||||
for provider, mount := range m.mounts {
|
||||
if mount.Mounted {
|
||||
providers = append(providers, provider)
|
||||
}
|
||||
}
|
||||
m.mountsMutex.RUnlock()
|
||||
|
||||
for _, provider := range providers {
|
||||
if !m.checkMountHealth(provider) {
|
||||
m.logger.Warn().Str("provider", provider).Msg("Mount health check failed, attempting recovery")
|
||||
|
||||
// Mark mount as unhealthy
|
||||
m.mountsMutex.Lock()
|
||||
if mount, exists := m.mounts[provider]; exists {
|
||||
mount.Error = "Health check failed"
|
||||
mount.Mounted = false
|
||||
}
|
||||
m.mountsMutex.Unlock()
|
||||
|
||||
// Attempt recovery
|
||||
go func(provider string) {
|
||||
if err := m.RecoverMount(provider); err != nil {
|
||||
m.logger.Error().Err(err).Str("provider", provider).Msg("Failed to recover mount")
|
||||
}
|
||||
}(provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
43
pkg/rclone/killed_unix.go
Normal file
@@ -0,0 +1,43 @@
|
||||
//go:build !windows
|
||||
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// WasHardTerminated reports true iff the process was ended by SIGKILL or SIGTERM.
|
||||
func WasHardTerminated(err error) bool {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
return false
|
||||
}
|
||||
ws, ok := ee.Sys().(syscall.WaitStatus)
|
||||
if !ok || !ws.Signaled() {
|
||||
return false
|
||||
}
|
||||
sig := ws.Signal()
|
||||
return sig == syscall.SIGKILL || sig == syscall.SIGTERM
|
||||
}
|
||||
|
||||
// ExitCode returns the numeric exit code when available.
|
||||
func ExitCode(err error) (int, bool) {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
return 0, false
|
||||
}
|
||||
ws, ok := ee.Sys().(syscall.WaitStatus)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if ws.Exited() {
|
||||
return ws.ExitStatus(), true
|
||||
}
|
||||
// Conventional shell “killed by signal” code is 128 + signal.
|
||||
if ws.Signaled() {
|
||||
return 128 + int(ws.Signal()), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
35
pkg/rclone/killed_windows.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build windows
|
||||
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func WasHardTerminated(err error) bool {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
return false
|
||||
}
|
||||
ws, ok := ee.Sys().(syscall.WaitStatus)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// No Signaled() on Windows; consider "hard terminated" if not success.
|
||||
return ws.ExitStatus() != 0 // Use the ExitStatus() method
|
||||
}
|
||||
|
||||
// ExitCode returns the process exit code when available.
|
||||
func ExitCode(err error) (int, bool) {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
return 0, false
|
||||
}
|
||||
ws, ok := ee.Sys().(syscall.WaitStatus)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return ws.ExitStatus(), true // Use the ExitStatus() method
|
||||
}
|
||||
394
pkg/rclone/manager.go
Normal file
@@ -0,0 +1,394 @@
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
)
|
||||
|
||||
// Manager handles the rclone RC server and provides mount operations
|
||||
type Manager struct {
|
||||
cmd *exec.Cmd
|
||||
rcPort string
|
||||
rcUser string
|
||||
rcPass string
|
||||
rcloneDir string
|
||||
mounts map[string]*MountInfo
|
||||
mountsMutex sync.RWMutex
|
||||
logger zerolog.Logger
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
httpClient *http.Client
|
||||
serverReady chan struct{}
|
||||
serverStarted bool
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
type MountInfo struct {
|
||||
Provider string `json:"provider"`
|
||||
LocalPath string `json:"local_path"`
|
||||
WebDAVURL string `json:"webdav_url"`
|
||||
Mounted bool `json:"mounted"`
|
||||
MountedAt string `json:"mounted_at,omitempty"`
|
||||
ConfigName string `json:"config_name"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type RCRequest struct {
|
||||
Command string `json:"command"`
|
||||
Args map[string]interface{} `json:"args,omitempty"`
|
||||
}
|
||||
|
||||
type RCResponse struct {
|
||||
Result interface{} `json:"result,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// NewManager creates a new rclone RC manager
|
||||
func NewManager() *Manager {
|
||||
cfg := config.Get()
|
||||
|
||||
rcPort := "5572"
|
||||
rcloneDir := filepath.Join(cfg.Path, "rclone")
|
||||
|
||||
// Ensure config directory exists
|
||||
if err := os.MkdirAll(rcloneDir, 0755); err != nil {
|
||||
_logger := logger.New("rclone")
|
||||
_logger.Error().Err(err).Msg("Failed to create rclone config directory")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
return &Manager{
|
||||
rcPort: rcPort,
|
||||
rcloneDir: rcloneDir,
|
||||
mounts: make(map[string]*MountInfo),
|
||||
logger: logger.New("rclone"),
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
httpClient: &http.Client{Timeout: 60 * time.Second},
|
||||
serverReady: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Start starts the rclone RC server
|
||||
func (m *Manager) Start(ctx context.Context) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if m.serverStarted {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
if !cfg.Rclone.Enabled {
|
||||
m.logger.Info().Msg("Rclone is disabled, skipping RC server startup")
|
||||
return nil
|
||||
}
|
||||
|
||||
logFile := filepath.Join(logger.GetLogPath(), "rclone.log")
|
||||
|
||||
// Delete old log file if it exists
|
||||
if _, err := os.Stat(logFile); err == nil {
|
||||
if err := os.Remove(logFile); err != nil {
|
||||
return fmt.Errorf("failed to remove old rclone log file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
args := []string{
|
||||
"rcd",
|
||||
"--rc-addr", ":" + m.rcPort,
|
||||
"--rc-no-auth", // We'll handle auth at the application level
|
||||
"--config", filepath.Join(m.rcloneDir, "rclone.conf"),
|
||||
"--log-file", logFile,
|
||||
}
|
||||
|
||||
logLevel := cfg.Rclone.LogLevel
|
||||
if logLevel != "" {
|
||||
if !slices.Contains([]string{"DEBUG", "INFO", "NOTICE", "ERROR"}, logLevel) {
|
||||
logLevel = "INFO"
|
||||
}
|
||||
args = append(args, "--log-level", logLevel)
|
||||
}
|
||||
|
||||
if cfg.Rclone.CacheDir != "" {
|
||||
if err := os.MkdirAll(cfg.Rclone.CacheDir, 0755); err == nil {
|
||||
args = append(args, "--cache-dir", cfg.Rclone.CacheDir)
|
||||
}
|
||||
}
|
||||
m.cmd = exec.CommandContext(ctx, "rclone", args...)
|
||||
|
||||
// Capture output for debugging
|
||||
var stdout, stderr bytes.Buffer
|
||||
m.cmd.Stdout = &stdout
|
||||
m.cmd.Stderr = &stderr
|
||||
|
||||
if err := m.cmd.Start(); err != nil {
|
||||
m.logger.Error().Str("stderr", stderr.String()).Str("stdout", stdout.String()).
|
||||
Err(err).Msg("Failed to start rclone RC server")
|
||||
return fmt.Errorf("failed to start rclone RC server: %w", err)
|
||||
}
|
||||
m.serverStarted = true
|
||||
|
||||
// Wait for server to be ready in a goroutine
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
m.logger.Error().Interface("panic", r).Msg("Panic in rclone RC server monitor")
|
||||
}
|
||||
}()
|
||||
|
||||
m.waitForServer()
|
||||
close(m.serverReady)
|
||||
|
||||
// Start mount monitoring once server is ready
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
m.logger.Error().Interface("panic", r).Msg("Panic in mount monitor")
|
||||
}
|
||||
}()
|
||||
m.MonitorMounts(ctx)
|
||||
}()
|
||||
|
||||
// Wait for command to finish and log output
|
||||
err := m.cmd.Wait()
|
||||
switch {
|
||||
case err == nil:
|
||||
m.logger.Info().Msg("Rclone RC server exited normally")
|
||||
|
||||
case errors.Is(err, context.Canceled):
|
||||
m.logger.Info().Msg("Rclone RC server terminated: context canceled")
|
||||
|
||||
case WasHardTerminated(err): // SIGKILL on *nix; non-zero exit on Windows
|
||||
m.logger.Info().Msg("Rclone RC server hard-terminated")
|
||||
|
||||
default:
|
||||
if code, ok := ExitCode(err); ok {
|
||||
m.logger.Debug().Int("exit_code", code).Err(err).
|
||||
Str("stderr", stderr.String()).
|
||||
Str("stdout", stdout.String()).
|
||||
Msg("Rclone RC server error")
|
||||
} else {
|
||||
m.logger.Debug().Err(err).Str("stderr", stderr.String()).
|
||||
Str("stdout", stdout.String()).Msg("Rclone RC server error (no exit code)")
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop stops the rclone RC server and unmounts all mounts
|
||||
func (m *Manager) Stop() error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if !m.serverStarted {
|
||||
return nil
|
||||
}
|
||||
|
||||
m.logger.Info().Msg("Stopping rclone RC server")
|
||||
|
||||
// Unmount all mounts first
|
||||
m.mountsMutex.RLock()
|
||||
mountList := make([]*MountInfo, 0, len(m.mounts))
|
||||
for _, mount := range m.mounts {
|
||||
if mount.Mounted {
|
||||
mountList = append(mountList, mount)
|
||||
}
|
||||
}
|
||||
m.mountsMutex.RUnlock()
|
||||
|
||||
// Unmount in parallel
|
||||
var wg sync.WaitGroup
|
||||
for _, mount := range mountList {
|
||||
wg.Add(1)
|
||||
go func(mount *MountInfo) {
|
||||
defer wg.Done()
|
||||
if err := m.unmount(mount.Provider); err != nil {
|
||||
m.logger.Error().Err(err).Str("provider", mount.Provider).Msg("Failed to unmount during shutdown")
|
||||
}
|
||||
}(mount)
|
||||
}
|
||||
|
||||
// Wait for unmounts with timeout
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
m.logger.Info().Msg("All mounts unmounted successfully")
|
||||
case <-time.After(30 * time.Second):
|
||||
m.logger.Warn().Msg("Timeout waiting for mounts to unmount, proceeding with shutdown")
|
||||
}
|
||||
|
||||
// Cancel context and stop process
|
||||
m.cancel()
|
||||
|
||||
if m.cmd != nil && m.cmd.Process != nil {
|
||||
// Try graceful shutdown first
|
||||
if err := m.cmd.Process.Signal(os.Interrupt); err != nil {
|
||||
m.logger.Warn().Err(err).Msg("Failed to send interrupt signal, using kill")
|
||||
if killErr := m.cmd.Process.Kill(); killErr != nil {
|
||||
m.logger.Error().Err(killErr).Msg("Failed to kill rclone process")
|
||||
return killErr
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for process to exit with timeout
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- m.cmd.Wait()
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
if err != nil && !errors.Is(err, context.Canceled) && !WasHardTerminated(err) {
|
||||
m.logger.Warn().Err(err).Msg("Rclone process exited with error")
|
||||
} else {
|
||||
m.logger.Info().Msg("Rclone process exited gracefully")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
m.logger.Warn().Msg("Timeout waiting for rclone to exit, force killing")
|
||||
if err := m.cmd.Process.Kill(); err != nil {
|
||||
// Check if the process already finished
|
||||
if !strings.Contains(err.Error(), "process already finished") {
|
||||
m.logger.Error().Err(err).Msg("Failed to force kill rclone process")
|
||||
return err
|
||||
}
|
||||
m.logger.Info().Msg("Process already finished during kill attempt")
|
||||
}
|
||||
|
||||
// Still wait for the Wait() to complete to clean up the process
|
||||
select {
|
||||
case <-done:
|
||||
m.logger.Info().Msg("Rclone process cleanup completed")
|
||||
case <-time.After(5 * time.Second):
|
||||
m.logger.Error().Msg("Process cleanup timeout")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m.serverStarted = false
|
||||
m.logger.Info().Msg("Rclone RC server stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForServer waits for the RC server to become available
|
||||
func (m *Manager) waitForServer() {
|
||||
maxAttempts := 30
|
||||
for i := 0; i < maxAttempts; i++ {
|
||||
if m.ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if m.pingServer() {
|
||||
m.logger.Info().Msg("Rclone RC server is ready")
|
||||
return
|
||||
}
|
||||
|
||||
time.Sleep(time.Second)
|
||||
}
|
||||
|
||||
m.logger.Error().Msg("Rclone RC server not responding - mount operations will be disabled")
|
||||
}
|
||||
|
||||
// pingServer checks if the RC server is responding
|
||||
func (m *Manager) pingServer() bool {
|
||||
req := RCRequest{Command: "core/version"}
|
||||
_, err := m.makeRequest(req, true)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (m *Manager) makeRequest(req RCRequest, close bool) (*http.Response, error) {
|
||||
reqBody, err := json.Marshal(req.Args)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("http://localhost:%s/%s", m.rcPort, req.Command)
|
||||
httpReq, err := http.NewRequestWithContext(m.ctx, "POST", url, bytes.NewBuffer(reqBody))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
httpReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := m.httpClient.Do(httpReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to make request: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// Read the response body to get more details
|
||||
defer func(Body io.ReadCloser) {
|
||||
err := Body.Close()
|
||||
if err != nil {
|
||||
m.logger.Debug().Err(err).Msg("Failed to close response body")
|
||||
}
|
||||
}(resp.Body)
|
||||
var errorResp RCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&errorResp); err != nil {
|
||||
return nil, fmt.Errorf("request failed with status %s, but could not decode error response: %w", resp.Status, err)
|
||||
}
|
||||
if errorResp.Error != "" {
|
||||
return nil, fmt.Errorf("%s", errorResp.Error)
|
||||
} else {
|
||||
return nil, fmt.Errorf("request failed with status %s and no error message", resp.Status)
|
||||
}
|
||||
}
|
||||
|
||||
if close {
|
||||
defer func() {
|
||||
if err := resp.Body.Close(); err != nil {
|
||||
m.logger.Debug().Err(err).Msg("Failed to close response body")
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// IsReady returns true if the RC server is ready
|
||||
func (m *Manager) IsReady() bool {
|
||||
select {
|
||||
case <-m.serverReady:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// WaitForReady waits for the RC server to be ready
|
||||
func (m *Manager) WaitForReady(timeout time.Duration) error {
|
||||
select {
|
||||
case <-m.serverReady:
|
||||
return nil
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("timeout waiting for rclone RC server to be ready")
|
||||
case <-m.ctx.Done():
|
||||
return m.ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) GetLogger() zerolog.Logger {
|
||||
return m.logger
|
||||
}
|
||||
131
pkg/rclone/mount.go
Normal file
@@ -0,0 +1,131 @@
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
)
|
||||
|
||||
// Mount represents a mount using the rclone RC client
|
||||
type Mount struct {
|
||||
Provider string
|
||||
LocalPath string
|
||||
WebDAVURL string
|
||||
logger zerolog.Logger
|
||||
rcManager *Manager
|
||||
}
|
||||
|
||||
// NewMount creates a new RC-based mount
|
||||
func NewMount(provider, customRcloneMount, webdavURL string, rcManager *Manager) *Mount {
|
||||
cfg := config.Get()
|
||||
var mountPath string
|
||||
if customRcloneMount != "" {
|
||||
mountPath = customRcloneMount
|
||||
} else {
|
||||
mountPath = filepath.Join(cfg.Rclone.MountPath, provider)
|
||||
}
|
||||
|
||||
_url, err := url.JoinPath(webdavURL, provider)
|
||||
if err != nil {
|
||||
_url = fmt.Sprintf("%s/%s", webdavURL, provider)
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(_url, "/") {
|
||||
_url += "/"
|
||||
}
|
||||
|
||||
return &Mount{
|
||||
Provider: provider,
|
||||
LocalPath: mountPath,
|
||||
WebDAVURL: _url,
|
||||
rcManager: rcManager,
|
||||
logger: rcManager.GetLogger(),
|
||||
}
|
||||
}
|
||||
|
||||
// Mount creates the mount using rclone RC
|
||||
func (m *Mount) Mount(ctx context.Context) error {
|
||||
if m.rcManager == nil {
|
||||
return fmt.Errorf("rclone manager is not available")
|
||||
}
|
||||
|
||||
// Check if already mounted
|
||||
if m.rcManager.IsMounted(m.Provider) {
|
||||
m.logger.Info().Msgf("Mount %s is already mounted at %s", m.Provider, m.LocalPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
m.logger.Info().
|
||||
Str("provider", m.Provider).
|
||||
Str("webdav_url", m.WebDAVURL).
|
||||
Str("mount_path", m.LocalPath).
|
||||
Msg("Creating mount via RC")
|
||||
|
||||
if err := m.rcManager.Mount(m.LocalPath, m.Provider, m.WebDAVURL); err != nil {
|
||||
m.logger.Error().Str("provider", m.Provider).Msg("Mount operation failed")
|
||||
return fmt.Errorf("mount failed for %s", m.Provider)
|
||||
}
|
||||
|
||||
m.logger.Info().Msgf("Successfully mounted %s WebDAV at %s via RC", m.Provider, m.LocalPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Unmount removes the mount using rclone RC
|
||||
func (m *Mount) Unmount() error {
|
||||
if m.rcManager == nil {
|
||||
m.logger.Warn().Msg("Rclone manager is not available, skipping unmount")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !m.rcManager.IsMounted(m.Provider) {
|
||||
m.logger.Info().Msgf("Mount %s is not mounted, skipping unmount", m.Provider)
|
||||
return nil
|
||||
}
|
||||
|
||||
m.logger.Info().Str("provider", m.Provider).Msg("Unmounting via RC")
|
||||
|
||||
if err := m.rcManager.Unmount(m.Provider); err != nil {
|
||||
return fmt.Errorf("failed to unmount %s via RC: %w", m.Provider, err)
|
||||
}
|
||||
|
||||
m.logger.Info().Msgf("Successfully unmounted %s", m.Provider)
|
||||
return nil
|
||||
}
|
||||
|
||||
// IsMounted checks if the mount is active via RC
|
||||
func (m *Mount) IsMounted() bool {
|
||||
if m.rcManager == nil {
|
||||
return false
|
||||
}
|
||||
return m.rcManager.IsMounted(m.Provider)
|
||||
}
|
||||
|
||||
// RefreshDir refreshes directories in the mount
|
||||
func (m *Mount) RefreshDir(dirs []string) error {
|
||||
if m.rcManager == nil {
|
||||
return fmt.Errorf("rclone manager is not available")
|
||||
}
|
||||
|
||||
if !m.IsMounted() {
|
||||
return fmt.Errorf("provider %s not properly mounted. Skipping refreshes", m.Provider)
|
||||
}
|
||||
|
||||
if err := m.rcManager.RefreshDir(m.Provider, dirs); err != nil {
|
||||
return fmt.Errorf("failed to refresh directories for %s: %w", m.Provider, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMountInfo returns mount information
|
||||
func (m *Mount) GetMountInfo() (*MountInfo, bool) {
|
||||
if m.rcManager == nil {
|
||||
return nil, false
|
||||
}
|
||||
return m.rcManager.GetMountInfo(m.Provider)
|
||||
}
|
||||
184
pkg/rclone/stats.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type TransferringStat struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
ETA int64 `json:"eta"`
|
||||
Name string `json:"name"`
|
||||
Speed float64 `json:"speed"`
|
||||
Size int64 `json:"size"`
|
||||
Progress float64 `json:"progress"`
|
||||
}
|
||||
|
||||
type VersionResponse struct {
|
||||
Arch string `json:"arch"`
|
||||
Version string `json:"version"`
|
||||
OS string `json:"os"`
|
||||
}
|
||||
|
||||
type CoreStatsResponse struct {
|
||||
Bytes int64 `json:"bytes"`
|
||||
Checks int `json:"checks"`
|
||||
DeletedDirs int `json:"deletedDirs"`
|
||||
Deletes int `json:"deletes"`
|
||||
ElapsedTime float64 `json:"elapsedTime"`
|
||||
Errors int `json:"errors"`
|
||||
Eta int `json:"eta"`
|
||||
Speed float64 `json:"speed"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
TotalChecks int `json:"totalChecks"`
|
||||
TotalTransfers int `json:"totalTransfers"`
|
||||
TransferTime float64 `json:"transferTime"`
|
||||
Transfers int `json:"transfers"`
|
||||
Transferring []TransferringStat `json:"transferring,omitempty"`
|
||||
}
|
||||
|
||||
type MemoryStats struct {
|
||||
Sys int `json:"Sys"`
|
||||
TotalAlloc int64 `json:"TotalAlloc"`
|
||||
}
|
||||
|
||||
type BandwidthStats struct {
|
||||
BytesPerSecond int64 `json:"bytesPerSecond"`
|
||||
Rate string `json:"rate"`
|
||||
}
|
||||
|
||||
// Stats represents rclone statistics
|
||||
type Stats struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Ready bool `json:"server_ready"`
|
||||
Core CoreStatsResponse `json:"core"`
|
||||
Memory MemoryStats `json:"memory"`
|
||||
Mount map[string]*MountInfo `json:"mount"`
|
||||
Bandwidth BandwidthStats `json:"bandwidth"`
|
||||
Version VersionResponse `json:"version"`
|
||||
}
|
||||
|
||||
// GetStats retrieves statistics from the rclone RC server
|
||||
func (m *Manager) GetStats() (*Stats, error) {
|
||||
stats := &Stats{}
|
||||
stats.Ready = m.IsReady()
|
||||
stats.Enabled = true
|
||||
|
||||
coreStats, err := m.GetCoreStats()
|
||||
if err == nil {
|
||||
stats.Core = *coreStats
|
||||
}
|
||||
|
||||
// Get memory usage
|
||||
memStats, err := m.GetMemoryUsage()
|
||||
if err == nil {
|
||||
stats.Memory = *memStats
|
||||
}
|
||||
// Get bandwidth stats
|
||||
bwStats, err := m.GetBandwidthStats()
|
||||
if err == nil && bwStats != nil {
|
||||
stats.Bandwidth = *bwStats
|
||||
} else {
|
||||
fmt.Println("Failed to get rclone stats", err)
|
||||
}
|
||||
|
||||
// Get version info
|
||||
versionResp, err := m.GetVersion()
|
||||
if err == nil {
|
||||
stats.Version = *versionResp
|
||||
}
|
||||
|
||||
// Get mount info
|
||||
stats.Mount = m.GetAllMounts()
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetCoreStats() (*CoreStatsResponse, error) {
|
||||
if !m.IsReady() {
|
||||
return nil, fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
|
||||
req := RCRequest{
|
||||
Command: "core/stats",
|
||||
}
|
||||
|
||||
resp, err := m.makeRequest(req, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get core stats: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var coreStats CoreStatsResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&coreStats); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode core stats response: %w", err)
|
||||
}
|
||||
return &coreStats, nil
|
||||
}
|
||||
|
||||
// GetMemoryUsage returns memory usage statistics
|
||||
func (m *Manager) GetMemoryUsage() (*MemoryStats, error) {
|
||||
if !m.IsReady() {
|
||||
return nil, fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
|
||||
req := RCRequest{
|
||||
Command: "core/memstats",
|
||||
}
|
||||
|
||||
resp, err := m.makeRequest(req, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get memory stats: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var memStats MemoryStats
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&memStats); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode memory stats response: %w", err)
|
||||
}
|
||||
return &memStats, nil
|
||||
}
|
||||
|
||||
// GetBandwidthStats returns bandwidth usage for all transfers
|
||||
func (m *Manager) GetBandwidthStats() (*BandwidthStats, error) {
|
||||
if !m.IsReady() {
|
||||
return nil, fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
|
||||
req := RCRequest{
|
||||
Command: "core/bwlimit",
|
||||
}
|
||||
|
||||
resp, err := m.makeRequest(req, false)
|
||||
if err != nil {
|
||||
// Bandwidth stats might not be available, return empty
|
||||
return nil, nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var bwStats BandwidthStats
|
||||
if err := json.NewDecoder(resp.Body).Decode(&bwStats); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode bandwidth stats response: %w", err)
|
||||
}
|
||||
return &bwStats, nil
|
||||
}
|
||||
|
||||
// GetVersion returns rclone version information
|
||||
func (m *Manager) GetVersion() (*VersionResponse, error) {
|
||||
if !m.IsReady() {
|
||||
return nil, fmt.Errorf("rclone RC server not ready")
|
||||
}
|
||||
|
||||
req := RCRequest{
|
||||
Command: "core/version",
|
||||
}
|
||||
|
||||
resp, err := m.makeRequest(req, false)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get version: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var versionResp VersionResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&versionResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode version response: %w", err)
|
||||
}
|
||||
return &versionResp, nil
|
||||
}
|
||||
@@ -2,11 +2,12 @@ package repair
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/store"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/common"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/store"
|
||||
)
|
||||
|
||||
func fileIsSymlinked(file string) bool {
|
||||
@@ -85,7 +86,7 @@ func collectFiles(media arr.Content) map[string][]arr.ContentFile {
|
||||
return uniqueParents
|
||||
}
|
||||
|
||||
func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile, clients map[string]types.Client, caches map[string]*store.Cache) []arr.ContentFile {
|
||||
func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile, clients map[string]common.Client, caches map[string]*store.Cache) []arr.ContentFile {
|
||||
brokenFiles := make([]arr.ContentFile, 0)
|
||||
|
||||
emptyFiles := make([]arr.ContentFile, 0)
|
||||
@@ -149,7 +150,7 @@ func (r *Repair) checkTorrentFiles(torrentPath string, files []arr.ContentFile,
|
||||
return brokenFiles
|
||||
}
|
||||
|
||||
func (r *Repair) findDebridForPath(dir string, clients map[string]types.Client) string {
|
||||
func (r *Repair) findDebridForPath(dir string, clients map[string]common.Client) string {
|
||||
// Check cache first
|
||||
if debridName, exists := r.debridPathCache.Load(dir); exists {
|
||||
return debridName.(string)
|
||||
|
||||
@@ -5,16 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/google/uuid"
|
||||
"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/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -25,6 +15,17 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/google/uuid"
|
||||
"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/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid"
|
||||
"golang.org/x/sync/errgroup"
|
||||
)
|
||||
|
||||
type Repair struct {
|
||||
@@ -105,10 +106,6 @@ func New(arrs *arr.Storage, engine *debrid.Storage) *Repair {
|
||||
func (r *Repair) Reset() {
|
||||
// Stop scheduler
|
||||
if r.scheduler != nil {
|
||||
if err := r.scheduler.StopJobs(); err != nil {
|
||||
r.logger.Error().Err(err).Msg("Error stopping scheduler")
|
||||
}
|
||||
|
||||
if err := r.scheduler.Shutdown(); err != nil {
|
||||
r.logger.Error().Err(err).Msg("Error shutting down scheduler")
|
||||
}
|
||||
|
||||
@@ -2,17 +2,18 @@ package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"runtime"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
debridTypes "github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"net/http"
|
||||
"runtime"
|
||||
"github.com/sirrobot01/decypharr/pkg/wire"
|
||||
)
|
||||
|
||||
func (s *Server) handleIngests(w http.ResponseWriter, r *http.Request) {
|
||||
ingests := make([]debridTypes.IngestData, 0)
|
||||
_store := store.Get()
|
||||
_store := wire.Get()
|
||||
debrids := _store.Debrid()
|
||||
if debrids == nil {
|
||||
http.Error(w, "Debrid service is not enabled", http.StatusInternalServerError)
|
||||
@@ -42,7 +43,7 @@ func (s *Server) handleIngestsByDebrid(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_store := store.Get()
|
||||
_store := wire.Get()
|
||||
debrids := _store.Debrid()
|
||||
|
||||
if debrids == nil {
|
||||
@@ -92,31 +93,56 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
"go_version": runtime.Version(),
|
||||
}
|
||||
|
||||
debrids := store.Get().Debrid()
|
||||
if debrids == nil {
|
||||
request.JSONResponse(w, stats, http.StatusOK)
|
||||
return
|
||||
}
|
||||
clients := debrids.Clients()
|
||||
caches := debrids.Caches()
|
||||
profiles := make([]*debridTypes.Profile, 0)
|
||||
for debridName, client := range clients {
|
||||
profile, err := client.GetProfile()
|
||||
profile.Name = debridName
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("Failed to get debrid profile")
|
||||
continue
|
||||
}
|
||||
cache, ok := caches[debridName]
|
||||
if ok {
|
||||
// Get torrent data
|
||||
profile.LibrarySize = cache.TotalTorrents()
|
||||
profile.BadTorrents = len(cache.GetListing("__bad__"))
|
||||
profile.ActiveLinks = cache.GetTotalActiveDownloadLinks()
|
||||
debrids := wire.Get().Debrid()
|
||||
if debrids != nil {
|
||||
clients := debrids.Clients()
|
||||
caches := debrids.Caches()
|
||||
debridStats := make([]debridTypes.Stats, 0)
|
||||
for debridName, client := range clients {
|
||||
debridStat := debridTypes.Stats{}
|
||||
libraryStat := debridTypes.LibraryStats{}
|
||||
profile, err := client.GetProfile()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Str("debrid", debridName).Msg("Failed to get debrid profile")
|
||||
profile = &debridTypes.Profile{
|
||||
Name: debridName,
|
||||
}
|
||||
}
|
||||
profile.Name = debridName
|
||||
debridStat.Profile = profile
|
||||
cache, ok := caches[debridName]
|
||||
if ok {
|
||||
// Get torrent data
|
||||
libraryStat.Total = cache.TotalTorrents()
|
||||
libraryStat.Bad = len(cache.GetListing("__bad__"))
|
||||
libraryStat.ActiveLinks = cache.GetTotalActiveDownloadLinks()
|
||||
|
||||
}
|
||||
debridStat.Library = libraryStat
|
||||
debridStat.Accounts = client.AccountManager().Stats()
|
||||
debridStats = append(debridStats, debridStat)
|
||||
}
|
||||
profiles = append(profiles, profile)
|
||||
stats["debrids"] = debridStats
|
||||
}
|
||||
stats["debrids"] = profiles
|
||||
|
||||
// Add rclone stats if available
|
||||
if rcManager := wire.Get().RcloneManager(); rcManager != nil && rcManager.IsReady() {
|
||||
rcStats, err := rcManager.GetStats()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("Failed to get rclone stats")
|
||||
stats["rclone"] = map[string]interface{}{
|
||||
"enabled": true,
|
||||
"server_ready": false,
|
||||
}
|
||||
} else {
|
||||
stats["rclone"] = rcStats
|
||||
}
|
||||
} else {
|
||||
stats["rclone"] = map[string]interface{}{
|
||||
"enabled": false,
|
||||
"server_ready": false,
|
||||
}
|
||||
}
|
||||
|
||||
request.JSONResponse(w, stats, http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -4,14 +4,16 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -23,6 +25,8 @@ func New(handlers map[string]http.Handler) *Server {
|
||||
l := logger.New("http")
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.Recoverer)
|
||||
r.Use(middleware.StripSlashes)
|
||||
r.Use(middleware.RedirectSlashes)
|
||||
|
||||
cfg := config.Get()
|
||||
|
||||
@@ -36,11 +40,12 @@ func New(handlers map[string]http.Handler) *Server {
|
||||
}
|
||||
|
||||
//logs
|
||||
r.Get("/logs", s.getLogs)
|
||||
r.Get("/logs", s.getLogs) // deprecated, use /debug/logs
|
||||
|
||||
//debugs
|
||||
r.Route("/debug", func(r chi.Router) {
|
||||
r.Get("/stats", s.handleStats)
|
||||
r.Get("/logs", s.getLogs)
|
||||
r.Get("/logs/rclone", s.getRcloneLogs)
|
||||
r.Get("/ingests", s.handleIngests)
|
||||
r.Get("/ingests/{debrid}", s.handleIngestsByDebrid)
|
||||
})
|
||||
@@ -75,7 +80,7 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
||||
logFile := logger.GetLogPath()
|
||||
logFile := filepath.Join(logger.GetLogPath(), "decypharr.log")
|
||||
|
||||
// Open and read the file
|
||||
file, err := os.Open(logFile)
|
||||
@@ -98,5 +103,42 @@ func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Expires", "0")
|
||||
|
||||
// Stream the file
|
||||
_, _ = io.Copy(w, file)
|
||||
if _, err := io.Copy(w, file); err != nil {
|
||||
s.logger.Error().Err(err).Msg("Error streaming log file")
|
||||
http.Error(w, "Error streaming log file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getRcloneLogs(w http.ResponseWriter, r *http.Request) {
|
||||
// Rclone logs resides in the same directory as the application logs
|
||||
logFile := filepath.Join(logger.GetLogPath(), "rclone.log")
|
||||
// Open and read the file
|
||||
file, err := os.Open(logFile)
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading log file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("Error closing log file")
|
||||
return
|
||||
|
||||
}
|
||||
}(file)
|
||||
|
||||
// Set headers
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "inline; filename=application.log")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
|
||||
// Stream the file
|
||||
if _, err := io.Copy(w, file); err != nil {
|
||||
s.logger.Error().Err(err).Msg("Error streaming log file")
|
||||
http.Error(w, "Error streaming log file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package server
|
||||
import (
|
||||
"cmp"
|
||||
"encoding/json"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"github.com/sirrobot01/decypharr/pkg/wire"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
@@ -38,7 +38,7 @@ func (s *Server) handleTautulli(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Invalid ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
repair := store.Get().Repair()
|
||||
repair := wire.Get().Repair()
|
||||
|
||||
mediaId := cmp.Or(payload.TmdbID, payload.TvdbID)
|
||||
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
|
||||
"github.com/cavaliergopher/grab/v3"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
)
|
||||
|
||||
func grabber(client *grab.Client, url, filename string, byterange *[2]int64, progressCallback func(int64, int64)) error {
|
||||
req, err := grab.NewRequest(filename, url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set byte range if specified
|
||||
if byterange != nil {
|
||||
byterangeStr := fmt.Sprintf("%d-%d", byterange[0], byterange[1])
|
||||
req.HTTPRequest.Header.Set("Range", "bytes="+byterangeStr)
|
||||
}
|
||||
|
||||
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 (s *Store) processDownload(torrent *Torrent, debridTorrent *types.Torrent) (string, error) {
|
||||
s.logger.Info().Msgf("Downloading %d files...", len(debridTorrent.Files))
|
||||
torrentPath := filepath.Join(torrent.SavePath, utils.RemoveExtension(debridTorrent.OriginalFilename))
|
||||
torrentPath = utils.RemoveInvalidChars(torrentPath)
|
||||
err := os.MkdirAll(torrentPath, os.ModePerm)
|
||||
if err != nil {
|
||||
// add the previous error to the error and return
|
||||
return "", fmt.Errorf("failed to create directory: %s: %v", torrentPath, err)
|
||||
}
|
||||
s.downloadFiles(torrent, debridTorrent, torrentPath)
|
||||
return torrentPath, nil
|
||||
}
|
||||
|
||||
func (s *Store) downloadFiles(torrent *Torrent, debridTorrent *types.Torrent, parent string) {
|
||||
var wg sync.WaitGroup
|
||||
|
||||
totalSize := int64(0)
|
||||
for _, file := range debridTorrent.GetFiles() {
|
||||
totalSize += file.Size
|
||||
}
|
||||
debridTorrent.Lock()
|
||||
debridTorrent.SizeDownloaded = 0 // Reset downloaded bytes
|
||||
debridTorrent.Progress = 0 // Reset progress
|
||||
debridTorrent.Unlock()
|
||||
progressCallback := func(downloaded int64, speed int64) {
|
||||
debridTorrent.Lock()
|
||||
defer debridTorrent.Unlock()
|
||||
torrent.Lock()
|
||||
defer torrent.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
|
||||
}
|
||||
s.partialTorrentUpdate(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.GetFiles() {
|
||||
if file.DownloadLink == nil {
|
||||
s.logger.Info().Msgf("No download link found for %s", file.Name)
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
s.downloadSemaphore <- struct{}{}
|
||||
go func(file types.File) {
|
||||
defer wg.Done()
|
||||
defer func() { <-s.downloadSemaphore }()
|
||||
filename := file.Name
|
||||
|
||||
err := grabber(
|
||||
client,
|
||||
file.DownloadLink.DownloadLink,
|
||||
filepath.Join(parent, filename),
|
||||
file.ByteRange,
|
||||
progressCallback,
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
s.logger.Error().Msgf("Failed to download %s: %v", filename, err)
|
||||
errChan <- err
|
||||
} else {
|
||||
s.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 {
|
||||
s.logger.Error().Msgf("Errors occurred during download: %v", errors)
|
||||
return
|
||||
}
|
||||
s.logger.Info().Msgf("Downloaded all files for %s", debridTorrent.Name)
|
||||
}
|
||||
|
||||
func (s *Store) processSymlink(torrent *Torrent, debridTorrent *types.Torrent) (string, error) {
|
||||
files := debridTorrent.Files
|
||||
if len(files) == 0 {
|
||||
return "", fmt.Errorf("no video files found")
|
||||
}
|
||||
s.logger.Info().Msgf("Checking symlinks for %d files...", len(files))
|
||||
rCloneBase := debridTorrent.MountPath
|
||||
torrentPath, err := s.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(torrent.SavePath, 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)
|
||||
}
|
||||
|
||||
realPaths := make(map[string]string)
|
||||
err = filepath.WalkDir(torrentRclonePath, func(path string, d os.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !d.IsDir() {
|
||||
filename := d.Name()
|
||||
rel, _ := filepath.Rel(torrentRclonePath, path)
|
||||
realPaths[filename] = rel
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
s.logger.Warn().Msgf("Error while scanning rclone path: %v", err)
|
||||
}
|
||||
|
||||
pending := make(map[string]types.File)
|
||||
for _, file := range files {
|
||||
if realRelPath, ok := realPaths[file.Name]; ok {
|
||||
file.Path = realRelPath
|
||||
}
|
||||
pending[file.Path] = file
|
||||
}
|
||||
ticker := time.NewTicker(200 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
timeout := time.After(30 * time.Minute)
|
||||
filePaths := make([]string, 0, len(pending))
|
||||
|
||||
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) {
|
||||
s.logger.Warn().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||
} else {
|
||||
filePaths = append(filePaths, fileSymlinkPath)
|
||||
delete(pending, path)
|
||||
s.logger.Info().Msgf("File is ready: %s", file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
case <-timeout:
|
||||
s.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 s.skipPreCache {
|
||||
return torrentSymlinkPath, nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
s.logger.Debug().Msgf("Pre-caching %s", debridTorrent.Name)
|
||||
if err := utils.PreCacheFile(filePaths); err != nil {
|
||||
s.logger.Error().Msgf("Failed to pre-cache file: %s", err)
|
||||
} else {
|
||||
s.logger.Trace().Msgf("Pre-cached %d files", len(filePaths))
|
||||
}
|
||||
}()
|
||||
return torrentSymlinkPath, nil
|
||||
}
|
||||
|
||||
func (s *Store) createSymlinksWebdav(torrent *Torrent, debridTorrent *types.Torrent, rclonePath, torrentFolder string) (string, error) {
|
||||
files := debridTorrent.Files
|
||||
symlinkPath := filepath.Join(torrent.SavePath, 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]types.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) {
|
||||
s.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||
} else {
|
||||
filePaths = append(filePaths, fileSymlinkPath)
|
||||
delete(remainingFiles, filename)
|
||||
s.logger.Info().Msgf("File is ready: %s", file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case <-timeout:
|
||||
s.logger.Warn().Msgf("Timeout waiting for files, %d files still pending", len(remainingFiles))
|
||||
return symlinkPath, fmt.Errorf("timeout waiting for files")
|
||||
}
|
||||
}
|
||||
|
||||
if s.skipPreCache {
|
||||
return symlinkPath, nil
|
||||
}
|
||||
|
||||
go func() {
|
||||
s.logger.Debug().Msgf("Pre-caching %s", debridTorrent.Name)
|
||||
if err := utils.PreCacheFile(filePaths); err != nil {
|
||||
s.logger.Error().Msgf("Failed to pre-cache file: %s", err)
|
||||
} else {
|
||||
s.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 (s *Store) getTorrentPath(rclonePath string, debridTorrent *types.Torrent) (string, error) {
|
||||
for {
|
||||
torrentPath, err := debridTorrent.GetMountFolder(rclonePath)
|
||||
if err == nil {
|
||||
s.logger.Debug().Msgf("Found torrent path: %s", torrentPath)
|
||||
return torrentPath, err
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
211
pkg/web/api.go
@@ -2,12 +2,15 @@ package web
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/wire"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"encoding/json"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
@@ -17,8 +20,8 @@ import (
|
||||
)
|
||||
|
||||
func (wb *Web) handleGetArrs(w http.ResponseWriter, r *http.Request) {
|
||||
_store := store.Get()
|
||||
request.JSONResponse(w, _store.Arr().GetAll(), http.StatusOK)
|
||||
arrStorage := wire.Get().Arr()
|
||||
request.JSONResponse(w, arrStorage.GetAll(), http.StatusOK)
|
||||
}
|
||||
|
||||
func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -27,9 +30,9 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_store := store.Get()
|
||||
_store := wire.Get()
|
||||
|
||||
results := make([]*store.ImportRequest, 0)
|
||||
results := make([]*wire.ImportRequest, 0)
|
||||
errs := make([]string, 0)
|
||||
|
||||
arrName := r.FormValue("arr")
|
||||
@@ -40,6 +43,7 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||
if downloadFolder == "" {
|
||||
downloadFolder = config.Get().QBitTorrent.DownloadFolder
|
||||
}
|
||||
skipMultiSeason := r.FormValue("skipMultiSeason") == "true"
|
||||
|
||||
downloadUncached := r.FormValue("downloadUncached") == "true"
|
||||
|
||||
@@ -65,7 +69,7 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
importReq := store.NewImportRequest(debridName, downloadFolder, magnet, _arr, action, downloadUncached, callbackUrl, store.ImportTypeAPI)
|
||||
importReq := wire.NewImportRequest(debridName, downloadFolder, magnet, _arr, action, downloadUncached, callbackUrl, wire.ImportTypeAPI, skipMultiSeason)
|
||||
if err := _store.AddTorrent(ctx, importReq); err != nil {
|
||||
wb.logger.Error().Err(err).Str("url", url).Msg("Failed to add torrent")
|
||||
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
|
||||
@@ -90,7 +94,7 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||
continue
|
||||
}
|
||||
|
||||
importReq := store.NewImportRequest(debridName, downloadFolder, magnet, _arr, action, downloadUncached, callbackUrl, store.ImportTypeAPI)
|
||||
importReq := wire.NewImportRequest(debridName, downloadFolder, magnet, _arr, action, downloadUncached, callbackUrl, wire.ImportTypeAPI, skipMultiSeason)
|
||||
err = _store.AddTorrent(ctx, importReq)
|
||||
if err != nil {
|
||||
wb.logger.Error().Err(err).Str("file", fileHeader.Filename).Msg("Failed to add torrent")
|
||||
@@ -102,8 +106,8 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
request.JSONResponse(w, struct {
|
||||
Results []*store.ImportRequest `json:"results"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
Results []*wire.ImportRequest `json:"results"`
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}{
|
||||
Results: results,
|
||||
Errors: errs,
|
||||
@@ -117,7 +121,7 @@ func (wb *Web) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_store := store.Get()
|
||||
_store := wire.Get()
|
||||
|
||||
var arrs []string
|
||||
|
||||
@@ -182,39 +186,29 @@ func (wb *Web) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (wb *Web) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
// Merge config arrs, with arr Storage
|
||||
unique := map[string]config.Arr{}
|
||||
arrStorage := wire.Get().Arr()
|
||||
cfg := config.Get()
|
||||
arrStorage := store.Get().Arr()
|
||||
cfg.Arrs = arrStorage.SyncToConfig()
|
||||
|
||||
// Add existing Arrs from storage
|
||||
for _, a := range arrStorage.GetAll() {
|
||||
if _, ok := unique[a.Name]; !ok {
|
||||
// Only add if not already in the unique map
|
||||
unique[a.Name] = config.Arr{
|
||||
Name: a.Name,
|
||||
Host: a.Host,
|
||||
Token: a.Token,
|
||||
Cleanup: a.Cleanup,
|
||||
SkipRepair: a.SkipRepair,
|
||||
DownloadUncached: a.DownloadUncached,
|
||||
SelectedDebrid: a.SelectedDebrid,
|
||||
Source: a.Source,
|
||||
}
|
||||
}
|
||||
// Create response with API token info
|
||||
type ConfigResponse struct {
|
||||
*config.Config
|
||||
APIToken string `json:"api_token,omitempty"`
|
||||
AuthUsername string `json:"auth_username,omitempty"`
|
||||
}
|
||||
|
||||
for _, a := range cfg.Arrs {
|
||||
if a.Host == "" || a.Token == "" {
|
||||
continue // Skip empty arrs
|
||||
response := &ConfigResponse{Config: cfg}
|
||||
|
||||
// Add API token and auth information
|
||||
auth := cfg.GetAuth()
|
||||
if auth != nil {
|
||||
if auth.APIToken != "" {
|
||||
response.APIToken = auth.APIToken
|
||||
}
|
||||
unique[a.Name] = a
|
||||
response.AuthUsername = auth.Username
|
||||
}
|
||||
cfg.Arrs = make([]config.Arr, 0, len(unique))
|
||||
for _, a := range unique {
|
||||
cfg.Arrs = append(cfg.Arrs, a)
|
||||
}
|
||||
request.JSONResponse(w, cfg, http.StatusOK)
|
||||
|
||||
request.JSONResponse(w, response, http.StatusOK)
|
||||
}
|
||||
|
||||
func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -236,6 +230,7 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
currentConfig.RemoveStalledAfter = updatedConfig.RemoveStalledAfter
|
||||
currentConfig.AllowedExt = updatedConfig.AllowedExt
|
||||
currentConfig.DiscordWebhook = updatedConfig.DiscordWebhook
|
||||
currentConfig.CallbackURL = updatedConfig.CallbackURL
|
||||
|
||||
// Should this be added?
|
||||
currentConfig.URLBase = updatedConfig.URLBase
|
||||
@@ -247,15 +242,13 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Update Repair config
|
||||
currentConfig.Repair = updatedConfig.Repair
|
||||
currentConfig.Rclone = updatedConfig.Rclone
|
||||
|
||||
// Update Debrids
|
||||
if len(updatedConfig.Debrids) > 0 {
|
||||
currentConfig.Debrids = updatedConfig.Debrids
|
||||
// Clear legacy single debrid if using array
|
||||
}
|
||||
currentConfig.Debrids = updatedConfig.Debrids
|
||||
|
||||
// Update Arrs through the service
|
||||
storage := store.Get()
|
||||
storage := wire.Get()
|
||||
arrStorage := storage.Arr()
|
||||
|
||||
newConfigArrs := make([]config.Arr, 0)
|
||||
@@ -268,28 +261,8 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
currentConfig.Arrs = newConfigArrs
|
||||
|
||||
// Add config arr into the config
|
||||
for _, a := range currentConfig.Arrs {
|
||||
if a.Host == "" || a.Token == "" {
|
||||
continue // Skip empty arrs
|
||||
}
|
||||
existingArr := arrStorage.Get(a.Name)
|
||||
if existingArr != nil {
|
||||
// Update existing Arr
|
||||
existingArr.Host = a.Host
|
||||
existingArr.Token = a.Token
|
||||
existingArr.Cleanup = a.Cleanup
|
||||
existingArr.SkipRepair = a.SkipRepair
|
||||
existingArr.DownloadUncached = a.DownloadUncached
|
||||
existingArr.SelectedDebrid = a.SelectedDebrid
|
||||
existingArr.Source = a.Source
|
||||
arrStorage.AddOrUpdate(existingArr)
|
||||
} else {
|
||||
// Create new Arr if it doesn't exist
|
||||
newArr := arr.New(a.Name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid, a.Source)
|
||||
arrStorage.AddOrUpdate(newArr)
|
||||
}
|
||||
}
|
||||
// Sync arrStorage with the new arrs
|
||||
arrStorage.SyncFromConfig(currentConfig.Arrs)
|
||||
|
||||
if err := currentConfig.Save(); err != nil {
|
||||
http.Error(w, "Error saving config: "+err.Error(), http.StatusInternalServerError)
|
||||
@@ -299,7 +272,7 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if restartFunc != nil {
|
||||
go func() {
|
||||
// Small delay to ensure the response is sent
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
restartFunc()
|
||||
}()
|
||||
}
|
||||
@@ -309,7 +282,7 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (wb *Web) handleGetRepairJobs(w http.ResponseWriter, r *http.Request) {
|
||||
_store := store.Get()
|
||||
_store := wire.Get()
|
||||
request.JSONResponse(w, _store.Repair().GetJobs(), http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -319,7 +292,7 @@ func (wb *Web) handleProcessRepairJob(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "No job ID provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_store := store.Get()
|
||||
_store := wire.Get()
|
||||
if err := _store.Repair().ProcessJob(id); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to process repair job")
|
||||
}
|
||||
@@ -340,7 +313,7 @@ func (wb *Web) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
_store := store.Get()
|
||||
_store := wire.Get()
|
||||
_store.Repair().DeleteJobs(req.IDs)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
@@ -351,7 +324,7 @@ func (wb *Web) handleStopRepairJob(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "No job ID provided", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_store := store.Get()
|
||||
_store := wire.Get()
|
||||
if err := _store.Repair().StopJob(id); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to stop repair job")
|
||||
http.Error(w, "Failed to stop job: "+err.Error(), http.StatusInternalServerError)
|
||||
@@ -359,3 +332,103 @@ func (wb *Web) handleStopRepairJob(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (wb *Web) handleRefreshAPIToken(w http.ResponseWriter, _ *http.Request) {
|
||||
token, err := wb.refreshAPIToken()
|
||||
if err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to refresh API token")
|
||||
http.Error(w, "Failed to refresh token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
request.JSONResponse(w, map[string]interface{}{
|
||||
"token": token,
|
||||
"message": "API token refreshed successfully",
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
func (wb *Web) handleUpdateAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ConfirmPassword string `json:"confirm_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
auth := cfg.GetAuth()
|
||||
if auth == nil {
|
||||
auth = &config.Auth{}
|
||||
}
|
||||
|
||||
// Check if trying to disable authentication (both empty)
|
||||
if req.Username == "" && req.Password == "" {
|
||||
// Disable authentication
|
||||
cfg.UseAuth = false
|
||||
auth.Username = ""
|
||||
auth.Password = ""
|
||||
if err := cfg.SaveAuth(auth); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save auth config")
|
||||
http.Error(w, "Failed to save authentication settings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save config")
|
||||
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
request.JSONResponse(w, map[string]string{
|
||||
"message": "Authentication disabled successfully",
|
||||
}, http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Username == "" {
|
||||
http.Error(w, "Username is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Password == "" {
|
||||
http.Error(w, "Password is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Password != req.ConfirmPassword {
|
||||
http.Error(w, "Passwords do not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, "Failed to process password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update auth settings
|
||||
auth.Username = req.Username
|
||||
auth.Password = string(hashedPassword)
|
||||
cfg.UseAuth = true
|
||||
|
||||
// Save auth config
|
||||
if err := cfg.SaveAuth(auth); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save auth config")
|
||||
http.Error(w, "Failed to save authentication settings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save main config
|
||||
if err := cfg.Save(); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save config")
|
||||
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
request.JSONResponse(w, map[string]string{
|
||||
"message": "Authentication settings updated successfully",
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||