Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2548c21e5b | ||
|
|
1b03ccefbb | ||
|
|
e3a249a9cc | ||
|
|
8696db42d2 | ||
|
|
742d8fb088 | ||
|
|
a0e9f7f553 | ||
|
|
4be4f6b293 | ||
|
|
6c8949b831 | ||
|
|
0dd1efb07c | ||
|
|
3aeb806033 | ||
|
|
7c8156eacf | ||
|
|
d8a963f77f | ||
|
|
27e7bc8f47 | ||
|
|
1d243dd12b | ||
|
|
b4efa22bfd | ||
|
|
6f9fafd7d8 | ||
|
|
eba24c9d63 | ||
|
|
c620ba3d56 | ||
|
|
fab3a7e4f7 | ||
|
|
01615cb51e | ||
|
|
cb63fc69f5 | ||
|
|
40755fbdde | ||
|
|
d0ae839617 | ||
|
|
ce972779c3 | ||
|
|
139249a1f3 | ||
|
|
a60d93677f | ||
|
|
9c31ad266e | ||
|
|
3d2fcf5656 | ||
|
|
afe577bf2f | ||
|
|
604402250e | ||
|
|
74615a80ff | ||
|
|
b901bd5175 | ||
|
|
8c56e59107 | ||
|
|
b8b9e76753 | ||
|
|
6fb54d322e | ||
|
|
cf61546bec | ||
|
|
c72867ff57 |
10
.air.toml
@@ -7,16 +7,16 @@ tmp_dir = "tmp"
|
||||
bin = "./tmp/main"
|
||||
cmd = "bash -c 'go build -ldflags \"-X github.com/sirrobot01/decypharr/pkg/version.Version=0.0.0 -X github.com/sirrobot01/decypharr/pkg/version.Channel=dev\" -o ./tmp/main .'"
|
||||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "data"]
|
||||
exclude_dir = ["tmp", "vendor", "testdata", "data", "logs", "docs", "dist", "node_modules", ".ven"]
|
||||
exclude_file = []
|
||||
exclude_regex = ["_test.go"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
include_dir = []
|
||||
include_ext = ["go", "tpl", "tmpl", "html", ".json"]
|
||||
include_ext = ["go", "tpl", "tmpl", "html", ".json", ".js", ".css"]
|
||||
include_file = []
|
||||
kill_delay = "0s"
|
||||
kill_delay = "1s"
|
||||
log = "build-errors.log"
|
||||
poll = false
|
||||
poll_interval = 0
|
||||
@@ -24,8 +24,8 @@ tmp_dir = "tmp"
|
||||
pre_cmd = []
|
||||
rerun = false
|
||||
rerun_delay = 500
|
||||
send_interrupt = false
|
||||
stop_on_error = false
|
||||
send_interrupt = true
|
||||
stop_on_error = true
|
||||
|
||||
[color]
|
||||
app = ""
|
||||
|
||||
@@ -11,3 +11,23 @@ torrents.json
|
||||
*.json
|
||||
.ven/**
|
||||
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/**
|
||||
2
.github/workflows/deploy-docs.yml
vendored
@@ -24,5 +24,5 @@ jobs:
|
||||
path: .cache
|
||||
restore-keys: |
|
||||
mkdocs-material-
|
||||
- run: pip install mkdocs-material
|
||||
- run: cd docs && pip install -r requirements.txt
|
||||
- run: cd docs && mkdocs gh-deploy --force
|
||||
7
.gitignore
vendored
@@ -12,4 +12,9 @@ tmp/**
|
||||
torrents.json
|
||||
logs/**
|
||||
auth.json
|
||||
.ven/
|
||||
.ven/
|
||||
.env
|
||||
node_modules/
|
||||
.venv/
|
||||
.stignore
|
||||
.stfolder/**
|
||||
51
Dockerfile
@@ -29,40 +29,49 @@ RUN --mount=type=cache,target=/go/pkg/mod \
|
||||
go build -trimpath -ldflags="-w -s" \
|
||||
-o /healthcheck cmd/healthcheck/main.go
|
||||
|
||||
# Stage 2: Create directory structure
|
||||
FROM alpine:3.19 as dirsetup
|
||||
RUN mkdir -p /app/logs && \
|
||||
mkdir -p /app/cache && \
|
||||
chmod 777 /app/logs && \
|
||||
touch /app/logs/decypharr.log && \
|
||||
chmod 666 /app/logs/decypharr.log
|
||||
# Stage 2: Final image
|
||||
FROM alpine:latest
|
||||
|
||||
# Stage 3: Final image
|
||||
FROM gcr.io/distroless/static-debian12:nonroot
|
||||
ARG VERSION=0.0.0
|
||||
ARG CHANNEL=dev
|
||||
|
||||
LABEL version = "${VERSION}-${CHANNEL}"
|
||||
|
||||
LABEL org.opencontainers.image.source = "https://github.com/sirrobot01/decypharr"
|
||||
LABEL org.opencontainers.image.title = "decypharr"
|
||||
LABEL org.opencontainers.image.authors = "sirrobot01"
|
||||
LABEL org.opencontainers.image.documentation = "https://github.com/sirrobot01/decypharr/blob/main/README.md"
|
||||
|
||||
# Copy binaries
|
||||
COPY --from=builder --chown=nonroot:nonroot /decypharr /usr/bin/decypharr
|
||||
COPY --from=builder --chown=nonroot:nonroot /healthcheck /usr/bin/healthcheck
|
||||
# Install dependencies including rclone
|
||||
RUN apk add --no-cache fuse3 ca-certificates su-exec shadow curl unzip && \
|
||||
echo "user_allow_other" >> /etc/fuse.conf && \
|
||||
case "$(uname -m)" in \
|
||||
x86_64) ARCH=amd64 ;; \
|
||||
aarch64) ARCH=arm64 ;; \
|
||||
armv7l) ARCH=arm ;; \
|
||||
*) echo "Unsupported architecture: $(uname -m)" && exit 1 ;; \
|
||||
esac && \
|
||||
curl -O "https://downloads.rclone.org/rclone-current-linux-${ARCH}.zip" && \
|
||||
unzip "rclone-current-linux-${ARCH}.zip" && \
|
||||
cp rclone-*/rclone /usr/local/bin/ && \
|
||||
chmod +x /usr/local/bin/rclone && \
|
||||
rm -rf rclone-* && \
|
||||
apk del curl unzip
|
||||
|
||||
# Copy pre-made directory structure
|
||||
COPY --from=dirsetup --chown=nonroot:nonroot /app /app
|
||||
# Copy binaries and entrypoint
|
||||
COPY --from=builder /decypharr /usr/bin/decypharr
|
||||
COPY --from=builder /healthcheck /usr/bin/healthcheck
|
||||
COPY scripts/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod +x /entrypoint.sh
|
||||
|
||||
|
||||
# Metadata
|
||||
# Set environment variables
|
||||
ENV PUID=1000
|
||||
ENV PGID=1000
|
||||
ENV LOG_PATH=/app/logs
|
||||
|
||||
EXPOSE 8282
|
||||
VOLUME ["/app"]
|
||||
USER nonroot:nonroot
|
||||
|
||||
HEALTHCHECK --interval=10s --retries=10 CMD ["/usr/bin/healthcheck", "--config", "/app", "--basic"]
|
||||
|
||||
# Base healthcheck
|
||||
HEALTHCHECK --interval=3s --retries=10 CMD ["/usr/bin/healthcheck", "--config", "/app", "--basic"]
|
||||
|
||||
ENTRYPOINT ["/entrypoint.sh"]
|
||||
CMD ["/usr/bin/decypharr", "--config", "/app"]
|
||||
35
README.md
@@ -12,9 +12,9 @@ Decypharr combines the power of QBittorrent with popular Debrid services to enha
|
||||
|
||||
- Mock Qbittorent API that supports the Arrs (Sonarr, Radarr, Lidarr etc)
|
||||
- Full-fledged UI for managing torrents
|
||||
- Proxy support for filtering out un-cached Debrid torrents
|
||||
- Multiple Debrid providers support
|
||||
- WebDAV server support for each debrid provider
|
||||
- Optional mounting of WebDAV to your system(using [Rclone](https://rclone.org/))
|
||||
- Repair Worker for missing files
|
||||
|
||||
## Supported Debrid Providers
|
||||
@@ -29,17 +29,22 @@ Decypharr combines the power of QBittorrent with popular Debrid services to enha
|
||||
### Docker (Recommended)
|
||||
|
||||
```yaml
|
||||
version: '3.7'
|
||||
services:
|
||||
decypharr:
|
||||
image: cy01/blackhole:latest # or cy01/blackhole:beta
|
||||
image: cy01/blackhole:latest
|
||||
container_name: decypharr
|
||||
ports:
|
||||
- "8282:8282" # qBittorrent
|
||||
- "8282:8282"
|
||||
volumes:
|
||||
- /mnt/:/mnt
|
||||
- /mnt/:/mnt:rshared
|
||||
- ./configs/:/app # config.json must be in this directory
|
||||
restart: unless-stopped
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse:rwm
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
```
|
||||
|
||||
## Documentation
|
||||
@@ -57,25 +62,7 @@ The documentation includes:
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
```json
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "your_api_key_here",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"use_webdav": true
|
||||
}
|
||||
],
|
||||
"qbittorrent": {
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr"]
|
||||
},
|
||||
"use_auth": false,
|
||||
"log_level": "info",
|
||||
"port": "8282"
|
||||
}
|
||||
```
|
||||
You can configure Decypharr through the Web UI or by editing the `config.json` file directly.
|
||||
|
||||
## Contributing
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ func Start(ctx context.Context) error {
|
||||
svcCtx, cancelSvc := context.WithCancel(ctx)
|
||||
defer cancelSvc()
|
||||
|
||||
// Create the logger path if it doesn't exist
|
||||
for {
|
||||
cfg := config.Get()
|
||||
_log := logger.Default()
|
||||
@@ -73,6 +74,14 @@ func Start(ctx context.Context) error {
|
||||
}
|
||||
srv := server.New(handlers)
|
||||
|
||||
reset := func() {
|
||||
// Reset the store and services
|
||||
qb.Reset()
|
||||
store.Reset()
|
||||
// refresh GC
|
||||
runtime.GC()
|
||||
}
|
||||
|
||||
done := make(chan struct{})
|
||||
go func(ctx context.Context) {
|
||||
if err := startServices(ctx, cancelSvc, wd, srv); err != nil {
|
||||
@@ -87,22 +96,18 @@ func Start(ctx context.Context) error {
|
||||
// graceful shutdown
|
||||
cancelSvc() // propagate to services
|
||||
<-done // wait for them to finish
|
||||
_log.Info().Msg("Decypharr has been stopped gracefully.")
|
||||
reset() // reset store and services
|
||||
return nil
|
||||
|
||||
case <-restartCh:
|
||||
cancelSvc() // tell existing services to shut down
|
||||
_log.Info().Msg("Restarting Decypharr...")
|
||||
<-done // wait for them to finish
|
||||
qb.Reset()
|
||||
store.Reset()
|
||||
|
||||
_log.Info().Msg("Decypharr has been restarted.")
|
||||
reset() // reset store and services
|
||||
// rebuild svcCtx off the original parent
|
||||
svcCtx, cancelSvc = context.WithCancel(ctx)
|
||||
runtime.GC()
|
||||
|
||||
config.Reload()
|
||||
store.Reset()
|
||||
// loop will restart services automatically
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,12 +149,13 @@ func startServices(ctx context.Context, cancelSvc context.CancelFunc, wd *webdav
|
||||
return srv.Start(ctx)
|
||||
})
|
||||
|
||||
// Start rclone RC server if enabled
|
||||
safeGo(func() error {
|
||||
arr := store.Get().Arr()
|
||||
if arr == nil {
|
||||
rcManager := store.Get().RcloneManager()
|
||||
if rcManager == nil {
|
||||
return nil
|
||||
}
|
||||
return arr.StartSchedule(ctx)
|
||||
return rcManager.Start(ctx)
|
||||
})
|
||||
|
||||
if cfg := config.Get(); cfg.Repair.Enabled {
|
||||
@@ -165,7 +171,8 @@ func startServices(ctx context.Context, cancelSvc context.CancelFunc, wd *webdav
|
||||
}
|
||||
|
||||
safeGo(func() error {
|
||||
return store.Get().StartQueueSchedule(ctx)
|
||||
store.Get().StartWorkers(ctx)
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
|
||||
418
docs/docs/api-spec.yaml
Normal file
@@ -0,0 +1,418 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Decypharr API
|
||||
description: QbitTorrent with Debrid Support API
|
||||
version: 1.0.0
|
||||
contact:
|
||||
name: Decypharr
|
||||
url: https://github.com/sirrobot01/decypharr
|
||||
|
||||
servers:
|
||||
- url: /api
|
||||
description: API endpoints
|
||||
|
||||
security:
|
||||
- cookieAuth: []
|
||||
- bearerAuth: []
|
||||
|
||||
paths:
|
||||
/arrs:
|
||||
get:
|
||||
summary: Get all configured Arrs
|
||||
description: Retrieve a list of all configured Arr applications (Sonarr, Radarr, etc.)
|
||||
tags:
|
||||
- Arrs
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully retrieved Arrs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Arr'
|
||||
|
||||
/add:
|
||||
post:
|
||||
summary: Add content for processing
|
||||
description: Add torrent files or magnet links for processing through debrid services
|
||||
tags:
|
||||
- Content
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
arr:
|
||||
type: string
|
||||
description: Name of the Arr application
|
||||
action:
|
||||
type: string
|
||||
description: Action to perform
|
||||
debrid:
|
||||
type: string
|
||||
description: Debrid service to use
|
||||
callbackUrl:
|
||||
type: string
|
||||
description: Optional callback URL
|
||||
downloadFolder:
|
||||
type: string
|
||||
description: Download folder path
|
||||
downloadUncached:
|
||||
type: boolean
|
||||
description: Whether to download uncached content
|
||||
urls:
|
||||
type: string
|
||||
description: Newline-separated URLs or magnet links
|
||||
files:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
format: binary
|
||||
description: Torrent files to upload
|
||||
responses:
|
||||
'200':
|
||||
description: Content added successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
results:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/ImportRequest'
|
||||
errors:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
/repair:
|
||||
post:
|
||||
summary: Repair media
|
||||
description: Start a repair process for specified media items
|
||||
tags:
|
||||
- Repair
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RepairRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Repair started or completed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: string
|
||||
'400':
|
||||
description: Bad request
|
||||
'404':
|
||||
description: Arr not found
|
||||
'500':
|
||||
description: Internal server error
|
||||
|
||||
/repair/jobs:
|
||||
get:
|
||||
summary: Get repair jobs
|
||||
description: Retrieve all repair jobs
|
||||
tags:
|
||||
- Repair
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully retrieved repair jobs
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/RepairJob'
|
||||
delete:
|
||||
summary: Delete repair jobs
|
||||
description: Delete multiple repair jobs by IDs
|
||||
tags:
|
||||
- Repair
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
ids:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
required:
|
||||
- ids
|
||||
responses:
|
||||
'200':
|
||||
description: Jobs deleted successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
/repair/jobs/{id}/process:
|
||||
post:
|
||||
summary: Process repair job
|
||||
description: Process a specific repair job by ID
|
||||
tags:
|
||||
- Repair
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Job ID
|
||||
responses:
|
||||
'200':
|
||||
description: Job processed successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
/repair/jobs/{id}/stop:
|
||||
post:
|
||||
summary: Stop repair job
|
||||
description: Stop a running repair job by ID
|
||||
tags:
|
||||
- Repair
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Job ID
|
||||
responses:
|
||||
'200':
|
||||
description: Job stopped successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
'500':
|
||||
description: Internal server error
|
||||
|
||||
/torrents:
|
||||
get:
|
||||
summary: Get all torrents
|
||||
description: Retrieve all torrents sorted by added date
|
||||
tags:
|
||||
- Torrents
|
||||
responses:
|
||||
'200':
|
||||
description: Successfully retrieved torrents
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/Torrent'
|
||||
delete:
|
||||
summary: Delete multiple torrents
|
||||
description: Delete multiple torrents by hash list
|
||||
tags:
|
||||
- Torrents
|
||||
parameters:
|
||||
- name: hashes
|
||||
in: query
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Comma-separated list of torrent hashes
|
||||
- name: removeFromDebrid
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Whether to remove from debrid service
|
||||
responses:
|
||||
'200':
|
||||
description: Torrents deleted successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
/torrents/{category}/{hash}:
|
||||
delete:
|
||||
summary: Delete single torrent
|
||||
description: Delete a specific torrent by category and hash
|
||||
tags:
|
||||
- Torrents
|
||||
parameters:
|
||||
- name: category
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Torrent category
|
||||
- name: hash
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: Torrent hash
|
||||
- name: removeFromDebrid
|
||||
in: query
|
||||
schema:
|
||||
type: boolean
|
||||
default: false
|
||||
description: Whether to remove from debrid service
|
||||
responses:
|
||||
'200':
|
||||
description: Torrent deleted successfully
|
||||
'400':
|
||||
description: Bad request
|
||||
|
||||
components:
|
||||
securitySchemes:
|
||||
cookieAuth:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: auth-session
|
||||
bearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: token
|
||||
description: API token for authentication
|
||||
|
||||
schemas:
|
||||
Arr:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Name of the Arr application
|
||||
host:
|
||||
type: string
|
||||
description: Host URL of the Arr application
|
||||
token:
|
||||
type: string
|
||||
description: API token for the Arr application
|
||||
cleanup:
|
||||
type: boolean
|
||||
description: Whether to cleanup after processing
|
||||
skipRepair:
|
||||
type: boolean
|
||||
description: Whether to skip repair operations
|
||||
downloadUncached:
|
||||
type: boolean
|
||||
description: Whether to download uncached content
|
||||
selectedDebrid:
|
||||
type: string
|
||||
description: Selected debrid service
|
||||
source:
|
||||
type: string
|
||||
description: Source of the Arr configuration
|
||||
|
||||
ImportRequest:
|
||||
type: object
|
||||
properties:
|
||||
debridName:
|
||||
type: string
|
||||
description: Name of the debrid service
|
||||
downloadFolder:
|
||||
type: string
|
||||
description: Download folder path
|
||||
magnet:
|
||||
type: string
|
||||
description: Magnet link
|
||||
arr:
|
||||
$ref: '#/components/schemas/Arr'
|
||||
action:
|
||||
type: string
|
||||
description: Action to perform
|
||||
downloadUncached:
|
||||
type: boolean
|
||||
description: Whether to download uncached content
|
||||
callbackUrl:
|
||||
type: string
|
||||
description: Callback URL
|
||||
importType:
|
||||
type: string
|
||||
description: Type of import (API, etc.)
|
||||
|
||||
RepairRequest:
|
||||
type: object
|
||||
properties:
|
||||
arrName:
|
||||
type: string
|
||||
description: Name of the Arr application
|
||||
mediaIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: List of media IDs to repair
|
||||
autoProcess:
|
||||
type: boolean
|
||||
description: Whether to auto-process the repair
|
||||
async:
|
||||
type: boolean
|
||||
description: Whether to run repair asynchronously
|
||||
required:
|
||||
- arrName
|
||||
|
||||
RepairJob:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Job ID
|
||||
status:
|
||||
type: string
|
||||
description: Job status
|
||||
arrName:
|
||||
type: string
|
||||
description: Associated Arr application
|
||||
mediaIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: Media IDs being repaired
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Job creation timestamp
|
||||
|
||||
Torrent:
|
||||
type: object
|
||||
properties:
|
||||
hash:
|
||||
type: string
|
||||
description: Torrent hash
|
||||
name:
|
||||
type: string
|
||||
description: Torrent name
|
||||
category:
|
||||
type: string
|
||||
description: Torrent category
|
||||
addedOn:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Date when torrent was added
|
||||
size:
|
||||
type: integer
|
||||
description: Torrent size in bytes
|
||||
progress:
|
||||
type: number
|
||||
format: float
|
||||
description: Download progress (0-1)
|
||||
status:
|
||||
type: string
|
||||
description: Torrent status
|
||||
|
||||
|
||||
tags:
|
||||
- name: Arrs
|
||||
description: Arr application management
|
||||
- name: Content
|
||||
description: Content addition and processing
|
||||
- name: Repair
|
||||
description: Media repair operations
|
||||
- name: Torrents
|
||||
description: Torrent management
|
||||
- name: Configuration
|
||||
description: Application configuration
|
||||
- name: Authentication
|
||||
description: API token management
|
||||
90
docs/docs/api.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# API Documentation
|
||||
|
||||
Decypharr provides a RESTful API for managing torrents, debrid services, and Arr integrations. The API requires authentication and all endpoints are prefixed with `/api`.
|
||||
|
||||
## Authentication
|
||||
|
||||
The API supports two authentication methods:
|
||||
|
||||
### 1. Session-based Authentication (Cookies)
|
||||
Log in through the web interface (`/login`) to establish an authenticated session. The session cookie (`auth-session`) will be automatically included in subsequent API requests from the same browser session.
|
||||
|
||||
### 2. API Token Authentication (Bearer Token)
|
||||
Use API tokens for programmatic access. Include the token in the `Authorization` header for each request:
|
||||
|
||||
- `Authorization: Bearer <your-token>`
|
||||
|
||||
## Interactive API Documentation
|
||||
|
||||
<swagger-ui src="api-spec.yaml"/>
|
||||
|
||||
## API Endpoints Overview
|
||||
|
||||
### Arrs Management
|
||||
- `GET /api/arrs` - Get all configured Arr applications (Sonarr, Radarr, etc.)
|
||||
|
||||
### Content Management
|
||||
- `POST /api/add` - Add torrent files or magnet links for processing through debrid services
|
||||
|
||||
### Repair Operations
|
||||
- `POST /api/repair` - Start repair process for media items
|
||||
- `GET /api/repair/jobs` - Get all repair jobs
|
||||
- `POST /api/repair/jobs/{id}/process` - Process a specific repair job
|
||||
- `POST /api/repair/jobs/{id}/stop` - Stop a running repair job
|
||||
- `DELETE /api/repair/jobs` - Delete multiple repair jobs
|
||||
|
||||
### Torrent Management
|
||||
- `GET /api/torrents` - Get all torrents
|
||||
- `DELETE /api/torrents/{category}/{hash}` - Delete a specific torrent
|
||||
- `DELETE /api/torrents/` - Delete multiple torrents
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Adding Content via API
|
||||
|
||||
#### Using API Token:
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $API_TOKEN" -X POST http://localhost:8080/api/add \
|
||||
-F "arr=sonarr" \
|
||||
-F "debrid=realdebrid" \
|
||||
-F "urls=magnet:?xt=urn:btih:..." \
|
||||
-F "downloadUncached=true"
|
||||
-F "file=@/path/to/torrent/file.torrent"
|
||||
-F "callbackUrl=http://your.callback.url/endpoint"
|
||||
```
|
||||
|
||||
#### Using Session Cookies:
|
||||
```bash
|
||||
# Login first (this sets the session cookie)
|
||||
curl -c cookies.txt -X POST http://localhost:8080/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"username": "your_username", "password": "your_password"}'
|
||||
|
||||
# Then use the session cookie for API calls
|
||||
curl -b cookies.txt -X POST http://localhost:8080/api/add \
|
||||
-F "arr=sonarr" \
|
||||
-F "debrid=realdebrid" \
|
||||
-F "urls=magnet:?xt=urn:btih:..." \
|
||||
-F "downloadUncached=true"
|
||||
```
|
||||
|
||||
### Getting Torrents
|
||||
|
||||
```bash
|
||||
# With API token
|
||||
curl -H "Authorization: Bearer $API_TOKEN" -X GET http://localhost:8080/api/torrents
|
||||
```
|
||||
|
||||
### Starting a Repair Job
|
||||
|
||||
```bash
|
||||
# With API token
|
||||
curl -H "Authorization: Bearer $API_TOKEN" -X POST http://localhost:8080/api/repair \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"arrName": "sonarr",
|
||||
"mediaIds": ["123", "456"],
|
||||
"autoProcess": true,
|
||||
"async": true
|
||||
}'
|
||||
```
|
||||
@@ -1,186 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
- Add WebDAV support for debrid providers
|
||||
- Some refactoring and code cleanup
|
||||
- Fixes
|
||||
- Fix Alldebrid not downloading torrents
|
||||
- Fix Alldebrid not downloading uncached torrents
|
||||
- Fix uncached torrents not being downloaded for RealDebrid
|
||||
- Add support for multiple download API keys for debrid providers
|
||||
- Add support for editable config.json via the UI
|
||||
- Fix downloading timeout
|
||||
- Fix UMASK for Windows
|
||||
- Retries 50x(except 503) errors for RD
|
||||
|
||||
|
||||
## 0.5.0
|
||||
|
||||
- A more refined repair worker (with more control)
|
||||
- UI Improvements
|
||||
- Pagination for torrents
|
||||
- Dark mode
|
||||
- Ordered torrents table
|
||||
- Fix Arr API flaky behavior
|
||||
- Discord Notifications
|
||||
- Minor bug fixes
|
||||
- Add Tautulli support
|
||||
- playback_failed event triggers a repair
|
||||
- Miscellaneous improvements
|
||||
- Add an option to skip the repair worker for a specific arr
|
||||
- Arr specific uncached downloading option
|
||||
- Option to download uncached torrents from UI
|
||||
- Remove QbitTorrent Log level (Use the global log level)
|
||||
|
||||
## 0.4.2
|
||||
|
||||
- Hotfixes
|
||||
- Fix saving torrents error
|
||||
- Fix bugs with the UI
|
||||
- Speed improvements
|
||||
|
||||
## 0.4.1
|
||||
|
||||
- Adds optional UI authentication
|
||||
- Downloaded Torrents persist on restart
|
||||
- Fixes
|
||||
- Fix Alldebrid struggling to find the correct file
|
||||
- Minor bug fixes or speed-gains
|
||||
- A new cleanup worker to clean up ARR queues
|
||||
|
||||
## 0.4.0
|
||||
|
||||
- Add support for multiple debrid providers
|
||||
- A full-fledged UI for adding torrents, repairing files, viewing config and managing torrents
|
||||
- Fix issues with Alldebrid
|
||||
- Fix file transversal bug
|
||||
- Fix files with no parent directory
|
||||
- Logging
|
||||
- Add a more robust logging system
|
||||
- Add logging to a file
|
||||
- Add logging to the UI
|
||||
- Qbittorrent
|
||||
- Add support for tags (creating, deleting, listing)
|
||||
- Add support for categories (creating, deleting, listing)
|
||||
- Fix issues with arr sending torrents using a different content type
|
||||
|
||||
## 0.3.3
|
||||
|
||||
- Add AllDebrid Support
|
||||
- Fix Torbox not downloading uncached torrents
|
||||
- Fix Rar files being downloaded
|
||||
|
||||
## 0.3.2
|
||||
|
||||
- Fix DebridLink not downloading
|
||||
- Fix Torbox with uncached torrents
|
||||
- Add new /internal/cached endpoint to check if an hash is cached
|
||||
- Implement per-debrid local cache
|
||||
- Fix file check for torbox
|
||||
- Other minor bug fixes
|
||||
|
||||
## 0.3.1
|
||||
|
||||
- Add DebridLink Support
|
||||
- Refactor error handling
|
||||
|
||||
## 0.3.0
|
||||
|
||||
- Add UI for adding torrents
|
||||
- Refraction of the code
|
||||
- Fix Torbox bug
|
||||
- Update CI/CD
|
||||
- Update Readme
|
||||
|
||||
## 0.2.7
|
||||
|
||||
- Add support for multiple debrid providers
|
||||
- Add Torbox support
|
||||
- Add support for configurable debrid cache checks
|
||||
- Add support for configurable debrid download uncached torrents
|
||||
|
||||
## 0.2.6
|
||||
|
||||
- Delete torrent for empty matched files
|
||||
- Update Readme
|
||||
|
||||
## 0.2.5
|
||||
|
||||
- Fix ContentPath not being set prior
|
||||
- Rewrote Readme
|
||||
- Cleaned up the code
|
||||
|
||||
## 0.2.4
|
||||
|
||||
- Add file download support (Sequential Download)
|
||||
- Fix http handler error
|
||||
- Fix *arrs map failing concurrently
|
||||
- Fix cache not being updated
|
||||
|
||||
## 0.2.3
|
||||
|
||||
- Delete uncached items from RD
|
||||
- Fail if the torrent is not cached (optional)
|
||||
- Fix cache not being updated
|
||||
|
||||
## 0.2.2
|
||||
|
||||
- Fix name mismatch in the cache
|
||||
- Fix directory mapping with mounts
|
||||
- Add Support for refreshing the *arrs
|
||||
|
||||
## 0.2.1
|
||||
|
||||
- Fix Uncached torrents not being downloaded/downloaded
|
||||
- Minor bug fixed
|
||||
- Fix Race condition in the cache and file system
|
||||
|
||||
## 0.2.0
|
||||
|
||||
- Implement 0.2.0-beta changes
|
||||
- Removed Blackhole
|
||||
- Added QbitTorrent API
|
||||
- Cleaned up the code
|
||||
|
||||
## 0.2.0-beta
|
||||
|
||||
- Switch to QbitTorrent API instead of Blackhole
|
||||
- Rewrote the whole codebase
|
||||
|
||||
## 0.1.4
|
||||
|
||||
- Rewrote Report log
|
||||
- Fix YTS, 1337x not grabbing infohash
|
||||
- Fix Torrent symlink bug
|
||||
|
||||
## 0.1.3
|
||||
|
||||
- Searching for infohashes in the xml description/summary/comments
|
||||
- Added local cache support
|
||||
- Added max cache size
|
||||
- Rewrite blackhole.go
|
||||
- Bug fixes
|
||||
- Fixed indexer getting disabled
|
||||
- Fixed blackhole not working
|
||||
|
||||
## 0.1.2
|
||||
|
||||
- Bug fixes
|
||||
- Code cleanup
|
||||
- Get available hashes at once
|
||||
|
||||
## 0.1.1
|
||||
|
||||
- Added support for "No Blackhole" for Arrs
|
||||
- Added support for "Cached Only" for Proxy
|
||||
- Bug Fixes
|
||||
|
||||
## 0.1.0
|
||||
|
||||
- Initial Release
|
||||
- Added Real Debrid Support
|
||||
- Added Arrs Support
|
||||
- Added Proxy Support
|
||||
- Added Basic Authentication for Proxy
|
||||
- Added Rate Limiting for Debrid Providers
|
||||
@@ -1,77 +0,0 @@
|
||||
# Arr Applications Configuration
|
||||
|
||||
Decypharr can integrate directly with Sonarr, Radarr, and other Arr applications. This section explains how to configure the Arr integration in your `config.json` file.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
The Arr applications are configured under the `arrs` key:
|
||||
|
||||
```json
|
||||
"arrs": [
|
||||
{
|
||||
"name": "sonarr",
|
||||
"host": "http://sonarr:8989",
|
||||
"token": "your-sonarr-api-key",
|
||||
"cleanup": true
|
||||
},
|
||||
{
|
||||
"name": "radarr",
|
||||
"host": "http://radarr:7878",
|
||||
"token": "your-radarr-api-key",
|
||||
"cleanup": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### !!! note
|
||||
This configuration is optional if you've already set up the qBittorrent client in your Arr applications with the correct host and token information. It's particularly useful for the Repair Worker functionality.
|
||||
|
||||
|
||||
### Configuration Options
|
||||
Each Arr application supports the following options:
|
||||
|
||||
- `name`: The name of the Arr application, which should match the category in qBittorrent
|
||||
- `host`: The host URL of the Arr application, including protocol and port
|
||||
- `token`: The API token/key of the Arr application
|
||||
- `cleanup`: Whether to clean up the Arr queue (removes completed downloads). This is only useful for Sonarr.
|
||||
- `skip_repair`: Automated repair will be skipped for this *arr.
|
||||
- `download_uncached`: Whether to download uncached torrents (defaults to debrid/manual setting)
|
||||
|
||||
### Finding Your API Key
|
||||
#### Sonarr/Radarr/Lidarr
|
||||
|
||||
1. Go to Sonarr > Settings > General
|
||||
2. Look for "API Key" in the "Security" section
|
||||
3. Copy the API key
|
||||
|
||||
### Multiple Arr Applications
|
||||
You can configure multiple Arr applications by adding more entries to the arrs array:
|
||||
|
||||
```json
|
||||
"arrs": [
|
||||
{
|
||||
"name": "sonarr",
|
||||
"host": "http://sonarr:8989",
|
||||
"token": "your-sonarr-api-key",
|
||||
"cleanup": true
|
||||
},
|
||||
{
|
||||
"name": "sonarr-anime",
|
||||
"host": "http://sonarr-anime:8989",
|
||||
"token": "your-sonarr-anime-api-key",
|
||||
"cleanup": true
|
||||
},
|
||||
{
|
||||
"name": "radarr",
|
||||
"host": "http://radarr:7878",
|
||||
"token": "your-radarr-api-key",
|
||||
"cleanup": false
|
||||
},
|
||||
{
|
||||
"name": "lidarr",
|
||||
"host": "http://lidarr:8686",
|
||||
"token": "your-lidarr-api-key",
|
||||
"cleanup": false
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -1,131 +0,0 @@
|
||||
|
||||
# Debrid Providers Configuration
|
||||
|
||||
Decypharr supports multiple Debrid providers. This section explains how to configure each provider in your `config.json` file.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
Each Debrid provider is configured in the `debrids` array:
|
||||
|
||||
```json
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
},
|
||||
{
|
||||
"name": "alldebrid",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/alldebrid/downloads/"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Provider Options
|
||||
|
||||
Each Debrid provider accepts the following configuration options:
|
||||
|
||||
|
||||
#### Basic(Required) Options
|
||||
|
||||
- `name`: The name of the Debrid provider (realdebrid, alldebrid, debridlink, torbox)
|
||||
- `host`: The API endpoint of the Debrid provider
|
||||
- `api_key`: Your API key for the Debrid service (can be comma-separated for multiple keys)
|
||||
- `folder`: The folder where your Debrid content is mounted (via webdav, rclone, zurg, etc.)
|
||||
|
||||
#### Advanced Options
|
||||
|
||||
- `rate_limit`: Rate limit for API requests (null by default)
|
||||
- `download_uncached`: Whether to download uncached torrents (disabled by default)
|
||||
- `check_cached`: Whether to check if torrents are cached (disabled by default)
|
||||
- `use_webdav`: Whether to create a WebDAV server for this Debrid provider (disabled by default)
|
||||
- `proxy`: Proxy URL for the Debrid provider (optional)
|
||||
|
||||
#### WebDAV and Rclone Options
|
||||
- `torrents_refresh_interval`: Interval for refreshing torrent data (e.g., `15s`, `1m`, `1h`).
|
||||
- `download_links_refresh_interval`: Interval for refreshing download links (e.g., `40m`, `1h`).
|
||||
- `workers`: Number of concurrent workers for processing requests.
|
||||
- `serve_from_rclone`: Whether to serve files directly from Rclone (disabled by default)
|
||||
- `add_samples`: Whether to add sample files when adding torrents to debrid (disabled by default)
|
||||
- `folder_naming`: Naming convention for folders:
|
||||
- `original_no_ext`: Original file name without extension
|
||||
- `original`: Original file name with extension
|
||||
- `filename`: Torrent filename
|
||||
- `filename_no_ext`: Torrent filename without extension
|
||||
- `id`: Torrent ID
|
||||
- `hash`: Torrent hash
|
||||
- `auto_expire_links_after`: Time after which download links will expire (e.g., `3d`, `1w`).
|
||||
- `rc_url`, `rc_user`, `rc_pass`, `rc_refresh_dirs`: Rclone RC configuration for VFS refreshes
|
||||
- `directories`: A map of virtual folders to serve via the webDAV server. The key is the virtual folder name, and the values are map of filters and their value
|
||||
|
||||
#### Example of `directories` configuration
|
||||
```json
|
||||
"directories": {
|
||||
"Newly Added": {
|
||||
"filters": {
|
||||
"exclude": "9-1-1",
|
||||
"last_added": "20h"
|
||||
}
|
||||
},
|
||||
"Spiderman Collection": {
|
||||
"filters": {
|
||||
"regex": "(?i)spider[-\\s]?man(\\s+collection|\\s+\\d|\\s+trilogy|\\s+complete|\\s+ultimate|\\s+box\\s+set|:?\\s+homecoming|:?\\s+far\\s+from\\s+home|:?\\s+no\\s+way\\s+home)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Example Configuration
|
||||
|
||||
#### Real Debrid
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"rate_limit": null,
|
||||
"download_uncached": false,
|
||||
"use_webdav": true
|
||||
}
|
||||
```
|
||||
|
||||
#### All Debrid
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "alldebrid",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/alldebrid/torrents/",
|
||||
"rate_limit": null,
|
||||
"download_uncached": false,
|
||||
"use_webdav": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Debrid Link
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "debridlink",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/debridlink/torrents/",
|
||||
"rate_limit": null,
|
||||
"download_uncached": false,
|
||||
"use_webdav": true
|
||||
}
|
||||
```
|
||||
|
||||
#### Torbox
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "torbox",
|
||||
"api_key": "your-api-key",
|
||||
"folder": "/mnt/remote/torbox/torrents/",
|
||||
"rate_limit": null,
|
||||
"download_uncached": false,
|
||||
"use_webdav": true
|
||||
}
|
||||
```
|
||||
@@ -1,81 +0,0 @@
|
||||
# General Configuration
|
||||
|
||||
This section covers the basic configuration options for Decypharr that apply to the entire application.
|
||||
|
||||
## Basic Settings
|
||||
|
||||
Here are the fundamental configuration options:
|
||||
|
||||
```json
|
||||
{
|
||||
"use_auth": false,
|
||||
"port": 8282,
|
||||
"log_level": "info",
|
||||
"discord_webhook_url": "",
|
||||
"min_file_size": 0,
|
||||
"max_file_size": 0,
|
||||
"allowed_file_types": ["mp4", "mkv", "avi", ...],
|
||||
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
#### Log Level
|
||||
The `log_level` setting determines how verbose the application logs will be:
|
||||
|
||||
- `debug`: Detailed information, useful for troubleshooting
|
||||
- `info`: General operational information (default)
|
||||
- `warn`: Warning messages
|
||||
- `error`: Error messages only
|
||||
- `trace`: Very detailed information, including all requests and responses
|
||||
|
||||
#### Port
|
||||
|
||||
The `port` setting specifies the port on which Decypharr will run. The default is `8282`. You can change this to any available port on your server.
|
||||
|
||||
Ensure this port:
|
||||
|
||||
- Is not used by other applications
|
||||
- Is accessible to your Arr applications
|
||||
- Is properly exposed if using Docker (see the Docker Compose example in the Installation guide)
|
||||
|
||||
|
||||
#### Authentication
|
||||
The `use_auth` option enables basic authentication for the UI:
|
||||
|
||||
```json
|
||||
"use_auth": true
|
||||
```
|
||||
|
||||
When enabled, you'll need to provide a username and password to access the Decypharr interface.
|
||||
|
||||
|
||||
#### File Size Limits
|
||||
|
||||
You can set minimum and maximum file size limits for torrents:
|
||||
```json
|
||||
"min_file_size": 0,
|
||||
"max_file_size": 0
|
||||
```
|
||||
|
||||
#### Allowed File Types
|
||||
You can restrict the types of files that Decypharr will process by specifying allowed file extensions. This is useful for filtering out unwanted file types.
|
||||
|
||||
```json
|
||||
"allowed_file_types": [
|
||||
"mp4", "mkv", "avi", "mov",
|
||||
"m4v", "mpg", "mpeg", "wmv",
|
||||
"m4a", "mp3", "flac", "wav"
|
||||
]
|
||||
```
|
||||
|
||||
If not specified, all movie, TV show, and music file types are allowed by default.
|
||||
|
||||
|
||||
#### Discord Notifications
|
||||
To receive notifications on Discord, add your webhook URL:
|
||||
```json
|
||||
"discord_webhook_url": "https://discord.com/api/webhooks/..."
|
||||
```
|
||||
This will send notifications for various events, such as successful downloads or errors.
|
||||
@@ -1,43 +0,0 @@
|
||||
# Configuration Overview
|
||||
|
||||
Decypharr uses a JSON configuration file to manage its settings. This file should be named `config.json` and placed in your configured directory.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
Here's a minimal configuration to get started:
|
||||
|
||||
```json
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "realdebrid_key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"use_webdav": true
|
||||
}
|
||||
],
|
||||
"qbittorrent": {
|
||||
"port": "8282",
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr"]
|
||||
},
|
||||
"repair": {
|
||||
"enabled": false,
|
||||
"interval": "12h"
|
||||
},
|
||||
"use_auth": false,
|
||||
"log_level": "info"
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Sections
|
||||
|
||||
Decypharr's configuration is divided into several sections:
|
||||
|
||||
- [General Configuration](general.md) - Basic settings like logging and authentication
|
||||
- [Debrid Providers](debrid.md) - Configure one or more Debrid services
|
||||
- [qBittorrent Settings](qbittorrent.md) - Settings for the qBittorrent API
|
||||
- [Arr Integration](arrs.md) - Configuration for Sonarr, Radarr, etc.
|
||||
|
||||
Full Configuration Example
|
||||
For a complete configuration file with all available options, see our [full configuration example](../extras/config.full.json).
|
||||
@@ -1,61 +0,0 @@
|
||||
# qBittorrent Configuration
|
||||
|
||||
Decypharr emulates a qBittorrent instance to integrate with Arr applications. This section explains how to configure the qBittorrent settings in your `config.json` file.
|
||||
|
||||
## Basic Configuration
|
||||
|
||||
The qBittorrent functionality is configured under the `qbittorrent` key:
|
||||
|
||||
```json
|
||||
"qbittorrent": {
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr", "lidarr"],
|
||||
"refresh_interval": 5
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
#### Required Settings
|
||||
|
||||
- `download_folder`: The folder where symlinks or downloaded files will be placed
|
||||
- `categories`: An array of categories to organize downloads (usually matches your Arr applications)
|
||||
|
||||
#### Advanced Settings
|
||||
|
||||
- `refresh_interval`: How often (in seconds) to refresh the Arrs Monitored Downloads (default: 5)
|
||||
- `max_downloads`: The maximum number of concurrent downloads. This is only for downloading real files(Not symlinks). If you set this to 0, it will download all files at once. This is not recommended for most users.(default: 5)
|
||||
- `skip_pre_cache`: This option disables the process of pre-caching files. This caches a small portion of the file to speed up your *arrs import process.
|
||||
|
||||
#### Categories
|
||||
Categories help organize your downloads and match them to specific Arr applications. Typically, you'll want to configure categories that match your Sonarr, Radarr, or other Arr applications:
|
||||
|
||||
```json
|
||||
"categories": ["sonarr", "radarr", "lidarr", "readarr"]
|
||||
```
|
||||
|
||||
When setting up your Arr applications to connect to Decypharr, you'll specify these same category names.
|
||||
|
||||
#### Download Folder
|
||||
|
||||
The `download_folder` setting specifies where Decypharr will place downloaded files or create symlinks:
|
||||
|
||||
```json
|
||||
"download_folder": "/mnt/symlinks/"
|
||||
```
|
||||
|
||||
This folder should be:
|
||||
|
||||
- Accessible to Decypharr
|
||||
- Accessible to your Arr applications
|
||||
- Have sufficient space if downloading files locally
|
||||
|
||||
|
||||
#### Refresh Interval
|
||||
The refresh_interval setting controls how often Decypharr checks for updates from your Arr applications:
|
||||
|
||||
```json
|
||||
"refresh_interval": 5
|
||||
```
|
||||
|
||||
|
||||
This value is in seconds. Lower values provide more responsive updates but may increase CPU usage.
|
||||
@@ -1,88 +0,0 @@
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "realdebrid_key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"download_api_keys": [],
|
||||
"proxy": "",
|
||||
"rate_limit": "250/minute",
|
||||
"download_uncached": false,
|
||||
"use_webdav": true,
|
||||
"torrents_refresh_interval": "15s",
|
||||
"folder_naming": "original_no_ext",
|
||||
"auto_expire_links_after": "3d",
|
||||
"rc_url": "http://your-ip-address:9990",
|
||||
"rc_user": "your_rclone_rc_user",
|
||||
"rc_pass": "your_rclone_rc_pass"
|
||||
},
|
||||
{
|
||||
"name": "torbox",
|
||||
"api_key": "torbox_api_key",
|
||||
"folder": "/mnt/remote/torbox/torrents/",
|
||||
"rate_limit": "250/minute",
|
||||
"download_uncached": false,
|
||||
},
|
||||
{
|
||||
"name": "debridlink",
|
||||
"api_key": "debridlink_key",
|
||||
"folder": "/mnt/remote/debridlink/torrents/",
|
||||
"rate_limit": "250/minute",
|
||||
"download_uncached": false,
|
||||
},
|
||||
{
|
||||
"name": "alldebrid",
|
||||
"api_key": "alldebrid_key",
|
||||
"folder": "/mnt/remote/alldebrid/magnet/",
|
||||
"rate_limit": "600/minute",
|
||||
"download_uncached": false,
|
||||
}
|
||||
],
|
||||
"max_cache_size": 1000,
|
||||
"qbittorrent": {
|
||||
"port": "8282",
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr"],
|
||||
"refresh_interval": 5,
|
||||
"skip_pre_cache": false
|
||||
},
|
||||
"arrs": [
|
||||
{
|
||||
"name": "sonarr",
|
||||
"host": "http://sonarr:8989",
|
||||
"token": "arr_key",
|
||||
"cleanup": true,
|
||||
"skip_repair": true,
|
||||
"download_uncached": false
|
||||
},
|
||||
{
|
||||
"name": "radarr",
|
||||
"host": "http://radarr:7878",
|
||||
"token": "arr_key",
|
||||
"cleanup": false,
|
||||
"download_uncached": false
|
||||
},
|
||||
{
|
||||
"name": "lidarr",
|
||||
"host": "http://lidarr:8686",
|
||||
"token": "arr_key",
|
||||
"cleanup": false,
|
||||
"skip_repair": true,
|
||||
"download_uncached": false
|
||||
}
|
||||
],
|
||||
"repair": {
|
||||
"enabled": false,
|
||||
"interval": "12h",
|
||||
"run_on_start": false,
|
||||
"zurg_url": "",
|
||||
"use_webdav": false,
|
||||
"auto_process": false
|
||||
},
|
||||
"log_level": "info",
|
||||
"min_file_size": "",
|
||||
"max_file_size": "",
|
||||
"allowed_file_types": [],
|
||||
"use_auth": false,
|
||||
"discord_webhook_url": "https://discord.com/api/webhooks/..."
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
[decypharr]
|
||||
type = webdav
|
||||
url = http://decypharr:8282/webdav/realdebrid
|
||||
vendor = other
|
||||
pacer_min_sleep = 0
|
||||
@@ -25,8 +25,10 @@ The Decypharr user interface provides:
|
||||
|
||||
Decypharr includes several advanced features that extend its capabilities:
|
||||
|
||||
- [Repair Worker](repair-worker.md): Identifies and fixes issues with your media files
|
||||
- [WebDAV Server](webdav.md): Provides direct access to your Debrid files
|
||||
- [Repair Support](repair-worker.md): Identifies and fixes issues with your media files
|
||||
- WebDav Server: Provides direct access to your Debrid files
|
||||
- Mounting Support: Allows you to mount Debrid services using [rclone](https://rclone.org), making it easy to access your files directly from your system
|
||||
- Multiple Debrid Providers: Supports Real Debrid, Torbox, Debrid Link, and All Debrid, allowing you to choose the best service for your needs
|
||||
|
||||
## Supported Debrid Providers
|
||||
|
||||
@@ -36,5 +38,7 @@ Decypharr supports multiple Debrid providers:
|
||||
- Torbox
|
||||
- Debrid Link
|
||||
- All Debrid
|
||||
- Premiumize(Coming Soon)
|
||||
- Usenet(Coming Soon)
|
||||
|
||||
Each provider can be configured separately, allowing you to use one or multiple services simultaneously.
|
||||
@@ -15,27 +15,4 @@ The Repair Worker performs the following tasks:
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable and configure the Repair Worker, add the following to your `config.json`:
|
||||
|
||||
```json
|
||||
"repair": {
|
||||
"enabled": true,
|
||||
"interval": "12h",
|
||||
"use_webdav": false,
|
||||
"zurg_url": "http://localhost:9999",
|
||||
"auto_process": true
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `enabled`: Set to `true` to enable the Repair Worker.
|
||||
- `interval`: The time interval for the Repair Worker to run (e.g., `12h`, `1d`).
|
||||
- `use_webdav`: If set to `true`, the Repair Worker will use WebDAV for file operations.
|
||||
- `zurg_url`: The URL for the Zurg service (if using).
|
||||
- `auto_process`: If set to `true`, the Repair Worker will automatically process files that it finds issues with.
|
||||
|
||||
|
||||
### Performance Tips
|
||||
- For users of the WebDAV server, enable `use_webdav` for exponentially faster repair processes
|
||||
- If using Zurg, set the `zurg_url` parameter to greatly improve repair speed
|
||||
You can enable and configure the Repair Worker in the Decypharr settings. It can be set to run at regular intervals, such as every 12 hours or daily.
|
||||
@@ -1,74 +0,0 @@
|
||||
# WebDAV Server
|
||||
|
||||

|
||||
|
||||
Decypharr includes a built-in WebDAV server that provides direct access to your Debrid files, making them easily accessible to media players and other applications.
|
||||
|
||||
|
||||
## Overview
|
||||
|
||||
While most Debrid providers have their own WebDAV servers, Decypharr's implementation offers faster access and additional features.
|
||||
|
||||
## Accessing the WebDAV Server
|
||||
|
||||
- URL: `http://localhost:8282/webdav` or `http://<your-server-ip>:8282/webdav`
|
||||
|
||||
## Configuration
|
||||
|
||||
You can configure WebDAV settings either globally or per-Debrid provider in your `config.json`:
|
||||
|
||||
```json
|
||||
"webdav": {
|
||||
"torrents_refresh_interval": "15s",
|
||||
"download_links_refresh_interval": "40m",
|
||||
"folder_naming": "original_no_ext",
|
||||
"auto_expire_links_after": "3d",
|
||||
"rc_url": "http://localhost:5572",
|
||||
"rc_user": "username",
|
||||
"rc_pass": "password",
|
||||
"serve_from_rclone": false,
|
||||
"directories": {
|
||||
"Newly Added": {
|
||||
"filters": {
|
||||
"exclude": "9-1-1",
|
||||
"last_added": "20h"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration Options
|
||||
|
||||
- `torrents_refresh_interval`: Interval for refreshing torrent data (e.g., `15s`, `1m`, `1h`).
|
||||
- `download_links_refresh_interval`: Interval for refreshing download links (e.g., `40m`, `1h`).
|
||||
- `workers`: Number of concurrent workers for processing requests.
|
||||
- folder_naming: Naming convention for folders:
|
||||
- `original_no_ext`: Original file name without extension
|
||||
- `original`: Original file name with extension
|
||||
- `filename`: Torrent filename
|
||||
- `filename_no_ext`: Torrent filename without extension
|
||||
- `id`: Torrent ID
|
||||
- `auto_expire_links_after`: Time after which download links will expire (e.g., `3d`, `1w`).
|
||||
- `rc_url`, `rc_user`, `rc_pass`: Rclone RC configuration for VFS refreshes
|
||||
- `directories`: A map of virtual folders to serve via the WebDAV server. The key is the virtual folder name, and the values are a map of filters and their values.
|
||||
- `serve_from_rclone`: Whether to serve files directly from Rclone (disabled by default).
|
||||
|
||||
### Using with Media Players
|
||||
The WebDAV server works well with media players like:
|
||||
|
||||
- Infuse
|
||||
- VidHub
|
||||
- Plex, Emby, Jellyfin (with rclone, Check [this guide](../guides/rclone.md))
|
||||
- Kodi
|
||||
|
||||
### Mounting with Rclone
|
||||
You can mount the WebDAV server locally using Rclone. Example configuration:
|
||||
|
||||
```conf
|
||||
[decypharr]
|
||||
type = webdav
|
||||
url = http://localhost:8282/webdav/realdebrid
|
||||
vendor = other
|
||||
```
|
||||
For a complete Rclone configuration example, see our [sample rclone.conf](../extras/rclone.conf).
|
||||
@@ -2,21 +2,25 @@
|
||||
|
||||
While Decypharr provides a Qbittorent API for integration with media management applications, it also allows you to manually download torrents directly through its interface. This guide will walk you through the process of downloading torrents using Decypharr.
|
||||
|
||||
- You can either use the Decypharr UI to add torrents manually or use its API to automate the process.
|
||||
- You can either use the Decypharr UI to add torrents manually or use its [API](../api.md) to automate the process.
|
||||
|
||||
## Manual Downloading
|
||||
|
||||

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

|
||||
|
||||
Instead of requiring users to install and configure rclone separately, Decypharr can now mount your WebDAV endpoints internally using rclone as a library dependency. This provides a seamless experience where files appear as regular filesystem paths without any external dependencies.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker users**: FUSE support may need to be enabled in the container depending on your Docker setup
|
||||
- **macOS users**: May need [macFUSE](https://osxfuse.github.io/) installed for mounting functionality
|
||||
- **Linux users**: FUSE should be available by default on most distributions
|
||||
- **Windows users**: Mounting functionality may be limited
|
||||
|
||||
### Configuration Options
|
||||
|
||||
You can set the options in the Web UI or directly in the configuration file:
|
||||
|
||||
#### Note:
|
||||
Check the Rclone documentation for more details on the available options: [Rclone Mount Options](https://rclone.org/commands/rclone_mount/).
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **WebDAV Server**: Decypharr starts its internal WebDAV server for enabled providers
|
||||
2. **Internal Mount**: Rclone is used internally to mount the WebDAV endpoint to a local filesystem path
|
||||
3. **File Access**: Your applications can access files using regular filesystem paths like `/mnt/decypharr/realdebrid/__all__/MyMovie/`
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Automatic Setup**: Mounting is handled automatically by Decypharr using internal rclone rcd
|
||||
- **Filesystem Access**: Files appear as regular directories and files
|
||||
- **Seamless Integration**: Works with existing media servers without changes
|
||||
|
||||
## Docker Compose
|
||||
|
||||
```yaml
|
||||
version: '3.8'
|
||||
services:
|
||||
decypharr:
|
||||
image: sirrobot01/decypharr:latest
|
||||
container_name: decypharr
|
||||
ports:
|
||||
- "8282:8282"
|
||||
volumes:
|
||||
- ./config:/config
|
||||
- /mnt:/mnt:rshared # Important: use 'rshared' for mount propagation
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse:rwm
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
environment:
|
||||
- UMASK=002
|
||||
- PUID=1000 # Change to your user ID
|
||||
- PGID=1000 # Change to your group ID
|
||||
```
|
||||
|
||||
**Important Docker Notes:**
|
||||
- Mount volumes with `:rshared` to allow mount propagation
|
||||
- Include `/dev/fuse` device for FUSE mounting
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Mount Failures
|
||||
|
||||
If mounting fails, check:
|
||||
|
||||
1. **FUSE Installation**:
|
||||
- **macOS**: Install macFUSE from https://osxfuse.github.io/
|
||||
- **Linux**: Install fuse package (`apt install fuse` or `yum install fuse`)
|
||||
- **Docker**: Fuse is already included in the container, but ensure the host supports it
|
||||
2. **Permissions**: Ensure the application has sufficient privileges
|
||||
|
||||
### No Mount Methods Available
|
||||
|
||||
If you see "no mount method available" errors:
|
||||
|
||||
1. **Check Platform Support**: Some platforms have limited FUSE support
|
||||
2. **Install Dependencies**: Ensure FUSE libraries are installed
|
||||
3. **Use WebDAV Directly**: Access files via `http://localhost:8282/webdav/provider/`
|
||||
4. **External Mounting**: Use OS-native WebDAV mounting as fallback
|
||||
@@ -1,152 +0,0 @@
|
||||
# Setting up Decypharr with Rclone
|
||||
|
||||
This guide will help you set up Decypharr with Rclone, allowing you to use your Debrid providers as a remote storage solution.
|
||||
|
||||
#### Rclone
|
||||
Make sure you have Rclone installed and configured on your system. You can follow the [Rclone installation guide](https://rclone.org/install/) for instructions.
|
||||
|
||||
It's recommended to use a docker version of Rclone, as it provides a consistent environment across different platforms.
|
||||
|
||||
|
||||
### Steps
|
||||
|
||||
We'll be using docker compose to set up Rclone and Decypharr together.
|
||||
|
||||
#### Note
|
||||
This guide assumes you have a basic understanding of Docker and Docker Compose. If you're new to Docker, consider checking out the [Docker documentation](https://docs.docker.com/get-started/) for more information.
|
||||
|
||||
Also, ensure you have Docker and Docker Compose installed on your system. You can find installation instructions in the [Docker documentation](https://docs.docker.com/get-docker/) and [Docker Compose documentation](https://docs.docker.com/compose/install/).
|
||||
|
||||
|
||||
Create a directory for your Decypharr and Rclone setup:
|
||||
```bash
|
||||
mkdir -p /opt/decypharr
|
||||
mkdir -p /opt/rclone
|
||||
mkdir -p /mnt/remote/realdebrid
|
||||
|
||||
# Set permissions
|
||||
chown -R $USER:$USER /opt/decypharr
|
||||
chown -R $USER:$USER /opt/rclone
|
||||
chown -R $USER:$USER /mnt/remote/realdebrid
|
||||
```
|
||||
|
||||
Create a `rclone.conf` file in `/opt/rclone/` with your Rclone configuration.
|
||||
|
||||
```conf
|
||||
[decypharr]
|
||||
type = webdav
|
||||
url = http://your-ip-or-domain:8282/webdav/realdebrid
|
||||
vendor = other
|
||||
pacer_min_sleep = 0
|
||||
```
|
||||
|
||||
Create a `config.json` file in `/opt/decypharr/` with your Decypharr configuration.
|
||||
|
||||
```json
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "realdebrid_key",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"rate_limit": "250/minute",
|
||||
"use_webdav": true,
|
||||
"rc_url": "rclone:5572"
|
||||
}
|
||||
],
|
||||
"qbittorrent": {
|
||||
"download_folder": "data/media/symlinks/",
|
||||
"refresh_interval": 10
|
||||
}
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
### Docker Compose Setup
|
||||
|
||||
- Check your current user and group IDs by running `id -u` and `id -g` in your terminal. You can use these values to set the `PUID` and `PGID` environment variables in the Docker Compose file.
|
||||
- You should also set `user` to your user ID and group ID in the Docker Compose file to ensure proper file permissions.
|
||||
|
||||
Create a `docker-compose.yml` file with the following content:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
decypharr:
|
||||
image: cy01/blackhole:latest
|
||||
container_name: decypharr
|
||||
user: "${PUID:-1000}:${PGID:-1000}"
|
||||
volumes:
|
||||
- /mnt/:/mnt:rslave
|
||||
- /opt/decypharr/:/app
|
||||
environment:
|
||||
- UMASK=002
|
||||
- PUID=1000 # Replace with your user ID
|
||||
- PGID=1000 # Replace with your group ID
|
||||
ports:
|
||||
- "8282:8282/tcp"
|
||||
restart: unless-stopped
|
||||
|
||||
rclone:
|
||||
image: rclone/rclone:latest
|
||||
container_name: rclone
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: UTC
|
||||
ports:
|
||||
- 5572:5572
|
||||
volumes:
|
||||
- /mnt/remote/realdebrid:/data:rshared
|
||||
- /opt/rclone/rclone.conf:/config/rclone/rclone.conf
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse:rwm
|
||||
depends_on:
|
||||
decypharr:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
command: "mount decypharr: /data --allow-non-empty --allow-other --dir-cache-time 10s --rc --rc-addr :5572 --rc-no-auth"
|
||||
```
|
||||
|
||||
#### Docker Notes
|
||||
|
||||
- Ensure that the `/mnt/` directory is mounted correctly to access your media files.
|
||||
- You can check your current user and group IDs and UMASK by running `id -a` and `umask` commands in your terminal.
|
||||
- You can adjust the `PUID` and `PGID` environment variables to match your user and group IDs for proper file permissions.
|
||||
- Also adding `--uid=$YOUR_PUID --gid=$YOUR_PGID` to the `rclone mount` command can help with permissions.
|
||||
- The `UMASK` environment variable can be set to control file permissions created by Decypharr.
|
||||
|
||||
Start the containers:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
Access the Decypharr web interface at `http://your-ip-address:8282` and configure your settings as needed.
|
||||
|
||||
- Access your webdav server at `http://your-ip-address:8282/webdav` to see your files.
|
||||
- You should be able to see your files in the `/mnt/remote/realdebrid/__all__/` directory.
|
||||
- You can now use your Debrid provider as a remote storage solution with Rclone and Decypharr.
|
||||
- You can also use the Rclone mount command to mount your Debrid provider locally. For example:
|
||||
|
||||
|
||||
### Notes
|
||||
|
||||
- Make sure to replace `your-ip-address` with the actual IP address of your server.
|
||||
- You can use multiple Debrid providers by adding them to the `debrids` array in the `config.json` file.
|
||||
|
||||
For each provider, you'll need a different rclone. OR you can change your `rclone.conf`
|
||||
|
||||
|
||||
```apache
|
||||
[decypharr]
|
||||
type = webdav
|
||||
url = http://your-ip-or-domain:8282/webdav/
|
||||
vendor = other
|
||||
pacer_min_sleep = 0
|
||||
```
|
||||
|
||||
You'll still be able to access the directories via `/mnt/remote/realdebrid, /mnt/remote/alldebrid` etc
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 218 KiB After Width: | Height: | Size: 293 KiB |
BIN
docs/docs/images/main-light.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
|
Before Width: | Height: | Size: 188 KiB After Width: | Height: | Size: 417 KiB |
|
Before Width: | Height: | Size: 226 KiB After Width: | Height: | Size: 286 KiB |
|
Before Width: | Height: | Size: 264 KiB After Width: | Height: | Size: 264 KiB |
BIN
docs/docs/images/settings/debrid.png
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
docs/docs/images/settings/qbittorent.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
docs/docs/images/settings/rclone.png
Normal file
|
After Width: | Height: | Size: 364 KiB |
BIN
docs/docs/images/settings/repair.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
@@ -1,20 +1,19 @@
|
||||
# Decypharr
|
||||
|
||||

|
||||
{: .light-mode-image}
|
||||
{: .dark-mode-image}
|
||||
|
||||
**Decypharr** is an implementation of QbitTorrent with **Multiple Debrid service support**, written in Go.
|
||||
|
||||
## What is Decypharr?
|
||||
|
||||
TLDR; Decypharr is a self-hosted, open-source torrent client that integrates with multiple Debrid services. It provides a user-friendly interface for managing torrents and supports popular media management applications like Sonarr and Radarr.
|
||||
**TLDR**; Decypharr is a self-hosted, open-source download client that integrates with multiple Debrid services. It provides a user-friendly interface for managing files and supports popular media management applications like Sonarr and Radarr.
|
||||
|
||||
|
||||
## Key Features
|
||||
|
||||
- Mock Qbittorent API that supports Sonarr, Radarr, Lidarr, and other Arr applications
|
||||
- Full-fledged UI for managing torrents
|
||||
- Multiple Debrid providers support
|
||||
- WebDAV server support for each Debrid provider
|
||||
- WebDAV server support for each Debrid provider with an optional mounting feature(using [rclone](https://rclone.org))
|
||||
- Repair Worker for missing files, symlinks etc
|
||||
|
||||
## Supported Debrid Providers
|
||||
|
||||
@@ -18,7 +18,6 @@ You can use either Docker Hub or GitHub Container Registry to pull the image:
|
||||
- `latest`: The latest stable release
|
||||
- `beta`: The latest beta release
|
||||
- `vX.Y.Z`: A specific version (e.g., `v0.1.0`)
|
||||
- `nightly`: The latest nightly build (usually unstable)
|
||||
- `experimental`: The latest experimental build (highly unstable)
|
||||
|
||||
### Docker CLI Setup
|
||||
@@ -31,12 +30,13 @@ Run the Docker container:
|
||||
```bash
|
||||
docker run -d \
|
||||
--name decypharr \
|
||||
--restart unless-stopped \
|
||||
-p 8282:8282 \
|
||||
-v /mnt/:/mnt \
|
||||
-v /mnt/:/mnt:rshared \
|
||||
-v ./config/:/app \
|
||||
-e PUID=1000 \
|
||||
-e PGID=1000 \
|
||||
-e UMASK=002 \
|
||||
--device /dev/fuse:/dev/fuse:rwm \
|
||||
--cap-add SYS_ADMIN \
|
||||
--security-opt apparmor:unconfined \
|
||||
cy01/blackhole:latest
|
||||
```
|
||||
|
||||
@@ -52,10 +52,15 @@ services:
|
||||
ports:
|
||||
- "8282:8282"
|
||||
volumes:
|
||||
- /mnt/:/mnt:rslave # Mount your media directory
|
||||
- ./config/:/app # config.json must be in this directory
|
||||
- QBIT_PORT=8282 # qBittorrent Port (optional)
|
||||
- /mnt/:/mnt:rshared
|
||||
- ./config/:/app
|
||||
restart: unless-stopped
|
||||
devices:
|
||||
- /dev/fuse:/dev/fuse:rwm
|
||||
cap_add:
|
||||
- SYS_ADMIN
|
||||
security_opt:
|
||||
- apparmor:unconfined
|
||||
```
|
||||
|
||||
Run the Docker Compose setup:
|
||||
@@ -67,7 +72,7 @@ docker-compose up -d
|
||||
## Binary Installation
|
||||
If you prefer not to use Docker, you can download and run the binary directly.
|
||||
|
||||
Download your OS-specific release from the [releases page](https://github.com/sirrobot01/decypharr/releases).
|
||||
Download your OS-specific release from the [release page](https://github.com/sirrobot01/decypharr/releases).
|
||||
Create a configuration file (see Configuration)
|
||||
Run the binary:
|
||||
|
||||
@@ -76,44 +81,15 @@ chmod +x decypharr
|
||||
./decypharr --config /path/to/config/folder
|
||||
```
|
||||
|
||||
The config directory should contain your config.json file.
|
||||
|
||||
## config.json
|
||||
|
||||
The `config.json` file is where you configure Decypharr. You can find a sample configuration file in the `configs` directory of the repository.
|
||||
|
||||
You can also configure Decypharr through the web interface, but it's recommended to start with the config file for initial setup.
|
||||
|
||||
```json
|
||||
{
|
||||
"debrids": [
|
||||
{
|
||||
"name": "realdebrid",
|
||||
"api_key": "your_api_key_here",
|
||||
"folder": "/mnt/remote/realdebrid/__all__/",
|
||||
"use_webdav": true
|
||||
}
|
||||
],
|
||||
"qbittorrent": {
|
||||
"download_folder": "/mnt/symlinks/",
|
||||
"categories": ["sonarr", "radarr"]
|
||||
},
|
||||
"use_auth": false,
|
||||
"log_level": "info",
|
||||
"port": "8282"
|
||||
}
|
||||
```
|
||||
|
||||
### Notes for Docker Users
|
||||
|
||||
- Ensure that the `/mnt/` directory is mounted correctly to access your media files.
|
||||
- The `./config/` directory should contain your `config.json` file.
|
||||
- You can adjust the `PUID` and `PGID` environment variables to match your user and group IDs for proper file permissions.
|
||||
- The `UMASK` environment variable can be set to control file permissions created by Decypharr.
|
||||
|
||||
##### Health Checks
|
||||
- Health checks are disabled by default. You can enable them by adding a `healthcheck` section in your `docker-compose.yml` file.
|
||||
- Health checks checks for availability of several parts of the application;
|
||||
- Health checks the availability of several parts of the application;
|
||||
- The main web interface
|
||||
- The qBittorrent API
|
||||
- The WebDAV server (if enabled). You should disable health checks for the initial indexes as they can take a long time to complete.
|
||||
@@ -125,7 +101,7 @@ services:
|
||||
...
|
||||
healthcheck:
|
||||
test: ["CMD", "/usr/bin/healthcheck", "--config", "/app/"]
|
||||
interval: 5s
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
```
|
||||
|
||||
24
docs/docs/styles/styles.css
Normal file
@@ -0,0 +1,24 @@
|
||||
/* Light mode image - visible by default */
|
||||
.light-mode-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Dark mode image - hidden by default */
|
||||
.dark-mode-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* When dark theme (slate) is active */
|
||||
[data-md-color-scheme="slate"] .light-mode-image {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-md-color-scheme="slate"] .dark-mode-image {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Optional: smooth transition */
|
||||
.light-mode-image,
|
||||
.dark-mode-image {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
@@ -2,15 +2,35 @@
|
||||
|
||||
This guide will help you get started with Decypharr after installation.
|
||||
|
||||
## Basic Setup
|
||||
After installing Decypharr, you can access the web interface at `http://localhost:8282` or your configured host/port.
|
||||
|
||||
1. Create your `config.json` file (see [Configuration](configuration/index.md) for details)
|
||||
2. Start the Decypharr service using Docker or binary
|
||||
3. Access the UI at `http://localhost:8282` (or your configured host/port)
|
||||
4. Connect your Arr applications (Sonarr, Radarr, etc.)
|
||||
### Initial Configuration
|
||||
If it's the first time you're accessing the UI, you will be prompted to set up your credentials. You can skip this step if you don't want to enable authentication. If you choose to set up credentials, enter a username and password confirm password, then click **Save**. You will be redirected to the settings page.
|
||||
|
||||
## Connecting to Sonarr/Radarr
|
||||
### Debrid Configuration
|
||||

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

|
||||
|
||||
- Click on **Qbittorrent** in the tab
|
||||
- Set the **Download Folder** to where you want Decypharr to save downloaded files. These files will be symlinked to the mount folder you configured earlier.
|
||||
You can leave the remaining settings as default for now.
|
||||
|
||||
### Arrs Configuration
|
||||
|
||||
You can skip Arr configuration for now. Decypharr will auto-add them when you connect to Sonarr or Radarr later.
|
||||
|
||||
|
||||
#### Connecting to Sonarr/Radarr
|
||||
|
||||

|
||||
To connect Decypharr to your Sonarr or Radarr instance:
|
||||
|
||||
1. In Sonarr/Radarr, go to **Settings → Download Client → Add Client → qBittorrent**
|
||||
@@ -18,22 +38,38 @@ To connect Decypharr to your Sonarr or Radarr instance:
|
||||
- **Host**: `localhost` (or the IP of your Decypharr server)
|
||||
- **Port**: `8282` (or your configured qBittorrent port)
|
||||
- **Username**: `http://sonarr:8989` (your Arr host with http/https)
|
||||
- **Password**: `sonarr_token` (your Arr API token)
|
||||
- **Password**: `sonarr_token` (your Arr API token, you can get this from Sonarr/Radarr settings)
|
||||
- **Category**: e.g., `sonarr`, `radarr` (match what you configured in Decypharr)
|
||||
- **Use SSL**: `No`
|
||||
- **Sequential Download**: `No` or `Yes` (if you want to download torrents locally instead of symlink)
|
||||
3. Click **Test** to verify the connection
|
||||
4. Click **Save** to add the download client
|
||||
|
||||

|
||||
|
||||
## Using the UI
|
||||
### Rclone Configuration
|
||||
|
||||
The Decypharr UI provides a familiar qBittorrent-like interface with additional features for Debrid services:
|
||||

|
||||
|
||||
- Add new torrents
|
||||
- Monitor download status
|
||||
- Access WebDAV functionality
|
||||
- Edit your configuration
|
||||
If you want Decypharr to automatically mount WebDAV folders using Rclone, you need to set up Rclone first:
|
||||
|
||||
Access the UI at `http://localhost:8282` or your configured host/port.
|
||||
If you're using Docker, the rclone binary is already included in the container. If you're running Decypharr directly, make sure Rclone is installed on your system.
|
||||
|
||||
Enable **Mount**
|
||||
- **Global Mount Path**: Set the path where you want to mount the WebDAV folders (e.g., `/mnt/remote`). Decypharr will create subfolders for each Debrid service. For example, if you set `/mnt/remote`, it will create `/mnt/remote/realdebrid`, `/mnt/remote/torbox`, etc. This should be the grandparent of your mount folder set in the Debrid configuration.
|
||||
- **User ID**: Set the user ID for Rclone mounts (default is gotten from the environment variable `PUID`).
|
||||
- **Group ID**: Set the group ID for Rclone mounts (default is gotten from the environment variable `PGID`).
|
||||
- **Buffer Size**: Set the buffer size for Rclone mounts.
|
||||
|
||||
You should set other options based on your use case. If you don't know what you're doing, leave it as defaults. Checkout the [Rclone documentation](https://rclone.org/commands/rclone_mount/) for more details.
|
||||
|
||||
### Repair Configuration
|
||||
|
||||

|
||||
|
||||
Repair is an optional feature that allows you to fix missing files, symlinks, and other issues in your media library.
|
||||
- Click on **Repair** in the tab
|
||||
- Enable **Scheduled Repair** if you want Decypharr to automatically check for missing files at your specified interval.
|
||||
- Set the **Repair Interval** to how often you want Decypharr to check for missing files (e.g 1h, 6h, 12h, 24h, you can also use cron syntax like `0 0 * * *` for daily checks).
|
||||
- Enable **WebDav**(You shoukd enable this, if you enabled WebDav in Debrid configuration)
|
||||
- **Auto Process**: Enable this if you want Decypharr to automatically process repair jobs when they are done. This could delete the original files, symlinks, be wary!!!
|
||||
- **Worker Threads**: Set the number of worker threads for processing repair jobs. More threads can speed up the process but may consume more resources.
|
||||
@@ -6,6 +6,9 @@ repo_name: sirrobot01/decypharr
|
||||
edit_uri: blob/main/docs
|
||||
|
||||
|
||||
extra_css:
|
||||
- styles/styles.css
|
||||
|
||||
theme:
|
||||
name: material
|
||||
logo: images/logo.png
|
||||
@@ -59,22 +62,17 @@ nav:
|
||||
- Home: index.md
|
||||
- Installation: installation.md
|
||||
- Usage: usage.md
|
||||
- Configuration:
|
||||
- Overview: configuration/index.md
|
||||
- General: configuration/general.md
|
||||
- Debrid Providers: configuration/debrid.md
|
||||
- qBittorrent: configuration/qbittorrent.md
|
||||
- Arr Integration: configuration/arrs.md
|
||||
- API Documentation: api.md
|
||||
- Features:
|
||||
- Overview: features/index.md
|
||||
- Repair Worker: features/repair-worker.md
|
||||
- WebDAV: features/webdav.md
|
||||
- Guides:
|
||||
- Overview: guides/index.md
|
||||
- Setting Up with Rclone: guides/rclone.md
|
||||
- Changelog: changelog.md
|
||||
- Manual Downloading: guides/downloading.md
|
||||
- Internal Mounting: guides/internal-mounting.md
|
||||
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- tags
|
||||
- tags
|
||||
- swagger-ui-tag
|
||||
3
docs/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
mkdocs==1.6.1
|
||||
mkdocs-material==9.6.16
|
||||
mkdocs-swagger-ui-tag==0.6.10
|
||||
16
go.mod
@@ -7,7 +7,7 @@ toolchain go1.24.3
|
||||
require (
|
||||
github.com/anacrolix/torrent v1.55.0
|
||||
github.com/cavaliergopher/grab/v3 v3.0.1
|
||||
github.com/go-chi/chi/v5 v5.1.0
|
||||
github.com/go-chi/chi/v5 v5.2.2
|
||||
github.com/go-co-op/gocron/v2 v2.16.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/sessions v1.4.0
|
||||
@@ -15,9 +15,9 @@ require (
|
||||
github.com/rs/zerolog v1.33.0
|
||||
github.com/stanNthe5/stringbuf v0.0.3
|
||||
go.uber.org/ratelimit v0.3.1
|
||||
golang.org/x/crypto v0.33.0
|
||||
golang.org/x/net v0.35.0
|
||||
golang.org/x/sync v0.12.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/net v0.41.0
|
||||
golang.org/x/sync v0.15.0
|
||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||
)
|
||||
|
||||
@@ -27,13 +27,13 @@ require (
|
||||
github.com/benbjohnson/clock v1.3.0 // indirect
|
||||
github.com/bradfitz/iter v0.0.0-20191230175014-e8f45d346db8 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/gorilla/securecookie v1.1.2 // indirect
|
||||
github.com/huandu/xstrings v1.3.2 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/rogpeppe/go-internal v1.13.1 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
)
|
||||
|
||||
31
go.sum
@@ -70,8 +70,8 @@ github.com/glycerine/go-unsnap-stream v0.0.0-20190901134440-81cf024a9e0a/go.mod
|
||||
github.com/glycerine/goconvey v0.0.0-20180728074245-46e3a41ad493/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/glycerine/goconvey v0.0.0-20190315024820-982ee783a72e/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/glycerine/goconvey v0.0.0-20190410193231-58a59202ab31/go.mod h1:Ogl1Tioa0aV7gstGFO7KhffUsb9M4ydbEbbxpcEDc24=
|
||||
github.com/go-chi/chi/v5 v5.1.0 h1:acVI1TYaD+hhedDJ3r54HyA6sExp3HfXq7QWEEY/xMw=
|
||||
github.com/go-chi/chi/v5 v5.1.0/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8=
|
||||
github.com/go-chi/chi/v5 v5.2.2 h1:CMwsvRVTbXVytCk1Wd72Zy1LAsAh9GxMmSNWLHCG618=
|
||||
github.com/go-chi/chi/v5 v5.2.2/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-co-op/gocron/v2 v2.16.1 h1:ux/5zxVRveCaCuTtNI3DiOk581KC1KpJbpJFYUEVYwo=
|
||||
github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
@@ -102,8 +102,8 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
@@ -143,8 +143,9 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
@@ -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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
|
||||
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
|
||||
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
|
||||
@@ -226,8 +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=
|
||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M=
|
||||
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
|
||||
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
@@ -243,8 +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-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
|
||||
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -252,8 +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-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8=
|
||||
golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -268,8 +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
@@ -2,6 +2,8 @@ package config
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -30,6 +32,7 @@ type Debrid struct {
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
DownloadAPIKeys []string `json:"download_api_keys,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
RcloneMountPath string `json:"rclone_mount_path,omitempty"` // Custom rclone mount path for this debrid service
|
||||
DownloadUncached bool `json:"download_uncached,omitempty"`
|
||||
CheckCached bool `json:"check_cached,omitempty"`
|
||||
RateLimit string `json:"rate_limit,omitempty"` // 200/minute or 10/second
|
||||
@@ -39,6 +42,7 @@ type Debrid struct {
|
||||
UnpackRar bool `json:"unpack_rar,omitempty"`
|
||||
AddSamples bool `json:"add_samples,omitempty"`
|
||||
MinimumFreeSlot int `json:"minimum_free_slot,omitempty"` // Minimum active pots to use this debrid
|
||||
Limit int `json:"limit,omitempty"` // Maximum number of total torrents
|
||||
|
||||
UseWebDav bool `json:"use_webdav,omitempty"`
|
||||
WebDav
|
||||
@@ -80,6 +84,42 @@ type Repair struct {
|
||||
type Auth struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
APIToken string `json:"api_token,omitempty"`
|
||||
}
|
||||
|
||||
type Rclone struct {
|
||||
// Global mount folder where all providers will be mounted as subfolders
|
||||
Enabled bool `json:"enabled,omitempty"`
|
||||
MountPath string `json:"mount_path,omitempty"`
|
||||
|
||||
// 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 {
|
||||
@@ -94,6 +134,7 @@ type Config struct {
|
||||
Arrs []Arr `json:"arrs,omitempty"`
|
||||
Repair Repair `json:"repair,omitempty"`
|
||||
WebDav WebDav `json:"webdav,omitempty"`
|
||||
Rclone Rclone `json:"rclone,omitempty"`
|
||||
AllowedExt []string `json:"allowed_file_types,omitempty"`
|
||||
MinFileSize string `json:"min_file_size,omitempty"` // Minimum file size to download, 10MB, 1GB, etc
|
||||
MaxFileSize string `json:"max_file_size,omitempty"` // Maximum file size to download (0 means no limit)
|
||||
@@ -197,6 +238,15 @@ func ValidateConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateAPIToken creates a new random API token
|
||||
func generateAPIToken() (string, error) {
|
||||
bytes := make([]byte, 32) // 256-bit token
|
||||
if _, err := rand.Read(bytes); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(bytes), nil
|
||||
}
|
||||
|
||||
func SetConfigPath(path string) {
|
||||
configPath = path
|
||||
}
|
||||
@@ -304,7 +354,7 @@ func (c *Config) updateDebrid(d Debrid) Debrid {
|
||||
}
|
||||
|
||||
if d.TorrentsRefreshInterval == "" {
|
||||
d.TorrentsRefreshInterval = cmp.Or(c.WebDav.TorrentsRefreshInterval, "15s") // 15 seconds
|
||||
d.TorrentsRefreshInterval = cmp.Or(c.WebDav.TorrentsRefreshInterval, "45s") // 45 seconds
|
||||
}
|
||||
if d.WebDav.DownloadLinksRefreshInterval == "" {
|
||||
d.DownloadLinksRefreshInterval = cmp.Or(c.WebDav.DownloadLinksRefreshInterval, "40m") // 40 minutes
|
||||
@@ -365,8 +415,42 @@ func (c *Config) setDefaults() {
|
||||
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
|
||||
c.Auth = c.GetAuth()
|
||||
|
||||
// Generate API token if auth is enabled and no token exists
|
||||
if c.UseAuth {
|
||||
if c.Auth == nil {
|
||||
c.Auth = &Auth{}
|
||||
}
|
||||
if c.Auth.APIToken == "" {
|
||||
if token, err := generateAPIToken(); err == nil {
|
||||
c.Auth.APIToken = token
|
||||
// Save the updated auth config
|
||||
_ = c.SaveAuth(c.Auth)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) Save() error {
|
||||
|
||||
@@ -24,7 +24,7 @@ func (c *Config) IsAllowedFile(filename string) bool {
|
||||
}
|
||||
|
||||
func getDefaultExtensions() []string {
|
||||
videoExts := strings.Split("webm,m4v,3gp,nsv,ty,strm,rm,rmvb,m3u,ifo,mov,qt,divx,xvid,bivx,nrg,pva,wmv,asf,asx,ogm,ogv,m2v,avi,bin,dat,dvr-ms,mpg,mpeg,mp4,avc,vp3,svq3,nuv,viv,dv,fli,flv,wpl,img,iso,vob,mkv,mk3d,ts,wtv,m2ts'", ",")
|
||||
videoExts := strings.Split("webm,m4v,3gp,nsv,ty,strm,rm,rmvb,m3u,ifo,mov,qt,divx,xvid,bivx,nrg,pva,wmv,asf,asx,ogm,ogv,m2v,avi,bin,dat,dvr-ms,mpg,mpeg,mp4,avc,vp3,svq3,nuv,viv,dv,fli,flv,wpl,vob,mkv,mk3d,ts,wtv,m2ts", ",")
|
||||
musicExts := strings.Split("MP3,WAV,FLAC,OGG,WMA,AIFF,ALAC,M4A,APE,AC3,DTS,M4P,MID,MIDI,MKA,MP2,MPA,RA,VOC,WV,AMR", ",")
|
||||
|
||||
// Combine both slices
|
||||
|
||||
@@ -26,7 +26,7 @@ func GetLogPath() string {
|
||||
}
|
||||
}
|
||||
|
||||
return filepath.Join(logsDir, "decypharr.log")
|
||||
return logsDir
|
||||
}
|
||||
|
||||
func New(prefix string) zerolog.Logger {
|
||||
@@ -34,7 +34,7 @@ func New(prefix string) zerolog.Logger {
|
||||
level := config.Get().LogLevel
|
||||
|
||||
rotatingLogFile := &lumberjack.Logger{
|
||||
Filename: GetLogPath(),
|
||||
Filename: filepath.Join(GetLogPath(), "decypharr.log"),
|
||||
MaxSize: 10,
|
||||
MaxAge: 15,
|
||||
Compress: true,
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
videoMatch = "(?i)(\\.)(webm|m4v|3gp|nsv|ty|strm|rm|rmvb|m3u|ifo|mov|qt|divx|xvid|bivx|nrg|pva|wmv|asf|asx|ogm|ogv|m2v|avi|bin|dat|dvr-ms|mpg|mpeg|mp4|avc|vp3|svq3|nuv|viv|dv|fli|flv|wpl|img|iso|vob|mkv|mk3d|ts|wtv|m2ts)$"
|
||||
videoMatch = "(?i)(\\.)(webm|m4v|3gp|nsv|ty|strm|rm|rmvb|m3u|ifo|mov|qt|divx|xvid|bivx|nrg|pva|wmv|asf|asx|ogm|ogv|m2v|avi|bin|dat|dvr-ms|mpg|mpeg|mp4|avc|vp3|svq3|nuv|viv|dv|fli|flv|wpl|vob|mkv|mk3d|ts|wtv|m2ts)$"
|
||||
musicMatch = "(?i)(\\.)(mp2|mp3|m4a|m4b|m4p|ogg|oga|opus|wma|wav|wv|flac|ape|aif|aiff|aifc)$"
|
||||
sampleMatch = `(?i)(^|[\s/\\])(sample|trailer|thumb|special|extras?)s?[-/]|(\((sample|trailer|thumb|special|extras?)s?\))|(-\s*(sample|trailer|thumb|special|extras?)s?)`
|
||||
)
|
||||
@@ -51,7 +51,8 @@ func IsMediaFile(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 RegexMatch(sampleRegex, path)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/robfig/cron/v3"
|
||||
@@ -10,25 +9,6 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func ScheduleJob(ctx context.Context, interval string, loc *time.Location, jobFunc func()) (gocron.Scheduler, error) {
|
||||
if loc == nil {
|
||||
loc = time.Local
|
||||
}
|
||||
s, err := gocron.NewScheduler(gocron.WithLocation(loc))
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("failed to create scheduler: %w", err)
|
||||
}
|
||||
jd, err := ConvertToJobDef(interval)
|
||||
if err != nil {
|
||||
return s, fmt.Errorf("failed to convert interval to job definition: %w", err)
|
||||
}
|
||||
// Schedule the job
|
||||
if _, err = s.NewJob(jd, gocron.NewTask(jobFunc), gocron.WithContext(ctx)); err != nil {
|
||||
return s, fmt.Errorf("failed to create job: %w", err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// ConvertToJobDef converts a string interval to a gocron.JobDefinition.
|
||||
func ConvertToJobDef(interval string) (gocron.JobDefinition, error) {
|
||||
// Parse the interval string
|
||||
|
||||
1624
package-lock.json
generated
Normal file
19
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ func (s *Storage) GetAll() []*Arr {
|
||||
return arrs
|
||||
}
|
||||
|
||||
func (s *Storage) StartSchedule(ctx context.Context) error {
|
||||
func (s *Storage) StartWorker(ctx context.Context) error {
|
||||
|
||||
ticker := time.NewTicker(10 * time.Second)
|
||||
|
||||
|
||||
@@ -133,7 +133,7 @@ func (a *Arr) CleanupQueue() error {
|
||||
messages := q.StatusMessages
|
||||
if len(messages) > 0 {
|
||||
for _, m := range messages {
|
||||
if strings.Contains(strings.Join(m.Messages, " "), "No files found are eligible for import in") {
|
||||
if strings.Contains(strings.Join(m.Messages, " "), "No files found are eligible") {
|
||||
isMessedUp = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -9,50 +9,70 @@ import (
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/alldebrid"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/debrid_link"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/debridlink"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/realdebrid"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/providers/torbox"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/store"
|
||||
debridStore "github.com/sirrobot01/decypharr/pkg/debrid/store"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"github.com/sirrobot01/decypharr/pkg/rclone"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Debrid struct {
|
||||
cache *store.Cache // Could be nil if not using WebDAV
|
||||
client types.Client // HTTP client for making requests to the debrid service
|
||||
cache *debridStore.Cache // Could be nil if not using WebDAV
|
||||
client types.Client // HTTP client for making requests to the debrid service
|
||||
}
|
||||
|
||||
func (de *Debrid) Client() types.Client {
|
||||
return de.client
|
||||
}
|
||||
|
||||
func (de *Debrid) Cache() *store.Cache {
|
||||
func (de *Debrid) Cache() *debridStore.Cache {
|
||||
return de.cache
|
||||
}
|
||||
|
||||
func (de *Debrid) Reset() {
|
||||
if de.cache != nil {
|
||||
de.cache.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
type Storage struct {
|
||||
debrids map[string]*Debrid
|
||||
mu sync.RWMutex
|
||||
lastUsed string
|
||||
}
|
||||
|
||||
func NewStorage() *Storage {
|
||||
func NewStorage(rcManager *rclone.Manager) *Storage {
|
||||
cfg := config.Get()
|
||||
|
||||
_logger := logger.Default()
|
||||
|
||||
debrids := make(map[string]*Debrid)
|
||||
|
||||
bindAddress := cfg.BindAddress
|
||||
if bindAddress == "" {
|
||||
bindAddress = "localhost"
|
||||
}
|
||||
webdavUrl := fmt.Sprintf("http://%s:%s%s/webdav", bindAddress, cfg.Port, cfg.URLBase)
|
||||
|
||||
for _, dc := range cfg.Debrids {
|
||||
client, err := createDebridClient(dc)
|
||||
if err != nil {
|
||||
_logger.Error().Err(err).Str("Debrid", dc.Name).Msg("failed to connect to debrid client")
|
||||
continue
|
||||
}
|
||||
var cache *store.Cache
|
||||
var (
|
||||
cache *debridStore.Cache
|
||||
mounter *rclone.Mount
|
||||
)
|
||||
_log := client.Logger()
|
||||
if dc.UseWebDav {
|
||||
cache = store.NewDebridCache(dc, client)
|
||||
if cfg.Rclone.Enabled && rcManager != nil {
|
||||
mounter = rclone.NewMount(dc.Name, dc.RcloneMountPath, webdavUrl, rcManager)
|
||||
}
|
||||
cache = debridStore.NewDebridCache(dc, client, mounter)
|
||||
_log.Info().Msg("Debrid Service started with WebDAV")
|
||||
} else {
|
||||
_log.Info().Msg("Debrid Service started")
|
||||
@@ -79,6 +99,47 @@ func (d *Storage) Debrid(name string) *Debrid {
|
||||
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 {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
@@ -102,8 +163,17 @@ func (d *Storage) Client(name string) types.Client {
|
||||
|
||||
func (d *Storage) Reset() {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
// Reset all debrid clients and caches
|
||||
for _, debrid := range d.debrids {
|
||||
if debrid != nil {
|
||||
debrid.Reset()
|
||||
}
|
||||
}
|
||||
|
||||
// Reinitialize the debrids map
|
||||
d.debrids = make(map[string]*Debrid)
|
||||
d.mu.Unlock()
|
||||
d.lastUsed = ""
|
||||
}
|
||||
|
||||
@@ -119,10 +189,10 @@ func (d *Storage) Clients() map[string]types.Client {
|
||||
return clientsCopy
|
||||
}
|
||||
|
||||
func (d *Storage) Caches() map[string]*store.Cache {
|
||||
func (d *Storage) Caches() map[string]*debridStore.Cache {
|
||||
d.mu.RLock()
|
||||
defer d.mu.RUnlock()
|
||||
cachesCopy := make(map[string]*store.Cache)
|
||||
cachesCopy := make(map[string]*debridStore.Cache)
|
||||
for name, debrid := range d.debrids {
|
||||
if debrid != nil && debrid.cache != nil {
|
||||
cachesCopy[name] = debrid.cache
|
||||
@@ -150,7 +220,7 @@ func createDebridClient(dc config.Debrid) (types.Client, error) {
|
||||
case "torbox":
|
||||
return torbox.New(dc)
|
||||
case "debridlink":
|
||||
return debrid_link.New(dc)
|
||||
return debridlink.New(dc)
|
||||
case "alldebrid":
|
||||
return alldebrid.New(dc)
|
||||
default:
|
||||
@@ -193,7 +263,7 @@ func Process(ctx context.Context, store *Storage, selectedDebrid string, magnet
|
||||
debridTorrent.DownloadUncached = false
|
||||
}
|
||||
|
||||
for index, db := range clients {
|
||||
for _, db := range clients {
|
||||
_logger := db.Logger()
|
||||
_logger.Info().
|
||||
Str("Debrid", db.Name()).
|
||||
@@ -214,7 +284,7 @@ func Process(ctx context.Context, store *Storage, selectedDebrid string, magnet
|
||||
}
|
||||
dbt.Arr = a
|
||||
_logger.Info().Str("id", dbt.Id).Msgf("Torrent: %s submitted to %s", dbt.Name, db.Name())
|
||||
store.lastUsed = index
|
||||
store.lastUsed = db.Name()
|
||||
|
||||
torrent, err := db.CheckStatus(dbt)
|
||||
if err != nil && torrent != nil && torrent.Id != "" {
|
||||
|
||||
@@ -25,6 +25,7 @@ type AllDebrid struct {
|
||||
autoExpiresLinksAfter time.Duration
|
||||
DownloadUncached bool
|
||||
client *request.Client
|
||||
Profile *types.Profile `json:"profile"`
|
||||
|
||||
MountPath string
|
||||
logger zerolog.Logger
|
||||
@@ -33,10 +34,6 @@ type AllDebrid struct {
|
||||
minimumFreeSlot int
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) GetProfile() (*types.Profile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*AllDebrid, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
|
||||
@@ -449,6 +446,58 @@ func (ad *AllDebrid) GetAvailableSlots() (int, error) {
|
||||
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 {
|
||||
return ad.accounts
|
||||
}
|
||||
|
||||
func (ad *AllDebrid) SyncAccounts() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -112,3 +112,22 @@ func (m *Magnets) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
return fmt.Errorf("magnets: unsupported JSON format")
|
||||
}
|
||||
|
||||
type UserProfileResponse struct {
|
||||
Status string `json:"status"`
|
||||
Error *errorResponse `json:"error"`
|
||||
Data struct {
|
||||
User struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
IsPremium bool `json:"isPremium"`
|
||||
IsSubscribed bool `json:"isSubscribed"`
|
||||
IsTrial bool `json:"isTrial"`
|
||||
PremiumUntil int64 `json:"premiumUntil"`
|
||||
Lang string `json:"lang"`
|
||||
FidelityPoints int `json:"fidelityPoints"`
|
||||
LimitedHostersQuotas map[string]int `json:"limitedHostersQuotas"`
|
||||
Notifications []string `json:"notifications"`
|
||||
} `json:"user"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package debrid_link
|
||||
package debridlink
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -30,6 +30,8 @@ type DebridLink struct {
|
||||
logger zerolog.Logger
|
||||
checkCached bool
|
||||
addSamples bool
|
||||
|
||||
Profile *types.Profile `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*DebridLink, error) {
|
||||
@@ -66,10 +68,6 @@ func New(dc config.Debrid) (*DebridLink, error) {
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) GetProfile() (*types.Profile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (dl *DebridLink) Name() string {
|
||||
return dl.name
|
||||
}
|
||||
@@ -476,6 +474,55 @@ func (dl *DebridLink) GetAvailableSlots() (int, error) {
|
||||
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 {
|
||||
return dl.accounts
|
||||
}
|
||||
|
||||
func (dl *DebridLink) SyncAccounts() error {
|
||||
return nil
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package debrid_link
|
||||
package debridlink
|
||||
|
||||
type APIResponse[T any] struct {
|
||||
Success bool `json:"success"`
|
||||
@@ -43,3 +43,12 @@ type _torrentInfo struct {
|
||||
type torrentInfo APIResponse[[]_torrentInfo]
|
||||
|
||||
type SubmitTorrentInfo APIResponse[_torrentInfo]
|
||||
|
||||
type UserInfo APIResponse[struct {
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
AccountType int `json:"accountType"`
|
||||
PremiumLeft int64 `json:"premiumLeft"`
|
||||
Points int `json:"pts"`
|
||||
Trafficshare int `json:"trafficshare"`
|
||||
}]
|
||||
@@ -46,7 +46,7 @@ type RealDebrid struct {
|
||||
addSamples bool
|
||||
Profile *types.Profile
|
||||
minimumFreeSlot int // Minimum number of active pots to maintain (used for cached stuffs, etc.)
|
||||
|
||||
limit int
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*RealDebrid, error) {
|
||||
@@ -101,6 +101,7 @@ func New(dc config.Debrid) (*RealDebrid, error) {
|
||||
checkCached: dc.CheckCached,
|
||||
addSamples: dc.AddSamples,
|
||||
minimumFreeSlot: dc.MinimumFreeSlot,
|
||||
limit: dc.Limit,
|
||||
}
|
||||
|
||||
if _, err := r.GetProfile(); err != nil {
|
||||
@@ -160,6 +161,23 @@ func (r *RealDebrid) getSelectedFiles(t *types.Torrent, data torrentInfo) (map[s
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (r *RealDebrid) handleRarFallback(t *types.Torrent, data torrentInfo) (map[string]types.File, error) {
|
||||
files := make(map[string]types.File)
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: "0",
|
||||
Name: t.Name + ".rar",
|
||||
Size: data.Bytes,
|
||||
IsRar: true,
|
||||
ByteRange: nil,
|
||||
Path: t.Name + ".rar",
|
||||
Link: data.Links[0],
|
||||
Generated: time.Now(),
|
||||
}
|
||||
files[file.Name] = file
|
||||
return files, nil
|
||||
}
|
||||
|
||||
// handleRarArchive processes RAR archives with multiple files
|
||||
func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, selectedFiles []types.File) (map[string]types.File, error) {
|
||||
// This will block if 2 RAR operations are already in progress
|
||||
@@ -171,21 +189,8 @@ func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, select
|
||||
files := make(map[string]types.File)
|
||||
|
||||
if !r.UnpackRar {
|
||||
r.logger.Debug().Msgf("RAR file detected, but unpacking is disabled: %s", t.Name)
|
||||
// Create a single file representing the RAR archive
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: "0",
|
||||
Name: t.Name + ".rar",
|
||||
Size: 0,
|
||||
IsRar: true,
|
||||
ByteRange: nil,
|
||||
Path: t.Name + ".rar",
|
||||
Link: data.Links[0],
|
||||
Generated: time.Now(),
|
||||
}
|
||||
files[file.Name] = file
|
||||
return files, nil
|
||||
r.logger.Debug().Msgf("RAR file detected, but unpacking is disabled: %s. Falling back to single file representation.", t.Name)
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
|
||||
r.logger.Info().Msgf("RAR file detected, unpacking: %s", t.Name)
|
||||
@@ -193,20 +198,23 @@ func (r *RealDebrid) handleRarArchive(t *types.Torrent, data torrentInfo, select
|
||||
downloadLinkObj, err := r.GetDownloadLink(t, linkFile)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get download link for RAR file: %w", err)
|
||||
r.logger.Debug().Err(err).Msgf("Error getting download link for RAR file: %s. Falling back to single file representation.", t.Name)
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
|
||||
dlLink := downloadLinkObj.DownloadLink
|
||||
reader, err := rar.NewReader(dlLink)
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create RAR reader: %w", err)
|
||||
r.logger.Debug().Err(err).Msgf("Error creating RAR reader for %s. Falling back to single file representation.", t.Name)
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
|
||||
rarFiles, err := reader.GetFiles()
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read RAR files: %w", err)
|
||||
r.logger.Debug().Err(err).Msgf("Error reading RAR files for %s. Falling back to single file representation.", t.Name)
|
||||
return r.handleRarFallback(t, data)
|
||||
}
|
||||
|
||||
// Create lookup map for faster matching
|
||||
@@ -231,7 +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())
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -510,7 +522,7 @@ func (r *RealDebrid) CheckStatus(t *types.Torrent) (*types.Torrent, error) {
|
||||
if status == "waiting_files_selection" {
|
||||
t.Files = r.getTorrentFiles(t, data)
|
||||
if len(t.Files) == 0 {
|
||||
return t, fmt.Errorf("no video files found")
|
||||
return t, fmt.Errorf("no valid files found")
|
||||
}
|
||||
filesId := make([]string, 0)
|
||||
for _, f := range t.Files {
|
||||
@@ -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) {
|
||||
|
||||
accounts := r.accounts.All()
|
||||
accounts := r.accounts.Active()
|
||||
|
||||
for _, account := range accounts {
|
||||
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) {
|
||||
limit := 5000
|
||||
if r.limit != 0 {
|
||||
limit = r.limit
|
||||
}
|
||||
hardLimit := r.limit
|
||||
|
||||
// Get first batch and total count
|
||||
allTorrents := make([]*types.Torrent, 0)
|
||||
@@ -812,6 +828,10 @@ func (r *RealDebrid) GetTorrents() ([]*types.Torrent, error) {
|
||||
}
|
||||
allTorrents = append(allTorrents, torrents...)
|
||||
offset += totalTorrents
|
||||
if hardLimit != 0 && len(allTorrents) >= hardLimit {
|
||||
// If hard limit is set, stop fetching more torrents
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if fetchError != nil {
|
||||
@@ -826,7 +846,7 @@ func (r *RealDebrid) GetDownloadLinks() (map[string]*types.DownloadLink, error)
|
||||
offset := 0
|
||||
limit := 1000
|
||||
|
||||
accounts := r.accounts.All()
|
||||
accounts := r.accounts.Active()
|
||||
|
||||
if len(accounts) < 1 {
|
||||
// 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
|
||||
}
|
||||
profile := &types.Profile{
|
||||
Name: r.name,
|
||||
Id: data.Id,
|
||||
Username: data.Username,
|
||||
Email: data.Email,
|
||||
@@ -953,3 +974,71 @@ func (r *RealDebrid) GetAvailableSlots() (int, error) {
|
||||
func (r *RealDebrid) Accounts() *types.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
|
||||
}
|
||||
|
||||
@@ -144,11 +144,11 @@ type profileResponse struct {
|
||||
Id int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Points int64 `json:"points"`
|
||||
Points int `json:"points"`
|
||||
Locale string `json:"locale"`
|
||||
Avatar string `json:"avatar"`
|
||||
Type string `json:"type"`
|
||||
Premium int `json:"premium"`
|
||||
Premium int64 `json:"premium"`
|
||||
Expiration time.Time `json:"expiration"`
|
||||
}
|
||||
|
||||
@@ -156,3 +156,10 @@ type AvailableSlotsResponse struct {
|
||||
ActiveSlots int `json:"nb"`
|
||||
TotalSlots int `json:"limit"`
|
||||
}
|
||||
|
||||
type hostData struct {
|
||||
Host map[string]int64 `json:"host"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
}
|
||||
|
||||
type TrafficResponse map[string]hostData
|
||||
|
||||
@@ -4,13 +4,6 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"github.com/sirrobot01/decypharr/pkg/version"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
gourl "net/url"
|
||||
@@ -21,6 +14,14 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/request"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"github.com/sirrobot01/decypharr/pkg/version"
|
||||
)
|
||||
|
||||
type Torbox struct {
|
||||
@@ -39,10 +40,6 @@ type Torbox struct {
|
||||
addSamples bool
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetProfile() (*types.Profile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func New(dc config.Debrid) (*Torbox, error) {
|
||||
rl := request.ParseRateLimit(dc.RateLimit)
|
||||
|
||||
@@ -168,7 +165,7 @@ func (tb *Torbox) SubmitMagnet(torrent *types.Torrent) (*types.Torrent, error) {
|
||||
return torrent, nil
|
||||
}
|
||||
|
||||
func getTorboxStatus(status string, finished bool) string {
|
||||
func (tb *Torbox) getTorboxStatus(status string, finished bool) string {
|
||||
if finished {
|
||||
return "downloaded"
|
||||
}
|
||||
@@ -176,12 +173,16 @@ func getTorboxStatus(status string, finished bool) string {
|
||||
"checkingResumeData", "metaDL", "pausedUP", "queuedUP", "checkingUP",
|
||||
"forcedUP", "allocating", "downloading", "metaDL", "pausedDL",
|
||||
"queuedDL", "checkingDL", "forcedDL", "checkingResumeData", "moving"}
|
||||
|
||||
var determinedStatus string
|
||||
switch {
|
||||
case utils.Contains(downloading, status):
|
||||
return "downloading"
|
||||
determinedStatus = "downloading"
|
||||
default:
|
||||
return "error"
|
||||
determinedStatus = "error"
|
||||
}
|
||||
|
||||
return determinedStatus
|
||||
}
|
||||
|
||||
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,
|
||||
Folder: data.Name,
|
||||
Progress: data.Progress * 100,
|
||||
Status: getTorboxStatus(data.DownloadState, data.DownloadFinished),
|
||||
Status: tb.getTorboxStatus(data.DownloadState, data.DownloadFinished),
|
||||
Speed: data.DownloadSpeed,
|
||||
Seeders: data.Seeds,
|
||||
Filename: data.Name,
|
||||
@@ -217,19 +218,33 @@ func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) {
|
||||
Added: data.CreatedAt.Format(time.RFC3339),
|
||||
}
|
||||
cfg := config.Get()
|
||||
|
||||
totalFiles := 0
|
||||
skippedSamples := 0
|
||||
skippedFileType := 0
|
||||
skippedSize := 0
|
||||
validFiles := 0
|
||||
filesWithLinks := 0
|
||||
|
||||
for _, f := range data.Files {
|
||||
totalFiles++
|
||||
fileName := filepath.Base(f.Name)
|
||||
|
||||
if !tb.addSamples && utils.IsSampleFile(f.AbsolutePath) {
|
||||
// Skip sample files
|
||||
skippedSamples++
|
||||
continue
|
||||
}
|
||||
if !cfg.IsAllowedFile(fileName) {
|
||||
skippedFileType++
|
||||
continue
|
||||
}
|
||||
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
skippedSize++
|
||||
continue
|
||||
}
|
||||
|
||||
validFiles++
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: strconv.Itoa(f.Id),
|
||||
@@ -237,8 +252,26 @@ func (tb *Torbox) GetTorrent(torrentId string) (*types.Torrent, error) {
|
||||
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)
|
||||
filesWithLinks++
|
||||
}
|
||||
|
||||
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
|
||||
if len(t.Files) > 0 {
|
||||
cleanPath = path.Clean(data.Files[0].Name)
|
||||
@@ -266,24 +299,33 @@ func (tb *Torbox) UpdateTorrent(t *types.Torrent) error {
|
||||
}
|
||||
data := res.Data
|
||||
name := data.Name
|
||||
|
||||
t.Name = name
|
||||
t.Bytes = data.Size
|
||||
t.Folder = name
|
||||
t.Progress = data.Progress * 100
|
||||
t.Status = getTorboxStatus(data.DownloadState, data.DownloadFinished)
|
||||
t.Status = tb.getTorboxStatus(data.DownloadState, data.DownloadFinished)
|
||||
t.Speed = data.DownloadSpeed
|
||||
t.Seeders = data.Seeds
|
||||
t.Filename = name
|
||||
t.OriginalFilename = name
|
||||
t.MountPath = tb.MountPath
|
||||
t.Debrid = tb.name
|
||||
|
||||
// Clear existing files map to rebuild it
|
||||
t.Files = make(map[string]types.File)
|
||||
|
||||
cfg := config.Get()
|
||||
validFiles := 0
|
||||
filesWithLinks := 0
|
||||
|
||||
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
|
||||
}
|
||||
@@ -291,6 +333,8 @@ func (tb *Torbox) UpdateTorrent(t *types.Torrent) error {
|
||||
if !cfg.IsSizeAllowed(f.Size) {
|
||||
continue
|
||||
}
|
||||
|
||||
validFiles++
|
||||
file := types.File{
|
||||
TorrentId: t.Id,
|
||||
Id: strconv.Itoa(f.Id),
|
||||
@@ -298,8 +342,16 @@ func (tb *Torbox) UpdateTorrent(t *types.Torrent) error {
|
||||
Size: f.Size,
|
||||
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
|
||||
}
|
||||
|
||||
var cleanPath string
|
||||
if len(t.Files) > 0 {
|
||||
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("file_id", file.Id)
|
||||
url += "?" + query.Encode()
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
resp, err := tb.client.MakeRequest(req)
|
||||
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
|
||||
}
|
||||
|
||||
var data DownloadLinksResponse
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
link := *data.Data
|
||||
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")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
return &types.DownloadLink{
|
||||
downloadLink := &types.DownloadLink{
|
||||
Link: file.Link,
|
||||
DownloadLink: link,
|
||||
Id: file.Id,
|
||||
Generated: now,
|
||||
ExpiresAt: now.Add(tb.autoExpiresLinksAfter),
|
||||
}, nil
|
||||
}
|
||||
|
||||
return downloadLink, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetDownloadingStatus() []string {
|
||||
@@ -440,7 +520,87 @@ func (tb *Torbox) GetDownloadingStatus() []string {
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -468,6 +628,14 @@ func (tb *Torbox) GetAvailableSlots() (int, error) {
|
||||
return 0, fmt.Errorf("not implemented")
|
||||
}
|
||||
|
||||
func (tb *Torbox) GetProfile() (*types.Profile, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (tb *Torbox) Accounts() *types.Accounts {
|
||||
return tb.accounts
|
||||
}
|
||||
|
||||
func (tb *Torbox) SyncAccounts() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ type torboxInfo struct {
|
||||
} `json:"files"`
|
||||
DownloadPath string `json:"download_path"`
|
||||
InactiveCheck int `json:"inactive_check"`
|
||||
Availability int `json:"availability"`
|
||||
Availability float64 `json:"availability"`
|
||||
DownloadFinished bool `json:"download_finished"`
|
||||
Tracker interface{} `json:"tracker"`
|
||||
TotalUploaded int `json:"total_uploaded"`
|
||||
@@ -73,3 +73,5 @@ type torboxInfo struct {
|
||||
type InfoResponse APIResponse[torboxInfo]
|
||||
|
||||
type DownloadLinksResponse APIResponse[string]
|
||||
|
||||
type TorrentsListResponse APIResponse[[]torboxInfo]
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"github.com/sirrobot01/decypharr/pkg/rclone"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
@@ -17,13 +17,16 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
|
||||
"encoding/json"
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
_ "time/tzdata"
|
||||
)
|
||||
|
||||
type WebDavFolderNaming string
|
||||
@@ -104,9 +107,10 @@ type Cache struct {
|
||||
|
||||
config config.Debrid
|
||||
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()
|
||||
cet, err := time.LoadLocation("CET")
|
||||
if err != nil {
|
||||
@@ -118,9 +122,13 @@ func NewDebridCache(dc config.Debrid, client types.Client) *Cache {
|
||||
cetSc, err := gocron.NewScheduler(gocron.WithLocation(cet))
|
||||
if err != nil {
|
||||
// If we can't create a CET scheduler, fallback to local time
|
||||
cetSc, _ = gocron.NewScheduler(gocron.WithLocation(time.Local))
|
||||
cetSc, _ = gocron.NewScheduler(gocron.WithLocation(time.Local), gocron.WithGlobalJobOptions(
|
||||
gocron.WithTags("decypharr-"+dc.Name)))
|
||||
}
|
||||
scheduler, err := gocron.NewScheduler(gocron.WithLocation(time.Local))
|
||||
scheduler, err := gocron.NewScheduler(
|
||||
gocron.WithLocation(time.Local),
|
||||
gocron.WithGlobalJobOptions(
|
||||
gocron.WithTags("decypharr-"+dc.Name)))
|
||||
if err != nil {
|
||||
// If we can't create a local scheduler, fallback to CET
|
||||
scheduler = cetSc
|
||||
@@ -161,6 +169,7 @@ func NewDebridCache(dc config.Debrid, client types.Client) *Cache {
|
||||
|
||||
config: dc,
|
||||
customFolders: customFolders,
|
||||
mounter: mounter,
|
||||
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
@@ -184,6 +193,15 @@ func (c *Cache) StreamWithRclone() bool {
|
||||
// and before you discard the instance on a restart.
|
||||
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 {
|
||||
c.logger.Error().Err(err).Msg("Failed to stop scheduler jobs")
|
||||
}
|
||||
@@ -196,7 +214,9 @@ func (c *Cache) Reset() {
|
||||
c.listingDebouncer.Stop()
|
||||
|
||||
// Close the repair channel
|
||||
close(c.repairChan)
|
||||
if c.repairChan != nil {
|
||||
close(c.repairChan)
|
||||
}
|
||||
|
||||
// 1. Reset torrent storage
|
||||
c.torrents.reset()
|
||||
@@ -217,6 +237,9 @@ func (c *Cache) Reset() {
|
||||
|
||||
// 6. Reset repair channel so the next Start() can spin it up
|
||||
c.repairChan = make(chan RepairRequest, 100)
|
||||
|
||||
// Reset the ready channel
|
||||
c.ready = make(chan struct{})
|
||||
}
|
||||
|
||||
func (c *Cache) Start(ctx context.Context) error {
|
||||
@@ -235,11 +258,6 @@ func (c *Cache) Start(ctx context.Context) error {
|
||||
|
||||
// initial download links
|
||||
go c.refreshDownloadLinks(ctx)
|
||||
|
||||
if err := c.StartSchedule(ctx); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to start cache worker")
|
||||
}
|
||||
|
||||
c.repairChan = make(chan RepairRequest, 100) // Initialize the repair channel, max 100 requests buffered
|
||||
go c.repairWorker(ctx)
|
||||
|
||||
@@ -248,10 +266,13 @@ func (c *Cache) Start(ctx context.Context) error {
|
||||
addr := cfg.BindAddress + ":" + cfg.Port + cfg.URLBase + "webdav/" + name + "/"
|
||||
c.logger.Info().Msgf("%s WebDav server running at %s", name, addr)
|
||||
|
||||
<-ctx.Done()
|
||||
c.logger.Info().Msgf("Stopping %s WebDav server", name)
|
||||
c.Reset()
|
||||
|
||||
if c.mounter != nil {
|
||||
if err := c.mounter.Mount(ctx); err != nil {
|
||||
c.logger.Error().Err(err).Msgf("Failed to mount %s", c.config.Name)
|
||||
}
|
||||
} else {
|
||||
c.logger.Warn().Msgf("Mounting is disabled for %s", c.config.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -682,8 +703,13 @@ func (c *Cache) ProcessTorrent(t *types.Torrent) error {
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
addedOn, err := time.Parse(time.RFC3339, t.Added)
|
||||
if err != nil {
|
||||
addedOn = time.Now()
|
||||
@@ -874,3 +900,7 @@ func (c *Cache) RemoveFile(torrentId string, filename string) error {
|
||||
func (c *Cache) Logger() zerolog.Logger {
|
||||
return c.logger
|
||||
}
|
||||
|
||||
func (c *Cache) GetConfig() config.Debrid {
|
||||
return c.config
|
||||
}
|
||||
|
||||
@@ -105,6 +105,11 @@ func (c *Cache) fetchDownloadLink(torrentName, filename, fileLink string) (*type
|
||||
downloadLink, err := c.client.GetDownloadLink(ct.Torrent, &file)
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
downloadLink, err = c.client.GetDownloadLink(ct.Torrent, &file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("retry failed to get download link: %w", err)
|
||||
}
|
||||
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
|
||||
} else if errors.Is(err, utils.TrafficExceededError) {
|
||||
|
||||
@@ -127,10 +127,21 @@ func (c *Cache) refreshTorrents(ctx context.Context) {
|
||||
|
||||
func (c *Cache) refreshRclone() error {
|
||||
cfg := c.config
|
||||
|
||||
if cfg.RcUrl == "" {
|
||||
return nil
|
||||
dirs := strings.FieldsFunc(cfg.RcRefreshDirs, func(r rune) bool {
|
||||
return r == ',' || r == '&'
|
||||
})
|
||||
if len(dirs) == 0 {
|
||||
dirs = []string{"__all__"}
|
||||
}
|
||||
if c.mounter != nil {
|
||||
return c.mounter.RefreshDir(dirs)
|
||||
} else {
|
||||
return c.refreshRcloneWithRC(dirs)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Cache) refreshRcloneWithRC(dirs []string) error {
|
||||
cfg := c.config
|
||||
|
||||
if cfg.RcUrl == "" {
|
||||
return nil
|
||||
@@ -138,7 +149,7 @@ func (c *Cache) refreshRclone() error {
|
||||
|
||||
client := http.DefaultClient
|
||||
// Create form data
|
||||
data := c.buildRcloneRequestData()
|
||||
data := c.buildRcloneRequestData(dirs)
|
||||
|
||||
if err := c.sendRcloneRequest(client, "vfs/forget", data); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to send rclone vfs/forget request")
|
||||
@@ -151,16 +162,7 @@ func (c *Cache) refreshRclone() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Cache) buildRcloneRequestData() string {
|
||||
cfg := c.config
|
||||
dirs := strings.FieldsFunc(cfg.RcRefreshDirs, func(r rune) bool {
|
||||
return r == ',' || r == '&'
|
||||
})
|
||||
|
||||
if len(dirs) == 0 {
|
||||
return "dir=__all__"
|
||||
}
|
||||
|
||||
func (c *Cache) buildRcloneRequestData(dirs []string) string {
|
||||
var data strings.Builder
|
||||
for index, dir := range dirs {
|
||||
if dir != "" {
|
||||
|
||||
@@ -6,9 +6,12 @@ import (
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
)
|
||||
|
||||
func (c *Cache) StartSchedule(ctx context.Context) error {
|
||||
func (c *Cache) StartWorker(ctx context.Context) error {
|
||||
// For now, we just want to refresh the listing and download links
|
||||
|
||||
// Stop any existing jobs before starting new ones
|
||||
c.scheduler.RemoveByTags("decypharr-%s", c.GetConfig().Name)
|
||||
|
||||
// Schedule download link refresh job
|
||||
if jd, err := utils.ConvertToJobDef(c.downloadLinksRefreshInterval); err != nil {
|
||||
c.logger.Error().Err(err).Msg("Failed to convert download link refresh interval to job definition")
|
||||
|
||||
@@ -33,15 +33,17 @@ func NewAccounts(debridConf config.Debrid) *Accounts {
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
Debrid string // e.g., "realdebrid", "torbox", etc.
|
||||
Order int
|
||||
Disabled bool
|
||||
Token string
|
||||
links map[string]*DownloadLink
|
||||
mu sync.RWMutex
|
||||
Debrid string // e.g., "realdebrid", "torbox", etc.
|
||||
Order int
|
||||
Disabled bool
|
||||
Token string `json:"token"`
|
||||
links map[string]*DownloadLink
|
||||
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()
|
||||
defer a.mu.RUnlock()
|
||||
activeAccounts := make([]*Account, 0)
|
||||
@@ -53,6 +55,12 @@ func (a *Accounts) All() []*Account {
|
||||
return activeAccounts
|
||||
}
|
||||
|
||||
func (a *Accounts) All() []*Account {
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
return a.accounts
|
||||
}
|
||||
|
||||
func (a *Accounts) Current() *Account {
|
||||
a.mu.RLock()
|
||||
if a.current != nil {
|
||||
@@ -177,6 +185,23 @@ func (a *Accounts) SetDownloadLinks(links map[string]*DownloadLink) {
|
||||
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 {
|
||||
return &Account{
|
||||
Debrid: debridName,
|
||||
@@ -213,7 +238,6 @@ func (a *Account) LinksCount() int {
|
||||
defer a.mu.RUnlock()
|
||||
return len(a.links)
|
||||
}
|
||||
|
||||
func (a *Account) disable() {
|
||||
a.Disabled = true
|
||||
}
|
||||
|
||||
@@ -25,4 +25,5 @@ type Client interface {
|
||||
DeleteDownloadLink(linkId string) error
|
||||
GetProfile() (*Profile, error)
|
||||
GetAvailableSlots() (int, error)
|
||||
SyncAccounts() error // Updates each accounts details(like traffic, username, etc.)
|
||||
}
|
||||
|
||||
@@ -114,19 +114,27 @@ type IngestData struct {
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
type LibraryStats struct {
|
||||
Total int `json:"total"`
|
||||
Bad int `json:"bad"`
|
||||
ActiveLinks int `json:"active_links"`
|
||||
}
|
||||
|
||||
type Stats struct {
|
||||
Profile *Profile `json:"profile"`
|
||||
Library LibraryStats `json:"library"`
|
||||
Accounts []map[string]any `json:"accounts"`
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
Name string `json:"name"`
|
||||
Id int64 `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email"`
|
||||
Points int64 `json:"points"`
|
||||
Points int `json:"points"`
|
||||
Type string `json:"type"`
|
||||
Premium int `json:"premium"`
|
||||
Premium int64 `json:"premium"`
|
||||
Expiration time.Time `json:"expiration"`
|
||||
|
||||
LibrarySize int `json:"library_size"`
|
||||
BadTorrents int `json:"bad_torrents"`
|
||||
ActiveLinks int `json:"active_links"`
|
||||
}
|
||||
|
||||
type DownloadLink struct {
|
||||
|
||||
@@ -5,8 +5,10 @@ import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"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
|
||||
func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
cfg := config.Get()
|
||||
host, token, err := decodeAuthHeader(r.Header.Get("Authorization"))
|
||||
category := getCategory(r.Context())
|
||||
arrs := store.Get().Arr()
|
||||
@@ -145,12 +148,22 @@ func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||
a.Token = token
|
||||
}
|
||||
}
|
||||
a.Source = "auto"
|
||||
if err := validateServiceURL(a.Host); err != nil {
|
||||
// Return silently, no need to raise a problem. Just do not add the Arr to the context/config.json
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
if cfg.NeedsAuth() {
|
||||
if a.Host == "" || a.Token == "" {
|
||||
http.Error(w, "Unauthorized: Host and token are required for authentication", http.StatusUnauthorized)
|
||||
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)
|
||||
ctx := context.WithValue(r.Context(), arrKey, a)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
@@ -176,3 +189,19 @@ func hashesContext(next http.Handler) http.Handler {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if err := _arr.Validate(); err != nil {
|
||||
q.logger.Error().Err(err).Msgf("Error validating arr")
|
||||
http.Error(w, "Invalid arr configuration", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("Ok."))
|
||||
}
|
||||
|
||||
403
pkg/rclone/client.go
Normal 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
@@ -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
@@ -0,0 +1,43 @@
|
||||
//go:build !windows
|
||||
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// WasHardTerminated reports true iff the process was ended by SIGKILL or SIGTERM.
|
||||
func WasHardTerminated(err error) bool {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
return false
|
||||
}
|
||||
ws, ok := ee.Sys().(syscall.WaitStatus)
|
||||
if !ok || !ws.Signaled() {
|
||||
return false
|
||||
}
|
||||
sig := ws.Signal()
|
||||
return sig == syscall.SIGKILL || sig == syscall.SIGTERM
|
||||
}
|
||||
|
||||
// ExitCode returns the numeric exit code when available.
|
||||
func ExitCode(err error) (int, bool) {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
return 0, false
|
||||
}
|
||||
ws, ok := ee.Sys().(syscall.WaitStatus)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
if ws.Exited() {
|
||||
return ws.ExitStatus(), true
|
||||
}
|
||||
// Conventional shell “killed by signal” code is 128 + signal.
|
||||
if ws.Signaled() {
|
||||
return 128 + int(ws.Signal()), true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
35
pkg/rclone/killed_windows.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build windows
|
||||
|
||||
package rclone
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func WasHardTerminated(err error) bool {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
return false
|
||||
}
|
||||
ws, ok := ee.Sys().(syscall.WaitStatus)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// No Signaled() on Windows; consider "hard terminated" if not success.
|
||||
return ws.ExitStatus() != 0 // Use the ExitStatus() method
|
||||
}
|
||||
|
||||
// ExitCode returns the process exit code when available.
|
||||
func ExitCode(err error) (int, bool) {
|
||||
var ee *exec.ExitError
|
||||
if !errors.As(err, &ee) {
|
||||
return 0, false
|
||||
}
|
||||
ws, ok := ee.Sys().(syscall.WaitStatus)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
return ws.ExitStatus(), true // Use the ExitStatus() method
|
||||
}
|
||||
402
pkg/rclone/manager.go
Normal 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
@@ -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
@@ -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
|
||||
}
|
||||
@@ -534,17 +534,21 @@ func (r *Repair) checkMountUp(media []arr.Content) error {
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no files found in media %s", firstMedia.Title)
|
||||
}
|
||||
firstFile := files[0]
|
||||
symlinkPath := getSymlinkTarget(firstFile.Path)
|
||||
|
||||
if symlinkPath == "" {
|
||||
return fmt.Errorf("no symlink target found for %s", firstFile.Path)
|
||||
}
|
||||
r.logger.Debug().Msgf("Checking symlink parent directory for %s", symlinkPath)
|
||||
|
||||
parentSymlink := filepath.Dir(filepath.Dir(symlinkPath)) // /mnt/zurg/torrents/movie/movie.mkv -> /mnt/zurg/torrents
|
||||
if _, err := os.Stat(parentSymlink); os.IsNotExist(err) {
|
||||
return fmt.Errorf("parent directory %s not accessible for %s", parentSymlink, firstFile.Path)
|
||||
for _, file := range files {
|
||||
if _, err := os.Stat(file.Path); os.IsNotExist(err) {
|
||||
// If the file does not exist, we can't check the symlink target
|
||||
r.logger.Debug().Msgf("File %s does not exist, skipping repair", file.Path)
|
||||
return fmt.Errorf("file %s does not exist, skipping repair", file.Path)
|
||||
}
|
||||
// Get the symlink target
|
||||
symlinkPath := getSymlinkTarget(file.Path)
|
||||
if symlinkPath != "" {
|
||||
r.logger.Trace().Msgf("Found symlink target for %s: %s", file.Path, symlinkPath)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -99,24 +99,77 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
clients := debrids.Clients()
|
||||
caches := debrids.Caches()
|
||||
profiles := make([]*debridTypes.Profile, 0)
|
||||
debridStats := make([]debridTypes.Stats, 0)
|
||||
for debridName, client := range clients {
|
||||
debridStat := debridTypes.Stats{}
|
||||
libraryStat := debridTypes.LibraryStats{}
|
||||
profile, err := client.GetProfile()
|
||||
profile.Name = debridName
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("Failed to get debrid profile")
|
||||
continue
|
||||
s.logger.Error().Err(err).Str("debrid", debridName).Msg("Failed to get debrid profile")
|
||||
profile = &debridTypes.Profile{
|
||||
Name: debridName,
|
||||
}
|
||||
}
|
||||
profile.Name = debridName
|
||||
debridStat.Profile = profile
|
||||
cache, ok := caches[debridName]
|
||||
if ok {
|
||||
// Get torrent data
|
||||
profile.LibrarySize = cache.TotalTorrents()
|
||||
profile.BadTorrents = len(cache.GetListing("__bad__"))
|
||||
profile.ActiveLinks = cache.GetTotalActiveDownloadLinks()
|
||||
libraryStat.Total = cache.TotalTorrents()
|
||||
libraryStat.Bad = len(cache.GetListing("__bad__"))
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
@@ -30,10 +30,6 @@ func New(handlers map[string]http.Handler) *Server {
|
||||
s := &Server{
|
||||
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) {
|
||||
for pattern, handler := range handlers {
|
||||
@@ -41,11 +37,12 @@ func New(handlers map[string]http.Handler) *Server {
|
||||
}
|
||||
|
||||
//logs
|
||||
r.Get("/logs", s.getLogs)
|
||||
r.Get("/logs", s.getLogs) // deprecated, use /debug/logs
|
||||
|
||||
//debugs
|
||||
r.Route("/debug", func(r chi.Router) {
|
||||
r.Get("/stats", s.handleStats)
|
||||
r.Get("/logs", s.getLogs)
|
||||
r.Get("/logs/rclone", s.getRcloneLogs)
|
||||
r.Get("/ingests", s.handleIngests)
|
||||
r.Get("/ingests/{debrid}", s.handleIngestsByDebrid)
|
||||
})
|
||||
@@ -80,7 +77,7 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
||||
logFile := logger.GetLogPath()
|
||||
logFile := filepath.Join(logger.GetLogPath(), "decypharr.log")
|
||||
|
||||
// Open and read the file
|
||||
file, err := os.Open(logFile)
|
||||
@@ -103,5 +100,42 @@ func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Expires", "0")
|
||||
|
||||
// Stream the file
|
||||
_, _ = io.Copy(w, file)
|
||||
if _, err := io.Copy(w, file); err != nil {
|
||||
s.logger.Error().Err(err).Msg("Error streaming log file")
|
||||
http.Error(w, "Error streaming log file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) getRcloneLogs(w http.ResponseWriter, r *http.Request) {
|
||||
// Rclone logs resides in the same directory as the application logs
|
||||
logFile := filepath.Join(logger.GetLogPath(), "rclone.log")
|
||||
// Open and read the file
|
||||
file, err := os.Open(logFile)
|
||||
if err != nil {
|
||||
http.Error(w, "Error reading log file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer func(file *os.File) {
|
||||
err := file.Close()
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("Error closing log file")
|
||||
return
|
||||
|
||||
}
|
||||
}(file)
|
||||
|
||||
// Set headers
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("Content-Disposition", "inline; filename=application.log")
|
||||
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||
w.Header().Set("Pragma", "no-cache")
|
||||
w.Header().Set("Expires", "0")
|
||||
|
||||
// Stream the file
|
||||
if _, err := io.Copy(w, file); err != nil {
|
||||
s.logger.Error().Err(err).Msg("Error streaming log file")
|
||||
http.Error(w, "Error streaming log file", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ package store
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid/types"
|
||||
|
||||
"github.com/cavaliergopher/grab/v3"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
)
|
||||
@@ -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) {
|
||||
files := debridTorrent.Files
|
||||
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))
|
||||
rCloneBase := debridTorrent.MountPath
|
||||
@@ -212,7 +213,7 @@ func (s *Store) processSymlink(torrent *Torrent, debridTorrent *types.Torrent) (
|
||||
if _, err := os.Stat(fullFilePath); !os.IsNotExist(err) {
|
||||
fileSymlinkPath := filepath.Join(torrentSymlinkPath, file.Name)
|
||||
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
||||
s.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||
s.logger.Warn().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||
} else {
|
||||
filePaths = append(filePaths, fileSymlinkPath)
|
||||
delete(pending, path)
|
||||
|
||||
@@ -3,6 +3,8 @@ package store
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/sirrobot01/decypharr/internal/utils"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -25,58 +27,51 @@ func (s *Store) addToQueue(importReq *ImportRequest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) StartQueueSchedule(ctx context.Context) error {
|
||||
// Start the slots processing in a separate goroutine
|
||||
go func() {
|
||||
if err := s.processSlotsQueue(ctx); err != nil {
|
||||
s.logger.Error().Err(err).Msg("Error processing slots queue")
|
||||
}
|
||||
}()
|
||||
func (s *Store) StartQueueWorkers(ctx context.Context) error {
|
||||
// This function is responsible for starting the scheduled tasks
|
||||
|
||||
// Start the remove stalled torrents processing in a separate goroutine
|
||||
go func() {
|
||||
if err := s.processRemoveStalledTorrents(ctx); err != nil {
|
||||
s.logger.Error().Err(err).Msg("Error processing remove stalled torrents")
|
||||
}
|
||||
}()
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
s.scheduler.RemoveByTags("decypharr-store")
|
||||
|
||||
func (s *Store) processSlotsQueue(ctx context.Context) error {
|
||||
s.trackAvailableSlots(ctx) // Initial tracking of available slots
|
||||
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if jd, err := utils.ConvertToJobDef("30s"); err != nil {
|
||||
s.logger.Error().Err(err).Msg("Failed to convert slots tracking interval to job definition")
|
||||
} else {
|
||||
// Schedule the job
|
||||
if _, err := s.scheduler.NewJob(jd, gocron.NewTask(func() {
|
||||
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 {
|
||||
return nil // No need to remove stalled torrents if the duration is not set
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(time.Minute)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := s.removeStalledTorrents(ctx); err != nil {
|
||||
s.logger.Error().Err(err).Msg("Error removing stalled torrents")
|
||||
if s.removeStalledAfter > 0 {
|
||||
// Stalled torrents removal job
|
||||
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 {
|
||||
// Schedule the job
|
||||
if _, err := s.scheduler.NewJob(jd, gocron.NewTask(func() {
|
||||
err := s.removeStalledTorrents(ctx)
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msg("Failed to process remove stalled torrents")
|
||||
}
|
||||
}), gocron.WithContext(ctx)); err != nil {
|
||||
s.logger.Error().Err(err).Msg("Failed to create remove stalled torrents job")
|
||||
} else {
|
||||
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) {
|
||||
|
||||
@@ -3,11 +3,13 @@ package store
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/sirrobot01/decypharr/internal/config"
|
||||
"github.com/sirrobot01/decypharr/internal/logger"
|
||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||
"github.com/sirrobot01/decypharr/pkg/debrid"
|
||||
"github.com/sirrobot01/decypharr/pkg/rclone"
|
||||
"github.com/sirrobot01/decypharr/pkg/repair"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -17,6 +19,7 @@ type Store struct {
|
||||
repair *repair.Repair
|
||||
arr *arr.Storage
|
||||
debrid *debrid.Storage
|
||||
rcloneManager *rclone.Manager
|
||||
importsQueue *ImportQueue // Queued import requests(probably from too_many_active_downloads)
|
||||
torrents *TorrentStorage
|
||||
logger zerolog.Logger
|
||||
@@ -24,6 +27,7 @@ type Store struct {
|
||||
skipPreCache bool
|
||||
downloadSemaphore chan struct{}
|
||||
removeStalledAfter time.Duration // Duration after which stalled torrents are removed
|
||||
scheduler gocron.Scheduler
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -34,21 +38,36 @@ var (
|
||||
// Get returns the singleton instance
|
||||
func Get() *Store {
|
||||
once.Do(func() {
|
||||
arrs := arr.NewStorage()
|
||||
deb := debrid.NewStorage()
|
||||
cfg := config.Get()
|
||||
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{
|
||||
repair: repair.New(arrs, deb),
|
||||
arr: arrs,
|
||||
debrid: deb,
|
||||
rcloneManager: rcManager,
|
||||
torrents: newTorrentStorage(cfg.TorrentsFile()),
|
||||
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,
|
||||
downloadSemaphore: make(chan struct{}, cmp.Or(qbitCfg.MaxDownloads, 5)),
|
||||
importsQueue: NewImportQueue(context.Background(), 1000),
|
||||
scheduler: scheduler,
|
||||
}
|
||||
if cfg.RemoveStalledAfter != "" {
|
||||
removeStalledAfter, err := time.ParseDuration(cfg.RemoveStalledAfter)
|
||||
@@ -66,11 +85,22 @@ func Reset() {
|
||||
instance.debrid.Reset()
|
||||
}
|
||||
|
||||
if instance.rcloneManager != nil {
|
||||
instance.rcloneManager.Stop()
|
||||
}
|
||||
|
||||
if instance.importsQueue != nil {
|
||||
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{}
|
||||
instance = nil
|
||||
@@ -88,3 +118,10 @@ func (s *Store) Repair() *repair.Repair {
|
||||
func (s *Store) Torrents() *TorrentStorage {
|
||||
return s.torrents
|
||||
}
|
||||
func (s *Store) RcloneManager() *rclone.Manager {
|
||||
return s.rcloneManager
|
||||
}
|
||||
|
||||
func (s *Store) Scheduler() gocron.Scheduler {
|
||||
return s.scheduler
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"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"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 {
|
||||
@@ -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)
|
||||
dbT, err := client.CheckStatus(debridTorrent)
|
||||
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 != "" {
|
||||
// Delete the torrent if it was not downloaded
|
||||
go func() {
|
||||
|
||||
44
pkg/store/worker.go
Normal 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")
|
||||
}
|
||||
}
|
||||
123
pkg/web/api.go
@@ -3,6 +3,7 @@ package web
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/sirrobot01/decypharr/pkg/store"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -214,7 +215,26 @@ func (wb *Web) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||
for _, a := range unique {
|
||||
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) {
|
||||
@@ -247,6 +267,7 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// Update Repair config
|
||||
currentConfig.Repair = updatedConfig.Repair
|
||||
currentConfig.Rclone = updatedConfig.Rclone
|
||||
|
||||
// Update Debrids
|
||||
if len(updatedConfig.Debrids) > 0 {
|
||||
@@ -359,3 +380,103 @@ func (wb *Web) handleStopRepairJob(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func (wb *Web) handleRefreshAPIToken(w http.ResponseWriter, _ *http.Request) {
|
||||
token, err := wb.refreshAPIToken()
|
||||
if err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to refresh API token")
|
||||
http.Error(w, "Failed to refresh token: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
request.JSONResponse(w, map[string]interface{}{
|
||||
"token": token,
|
||||
"message": "API token refreshed successfully",
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
func (wb *Web) handleUpdateAuth(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
ConfirmPassword string `json:"confirm_password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
cfg := config.Get()
|
||||
auth := cfg.GetAuth()
|
||||
if auth == nil {
|
||||
auth = &config.Auth{}
|
||||
}
|
||||
|
||||
// Check if trying to disable authentication (both empty)
|
||||
if req.Username == "" && req.Password == "" {
|
||||
// Disable authentication
|
||||
cfg.UseAuth = false
|
||||
auth.Username = ""
|
||||
auth.Password = ""
|
||||
if err := cfg.SaveAuth(auth); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save auth config")
|
||||
http.Error(w, "Failed to save authentication settings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := cfg.Save(); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save config")
|
||||
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
request.JSONResponse(w, map[string]string{
|
||||
"message": "Authentication disabled successfully",
|
||||
}, http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Username == "" {
|
||||
http.Error(w, "Username is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Password == "" {
|
||||
http.Error(w, "Password is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if req.Password != req.ConfirmPassword {
|
||||
http.Error(w, "Passwords do not match", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Hash the password
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to hash password")
|
||||
http.Error(w, "Failed to process password", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Update auth settings
|
||||
auth.Username = req.Username
|
||||
auth.Password = string(hashedPassword)
|
||||
cfg.UseAuth = true
|
||||
|
||||
// Save auth config
|
||||
if err := cfg.SaveAuth(auth); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save auth config")
|
||||
http.Error(w, "Failed to save authentication settings", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Save main config
|
||||
if err := cfg.Save(); err != nil {
|
||||
wb.logger.Error().Err(err).Msg("Failed to save config")
|
||||
http.Error(w, "Failed to save configuration", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
request.JSONResponse(w, map[string]string{
|
||||
"message": "Authentication settings updated successfully",
|
||||
}, http.StatusOK)
|
||||
}
|
||||
|
||||
7
pkg/web/assets/build/css/styles.css
Normal file
BIN
pkg/web/assets/build/fonts/bootstrap-icons.woff
Normal file
BIN
pkg/web/assets/build/fonts/bootstrap-icons.woff2
Normal file
1
pkg/web/assets/build/js/common.js
Normal file
1
pkg/web/assets/build/js/config.js
Normal file
1
pkg/web/assets/build/js/dashboard.js
Normal file
1
pkg/web/assets/build/js/download.js
Normal file
2
pkg/web/assets/build/js/jquery-3.7.1.min.js
vendored
Normal file
1
pkg/web/assets/build/js/repair.js
Normal file
2078
pkg/web/assets/css/bootstrap-icons.css
vendored
Normal file
BIN
pkg/web/assets/images/favicon/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
pkg/web/assets/images/favicon/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 284 KiB |
BIN
pkg/web/assets/images/favicon/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
pkg/web/assets/images/favicon/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 665 B |
BIN
pkg/web/assets/images/favicon/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
pkg/web/assets/images/favicon/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |