37 Commits

Author SHA1 Message Date
Mukhtar Akere
2548c21e5b Fix rclone file log
Some checks failed
GoReleaser / goreleaser (push) Has been cancelled
Release Docker Build / docker (push) Has been cancelled
2025-08-19 01:01:53 +01:00
Mukhtar Akere
1b03ccefbb Hotfix rclone logging flags 2025-08-19 00:55:43 +01:00
Mukhtar Akere
e3a249a9cc Fix issues with rclone mounting
Some checks failed
GoReleaser / goreleaser (push) Has been cancelled
Release Docker Build / docker (push) Has been cancelled
2025-08-18 22:12:26 +01:00
Mukhtar Akere
8696db42d2 - Add more rclone supports
- Add rclone log viewer
- Add more stats to Stats page
- Fix some minor bugs
2025-08-18 01:57:02 +01:00
Mukhtar Akere
742d8fb088 - Fix issues with cache dir
Some checks failed
GoReleaser / goreleaser (push) Has been cancelled
Release Docker Build / docker (push) Has been cancelled
- Fix responsiveness issue with navbars
- Support user entry for users running as non-root
- Other minor fixes
2025-08-12 15:14:42 +01:00
Mukhtar Akere
a0e9f7f553 Fix issues with exit code on windows, fix gh-docs
Some checks failed
GoReleaser / goreleaser (push) Has been cancelled
Release Docker Build / docker (push) Has been cancelled
2025-08-10 11:35:50 +01:00
Mukhtar Akere
4be4f6b293 Merge branch 'beta' 2025-08-10 11:09:11 +01:00
Mukhtar Akere
6c8949b831 Add auth to qbittorent middleware 2025-08-09 20:25:16 +01:00
Mukhtar Akere
0dd1efb07c Final bug fixes 2025-08-09 19:57:32 +01:00
Mukhtar Akere
3aeb806033 Wrap up 1.1.0 2025-08-09 10:55:10 +01:00
Mukhtar Akere
7c8156eacf Fix nil map 2025-08-08 13:17:09 +01:00
Mukhtar Akere
d8a963f77f fix failed cache dir 2025-08-08 12:49:29 +01:00
Mukhtar Akere
27e7bc8f47 fix failed cache dir 2025-08-08 12:48:05 +01:00
Mukhtar Akere
1d243dd12b Add Stats page 2025-08-08 12:45:58 +01:00
Mukhtar Akere
b4efa22bfd Fix issues with no gobal config 2025-08-08 06:04:42 +01:00
Mukhtar Akere
6f9fafd7d8 Migrate to full rclone rcd 2025-08-08 05:22:52 +01:00
Mukhtar Akere
eba24c9d63 Fix issues with rclone setup 2025-08-07 05:31:07 +01:00
Mukhtar Akere
c620ba3d56 Add vfs cache poll interval 2025-08-05 12:29:55 +01:00
Mukhtar Akere
fab3a7e4f7 minor fixes, change help text 2025-08-05 11:49:52 +01:00
Mukhtar Akere
01615cb51e Cleanup mounts 2025-08-05 05:18:24 +01:00
Mukhtar Akere
cb63fc69f5 Final fix for writeheader 2025-08-05 05:01:34 +01:00
Mukhtar Akere
40755fbdde Fix issues with headers 2025-08-05 04:39:03 +01:00
Mukhtar Akere
d0ae839617 Fix issues with headers 2025-08-05 04:28:38 +01:00
Mukhtar Akere
ce972779c3 Fix superflous header issue 2025-08-05 04:01:41 +01:00
Mukhtar Akere
139249a1f3 - Add mounting support
- Fix minor issues
2025-08-04 16:57:09 +01:00
Mukhtar Akere
a60d93677f Fix config.html 2025-07-24 03:07:20 +01:00
Mukhtar Akere
9c31ad266e Fix config.html 2025-07-24 03:03:18 +01:00
Mukhtar Akere
3d2fcf5656 Fix superflous header, other minor bugs 2025-07-21 20:35:49 +01:00
Mukhtar Akere
afe577bf2f - Fix repair bugs
- Minor html/js bugs from new template
- Other minor issues
2025-07-13 06:30:02 +01:00
Mukhtar Akere
604402250e hotfix login and registration 2025-07-12 00:57:48 +01:00
Mukhtar Akere
74615a80ff Fix config.js 2025-07-11 13:17:43 +01:00
Sadman Sakib
b901bd5175 Feature/torbox provider improvements (#100)
- Add Torbox WebDAV implementation
- Fix Issues with sample and extension checks
2025-07-11 13:17:03 +01:00
Mukhtar Akere
8c56e59107 Fix some UI bugs; colors etc 2025-07-11 06:03:11 +01:00
Mukhtar Akere
b8b9e76753 Add seeders, add Remove selected from debrid button 2025-07-10 15:15:02 +01:00
Mukhtar Akere
6fb54d322e Fix dockerignore 2025-07-10 02:31:30 +01:00
Mukhtar Akere
cf61546bec Move to tailwind-build instead of CDNs 2025-07-10 02:17:35 +01:00
Mukhtar Akere
c72867ff57 Testing a new UI 2025-07-09 20:08:09 +01:00
129 changed files with 14463 additions and 4632 deletions

View File

@@ -7,16 +7,16 @@ tmp_dir = "tmp"
bin = "./tmp/main" bin = "./tmp/main"
cmd = "bash -c 'go build -ldflags \"-X github.com/sirrobot01/decypharr/pkg/version.Version=0.0.0 -X github.com/sirrobot01/decypharr/pkg/version.Channel=dev\" -o ./tmp/main .'" 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 delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "data"] exclude_dir = ["tmp", "vendor", "testdata", "data", "logs", "docs", "dist", "node_modules", ".ven"]
exclude_file = [] exclude_file = []
exclude_regex = ["_test.go"] exclude_regex = ["_test.go"]
exclude_unchanged = false exclude_unchanged = false
follow_symlink = false follow_symlink = false
full_bin = "" full_bin = ""
include_dir = [] include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", ".json"] include_ext = ["go", "tpl", "tmpl", "html", ".json", ".js", ".css"]
include_file = [] include_file = []
kill_delay = "0s" kill_delay = "1s"
log = "build-errors.log" log = "build-errors.log"
poll = false poll = false
poll_interval = 0 poll_interval = 0
@@ -24,8 +24,8 @@ tmp_dir = "tmp"
pre_cmd = [] pre_cmd = []
rerun = false rerun = false
rerun_delay = 500 rerun_delay = 500
send_interrupt = false send_interrupt = true
stop_on_error = false stop_on_error = true
[color] [color]
app = "" app = ""

View File

@@ -11,3 +11,23 @@ torrents.json
*.json *.json
.ven/** .ven/**
docs/** docs/**
# Don't copy node modules
node_modules/
# Don't copy development files
.git/
.gitignore
*.md
.env*
*.log
# Build artifacts
decypharr
healthcheck
*.exe
.venv/
data/**
.stignore
.stfolder/**

View File

@@ -24,5 +24,5 @@ jobs:
path: .cache path: .cache
restore-keys: | restore-keys: |
mkdocs-material- mkdocs-material-
- run: pip install mkdocs-material - run: cd docs && pip install -r requirements.txt
- run: cd docs && mkdocs gh-deploy --force - run: cd docs && mkdocs gh-deploy --force

7
.gitignore vendored
View File

@@ -12,4 +12,9 @@ tmp/**
torrents.json torrents.json
logs/** logs/**
auth.json auth.json
.ven/ .ven/
.env
node_modules/
.venv/
.stignore
.stfolder/**

View File

@@ -29,40 +29,49 @@ RUN --mount=type=cache,target=/go/pkg/mod \
go build -trimpath -ldflags="-w -s" \ go build -trimpath -ldflags="-w -s" \
-o /healthcheck cmd/healthcheck/main.go -o /healthcheck cmd/healthcheck/main.go
# Stage 2: Create directory structure # Stage 2: Final image
FROM alpine:3.19 as dirsetup FROM alpine:latest
RUN mkdir -p /app/logs && \
mkdir -p /app/cache && \
chmod 777 /app/logs && \
touch /app/logs/decypharr.log && \
chmod 666 /app/logs/decypharr.log
# Stage 3: Final image ARG VERSION=0.0.0
FROM gcr.io/distroless/static-debian12:nonroot ARG CHANNEL=dev
LABEL version = "${VERSION}-${CHANNEL}" LABEL version = "${VERSION}-${CHANNEL}"
LABEL org.opencontainers.image.source = "https://github.com/sirrobot01/decypharr" LABEL org.opencontainers.image.source = "https://github.com/sirrobot01/decypharr"
LABEL org.opencontainers.image.title = "decypharr" LABEL org.opencontainers.image.title = "decypharr"
LABEL org.opencontainers.image.authors = "sirrobot01" LABEL org.opencontainers.image.authors = "sirrobot01"
LABEL org.opencontainers.image.documentation = "https://github.com/sirrobot01/decypharr/blob/main/README.md" LABEL org.opencontainers.image.documentation = "https://github.com/sirrobot01/decypharr/blob/main/README.md"
# Copy binaries # Install dependencies including rclone
COPY --from=builder --chown=nonroot:nonroot /decypharr /usr/bin/decypharr RUN apk add --no-cache fuse3 ca-certificates su-exec shadow curl unzip && \
COPY --from=builder --chown=nonroot:nonroot /healthcheck /usr/bin/healthcheck 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 binaries and entrypoint
COPY --from=dirsetup --chown=nonroot:nonroot /app /app 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
# Set environment variables
# Metadata ENV PUID=1000
ENV PGID=1000
ENV LOG_PATH=/app/logs ENV LOG_PATH=/app/logs
EXPOSE 8282 EXPOSE 8282
VOLUME ["/app"] VOLUME ["/app"]
USER nonroot:nonroot
HEALTHCHECK --interval=10s --retries=10 CMD ["/usr/bin/healthcheck", "--config", "/app", "--basic"]
# Base healthcheck ENTRYPOINT ["/entrypoint.sh"]
HEALTHCHECK --interval=3s --retries=10 CMD ["/usr/bin/healthcheck", "--config", "/app", "--basic"]
CMD ["/usr/bin/decypharr", "--config", "/app"] CMD ["/usr/bin/decypharr", "--config", "/app"]

View File

@@ -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) - Mock Qbittorent API that supports the Arrs (Sonarr, Radarr, Lidarr etc)
- Full-fledged UI for managing torrents - Full-fledged UI for managing torrents
- Proxy support for filtering out un-cached Debrid torrents
- Multiple Debrid providers support - Multiple Debrid providers support
- WebDAV server support for each debrid provider - WebDAV server support for each debrid provider
- Optional mounting of WebDAV to your system(using [Rclone](https://rclone.org/))
- Repair Worker for missing files - Repair Worker for missing files
## Supported Debrid Providers ## Supported Debrid Providers
@@ -29,17 +29,22 @@ Decypharr combines the power of QBittorrent with popular Debrid services to enha
### Docker (Recommended) ### Docker (Recommended)
```yaml ```yaml
version: '3.7'
services: services:
decypharr: decypharr:
image: cy01/blackhole:latest # or cy01/blackhole:beta image: cy01/blackhole:latest
container_name: decypharr container_name: decypharr
ports: ports:
- "8282:8282" # qBittorrent - "8282:8282"
volumes: volumes:
- /mnt/:/mnt - /mnt/:/mnt:rshared
- ./configs/:/app # config.json must be in this directory - ./configs/:/app # config.json must be in this directory
restart: unless-stopped restart: unless-stopped
devices:
- /dev/fuse:/dev/fuse:rwm
cap_add:
- SYS_ADMIN
security_opt:
- apparmor:unconfined
``` ```
## Documentation ## Documentation
@@ -57,25 +62,7 @@ The documentation includes:
## Basic Configuration ## Basic Configuration
```json You can configure Decypharr through the Web UI or by editing the `config.json` file directly.
{
"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"
}
```
## Contributing ## Contributing

View File

@@ -40,6 +40,7 @@ func Start(ctx context.Context) error {
svcCtx, cancelSvc := context.WithCancel(ctx) svcCtx, cancelSvc := context.WithCancel(ctx)
defer cancelSvc() defer cancelSvc()
// Create the logger path if it doesn't exist
for { for {
cfg := config.Get() cfg := config.Get()
_log := logger.Default() _log := logger.Default()
@@ -73,6 +74,14 @@ func Start(ctx context.Context) error {
} }
srv := server.New(handlers) srv := server.New(handlers)
reset := func() {
// Reset the store and services
qb.Reset()
store.Reset()
// refresh GC
runtime.GC()
}
done := make(chan struct{}) done := make(chan struct{})
go func(ctx context.Context) { go func(ctx context.Context) {
if err := startServices(ctx, cancelSvc, wd, srv); err != nil { if err := startServices(ctx, cancelSvc, wd, srv); err != nil {
@@ -87,22 +96,18 @@ func Start(ctx context.Context) error {
// graceful shutdown // graceful shutdown
cancelSvc() // propagate to services cancelSvc() // propagate to services
<-done // wait for them to finish <-done // wait for them to finish
_log.Info().Msg("Decypharr has been stopped gracefully.")
reset() // reset store and services
return nil return nil
case <-restartCh: case <-restartCh:
cancelSvc() // tell existing services to shut down cancelSvc() // tell existing services to shut down
_log.Info().Msg("Restarting Decypharr...") _log.Info().Msg("Restarting Decypharr...")
<-done // wait for them to finish <-done // wait for them to finish
qb.Reset() _log.Info().Msg("Decypharr has been restarted.")
store.Reset() reset() // reset store and services
// rebuild svcCtx off the original parent // rebuild svcCtx off the original parent
svcCtx, cancelSvc = context.WithCancel(ctx) svcCtx, cancelSvc = context.WithCancel(ctx)
runtime.GC()
config.Reload()
store.Reset()
// loop will restart services automatically
} }
} }
} }
@@ -144,12 +149,13 @@ func startServices(ctx context.Context, cancelSvc context.CancelFunc, wd *webdav
return srv.Start(ctx) return srv.Start(ctx)
}) })
// Start rclone RC server if enabled
safeGo(func() error { safeGo(func() error {
arr := store.Get().Arr() rcManager := store.Get().RcloneManager()
if arr == nil { if rcManager == nil {
return nil return nil
} }
return arr.StartSchedule(ctx) return rcManager.Start(ctx)
}) })
if cfg := config.Get(); cfg.Repair.Enabled { if cfg := config.Get(); cfg.Repair.Enabled {
@@ -165,7 +171,8 @@ func startServices(ctx context.Context, cancelSvc context.CancelFunc, wd *webdav
} }
safeGo(func() error { safeGo(func() error {
return store.Get().StartQueueSchedule(ctx) store.Get().StartWorkers(ctx)
return nil
}) })
go func() { go func() {

418
docs/docs/api-spec.yaml Normal file
View 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
View 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
}'
```

View File

@@ -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

View File

@@ -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
}
]
```

View File

@@ -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
}
```

View File

@@ -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.

View File

@@ -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).

View File

@@ -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.

View File

@@ -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/..."
}

View File

@@ -1,5 +0,0 @@
[decypharr]
type = webdav
url = http://decypharr:8282/webdav/realdebrid
vendor = other
pacer_min_sleep = 0

View File

@@ -25,8 +25,10 @@ The Decypharr user interface provides:
Decypharr includes several advanced features that extend its capabilities: Decypharr includes several advanced features that extend its capabilities:
- [Repair Worker](repair-worker.md): Identifies and fixes issues with your media files - [Repair Support](repair-worker.md): Identifies and fixes issues with your media files
- [WebDAV Server](webdav.md): Provides direct access to your Debrid 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 ## Supported Debrid Providers
@@ -36,5 +38,7 @@ Decypharr supports multiple Debrid providers:
- Torbox - Torbox
- Debrid Link - Debrid Link
- All Debrid - All Debrid
- Premiumize(Coming Soon)
- Usenet(Coming Soon)
Each provider can be configured separately, allowing you to use one or multiple services simultaneously. Each provider can be configured separately, allowing you to use one or multiple services simultaneously.

View File

@@ -15,27 +15,4 @@ The Repair Worker performs the following tasks:
## Configuration ## Configuration
To enable and configure the Repair Worker, add the following to your `config.json`: 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.
```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

View File

@@ -1,74 +0,0 @@
# WebDAV Server
![WebDAV Server](../images/webdav.png)
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).

View File

@@ -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. 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 ## Manual Downloading
![Downloading UI](../images/download.png) ![Downloading UI](../images/download.png)
To manually download a torrent using Decypharr, follow these steps: 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 2. You can either upload torrent file(s) or paste magnet links directly into the input fields
3. Select the action(defaults to Symlink) 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. - *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. - *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) - **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. - **Download Uncached**: If enabled, Decypharr will attempt to download uncached files from the Debrid service.
- **Allowed File Types**: Specify which file types are allowed for download.
Note: Note:
- If you use an arr category, your download will go into **{download_folder}/{arr}** - If you use an arr category, your download will go into **{download_folder}/{arr}**

View File

@@ -1,5 +1,4 @@
# Guides for setting up Decypharr # Guides for setting up Decypharr
- [Manual Downloading with Decypharr](downloading.md)
- [Setting up with Rclone](rclone.md) - [Internal Mounting](internal-mounting.md)
- [Manual Downloading with Decypharr](downloading.md)

View 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
![Decypharr Internal Mounting](../images/settings/rclone.png)
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

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 226 KiB

After

Width:  |  Height:  |  Size: 286 KiB

View File

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@@ -1,20 +1,19 @@
# Decypharr # Decypharr
![Decypharr UI - Light Mode](images/main-light.png){: .light-mode-image}
![Decypharr UI](images/main.png) ![Decypharr UI - Dark Mode](images/main.png){: .dark-mode-image}
**Decypharr** is an implementation of QbitTorrent with **Multiple Debrid service support**, written in Go. **Decypharr** is an implementation of QbitTorrent with **Multiple Debrid service support**, written in Go.
## What is Decypharr? ## 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 ## Key Features
- Mock Qbittorent API that supports Sonarr, Radarr, Lidarr, and other Arr applications - Mock Qbittorent API that supports Sonarr, Radarr, Lidarr, and other Arr applications
- Full-fledged UI for managing torrents
- Multiple Debrid providers support - 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 - Repair Worker for missing files, symlinks etc
## Supported Debrid Providers ## Supported Debrid Providers

View File

@@ -18,7 +18,6 @@ You can use either Docker Hub or GitHub Container Registry to pull the image:
- `latest`: The latest stable release - `latest`: The latest stable release
- `beta`: The latest beta release - `beta`: The latest beta release
- `vX.Y.Z`: A specific version (e.g., `v0.1.0`) - `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) - `experimental`: The latest experimental build (highly unstable)
### Docker CLI Setup ### Docker CLI Setup
@@ -31,12 +30,13 @@ Run the Docker container:
```bash ```bash
docker run -d \ docker run -d \
--name decypharr \ --name decypharr \
--restart unless-stopped \
-p 8282:8282 \ -p 8282:8282 \
-v /mnt/:/mnt \ -v /mnt/:/mnt:rshared \
-v ./config/:/app \ -v ./config/:/app \
-e PUID=1000 \ --device /dev/fuse:/dev/fuse:rwm \
-e PGID=1000 \ --cap-add SYS_ADMIN \
-e UMASK=002 \ --security-opt apparmor:unconfined \
cy01/blackhole:latest cy01/blackhole:latest
``` ```
@@ -52,10 +52,15 @@ services:
ports: ports:
- "8282:8282" - "8282:8282"
volumes: volumes:
- /mnt/:/mnt:rslave # Mount your media directory - /mnt/:/mnt:rshared
- ./config/:/app # config.json must be in this directory - ./config/:/app
- QBIT_PORT=8282 # qBittorrent Port (optional)
restart: unless-stopped restart: unless-stopped
devices:
- /dev/fuse:/dev/fuse:rwm
cap_add:
- SYS_ADMIN
security_opt:
- apparmor:unconfined
``` ```
Run the Docker Compose setup: Run the Docker Compose setup:
@@ -67,7 +72,7 @@ docker-compose up -d
## Binary Installation ## Binary Installation
If you prefer not to use Docker, you can download and run the binary directly. 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) Create a configuration file (see Configuration)
Run the binary: Run the binary:
@@ -76,44 +81,15 @@ chmod +x decypharr
./decypharr --config /path/to/config/folder ./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 ### Notes for Docker Users
- Ensure that the `/mnt/` directory is mounted correctly to access your media files. - 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. - 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. - The `UMASK` environment variable can be set to control file permissions created by Decypharr.
##### Health Checks ##### 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 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 main web interface
- The qBittorrent API - 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. - 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: healthcheck:
test: ["CMD", "/usr/bin/healthcheck", "--config", "/app/"] test: ["CMD", "/usr/bin/healthcheck", "--config", "/app/"]
interval: 5s interval: 10s
timeout: 10s timeout: 10s
retries: 3 retries: 3
``` ```

View 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;
}

View File

@@ -2,15 +2,35 @@
This guide will help you get started with Decypharr after installation. 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) ### Initial Configuration
2. Start the Decypharr service using Docker or binary 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.
3. Access the UI at `http://localhost:8282` (or your configured host/port)
4. Connect your Arr applications (Sonarr, Radarr, etc.)
## Connecting to Sonarr/Radarr ### Debrid Configuration
![Decypharr Settings](images/settings/debrid.png)
- 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
![Qbittorrent Settings](images/settings/qbittorent.png)
- 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
![Sonarr/Radarr Setup](images/settings/arr.png)
To connect Decypharr to your Sonarr or Radarr instance: To connect Decypharr to your Sonarr or Radarr instance:
1. In Sonarr/Radarr, go to **Settings → Download Client → Add Client → qBittorrent** 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) - **Host**: `localhost` (or the IP of your Decypharr server)
- **Port**: `8282` (or your configured qBittorrent port) - **Port**: `8282` (or your configured qBittorrent port)
- **Username**: `http://sonarr:8989` (your Arr host with http/https) - **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) - **Category**: e.g., `sonarr`, `radarr` (match what you configured in Decypharr)
- **Use SSL**: `No` - **Use SSL**: `No`
- **Sequential Download**: `No` or `Yes` (if you want to download torrents locally instead of symlink) - **Sequential Download**: `No` or `Yes` (if you want to download torrents locally instead of symlink)
3. Click **Test** to verify the connection 3. Click **Test** to verify the connection
4. Click **Save** to add the download client 4. Click **Save** to add the download client
![Sonarr/Radarr Setup](images/sonarr-setup.png)
## Using the UI ### Rclone Configuration
The Decypharr UI provides a familiar qBittorrent-like interface with additional features for Debrid services: ![Rclone Settings](images/settings/rclone.png)
- Add new torrents If you want Decypharr to automatically mount WebDAV folders using Rclone, you need to set up Rclone first:
- Monitor download status
- Access WebDAV functionality
- Edit your configuration
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 Settings](images/settings/repair.png)
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.

View File

@@ -6,6 +6,9 @@ repo_name: sirrobot01/decypharr
edit_uri: blob/main/docs edit_uri: blob/main/docs
extra_css:
- styles/styles.css
theme: theme:
name: material name: material
logo: images/logo.png logo: images/logo.png
@@ -59,22 +62,17 @@ nav:
- Home: index.md - Home: index.md
- Installation: installation.md - Installation: installation.md
- Usage: usage.md - Usage: usage.md
- Configuration: - API Documentation: api.md
- Overview: configuration/index.md
- General: configuration/general.md
- Debrid Providers: configuration/debrid.md
- qBittorrent: configuration/qbittorrent.md
- Arr Integration: configuration/arrs.md
- Features: - Features:
- Overview: features/index.md - Overview: features/index.md
- Repair Worker: features/repair-worker.md - Repair Worker: features/repair-worker.md
- WebDAV: features/webdav.md
- Guides: - Guides:
- Overview: guides/index.md - Overview: guides/index.md
- Setting Up with Rclone: guides/rclone.md - Manual Downloading: guides/downloading.md
- Changelog: changelog.md - Internal Mounting: guides/internal-mounting.md
plugins: plugins:
- search - search
- tags - tags
- swagger-ui-tag

3
docs/requirements.txt Normal file
View File

@@ -0,0 +1,3 @@
mkdocs==1.6.1
mkdocs-material==9.6.16
mkdocs-swagger-ui-tag==0.6.10

16
go.mod
View File

@@ -7,7 +7,7 @@ toolchain go1.24.3
require ( require (
github.com/anacrolix/torrent v1.55.0 github.com/anacrolix/torrent v1.55.0
github.com/cavaliergopher/grab/v3 v3.0.1 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/go-co-op/gocron/v2 v2.16.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
@@ -15,9 +15,9 @@ require (
github.com/rs/zerolog v1.33.0 github.com/rs/zerolog v1.33.0
github.com/stanNthe5/stringbuf v0.0.3 github.com/stanNthe5/stringbuf v0.0.3
go.uber.org/ratelimit v0.3.1 go.uber.org/ratelimit v0.3.1
golang.org/x/crypto v0.33.0 golang.org/x/crypto v0.39.0
golang.org/x/net v0.35.0 golang.org/x/net v0.41.0
golang.org/x/sync v0.12.0 golang.org/x/sync v0.15.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )
@@ -27,13 +27,13 @@ require (
github.com/benbjohnson/clock v1.3.0 // indirect github.com/benbjohnson/clock v1.3.0 // indirect
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // 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/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/gorilla/securecookie v1.1.2 // indirect
github.com/huandu/xstrings v1.3.2 // indirect github.com/huandu/xstrings v1.3.2 // indirect
github.com/jonboulle/clockwork v0.5.0 // 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/mattn/go-isatty v0.0.20 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.33.0 // indirect
) )

31
go.sum
View File

@@ -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-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-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/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.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 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 h1:ux/5zxVRveCaCuTtNI3DiOk581KC1KpJbpJFYUEVYwo=
github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc= github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.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.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.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.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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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.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 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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.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.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.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -188,8 +189,8 @@ github.com/prometheus/procfs v0.0.11/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4
github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= 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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= 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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 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/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 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
@@ -226,8 +227,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= 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-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.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.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= 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/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-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@@ -243,8 +244,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-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-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.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.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 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-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= 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= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -252,8 +253,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-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-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.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.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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-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-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -268,8 +269,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.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

View File

@@ -2,6 +2,8 @@ package config
import ( import (
"cmp" "cmp"
"crypto/rand"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
@@ -30,6 +32,7 @@ type Debrid struct {
APIKey string `json:"api_key,omitempty"` APIKey string `json:"api_key,omitempty"`
DownloadAPIKeys []string `json:"download_api_keys,omitempty"` DownloadAPIKeys []string `json:"download_api_keys,omitempty"`
Folder string `json:"folder,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"` DownloadUncached bool `json:"download_uncached,omitempty"`
CheckCached bool `json:"check_cached,omitempty"` CheckCached bool `json:"check_cached,omitempty"`
RateLimit string `json:"rate_limit,omitempty"` // 200/minute or 10/second RateLimit string `json:"rate_limit,omitempty"` // 200/minute or 10/second
@@ -39,6 +42,7 @@ type Debrid struct {
UnpackRar bool `json:"unpack_rar,omitempty"` UnpackRar bool `json:"unpack_rar,omitempty"`
AddSamples bool `json:"add_samples,omitempty"` AddSamples bool `json:"add_samples,omitempty"`
MinimumFreeSlot int `json:"minimum_free_slot,omitempty"` // Minimum active pots to use this debrid 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"` UseWebDav bool `json:"use_webdav,omitempty"`
WebDav WebDav
@@ -80,6 +84,42 @@ type Repair struct {
type Auth struct { type Auth struct {
Username string `json:"username,omitempty"` Username string `json:"username,omitempty"`
Password string `json:"password,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"`
// 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)
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
VfsPollInterval string `json:"vfs_poll_interval,omitempty"` // How often to rclone cleans the cache (default 1m)
BufferSize string `json:"buffer_size,omitempty"` // Buffer size for reading files (default 16M)
// 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 { type Config struct {
@@ -94,6 +134,7 @@ type Config struct {
Arrs []Arr `json:"arrs,omitempty"` Arrs []Arr `json:"arrs,omitempty"`
Repair Repair `json:"repair,omitempty"` Repair Repair `json:"repair,omitempty"`
WebDav WebDav `json:"webdav,omitempty"` WebDav WebDav `json:"webdav,omitempty"`
Rclone Rclone `json:"rclone,omitempty"`
AllowedExt []string `json:"allowed_file_types,omitempty"` AllowedExt []string `json:"allowed_file_types,omitempty"`
MinFileSize string `json:"min_file_size,omitempty"` // Minimum file size to download, 10MB, 1GB, etc 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) MaxFileSize string `json:"max_file_size,omitempty"` // Maximum file size to download (0 means no limit)
@@ -197,6 +238,15 @@ func ValidateConfig(config *Config) error {
return nil 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) { func SetConfigPath(path string) {
configPath = path configPath = path
} }
@@ -304,7 +354,7 @@ func (c *Config) updateDebrid(d Debrid) Debrid {
} }
if d.TorrentsRefreshInterval == "" { 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 == "" { if d.WebDav.DownloadLinksRefreshInterval == "" {
d.DownloadLinksRefreshInterval = cmp.Or(c.WebDav.DownloadLinksRefreshInterval, "40m") // 40 minutes d.DownloadLinksRefreshInterval = cmp.Or(c.WebDav.DownloadLinksRefreshInterval, "40m") // 40 minutes
@@ -365,8 +415,42 @@ func (c *Config) setDefaults() {
c.Repair.Strategy = RepairStrategyPerTorrent c.Repair.Strategy = RepairStrategyPerTorrent
} }
// Rclone defaults
if c.Rclone.Enabled {
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.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 // Load the auth file
c.Auth = c.GetAuth() 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 { func (c *Config) Save() error {

View File

@@ -24,7 +24,7 @@ func (c *Config) IsAllowedFile(filename string) bool {
} }
func getDefaultExtensions() []string { 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", ",") 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 // Combine both slices

View File

@@ -26,7 +26,7 @@ func GetLogPath() string {
} }
} }
return filepath.Join(logsDir, "decypharr.log") return logsDir
} }
func New(prefix string) zerolog.Logger { func New(prefix string) zerolog.Logger {
@@ -34,7 +34,7 @@ func New(prefix string) zerolog.Logger {
level := config.Get().LogLevel level := config.Get().LogLevel
rotatingLogFile := &lumberjack.Logger{ rotatingLogFile := &lumberjack.Logger{
Filename: GetLogPath(), Filename: filepath.Join(GetLogPath(), "decypharr.log"),
MaxSize: 10, MaxSize: 10,
MaxAge: 15, MaxAge: 15,
Compress: true, Compress: true,

View File

@@ -7,7 +7,7 @@ import (
) )
var ( 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)$" 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?)` sampleMatch = `(?i)(^|[\s/\\])(sample|trailer|thumb|special|extras?)s?[-/]|(\((sample|trailer|thumb|special|extras?)s?\))|(-\s*(sample|trailer|thumb|special|extras?)s?)`
) )
@@ -51,7 +51,8 @@ func IsMediaFile(path string) bool {
} }
func IsSampleFile(path string) bool { func IsSampleFile(path string) bool {
if strings.HasSuffix(strings.ToLower(path), "sample.mkv") { filename := filepath.Base(path)
if strings.HasSuffix(strings.ToLower(filename), "sample.mkv") {
return true return true
} }
return RegexMatch(sampleRegex, path) return RegexMatch(sampleRegex, path)

View File

@@ -1,7 +1,6 @@
package utils package utils
import ( import (
"context"
"fmt" "fmt"
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/robfig/cron/v3" "github.com/robfig/cron/v3"
@@ -10,25 +9,6 @@ import (
"time" "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. // ConvertToJobDef converts a string interval to a gocron.JobDefinition.
func ConvertToJobDef(interval string) (gocron.JobDefinition, error) { func ConvertToJobDef(interval string) (gocron.JobDefinition, error) {
// Parse the interval string // Parse the interval string

1624
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "decypharr",
"version": "1.0.0",
"description": "Media management tool",
"scripts": {
"build-css": "tailwindcss -i ./pkg/web/assets/styles.css -o ./pkg/web/assets/build/css/styles.css --minify",
"minify-js": "node scripts/minify-js.js",
"download-assets": "node scripts/download-assets.js",
"build": "npm run build-css && npm run minify-js",
"build-all": "npm run download-assets && npm run build",
"dev": "npm run build && air"
},
"devDependencies": {
"tailwindcss": "^3.4.0",
"daisyui": "^4.12.10",
"terser": "^5.24.0",
"clean-css": "^5.3.3"
}
}

View File

@@ -190,7 +190,7 @@ func (s *Storage) GetAll() []*Arr {
return arrs return arrs
} }
func (s *Storage) StartSchedule(ctx context.Context) error { func (s *Storage) StartWorker(ctx context.Context) error {
ticker := time.NewTicker(10 * time.Second) ticker := time.NewTicker(10 * time.Second)

View File

@@ -133,7 +133,7 @@ func (a *Arr) CleanupQueue() error {
messages := q.StatusMessages messages := q.StatusMessages
if len(messages) > 0 { if len(messages) > 0 {
for _, m := range messages { 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 isMessedUp = true
break break
} }

View File

@@ -9,50 +9,70 @@ import (
"github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/arr" "github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/debrid/providers/alldebrid" "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/realdebrid"
"github.com/sirrobot01/decypharr/pkg/debrid/providers/torbox" "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" "github.com/sirrobot01/decypharr/pkg/debrid/types"
"github.com/sirrobot01/decypharr/pkg/rclone"
"sync" "sync"
"time"
) )
type Debrid struct { type Debrid struct {
cache *store.Cache // Could be nil if not using WebDAV cache *debridStore.Cache // Could be nil if not using WebDAV
client types.Client // HTTP client for making requests to the debrid service client types.Client // HTTP client for making requests to the debrid service
} }
func (de *Debrid) Client() types.Client { func (de *Debrid) Client() types.Client {
return de.client return de.client
} }
func (de *Debrid) Cache() *store.Cache { func (de *Debrid) Cache() *debridStore.Cache {
return de.cache return de.cache
} }
func (de *Debrid) Reset() {
if de.cache != nil {
de.cache.Reset()
}
}
type Storage struct { type Storage struct {
debrids map[string]*Debrid debrids map[string]*Debrid
mu sync.RWMutex mu sync.RWMutex
lastUsed string lastUsed string
} }
func NewStorage() *Storage { func NewStorage(rcManager *rclone.Manager) *Storage {
cfg := config.Get() cfg := config.Get()
_logger := logger.Default() _logger := logger.Default()
debrids := make(map[string]*Debrid) 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 { for _, dc := range cfg.Debrids {
client, err := createDebridClient(dc) client, err := createDebridClient(dc)
if err != nil { if err != nil {
_logger.Error().Err(err).Str("Debrid", dc.Name).Msg("failed to connect to debrid client") _logger.Error().Err(err).Str("Debrid", dc.Name).Msg("failed to connect to debrid client")
continue continue
} }
var cache *store.Cache var (
cache *debridStore.Cache
mounter *rclone.Mount
)
_log := client.Logger() _log := client.Logger()
if dc.UseWebDav { 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") _log.Info().Msg("Debrid Service started with WebDAV")
} else { } else {
_log.Info().Msg("Debrid Service started") _log.Info().Msg("Debrid Service started")
@@ -79,6 +99,47 @@ func (d *Storage) Debrid(name string) *Debrid {
return nil return nil
} }
func (d *Storage) StartWorker(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
// Start all debrid syncAccounts
// Runs every 1m
if err := d.syncAccounts(); err != nil {
return err
}
ticker := time.NewTicker(5 * time.Minute)
go func() {
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
_ = d.syncAccounts()
}
}
}()
return nil
}
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 { func (d *Storage) Debrids() map[string]*Debrid {
d.mu.RLock() d.mu.RLock()
defer d.mu.RUnlock() defer d.mu.RUnlock()
@@ -102,8 +163,17 @@ func (d *Storage) Client(name string) types.Client {
func (d *Storage) Reset() { func (d *Storage) Reset() {
d.mu.Lock() 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.debrids = make(map[string]*Debrid)
d.mu.Unlock()
d.lastUsed = "" d.lastUsed = ""
} }
@@ -119,10 +189,10 @@ func (d *Storage) Clients() map[string]types.Client {
return clientsCopy return clientsCopy
} }
func (d *Storage) Caches() map[string]*store.Cache { func (d *Storage) Caches() map[string]*debridStore.Cache {
d.mu.RLock() d.mu.RLock()
defer d.mu.RUnlock() defer d.mu.RUnlock()
cachesCopy := make(map[string]*store.Cache) cachesCopy := make(map[string]*debridStore.Cache)
for name, debrid := range d.debrids { for name, debrid := range d.debrids {
if debrid != nil && debrid.cache != nil { if debrid != nil && debrid.cache != nil {
cachesCopy[name] = debrid.cache cachesCopy[name] = debrid.cache
@@ -150,7 +220,7 @@ func createDebridClient(dc config.Debrid) (types.Client, error) {
case "torbox": case "torbox":
return torbox.New(dc) return torbox.New(dc)
case "debridlink": case "debridlink":
return debrid_link.New(dc) return debridlink.New(dc)
case "alldebrid": case "alldebrid":
return alldebrid.New(dc) return alldebrid.New(dc)
default: default:
@@ -193,7 +263,7 @@ func Process(ctx context.Context, store *Storage, selectedDebrid string, magnet
debridTorrent.DownloadUncached = false debridTorrent.DownloadUncached = false
} }
for index, db := range clients { for _, db := range clients {
_logger := db.Logger() _logger := db.Logger()
_logger.Info(). _logger.Info().
Str("Debrid", db.Name()). Str("Debrid", db.Name()).
@@ -214,7 +284,7 @@ func Process(ctx context.Context, store *Storage, selectedDebrid string, magnet
} }
dbt.Arr = a dbt.Arr = a
_logger.Info().Str("id", dbt.Id).Msgf("Torrent: %s submitted to %s", dbt.Name, db.Name()) _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) torrent, err := db.CheckStatus(dbt)
if err != nil && torrent != nil && torrent.Id != "" { if err != nil && torrent != nil && torrent.Id != "" {

View File

@@ -25,6 +25,7 @@ type AllDebrid struct {
autoExpiresLinksAfter time.Duration autoExpiresLinksAfter time.Duration
DownloadUncached bool DownloadUncached bool
client *request.Client client *request.Client
Profile *types.Profile `json:"profile"`
MountPath string MountPath string
logger zerolog.Logger logger zerolog.Logger
@@ -33,10 +34,6 @@ type AllDebrid struct {
minimumFreeSlot int minimumFreeSlot int
} }
func (ad *AllDebrid) GetProfile() (*types.Profile, error) {
return nil, nil
}
func New(dc config.Debrid) (*AllDebrid, error) { func New(dc config.Debrid) (*AllDebrid, error) {
rl := request.ParseRateLimit(dc.RateLimit) rl := request.ParseRateLimit(dc.RateLimit)
@@ -449,6 +446,58 @@ func (ad *AllDebrid) GetAvailableSlots() (int, error) {
return 0, fmt.Errorf("GetAvailableSlots not implemented for AllDebrid") return 0, fmt.Errorf("GetAvailableSlots not implemented for AllDebrid")
} }
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) Accounts() *types.Accounts { func (ad *AllDebrid) Accounts() *types.Accounts {
return ad.accounts return ad.accounts
} }
func (ad *AllDebrid) SyncAccounts() error {
return nil
}

View File

@@ -112,3 +112,22 @@ func (m *Magnets) UnmarshalJSON(data []byte) error {
} }
return fmt.Errorf("magnets: unsupported JSON format") 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"`
}

View File

@@ -1,4 +1,4 @@
package debrid_link package debridlink
import ( import (
"bytes" "bytes"
@@ -30,6 +30,8 @@ type DebridLink struct {
logger zerolog.Logger logger zerolog.Logger
checkCached bool checkCached bool
addSamples bool addSamples bool
Profile *types.Profile `json:"profile,omitempty"`
} }
func New(dc config.Debrid) (*DebridLink, error) { func New(dc config.Debrid) (*DebridLink, error) {
@@ -66,10 +68,6 @@ func New(dc config.Debrid) (*DebridLink, error) {
}, nil }, nil
} }
func (dl *DebridLink) GetProfile() (*types.Profile, error) {
return nil, nil
}
func (dl *DebridLink) Name() string { func (dl *DebridLink) Name() string {
return dl.name return dl.name
} }
@@ -476,6 +474,55 @@ func (dl *DebridLink) GetAvailableSlots() (int, error) {
return 0, fmt.Errorf("GetAvailableSlots not implemented for DebridLink") return 0, fmt.Errorf("GetAvailableSlots not implemented for DebridLink")
} }
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) Accounts() *types.Accounts { func (dl *DebridLink) Accounts() *types.Accounts {
return dl.accounts return dl.accounts
} }
func (dl *DebridLink) SyncAccounts() error {
return nil
}

View File

@@ -1,4 +1,4 @@
package debrid_link package debridlink
type APIResponse[T any] struct { type APIResponse[T any] struct {
Success bool `json:"success"` Success bool `json:"success"`
@@ -43,3 +43,12 @@ type _torrentInfo struct {
type torrentInfo APIResponse[[]_torrentInfo] type torrentInfo APIResponse[[]_torrentInfo]
type SubmitTorrentInfo 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"`
}]

View File

@@ -46,7 +46,7 @@ type RealDebrid struct {
addSamples bool addSamples bool
Profile *types.Profile Profile *types.Profile
minimumFreeSlot int // Minimum number of active pots to maintain (used for cached stuffs, etc.) minimumFreeSlot int // Minimum number of active pots to maintain (used for cached stuffs, etc.)
limit int
} }
func New(dc config.Debrid) (*RealDebrid, error) { func New(dc config.Debrid) (*RealDebrid, error) {
@@ -101,6 +101,7 @@ func New(dc config.Debrid) (*RealDebrid, error) {
checkCached: dc.CheckCached, checkCached: dc.CheckCached,
addSamples: dc.AddSamples, addSamples: dc.AddSamples,
minimumFreeSlot: dc.MinimumFreeSlot, minimumFreeSlot: dc.MinimumFreeSlot,
limit: dc.Limit,
} }
if _, err := r.GetProfile(); err != nil { if _, err := r.GetProfile(); err != nil {
@@ -160,6 +161,23 @@ func (r *RealDebrid) getSelectedFiles(t *types.Torrent, data torrentInfo) (map[s
return files, nil 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 // handleRarArchive processes RAR archives with multiple files
func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, selectedFiles []types.File) (map[string]types.File, error) { 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 // This will block if 2 RAR operations are already in progress
@@ -171,21 +189,8 @@ func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, select
files := make(map[string]types.File) files := make(map[string]types.File)
if !r.UnpackRar { if !r.UnpackRar {
r.logger.Debug().Msgf("RAR file detected, but unpacking is disabled: %s", t.Name) r.logger.Debug().Msgf("RAR file detected, but unpacking is disabled: %s. Falling back to single file representation.", t.Name)
// Create a single file representing the RAR archive return r.handleRarFallback(t, data)
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.Info().Msgf("RAR file detected, unpacking: %s", t.Name) r.logger.Info().Msgf("RAR file detected, unpacking: %s", t.Name)
@@ -193,20 +198,23 @@ func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, select
downloadLinkObj, err := r.GetDownloadLink(t, linkFile) downloadLinkObj, err := r.GetDownloadLink(t, linkFile)
if err != nil { 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 dlLink := downloadLinkObj.DownloadLink
reader, err := rar.NewReader(dlLink) reader, err := rar.NewReader(dlLink)
if err != nil { 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() rarFiles, err := reader.GetFiles()
if err != nil { 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 // Create lookup map for faster matching
@@ -231,7 +239,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()) 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 return files, nil
} }
@@ -510,7 +522,7 @@ func (r *RealDebrid) CheckStatus(t *types.Torrent) (*types.Torrent, error) {
if status == "waiting_files_selection" { if status == "waiting_files_selection" {
t.Files = r.getTorrentFiles(t, data) t.Files = r.getTorrentFiles(t, data)
if len(t.Files) == 0 { 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) filesId := make([]string, 0)
for _, f := range t.Files { for _, f := range t.Files {
@@ -699,7 +711,7 @@ func (r *RealDebrid) _getDownloadLink(file *types.File) (*types.DownloadLink, er
func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) { func (r *RealDebrid) GetDownloadLink(t *types.Torrent, file *types.File) (*types.DownloadLink, error) {
accounts := r.accounts.All() accounts := r.accounts.Active()
for _, account := range accounts { for _, account := range accounts {
r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", account.Token)) r.downloadClient.SetHeader("Authorization", fmt.Sprintf("Bearer %s", account.Token))
@@ -794,6 +806,10 @@ func (r *RealDebrid) getTorrents(offset int, limit int) (int, []*types.Torrent,
func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) { func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) {
limit := 5000 limit := 5000
if r.limit != 0 {
limit = r.limit
}
hardLimit := r.limit
// Get first batch and total count // Get first batch and total count
allTorrents := make([]*types.Torrent, 0) allTorrents := make([]*types.Torrent, 0)
@@ -812,6 +828,10 @@ func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) {
} }
allTorrents = append(allTorrents, torrents...) allTorrents = append(allTorrents, torrents...)
offset += totalTorrents offset += totalTorrents
if hardLimit != 0 && len(allTorrents) >= hardLimit {
// If hard limit is set, stop fetching more torrents
break
}
} }
if fetchError != nil { if fetchError != nil {
@@ -826,7 +846,7 @@ func (r *RealDebrid) GetDownloadLinks() (map[string]*types.DownloadLink, error)
offset := 0 offset := 0
limit := 1000 limit := 1000
accounts := r.accounts.All() accounts := r.accounts.Active()
if len(accounts) < 1 { if len(accounts) < 1 {
// No active download keys. It's likely that the key has reached bandwidth limit // No active download keys. It's likely that the key has reached bandwidth limit
@@ -924,6 +944,7 @@ func (r *RealDebrid) GetProfile() (*types.Profile, error) {
return nil, err return nil, err
} }
profile := &types.Profile{ profile := &types.Profile{
Name: r.name,
Id: data.Id, Id: data.Id,
Username: data.Username, Username: data.Username,
Email: data.Email, Email: data.Email,
@@ -953,3 +974,71 @@ func (r *RealDebrid) GetAvailableSlots() (int, error) {
func (r *RealDebrid) Accounts() *types.Accounts { func (r *RealDebrid) Accounts() *types.Accounts {
return r.accounts return r.accounts
} }
func (r *RealDebrid) SyncAccounts() error {
// Sync accounts with the current configuration
if len(r.accounts.Active()) == 0 {
return nil
}
for idx, account := range r.accounts.Active() {
if err := r.syncAccount(idx, 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(index int, account *types.Account) error {
if account.Token == "" {
return fmt.Errorf("account %s has no token", account.Username)
}
client := http.DefaultClient
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)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", account.Token))
resp, err := 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)
}
trafficReq.Header.Set("Authorization", fmt.Sprintf("Bearer %s", account.Token))
trafficResp, err := 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 {
return fmt.Errorf("error decoding traffic details for account %s: %w", account.Username, err)
}
today := time.Now().Format(time.DateOnly)
if todayData, exists := trafficData[today]; exists {
account.TrafficUsed = todayData.Bytes
}
r.accounts.Update(index, account)
return nil
}

View File

@@ -144,11 +144,11 @@ type profileResponse struct {
Id int64 `json:"id"` Id int64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Points int64 `json:"points"` Points int `json:"points"`
Locale string `json:"locale"` Locale string `json:"locale"`
Avatar string `json:"avatar"` Avatar string `json:"avatar"`
Type string `json:"type"` Type string `json:"type"`
Premium int `json:"premium"` Premium int64 `json:"premium"`
Expiration time.Time `json:"expiration"` Expiration time.Time `json:"expiration"`
} }
@@ -156,3 +156,10 @@ type AvailableSlotsResponse struct {
ActiveSlots int `json:"nb"` ActiveSlots int `json:"nb"`
TotalSlots int `json:"limit"` TotalSlots int `json:"limit"`
} }
type hostData struct {
Host map[string]int64 `json:"host"`
Bytes int64 `json:"bytes"`
}
type TrafficResponse map[string]hostData

View File

@@ -4,13 +4,6 @@ import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/internal/logger"
"github.com/sirrobot01/decypharr/internal/request"
"github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"github.com/sirrobot01/decypharr/pkg/version"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
gourl "net/url" gourl "net/url"
@@ -21,6 +14,14 @@ import (
"strings" "strings"
"sync" "sync"
"time" "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/types"
"github.com/sirrobot01/decypharr/pkg/version"
) )
type Torbox struct { type Torbox struct {
@@ -39,10 +40,6 @@ type Torbox struct {
addSamples bool addSamples bool
} }
func (tb *Torbox) GetProfile() (*types.Profile, error) {
return nil, nil
}
func New(dc config.Debrid) (*Torbox, error) { func New(dc config.Debrid) (*Torbox, error) {
rl := request.ParseRateLimit(dc.RateLimit) rl := request.ParseRateLimit(dc.RateLimit)
@@ -168,7 +165,7 @@ func (tb *Torbox) SubmitMagnet(torrent *types.Torrent) (*types.Torrent, error) {
return torrent, nil return torrent, nil
} }
func getTorboxStatus(status string, finished bool) string { func (tb *Torbox) getTorboxStatus(status string, finished bool) string {
if finished { if finished {
return "downloaded" return "downloaded"
} }
@@ -176,12 +173,16 @@ func getTorboxStatus(status string, finished bool) string {
"checkingResumeData", "metaDL", "pausedUP", "queuedUP", "checkingUP", "checkingResumeData", "metaDL", "pausedUP", "queuedUP", "checkingUP",
"forcedUP", "allocating", "downloading", "metaDL", "pausedDL", "forcedUP", "allocating", "downloading", "metaDL", "pausedDL",
"queuedDL", "checkingDL", "forcedDL", "checkingResumeData", "moving"} "queuedDL", "checkingDL", "forcedDL", "checkingResumeData", "moving"}
var determinedStatus string
switch { switch {
case utils.Contains(downloading, status): case utils.Contains(downloading, status):
return "downloading" determinedStatus = "downloading"
default: default:
return "error" determinedStatus = "error"
} }
return determinedStatus
} }
func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) { func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) {
@@ -206,7 +207,7 @@ func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) {
Bytes: data.Size, Bytes: data.Size,
Folder: data.Name, Folder: data.Name,
Progress: data.Progress * 100, Progress: data.Progress * 100,
Status: getTorboxStatus(data.DownloadState, data.DownloadFinished), Status: tb.getTorboxStatus(data.DownloadState, data.DownloadFinished),
Speed: data.DownloadSpeed, Speed: data.DownloadSpeed,
Seeders: data.Seeds, Seeders: data.Seeds,
Filename: data.Name, Filename: data.Name,
@@ -217,19 +218,33 @@ func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) {
Added: data.CreatedAt.Format(time.RFC3339), Added: data.CreatedAt.Format(time.RFC3339),
} }
cfg := config.Get() cfg := config.Get()
totalFiles := 0
skippedSamples := 0
skippedFileType := 0
skippedSize := 0
validFiles := 0
filesWithLinks := 0
for _, f := range data.Files { for _, f := range data.Files {
totalFiles++
fileName := filepath.Base(f.Name) fileName := filepath.Base(f.Name)
if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) { if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) {
// Skip sample files skippedSamples++
continue continue
} }
if !cfg.IsAllowedFile(fileName) { if !cfg.IsAllowedFile(fileName) {
skippedFileType++
continue continue
} }
if !cfg.IsSizeAllowed(f.Size) { if !cfg.IsSizeAllowed(f.Size) {
skippedSize++
continue continue
} }
validFiles++
file := types.File{ file := types.File{
TorrentId: t.Id, TorrentId: t.Id,
Id: strconv.Itoa(f.Id), Id: strconv.Itoa(f.Id),
@@ -237,8 +252,26 @@ func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) {
Size: f.Size, Size: f.Size,
Path: f.Name, Path: f.Name,
} }
// For downloaded torrents, set a placeholder link to indicate file is available
if data.DownloadFinished {
file.Link = fmt.Sprintf("torbox://%s/%d", t.Id, f.Id)
filesWithLinks++
}
t.Files[fileName] = file t.Files[fileName] = file
} }
// Log summary only if there are issues or for debugging
tb.logger.Debug().
Str("torrent_id", t.Id).
Str("torrent_name", t.Name).
Bool("download_finished", data.DownloadFinished).
Str("status", t.Status).
Int("total_files", totalFiles).
Int("valid_files", validFiles).
Int("final_file_count", len(t.Files)).
Msg("Torrent file processing completed")
var cleanPath string var cleanPath string
if len(t.Files) > 0 { if len(t.Files) > 0 {
cleanPath = path.Clean(data.Files[0].Name) cleanPath = path.Clean(data.Files[0].Name)
@@ -266,24 +299,33 @@ func (tb *Torbox) UpdateTorrent(t *types.Torrent) error {
} }
data := res.Data data := res.Data
name := data.Name name := data.Name
t.Name = name t.Name = name
t.Bytes = data.Size t.Bytes = data.Size
t.Folder = name t.Folder = name
t.Progress = data.Progress * 100 t.Progress = data.Progress * 100
t.Status = getTorboxStatus(data.DownloadState, data.DownloadFinished) t.Status = tb.getTorboxStatus(data.DownloadState, data.DownloadFinished)
t.Speed = data.DownloadSpeed t.Speed = data.DownloadSpeed
t.Seeders = data.Seeds t.Seeders = data.Seeds
t.Filename = name t.Filename = name
t.OriginalFilename = name t.OriginalFilename = name
t.MountPath = tb.MountPath t.MountPath = tb.MountPath
t.Debrid = tb.name t.Debrid = tb.name
// Clear existing files map to rebuild it
t.Files = make(map[string]types.File)
cfg := config.Get() cfg := config.Get()
validFiles := 0
filesWithLinks := 0
for _, f := range data.Files { for _, f := range data.Files {
fileName := filepath.Base(f.Name) fileName := filepath.Base(f.Name)
if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) { if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) {
// Skip sample files
continue continue
} }
if !cfg.IsAllowedFile(fileName) { if !cfg.IsAllowedFile(fileName) {
continue continue
} }
@@ -291,6 +333,8 @@ func (tb *Torbox) UpdateTorrent(t *types.Torrent) error {
if !cfg.IsSizeAllowed(f.Size) { if !cfg.IsSizeAllowed(f.Size) {
continue continue
} }
validFiles++
file := types.File{ file := types.File{
TorrentId: t.Id, TorrentId: t.Id,
Id: strconv.Itoa(f.Id), Id: strconv.Itoa(f.Id),
@@ -298,8 +342,16 @@ func (tb *Torbox) UpdateTorrent(t *types.Torrent) error {
Size: f.Size, Size: f.Size,
Path: fileName, Path: fileName,
} }
// For downloaded torrents, set a placeholder link to indicate file is available
if data.DownloadFinished {
file.Link = fmt.Sprintf("torbox://%s/%s", t.Id, strconv.Itoa(f.Id))
filesWithLinks++
}
t.Files[fileName] = file t.Files[fileName] = file
} }
var cleanPath string var cleanPath string
if len(t.Files) > 0 { if len(t.Files) > 0 {
cleanPath = path.Clean(data.Files[0].Name) cleanPath = path.Clean(data.Files[0].Name)
@@ -409,30 +461,58 @@ func (tb *Torbox) GetDownloadLink(t *types.Torrent, file *types.File) (*types.Do
query.Add("token", tb.APIKey) query.Add("token", tb.APIKey)
query.Add("file_id", file.Id) query.Add("file_id", file.Id)
url += "?" + query.Encode() url += "?" + query.Encode()
req, _ := http.NewRequest(http.MethodGet, url, nil) req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := tb.client.MakeRequest(req) resp, err := tb.client.MakeRequest(req)
if err != nil { if err != nil {
tb.logger.Error().
Err(err).
Str("torrent_id", t.Id).
Str("file_id", file.Id).
Msg("Failed to make request to Torbox API")
return nil, err return nil, err
} }
var data DownloadLinksResponse var data DownloadLinksResponse
if err = json.Unmarshal(resp, &data); err != nil { if err = json.Unmarshal(resp, &data); err != nil {
tb.logger.Error().
Err(err).
Str("torrent_id", t.Id).
Str("file_id", file.Id).
Msg("Failed to unmarshal Torbox API response")
return nil, err return nil, err
} }
if data.Data == nil { if data.Data == nil {
tb.logger.Error().
Str("torrent_id", t.Id).
Str("file_id", file.Id).
Bool("success", data.Success).
Interface("error", data.Error).
Str("detail", data.Detail).
Msg("Torbox API returned no data")
return nil, fmt.Errorf("error getting download links") return nil, fmt.Errorf("error getting download links")
} }
link := *data.Data link := *data.Data
if link == "" { if link == "" {
tb.logger.Error().
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 nil, fmt.Errorf("error getting download links")
} }
now := time.Now() now := time.Now()
return &types.DownloadLink{ downloadLink := &types.DownloadLink{
Link: file.Link, Link: file.Link,
DownloadLink: link, DownloadLink: link,
Id: file.Id, Id: file.Id,
Generated: now, Generated: now,
ExpiresAt: now.Add(tb.autoExpiresLinksAfter), ExpiresAt: now.Add(tb.autoExpiresLinksAfter),
}, nil }
return downloadLink, nil
} }
func (tb *Torbox) GetDownloadingStatus() []string { func (tb *Torbox) GetDownloadingStatus() []string {
@@ -440,7 +520,87 @@ func (tb *Torbox) GetDownloadingStatus() []string {
} }
func (tb *Torbox) GetTorrents() ([]*types.Torrent, error) { func (tb *Torbox) GetTorrents() ([]*types.Torrent, error) {
return nil, nil url := fmt.Sprintf("%s/api/torrents/mylist", tb.Host)
req, _ := http.NewRequest(http.MethodGet, url, nil)
resp, err := tb.client.MakeRequest(req)
if err != nil {
return nil, err
}
var res TorrentsListResponse
err = json.Unmarshal(resp, &res)
if err != nil {
return nil, err
}
if !res.Success || res.Data == nil {
return nil, fmt.Errorf("torbox API error: %v", res.Error)
}
torrents := make([]*types.Torrent, 0, len(*res.Data))
cfg := config.Get()
for _, data := range *res.Data {
t := &types.Torrent{
Id: strconv.Itoa(data.Id),
Name: data.Name,
Bytes: data.Size,
Folder: data.Name,
Progress: data.Progress * 100,
Status: tb.getTorboxStatus(data.DownloadState, data.DownloadFinished),
Speed: data.DownloadSpeed,
Seeders: data.Seeds,
Filename: data.Name,
OriginalFilename: data.Name,
MountPath: tb.MountPath,
Debrid: tb.name,
Files: make(map[string]types.File),
Added: data.CreatedAt.Format(time.RFC3339),
InfoHash: data.Hash,
}
// Process files
for _, f := range data.Files {
fileName := filepath.Base(f.Name)
if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) {
// Skip sample files
continue
}
if !cfg.IsAllowedFile(fileName) {
continue
}
if !cfg.IsSizeAllowed(f.Size) {
continue
}
file := types.File{
TorrentId: t.Id,
Id: strconv.Itoa(f.Id),
Name: fileName,
Size: f.Size,
Path: f.Name,
}
// For downloaded torrents, set a placeholder link to indicate file is available
if data.DownloadFinished {
file.Link = fmt.Sprintf("torbox://%s/%d", t.Id, f.Id)
}
t.Files[fileName] = file
}
// Set original filename based on first file or torrent name
var cleanPath string
if len(t.Files) > 0 {
cleanPath = path.Clean(data.Files[0].Name)
} else {
cleanPath = path.Clean(data.Name)
}
t.OriginalFilename = strings.Split(cleanPath, "/")[0]
torrents = append(torrents, t)
}
return torrents, nil
} }
func (tb *Torbox) GetDownloadUncached() bool { func (tb *Torbox) GetDownloadUncached() bool {
@@ -468,6 +628,14 @@ func (tb *Torbox) GetAvailableSlots() (int, error) {
return 0, fmt.Errorf("not implemented") return 0, fmt.Errorf("not implemented")
} }
func (tb *Torbox) GetProfile() (*types.Profile, error) {
return nil, nil
}
func (tb *Torbox) Accounts() *types.Accounts { func (tb *Torbox) Accounts() *types.Accounts {
return tb.accounts return tb.accounts
} }
func (tb *Torbox) SyncAccounts() error {
return nil
}

View File

@@ -57,7 +57,7 @@ type torboxInfo struct {
} `json:"files"` } `json:"files"`
DownloadPath string `json:"download_path"` DownloadPath string `json:"download_path"`
InactiveCheck int `json:"inactive_check"` InactiveCheck int `json:"inactive_check"`
Availability int `json:"availability"` Availability float64 `json:"availability"`
DownloadFinished bool `json:"download_finished"` DownloadFinished bool `json:"download_finished"`
Tracker interface{} `json:"tracker"` Tracker interface{} `json:"tracker"`
TotalUploaded int `json:"total_uploaded"` TotalUploaded int `json:"total_uploaded"`
@@ -73,3 +73,5 @@ type torboxInfo struct {
type InfoResponse APIResponse[torboxInfo] type InfoResponse APIResponse[torboxInfo]
type DownloadLinksResponse APIResponse[string] type DownloadLinksResponse APIResponse[string]
type TorrentsListResponse APIResponse[[]torboxInfo]

View File

@@ -6,7 +6,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/sirrobot01/decypharr/pkg/debrid/types" "github.com/sirrobot01/decypharr/pkg/rclone"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -17,13 +17,16 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"encoding/json" "encoding/json"
_ "time/tzdata"
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/config" "github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/internal/logger" "github.com/sirrobot01/decypharr/internal/logger"
"github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/internal/utils"
_ "time/tzdata"
) )
type WebDavFolderNaming string type WebDavFolderNaming string
@@ -104,9 +107,10 @@ type Cache struct {
config config.Debrid config config.Debrid
customFolders []string customFolders []string
mounter *rclone.Mount
} }
func NewDebridCache(dc config.Debrid, client types.Client) *Cache { func NewDebridCache(dc config.Debrid, client types.Client, mounter *rclone.Mount) *Cache {
cfg := config.Get() cfg := config.Get()
cet, err := time.LoadLocation("CET") cet, err := time.LoadLocation("CET")
if err != nil { if err != nil {
@@ -118,9 +122,13 @@ func NewDebridCache(dc config.Debrid, client types.Client) *Cache {
cetSc, err := gocron.NewScheduler(gocron.WithLocation(cet)) cetSc, err := gocron.NewScheduler(gocron.WithLocation(cet))
if err != nil { if err != nil {
// If we can't create a CET scheduler, fallback to local time // 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 err != nil {
// If we can't create a local scheduler, fallback to CET // If we can't create a local scheduler, fallback to CET
scheduler = cetSc scheduler = cetSc
@@ -161,6 +169,7 @@ func NewDebridCache(dc config.Debrid, client types.Client) *Cache {
config: dc, config: dc,
customFolders: customFolders, customFolders: customFolders,
mounter: mounter,
ready: make(chan struct{}), ready: make(chan struct{}),
} }
@@ -184,6 +193,15 @@ func (c *Cache) StreamWithRclone() bool {
// and before you discard the instance on a restart. // and before you discard the instance on a restart.
func (c *Cache) Reset() { func (c *Cache) Reset() {
// 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)
}
}
if err := c.scheduler.StopJobs(); err != nil { if err := c.scheduler.StopJobs(); err != nil {
c.logger.Error().Err(err).Msg("Failed to stop scheduler jobs") c.logger.Error().Err(err).Msg("Failed to stop scheduler jobs")
} }
@@ -196,7 +214,9 @@ func (c *Cache) Reset() {
c.listingDebouncer.Stop() c.listingDebouncer.Stop()
// Close the repair channel // Close the repair channel
close(c.repairChan) if c.repairChan != nil {
close(c.repairChan)
}
// 1. Reset torrent storage // 1. Reset torrent storage
c.torrents.reset() c.torrents.reset()
@@ -217,6 +237,9 @@ func (c *Cache) Reset() {
// 6. Reset repair channel so the next Start() can spin it up // 6. Reset repair channel so the next Start() can spin it up
c.repairChan = make(chan RepairRequest, 100) c.repairChan = make(chan RepairRequest, 100)
// Reset the ready channel
c.ready = make(chan struct{})
} }
func (c *Cache) Start(ctx context.Context) error { func (c *Cache) Start(ctx context.Context) error {
@@ -235,11 +258,6 @@ func (c *Cache) Start(ctx context.Context) error {
// initial download links // initial download links
go c.refreshDownloadLinks(ctx) 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 c.repairChan = make(chan RepairRequest, 100) // Initialize the repair channel, max 100 requests buffered
go c.repairWorker(ctx) go c.repairWorker(ctx)
@@ -248,10 +266,13 @@ func (c *Cache) Start(ctx context.Context) error {
addr := cfg.BindAddress + ":" + cfg.Port + cfg.URLBase + "webdav/" + name + "/" addr := cfg.BindAddress + ":" + cfg.Port + cfg.URLBase + "webdav/" + name + "/"
c.logger.Info().Msgf("%s WebDav server running at %s", name, addr) c.logger.Info().Msgf("%s WebDav server running at %s", name, addr)
<-ctx.Done() if c.mounter != nil {
c.logger.Info().Msgf("Stopping %s WebDav server", name) if err := c.mounter.Mount(ctx); err != nil {
c.Reset() 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 return nil
} }
@@ -682,8 +703,13 @@ func (c *Cache) ProcessTorrent(t *types.Torrent) error {
} }
if !isComplete(t.Files) { if !isComplete(t.Files) {
c.logger.Debug().Msgf("Torrent %s is still not complete. Triggering a reinsert(disabled)", t.Id) c.logger.Debug().
Str("torrent_id", t.Id).
Str("torrent_name", t.Name).
Int("total_files", len(t.Files)).
Msg("Torrent still not complete after refresh, marking as bad")
} else { } else {
addedOn, err := time.Parse(time.RFC3339, t.Added) addedOn, err := time.Parse(time.RFC3339, t.Added)
if err != nil { if err != nil {
addedOn = time.Now() addedOn = time.Now()
@@ -874,3 +900,7 @@ func (c *Cache) RemoveFile(torrentId string, filename string) error {
func (c *Cache) Logger() zerolog.Logger { func (c *Cache) Logger() zerolog.Logger {
return c.logger return c.logger
} }
func (c *Cache) GetConfig() config.Debrid {
return c.config
}

View File

@@ -105,6 +105,11 @@ func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (*type
downloadLink, err := c.client.GetDownloadLink(ct.Torrent, &file) downloadLink, err := c.client.GetDownloadLink(ct.Torrent, &file)
if err != nil { if err != nil {
if errors.Is(err, utils.HosterUnavailableError) { if errors.Is(err, utils.HosterUnavailableError) {
c.logger.Trace().
Str("filename", filename).
Str("torrent_id", ct.Id).
Msg("Hoster unavailable, attempting to reinsert torrent")
newCt, err := c.reInsertTorrent(ct) newCt, err := c.reInsertTorrent(ct)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to reinsert torrent: %w", err) return nil, fmt.Errorf("failed to reinsert torrent: %w", err)
@@ -117,10 +122,10 @@ func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (*type
// Retry getting the download link // Retry getting the download link
downloadLink, err = c.client.GetDownloadLink(ct.Torrent, &file) downloadLink, err = c.client.GetDownloadLink(ct.Torrent, &file)
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("retry failed to get download link: %w", err)
} }
if downloadLink == nil { if downloadLink == nil {
return nil, fmt.Errorf("download link is empty for") return nil, fmt.Errorf("download link is empty after retry")
} }
return nil, nil return nil, nil
} else if errors.Is(err, utils.TrafficExceededError) { } else if errors.Is(err, utils.TrafficExceededError) {

View File

@@ -127,10 +127,21 @@ func (c *Cache) refreshTorrents(ctx context.Context) {
func (c *Cache) refreshRclone() error { func (c *Cache) refreshRclone() error {
cfg := c.config cfg := c.config
dirs := strings.FieldsFunc(cfg.RcRefreshDirs, func(r rune) bool {
if cfg.RcUrl == "" { return r == ',' || r == '&'
return nil })
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 == "" { if cfg.RcUrl == "" {
return nil return nil
@@ -138,7 +149,7 @@ func (c *Cache) refreshRclone() error {
client := http.DefaultClient client := http.DefaultClient
// Create form data // Create form data
data := c.buildRcloneRequestData() data := c.buildRcloneRequestData(dirs)
if err := c.sendRcloneRequest(client, "vfs/forget", data); err != nil { if err := c.sendRcloneRequest(client, "vfs/forget", data); err != nil {
c.logger.Error().Err(err).Msg("Failed to send rclone vfs/forget request") c.logger.Error().Err(err).Msg("Failed to send rclone vfs/forget request")
@@ -151,16 +162,7 @@ func (c *Cache) refreshRclone() error {
return nil return nil
} }
func (c *Cache) buildRcloneRequestData() string { func (c *Cache) buildRcloneRequestData(dirs []string) string {
cfg := c.config
dirs := strings.FieldsFunc(cfg.RcRefreshDirs, func(r rune) bool {
return r == ',' || r == '&'
})
if len(dirs) == 0 {
return "dir=__all__"
}
var data strings.Builder var data strings.Builder
for index, dir := range dirs { for index, dir := range dirs {
if dir != "" { if dir != "" {

View File

@@ -6,9 +6,12 @@ import (
"github.com/sirrobot01/decypharr/internal/utils" "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 // 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 // Schedule download link refresh job
if jd, err := utils.ConvertToJobDef(c.downloadLinksRefreshInterval); err != nil { 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") c.logger.Error().Err(err).Msg("Failed to convert download link refresh interval to job definition")

View File

@@ -33,15 +33,17 @@ func NewAccounts(debridConf config.Debrid) *Accounts {
} }
type Account struct { type Account struct {
Debrid string // e.g., "realdebrid", "torbox", etc. Debrid string // e.g., "realdebrid", "torbox", etc.
Order int Order int
Disabled bool Disabled bool
Token string Token string `json:"token"`
links map[string]*DownloadLink links map[string]*DownloadLink
mu sync.RWMutex mu sync.RWMutex
TrafficUsed int64 `json:"traffic_used"` // Traffic used in bytes
Username string `json:"username"` // Username for the account
} }
func (a *Accounts) All() []*Account { func (a *Accounts) Active() []*Account {
a.mu.RLock() a.mu.RLock()
defer a.mu.RUnlock() defer a.mu.RUnlock()
activeAccounts := make([]*Account, 0) activeAccounts := make([]*Account, 0)
@@ -53,6 +55,12 @@ func (a *Accounts) All() []*Account {
return activeAccounts return activeAccounts
} }
func (a *Accounts) All() []*Account {
a.mu.RLock()
defer a.mu.RUnlock()
return a.accounts
}
func (a *Accounts) Current() *Account { func (a *Accounts) Current() *Account {
a.mu.RLock() a.mu.RLock()
if a.current != nil { if a.current != nil {
@@ -177,6 +185,23 @@ func (a *Accounts) SetDownloadLinks(links map[string]*DownloadLink) {
a.Current().setLinks(links) a.Current().setLinks(links)
} }
func (a *Accounts) Update(index int, account *Account) {
a.mu.Lock()
defer a.mu.Unlock()
if index < 0 || index >= len(a.accounts) {
return // Index out of bounds
}
// Update the account at the specified index
a.accounts[index] = account
// If the updated account is the current one, update the current reference
if a.current == nil || a.current.Order == index {
a.current = account
}
}
func newAccount(debridName, token string, index int) *Account { func newAccount(debridName, token string, index int) *Account {
return &Account{ return &Account{
Debrid: debridName, Debrid: debridName,
@@ -213,7 +238,6 @@ func (a *Account) LinksCount() int {
defer a.mu.RUnlock() defer a.mu.RUnlock()
return len(a.links) return len(a.links)
} }
func (a *Account) disable() { func (a *Account) disable() {
a.Disabled = true a.Disabled = true
} }

View File

@@ -25,4 +25,5 @@ type Client interface {
DeleteDownloadLink(linkId string) error DeleteDownloadLink(linkId string) error
GetProfile() (*Profile, error) GetProfile() (*Profile, error)
GetAvailableSlots() (int, error) GetAvailableSlots() (int, error)
SyncAccounts() error // Updates each accounts details(like traffic, username, etc.)
} }

View File

@@ -114,19 +114,27 @@ type IngestData struct {
Size int64 `json:"size"` 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 { type Profile struct {
Name string `json:"name"` Name string `json:"name"`
Id int64 `json:"id"` Id int64 `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email"` Email string `json:"email"`
Points int64 `json:"points"` Points int `json:"points"`
Type string `json:"type"` Type string `json:"type"`
Premium int `json:"premium"` Premium int64 `json:"premium"`
Expiration time.Time `json:"expiration"` Expiration time.Time `json:"expiration"`
LibrarySize int `json:"library_size"`
BadTorrents int `json:"bad_torrents"`
ActiveLinks int `json:"active_links"`
} }
type DownloadLink struct { type DownloadLink struct {

View File

@@ -5,8 +5,10 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/pkg/arr" "github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/store" "github.com/sirrobot01/decypharr/pkg/store"
"golang.org/x/crypto/bcrypt"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -125,6 +127,7 @@ 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 // 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 { func (q *QBit) authContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
host, token, err := decodeAuthHeader(r.Header.Get("Authorization")) host, token, err := decodeAuthHeader(r.Header.Get("Authorization"))
category := getCategory(r.Context()) category := getCategory(r.Context())
arrs := store.Get().Arr() arrs := store.Get().Arr()
@@ -145,12 +148,22 @@ func (q *QBit) authContext(next http.Handler) http.Handler {
a.Token = token a.Token = token
} }
} }
a.Source = "auto" if cfg.NeedsAuth() {
if err := validateServiceURL(a.Host); err != nil { if a.Host == "" || a.Token == "" {
// Return silently, no need to raise a problem. Just do not add the Arr to the context/config.json http.Error(w, "Unauthorized: Host and token are required for authentication", http.StatusUnauthorized)
next.ServeHTTP(w, r) return
return }
// try to use either Arr validate, or user auth validation
if err := a.Validate(); err != nil {
// If this failed, try to use user auth validation
if !verifyAuth(host, token) {
http.Error(w, "Unauthorized: Invalid host or token", http.StatusUnauthorized)
return
}
}
} }
a.Source = "auto"
arrs.AddOrUpdate(a) arrs.AddOrUpdate(a)
ctx := context.WithValue(r.Context(), arrKey, a) ctx := context.WithValue(r.Context(), arrKey, a)
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
@@ -176,3 +189,19 @@ func hashesContext(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(ctx)) next.ServeHTTP(w, r.WithContext(ctx))
}) })
} }
func verifyAuth(username, password string) bool {
// If you're storing hashed password, use bcrypt to compare
if username == "" {
return false
}
auth := config.Get().GetAuth()
if auth == nil {
return false
}
if username != auth.Username {
return false
}
err := bcrypt.CompareHashAndPassword([]byte(auth.Password), []byte(password))
return err == nil
}

View File

@@ -19,6 +19,7 @@ func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
if err := _arr.Validate(); err != nil { if err := _arr.Validate(); err != nil {
q.logger.Error().Err(err).Msgf("Error validating arr") q.logger.Error().Err(err).Msgf("Error validating arr")
http.Error(w, "Invalid arr configuration", http.StatusBadRequest) http.Error(w, "Invalid arr configuration", http.StatusBadRequest)
return
} }
_, _ = w.Write([]byte("Ok.")) _, _ = w.Write([]byte("Ok."))
} }

403
pkg/rclone/client.go Normal file
View File

@@ -0,0 +1,403 @@
package rclone
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"time"
"github.com/sirrobot01/decypharr/internal/config"
)
// Mount creates a mount using the rclone RC API with retry logic
func (m *Manager) Mount(provider, webdavURL string) error {
return m.mountWithRetry(provider, webdavURL, 3)
}
// mountWithRetry attempts to mount with retry logic
func (m *Manager) mountWithRetry(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(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(provider, webdavURL string) error {
cfg := config.Get()
mountPath := filepath.Join(cfg.Rclone.MountPath, provider)
// 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),
}
configOpts := make(map[string]interface{})
if cfg.Rclone.BufferSize != "" {
configOpts["BufferSize"] = cfg.Rclone.BufferSize
}
if len(configOpts) > 0 {
// Only add _config if there are options to set
mountArgs["_config"] = configOpts
}
vfsOpt := map[string]interface{}{
"CacheMode": cfg.Rclone.VfsCacheMode,
}
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.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.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 {
mountOpt["UID"] = cfg.Rclone.UID
}
if cfg.Rclone.GID != 0 {
mountOpt["GID"] = cfg.Rclone.GID
}
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
View 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(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
View 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
}

View 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
}

402
pkg/rclone/manager.go Normal file
View File

@@ -0,0 +1,402 @@
package rclone
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"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: 30 * 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")
}
case <-time.After(10 * time.Second):
m.logger.Warn().Msg("Timeout waiting for rclone to exit, force killing")
if err := m.cmd.Process.Kill(); err != nil {
m.logger.Error().Err(err).Msg("Failed to force kill rclone process")
return err
}
// Wait a bit more for the kill to take effect
select {
case <-done:
m.logger.Info().Msg("Rclone process killed successfully")
case <-time.After(5 * time.Second):
m.logger.Error().Msg("Process may still be running after kill")
}
}
}
// Clean up any remaining mount directories
cfg := config.Get()
if cfg.Rclone.MountPath != "" {
m.cleanupMountDirectories(cfg.Rclone.MountPath)
}
m.serverStarted = false
m.logger.Info().Msg("Rclone RC server stopped")
return nil
}
// cleanupMountDirectories removes empty mount directories
func (m *Manager) cleanupMountDirectories(_ string) {
m.mountsMutex.RLock()
defer m.mountsMutex.RUnlock()
for _, mount := range m.mounts {
if mount.LocalPath != "" {
// Try to remove the directory if it's empty
if err := os.Remove(mount.LocalPath); err == nil {
m.logger.Debug().Str("path", mount.LocalPath).Msg("Removed empty mount directory")
}
// Don't log errors here as the directory might not be empty, which is fine
}
}
}
// 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 resp.Body.Close()
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
}

130
pkg/rclone/mount.go Normal file
View File

@@ -0,0 +1,130 @@
package rclone
import (
"context"
"fmt"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/config"
"net/url"
"path/filepath"
"strings"
)
// 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.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
View 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
}

View File

@@ -534,17 +534,21 @@ func (r *Repair) checkMountUp(media []arr.Content) error {
if len(files) == 0 { if len(files) == 0 {
return fmt.Errorf("no files found in media %s", firstMedia.Title) return fmt.Errorf("no files found in media %s", firstMedia.Title)
} }
firstFile := files[0] for _, file := range files {
symlinkPath := getSymlinkTarget(firstFile.Path) if _, err := os.Stat(file.Path); os.IsNotExist(err) {
// If the file does not exist, we can't check the symlink target
if symlinkPath == "" { r.logger.Debug().Msgf("File %s does not exist, skipping repair", file.Path)
return fmt.Errorf("no symlink target found for %s", firstFile.Path) return fmt.Errorf("file %s does not exist, skipping repair", file.Path)
} }
r.logger.Debug().Msgf("Checking symlink parent directory for %s", symlinkPath) // Get the symlink target
symlinkPath := getSymlinkTarget(file.Path)
parentSymlink := filepath.Dir(filepath.Dir(symlinkPath)) // /mnt/zurg/torrents/movie/movie.mkv -> /mnt/zurg/torrents if symlinkPath != "" {
if _, err := os.Stat(parentSymlink); os.IsNotExist(err) { r.logger.Trace().Msgf("Found symlink target for %s: %s", file.Path, symlinkPath)
return fmt.Errorf("parent directory %s not accessible for %s", parentSymlink, firstFile.Path) if _, err := os.Stat(symlinkPath); os.IsNotExist(err) {
r.logger.Debug().Msgf("Symlink target %s does not exist, skipping repair", symlinkPath)
return fmt.Errorf("symlink target %s does not exist for %s. skipping repair", symlinkPath, file.Path)
}
}
} }
return nil return nil
} }

View File

@@ -99,24 +99,77 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
} }
clients := debrids.Clients() clients := debrids.Clients()
caches := debrids.Caches() caches := debrids.Caches()
profiles := make([]*debridTypes.Profile, 0) debridStats := make([]debridTypes.Stats, 0)
for debridName, client := range clients { for debridName, client := range clients {
debridStat := debridTypes.Stats{}
libraryStat := debridTypes.LibraryStats{}
profile, err := client.GetProfile() profile, err := client.GetProfile()
profile.Name = debridName
if err != nil { if err != nil {
s.logger.Error().Err(err).Msg("Failed to get debrid profile") s.logger.Error().Err(err).Str("debrid", debridName).Msg("Failed to get debrid profile")
continue profile = &debridTypes.Profile{
Name: debridName,
}
} }
profile.Name = debridName
debridStat.Profile = profile
cache, ok := caches[debridName] cache, ok := caches[debridName]
if ok { if ok {
// Get torrent data // Get torrent data
profile.LibrarySize = cache.TotalTorrents() libraryStat.Total = cache.TotalTorrents()
profile.BadTorrents = len(cache.GetListing("__bad__")) libraryStat.Bad = len(cache.GetListing("__bad__"))
profile.ActiveLinks = cache.GetTotalActiveDownloadLinks() libraryStat.ActiveLinks = cache.GetTotalActiveDownloadLinks()
} }
profiles = append(profiles, profile) debridStat.Library = libraryStat
// Get detailed account information
accounts := client.Accounts().All()
accountDetails := make([]map[string]any, 0)
for _, account := range accounts {
// Mask token - show first 8 characters and last 4 characters
maskedToken := ""
if len(account.Token) > 12 {
maskedToken = account.Token[:8] + "****" + account.Token[len(account.Token)-4:]
} else if len(account.Token) > 8 {
maskedToken = account.Token[:4] + "****" + account.Token[len(account.Token)-2:]
} else {
maskedToken = "****"
}
accountDetail := map[string]any{
"order": account.Order,
"disabled": account.Disabled,
"token_masked": maskedToken,
"username": account.Username,
"traffic_used": account.TrafficUsed,
"links_count": account.LinksCount(),
"debrid": account.Debrid,
}
accountDetails = append(accountDetails, accountDetail)
}
debridStat.Accounts = accountDetails
debridStats = append(debridStats, debridStat)
} }
stats["debrids"] = profiles stats["debrids"] = debridStats
// Add rclone stats if available
if rcManager := store.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) request.JSONResponse(w, stats, http.StatusOK)
} }

View File

@@ -11,8 +11,8 @@ import (
"github.com/sirrobot01/decypharr/internal/logger" "github.com/sirrobot01/decypharr/internal/logger"
"io" "io"
"net/http" "net/http"
"net/url"
"os" "os"
"path/filepath"
) )
type Server struct { type Server struct {
@@ -30,10 +30,6 @@ func New(handlers map[string]http.Handler) *Server {
s := &Server{ s := &Server{
logger: l, logger: l,
} }
staticPath, _ := url.JoinPath(cfg.URLBase, "static")
r.Handle(staticPath+"/*",
http.StripPrefix(staticPath, http.FileServer(http.Dir("static"))),
)
r.Route(cfg.URLBase, func(r chi.Router) { r.Route(cfg.URLBase, func(r chi.Router) {
for pattern, handler := range handlers { for pattern, handler := range handlers {
@@ -41,11 +37,12 @@ func New(handlers map[string]http.Handler) *Server {
} }
//logs //logs
r.Get("/logs", s.getLogs) r.Get("/logs", s.getLogs) // deprecated, use /debug/logs
//debugs
r.Route("/debug", func(r chi.Router) { r.Route("/debug", func(r chi.Router) {
r.Get("/stats", s.handleStats) r.Get("/stats", s.handleStats)
r.Get("/logs", s.getLogs)
r.Get("/logs/rclone", s.getRcloneLogs)
r.Get("/ingests", s.handleIngests) r.Get("/ingests", s.handleIngests)
r.Get("/ingests/{debrid}", s.handleIngestsByDebrid) r.Get("/ingests/{debrid}", s.handleIngestsByDebrid)
}) })
@@ -80,7 +77,7 @@ func (s *Server) Start(ctx context.Context) error {
} }
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) { 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 // Open and read the file
file, err := os.Open(logFile) file, err := os.Open(logFile)
@@ -103,5 +100,42 @@ func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Expires", "0") w.Header().Set("Expires", "0")
// Stream the file // 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
}
} }

View File

@@ -2,13 +2,14 @@ package store
import ( import (
"fmt" "fmt"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"sync" "sync"
"time" "time"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"github.com/cavaliergopher/grab/v3" "github.com/cavaliergopher/grab/v3"
"github.com/sirrobot01/decypharr/internal/utils" "github.com/sirrobot01/decypharr/internal/utils"
) )
@@ -152,7 +153,7 @@ func (s *Store) downloadFiles(torrent *Torrent, debridTorrent *types.Torrent, pa
func (s *Store) processSymlink(torrent *Torrent, debridTorrent *types.Torrent) (string, error) { func (s *Store) processSymlink(torrent *Torrent, debridTorrent *types.Torrent) (string, error) {
files := debridTorrent.Files files := debridTorrent.Files
if len(files) == 0 { if len(files) == 0 {
return "", fmt.Errorf("no video files found") return "", fmt.Errorf("no valid files found")
} }
s.logger.Info().Msgf("Checking symlinks for %d files...", len(files)) s.logger.Info().Msgf("Checking symlinks for %d files...", len(files))
rCloneBase := debridTorrent.MountPath rCloneBase := debridTorrent.MountPath
@@ -212,7 +213,7 @@ func (s *Store) processSymlink(torrent *Torrent, debridTorrent *types.Torrent) (
if _, err := os.Stat(fullFilePath); !os.IsNotExist(err) { if _, err := os.Stat(fullFilePath); !os.IsNotExist(err) {
fileSymlinkPath := filepath.Join(torrentSymlinkPath, file.Name) fileSymlinkPath := filepath.Join(torrentSymlinkPath, file.Name)
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) { if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
s.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err) s.logger.Warn().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
} else { } else {
filePaths = append(filePaths, fileSymlinkPath) filePaths = append(filePaths, fileSymlinkPath)
delete(pending, path) delete(pending, path)

View File

@@ -3,6 +3,8 @@ package store
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/go-co-op/gocron/v2"
"github.com/sirrobot01/decypharr/internal/utils"
"time" "time"
) )
@@ -25,58 +27,51 @@ func (s *Store) addToQueue(importReq *ImportRequest) error {
return nil return nil
} }
func (s *Store) StartQueueSchedule(ctx context.Context) error { func (s *Store) StartQueueWorkers(ctx context.Context) error {
// Start the slots processing in a separate goroutine // This function is responsible for starting the scheduled tasks
go func() {
if err := s.processSlotsQueue(ctx); err != nil {
s.logger.Error().Err(err).Msg("Error processing slots queue")
}
}()
// Start the remove stalled torrents processing in a separate goroutine if ctx == nil {
go func() { ctx = context.Background()
if err := s.processRemoveStalledTorrents(ctx); err != nil { }
s.logger.Error().Err(err).Msg("Error processing remove stalled torrents")
}
}()
return nil s.scheduler.RemoveByTags("decypharr-store")
}
func (s *Store) processSlotsQueue(ctx context.Context) error { if jd, err := utils.ConvertToJobDef("30s"); err != nil {
s.trackAvailableSlots(ctx) // Initial tracking of available slots s.logger.Error().Err(err).Msg("Failed to convert slots tracking interval to job definition")
} else {
ticker := time.NewTicker(30 * time.Second) // Schedule the job
defer ticker.Stop() if _, err := s.scheduler.NewJob(jd, gocron.NewTask(func() {
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
s.trackAvailableSlots(ctx) s.trackAvailableSlots(ctx)
}), gocron.WithContext(ctx)); err != nil {
s.logger.Error().Err(err).Msg("Failed to create slots tracking job")
} else {
s.logger.Trace().Msgf("Download link refresh job scheduled for every %s", "30s")
} }
} }
}
func (s *Store) processRemoveStalledTorrents(ctx context.Context) error { if s.removeStalledAfter > 0 {
if s.removeStalledAfter <= 0 { // Stalled torrents removal job
return nil // No need to remove stalled torrents if the duration is not set if jd, err := utils.ConvertToJobDef("1m"); err != nil {
} s.logger.Error().Err(err).Msg("Failed to convert remove stalled torrents interval to job definition")
} else {
ticker := time.NewTicker(time.Minute) // Schedule the job
defer ticker.Stop() if _, err := s.scheduler.NewJob(jd, gocron.NewTask(func() {
err := s.removeStalledTorrents(ctx)
for { if err != nil {
select { s.logger.Error().Err(err).Msg("Failed to process remove stalled torrents")
case <-ctx.Done(): }
return nil }), gocron.WithContext(ctx)); err != nil {
case <-ticker.C: s.logger.Error().Err(err).Msg("Failed to create remove stalled torrents job")
if err := s.removeStalledTorrents(ctx); err != nil { } else {
s.logger.Error().Err(err).Msg("Error removing stalled torrents") s.logger.Trace().Msgf("Remove stalled torrents job scheduled for every %s", "1m")
} }
} }
} }
// Start the scheduler
s.scheduler.Start()
s.logger.Debug().Msg("Store worker started")
return nil
} }
func (s *Store) trackAvailableSlots(ctx context.Context) { func (s *Store) trackAvailableSlots(ctx context.Context) {

View File

@@ -3,11 +3,13 @@ package store
import ( import (
"cmp" "cmp"
"context" "context"
"github.com/go-co-op/gocron/v2"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/config" "github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/internal/logger" "github.com/sirrobot01/decypharr/internal/logger"
"github.com/sirrobot01/decypharr/pkg/arr" "github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/debrid" "github.com/sirrobot01/decypharr/pkg/debrid"
"github.com/sirrobot01/decypharr/pkg/rclone"
"github.com/sirrobot01/decypharr/pkg/repair" "github.com/sirrobot01/decypharr/pkg/repair"
"sync" "sync"
"time" "time"
@@ -17,6 +19,7 @@ type Store struct {
repair *repair.Repair repair *repair.Repair
arr *arr.Storage arr *arr.Storage
debrid *debrid.Storage debrid *debrid.Storage
rcloneManager *rclone.Manager
importsQueue *ImportQueue // Queued import requests(probably from too_many_active_downloads) importsQueue *ImportQueue // Queued import requests(probably from too_many_active_downloads)
torrents *TorrentStorage torrents *TorrentStorage
logger zerolog.Logger logger zerolog.Logger
@@ -24,6 +27,7 @@ type Store struct {
skipPreCache bool skipPreCache bool
downloadSemaphore chan struct{} downloadSemaphore chan struct{}
removeStalledAfter time.Duration // Duration after which stalled torrents are removed removeStalledAfter time.Duration // Duration after which stalled torrents are removed
scheduler gocron.Scheduler
} }
var ( var (
@@ -34,21 +38,36 @@ var (
// Get returns the singleton instance // Get returns the singleton instance
func Get() *Store { func Get() *Store {
once.Do(func() { once.Do(func() {
arrs := arr.NewStorage()
deb := debrid.NewStorage()
cfg := config.Get() cfg := config.Get()
qbitCfg := cfg.QBitTorrent qbitCfg := cfg.QBitTorrent
// Create rclone manager if enabled
var rcManager *rclone.Manager
if cfg.Rclone.Enabled {
rcManager = rclone.NewManager()
}
// Create services with dependencies
arrs := arr.NewStorage()
deb := debrid.NewStorage(rcManager)
scheduler, err := gocron.NewScheduler(gocron.WithLocation(time.Local), gocron.WithGlobalJobOptions(gocron.WithTags("decypharr-store")))
if err != nil {
scheduler, _ = gocron.NewScheduler(gocron.WithGlobalJobOptions(gocron.WithTags("decypharr-store")))
}
instance = &Store{ instance = &Store{
repair: repair.New(arrs, deb), repair: repair.New(arrs, deb),
arr: arrs, arr: arrs,
debrid: deb, debrid: deb,
rcloneManager: rcManager,
torrents: newTorrentStorage(cfg.TorrentsFile()), torrents: newTorrentStorage(cfg.TorrentsFile()),
logger: logger.Default(), // Use default logger [decypharr] logger: logger.Default(), // Use default logger [decypharr]
refreshInterval: time.Duration(cmp.Or(qbitCfg.RefreshInterval, 10)) * time.Minute, refreshInterval: time.Duration(cmp.Or(qbitCfg.RefreshInterval, 30)) * time.Second,
skipPreCache: qbitCfg.SkipPreCache, skipPreCache: qbitCfg.SkipPreCache,
downloadSemaphore: make(chan struct{}, cmp.Or(qbitCfg.MaxDownloads, 5)), downloadSemaphore: make(chan struct{}, cmp.Or(qbitCfg.MaxDownloads, 5)),
importsQueue: NewImportQueue(context.Background(), 1000), importsQueue: NewImportQueue(context.Background(), 1000),
scheduler: scheduler,
} }
if cfg.RemoveStalledAfter != "" { if cfg.RemoveStalledAfter != "" {
removeStalledAfter, err := time.ParseDuration(cfg.RemoveStalledAfter) removeStalledAfter, err := time.ParseDuration(cfg.RemoveStalledAfter)
@@ -66,11 +85,22 @@ func Reset() {
instance.debrid.Reset() instance.debrid.Reset()
} }
if instance.rcloneManager != nil {
instance.rcloneManager.Stop()
}
if instance.importsQueue != nil { if instance.importsQueue != nil {
instance.importsQueue.Close() instance.importsQueue.Close()
} }
if instance.downloadSemaphore != nil {
// Close the semaphore channel to
close(instance.downloadSemaphore)
}
close(instance.downloadSemaphore) if instance.scheduler != nil {
_ = instance.scheduler.StopJobs()
_ = instance.scheduler.Shutdown()
}
} }
once = sync.Once{} once = sync.Once{}
instance = nil instance = nil
@@ -88,3 +118,10 @@ func (s *Store) Repair() *repair.Repair {
func (s *Store) Torrents() *TorrentStorage { func (s *Store) Torrents() *TorrentStorage {
return s.torrents return s.torrents
} }
func (s *Store) RcloneManager() *rclone.Manager {
return s.rcloneManager
}
func (s *Store) Scheduler() gocron.Scheduler {
return s.scheduler
}

View File

@@ -5,14 +5,15 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"github.com/sirrobot01/decypharr/internal/request"
"github.com/sirrobot01/decypharr/internal/utils"
debridTypes "github.com/sirrobot01/decypharr/pkg/debrid"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
"math" "math"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/sirrobot01/decypharr/internal/request"
"github.com/sirrobot01/decypharr/internal/utils"
debridTypes "github.com/sirrobot01/decypharr/pkg/debrid"
"github.com/sirrobot01/decypharr/pkg/debrid/types"
) )
func (s *Store) AddTorrent(ctx context.Context, importReq *ImportRequest) error { func (s *Store) AddTorrent(ctx context.Context, importReq *ImportRequest) error {
@@ -64,6 +65,11 @@ func (s *Store) processFiles(torrent *Torrent, debridTorrent *types.Torrent, imp
s.logger.Debug().Msgf("%s <- (%s) Download Progress: %.2f%%", debridTorrent.Debrid, debridTorrent.Name, debridTorrent.Progress) s.logger.Debug().Msgf("%s <- (%s) Download Progress: %.2f%%", debridTorrent.Debrid, debridTorrent.Name, debridTorrent.Progress)
dbT, err := client.CheckStatus(debridTorrent) dbT, err := client.CheckStatus(debridTorrent)
if err != nil { if err != nil {
s.logger.Error().
Str("torrent_id", debridTorrent.Id).
Str("torrent_name", debridTorrent.Name).
Err(err).
Msg("Error checking torrent status")
if dbT != nil && dbT.Id != "" { if dbT != nil && dbT.Id != "" {
// Delete the torrent if it was not downloaded // Delete the torrent if it was not downloaded
go func() { go func() {

44
pkg/store/worker.go Normal file
View File

@@ -0,0 +1,44 @@
package store
import "context"
func (s *Store) StartWorkers(ctx context.Context) {
if ctx == nil {
ctx = context.Background()
}
// Start debrid workers
if err := s.Debrid().StartWorker(ctx); err != nil {
s.logger.Error().Err(err).Msg("Failed to start debrid worker")
} else {
s.logger.Debug().Msg("Started debrid worker")
}
// Cache workers
for _, cache := range s.Debrid().Caches() {
if cache == nil {
continue
}
go func() {
if err := cache.StartWorker(ctx); err != nil {
s.logger.Error().Err(err).Msg("Failed to start debrid cache worker")
} else {
s.logger.Debug().Msgf("Started debrid cache worker for %s", cache.GetConfig().Name)
}
}()
}
// Store queue workers
if err := s.StartQueueWorkers(ctx); err != nil {
s.logger.Error().Err(err).Msg("Failed to start store worker")
} else {
s.logger.Debug().Msg("Started store worker")
}
// Arr workers
if err := s.Arr().StartWorker(ctx); err != nil {
s.logger.Error().Err(err).Msg("Failed to start Arr worker")
} else {
s.logger.Debug().Msg("Started Arr worker")
}
}

View File

@@ -3,6 +3,7 @@ package web
import ( import (
"fmt" "fmt"
"github.com/sirrobot01/decypharr/pkg/store" "github.com/sirrobot01/decypharr/pkg/store"
"golang.org/x/crypto/bcrypt"
"net/http" "net/http"
"strings" "strings"
"time" "time"
@@ -214,7 +215,26 @@ func (wb *Web) handleGetConfig(w http.ResponseWriter, r *http.Request) {
for _, a := range unique { for _, a := range unique {
cfg.Arrs = append(cfg.Arrs, a) cfg.Arrs = append(cfg.Arrs, a)
} }
request.JSONResponse(w, cfg, http.StatusOK)
// Create response with API token info
type ConfigResponse struct {
*config.Config
APIToken string `json:"api_token,omitempty"`
AuthUsername string `json:"auth_username,omitempty"`
}
response := &ConfigResponse{Config: cfg}
// Add API token and auth information
auth := cfg.GetAuth()
if auth != nil {
if auth.APIToken != "" {
response.APIToken = auth.APIToken
}
response.AuthUsername = auth.Username
}
request.JSONResponse(w, response, http.StatusOK)
} }
func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) { func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
@@ -247,6 +267,7 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
// Update Repair config // Update Repair config
currentConfig.Repair = updatedConfig.Repair currentConfig.Repair = updatedConfig.Repair
currentConfig.Rclone = updatedConfig.Rclone
// Update Debrids // Update Debrids
if len(updatedConfig.Debrids) > 0 { if len(updatedConfig.Debrids) > 0 {
@@ -359,3 +380,103 @@ func (wb *Web) handleStopRepairJob(w http.ResponseWriter, r *http.Request) {
} }
w.WriteHeader(http.StatusOK) 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)
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

2078
pkg/web/assets/css/bootstrap-icons.css vendored Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 665 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

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