Improve Arr integerations

This commit is contained in:
Mukhtar Akere
2025-06-19 14:40:12 +01:00
parent c15e9d8f70
commit 086aa3b1ff
7 changed files with 472 additions and 145 deletions

View File

@@ -54,6 +54,7 @@ type Arr struct {
SkipRepair bool `json:"skip_repair,omitempty"`
DownloadUncached *bool `json:"download_uncached,omitempty"`
SelectedDebrid string `json:"selected_debrid,omitempty"`
Source string `json:"source,omitempty"` // The source of the arr, e.g. "auto", "config", "". Auto means it was automatically detected from the arr
}
type Repair struct {

View File

@@ -3,6 +3,7 @@ package arr
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"github.com/rs/zerolog"
@@ -19,6 +20,13 @@ import (
// Type is a type of arr
type Type string
var sharedClient = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
Timeout: 60 * time.Second,
}
const (
Sonarr Type = "sonarr"
Radarr Type = "radarr"
@@ -35,10 +43,10 @@ type Arr struct {
SkipRepair bool `json:"skip_repair"`
DownloadUncached *bool `json:"download_uncached"`
SelectedDebrid string `json:"selected_debrid,omitempty"` // The debrid service selected for this arr
client *request.Client
Source string `json:"source,omitempty"` // The source of the arr, e.g. "auto", "manual". Auto means it was automatically detected from the arr
}
func New(name, host, token string, cleanup, skipRepair bool, downloadUncached *bool, selectedDebrid string) *Arr {
func New(name, host, token string, cleanup, skipRepair bool, downloadUncached *bool, selectedDebrid, source string) *Arr {
return &Arr{
Name: name,
Host: host,
@@ -47,8 +55,8 @@ func New(name, host, token string, cleanup, skipRepair bool, downloadUncached *b
Cleanup: cleanup,
SkipRepair: skipRepair,
DownloadUncached: downloadUncached,
client: request.New(),
SelectedDebrid: selectedDebrid,
Source: source,
}
}
@@ -75,14 +83,11 @@ func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Respo
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", a.Token)
if a.client == nil {
a.client = request.New()
}
var resp *http.Response
for attempts := 0; attempts < 5; attempts++ {
resp, err = a.client.Do(req)
resp, err = sharedClient.Do(req)
if err != nil {
return nil, err
}
@@ -104,7 +109,7 @@ func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Respo
func (a *Arr) Validate() error {
if a.Token == "" || a.Host == "" {
return nil
return fmt.Errorf("arr not configured: %s", a.Name)
}
resp, err := a.Request("GET", "/api/v3/health", nil)
if err != nil {
@@ -146,8 +151,11 @@ func InferType(host, name string) Type {
func NewStorage() *Storage {
arrs := make(map[string]*Arr)
for _, a := range config.Get().Arrs {
if a.Host == "" || a.Token == "" || a.Name == "" {
continue // Skip if host or token is not set
}
name := a.Name
arrs[name] = New(name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid)
arrs[name] = New(name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid, a.Source)
}
return &Storage{
Arrs: arrs,
@@ -158,7 +166,7 @@ func NewStorage() *Storage {
func (s *Storage) AddOrUpdate(arr *Arr) {
s.mu.Lock()
defer s.mu.Unlock()
if arr.Name == "" {
if arr.Host == "" || arr.Token == "" || arr.Name == "" {
return
}
s.Arrs[arr.Name] = arr
@@ -175,19 +183,11 @@ func (s *Storage) GetAll() []*Arr {
defer s.mu.Unlock()
arrs := make([]*Arr, 0, len(s.Arrs))
for _, arr := range s.Arrs {
if arr.Host != "" && arr.Token != "" {
arrs = append(arrs, arr)
}
arrs = append(arrs, arr)
}
return arrs
}
func (s *Storage) Clear() {
s.mu.Lock()
defer s.mu.Unlock()
s.Arrs = make(map[string]*Arr)
}
func (s *Storage) StartSchedule(ctx context.Context) error {
ticker := time.NewTicker(10 * time.Second)

View File

@@ -3,10 +3,12 @@ package qbit
import (
"context"
"encoding/base64"
"fmt"
"github.com/go-chi/chi/v5"
"github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/store"
"net/http"
"net/url"
"strings"
)
@@ -18,6 +20,45 @@ const (
arrKey contextKey = "arr"
)
func validateServiceURL(urlStr string) error {
if urlStr == "" {
return fmt.Errorf("URL cannot be empty")
}
// Try parsing as full URL first
u, err := url.Parse(urlStr)
if err == nil && u.Scheme != "" && u.Host != "" {
// It's a full URL, validate scheme
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("URL scheme must be http or https")
}
return nil
}
// Check if it's a host:port format (no scheme)
if strings.Contains(urlStr, ":") && !strings.Contains(urlStr, "://") {
// Try parsing with http:// prefix
testURL := "http://" + urlStr
u, err := url.Parse(testURL)
if err != nil {
return fmt.Errorf("invalid host:port format: %w", err)
}
if u.Host == "" {
return fmt.Errorf("host is required in host:port format")
}
// Validate port number
if u.Port() == "" {
return fmt.Errorf("port is required in host:port format")
}
return nil
}
return fmt.Errorf("invalid URL format: %s", urlStr)
}
func getCategory(ctx context.Context) string {
if category, ok := ctx.Value(categoryKey).(string); ok {
return category
@@ -32,7 +73,7 @@ func getHashes(ctx context.Context) []string {
return nil
}
func getArr(ctx context.Context) *arr.Arr {
func getArrFromContext(ctx context.Context) *arr.Arr {
if a, ok := ctx.Value(arrKey).(*arr.Arr); ok {
return a
}
@@ -78,6 +119,10 @@ func (q *QBit) categoryContext(next http.Handler) http.Handler {
})
}
// authContext creates a middleware that extracts the Arr host and token from the Authorization header
// and adds it to the request context.
// This is used to identify the Arr instance for the request.
// Only a valid host and token will be added to the context/config. The rest are manual
func (q *QBit) authContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, token, err := decodeAuthHeader(r.Header.Get("Authorization"))
@@ -86,8 +131,9 @@ func (q *QBit) authContext(next http.Handler) http.Handler {
// Check if arr exists
a := arrs.Get(category)
if a == nil {
// Arr is not configured, create a new one
downloadUncached := false
a = arr.New(category, "", "", false, false, &downloadUncached, "")
a = arr.New(category, "", "", false, false, &downloadUncached, "", "auto")
}
if err == nil {
host = strings.TrimSpace(host)
@@ -99,7 +145,11 @@ 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
return
}
arrs.AddOrUpdate(a)
ctx := context.WithValue(r.Context(), arrKey, a)
next.ServeHTTP(w, r.WithContext(ctx))

View File

@@ -10,14 +10,15 @@ import (
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_arr := getArr(ctx)
_arr := getArrFromContext(ctx)
if _arr == nil {
// No arr
// Arr not in context, return OK
_, _ = w.Write([]byte("Ok."))
return
}
if err := _arr.Validate(); err != nil {
q.logger.Error().Err(err).Msgf("Error validating arr")
http.Error(w, "Invalid arr configuration", http.StatusBadRequest)
}
_, _ = w.Write([]byte("Ok."))
}
@@ -94,9 +95,10 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
}
debridName := r.FormValue("debrid")
category := r.FormValue("category")
_arr := getArr(ctx)
_arr := getArrFromContext(ctx)
if _arr == nil {
_arr = arr.New(category, "", "", false, false, nil, "")
// Arr is not in context
_arr = arr.New(category, "", "", false, false, nil, "", "")
}
atleastOne := false

View File

@@ -45,7 +45,8 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
_arr := _store.Arr().Get(arrName)
if _arr == nil {
_arr = arr.New(arrName, "", "", false, false, &downloadUncached, "")
// These are not found in the config. They are throwaway arrs.
_arr = arr.New(arrName, "", "", false, false, &downloadUncached, "", "")
}
// Handle URLs
@@ -181,20 +182,38 @@ func (wb *Web) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
}
func (wb *Web) handleGetConfig(w http.ResponseWriter, r *http.Request) {
// Merge config arrs, with arr Storage
unique := map[string]config.Arr{}
cfg := config.Get()
arrCfgs := make([]config.Arr, 0)
_store := store.Get()
for _, a := range _store.Arr().GetAll() {
arrCfgs = append(arrCfgs, config.Arr{
Host: a.Host,
Name: a.Name,
Token: a.Token,
Cleanup: a.Cleanup,
SkipRepair: a.SkipRepair,
DownloadUncached: a.DownloadUncached,
})
arrStorage := store.Get().Arr()
// Add existing Arrs from storage
for _, a := range arrStorage.GetAll() {
if _, ok := unique[a.Name]; !ok {
// Only add if not already in the unique map
unique[a.Name] = config.Arr{
Name: a.Name,
Host: a.Host,
Token: a.Token,
Cleanup: a.Cleanup,
SkipRepair: a.SkipRepair,
DownloadUncached: a.DownloadUncached,
SelectedDebrid: a.SelectedDebrid,
Source: a.Source,
}
}
}
for _, a := range cfg.Arrs {
if a.Host == "" || a.Token == "" {
continue // Skip empty arrs
}
unique[a.Name] = a
}
cfg.Arrs = make([]config.Arr, 0, len(unique))
for _, a := range unique {
cfg.Arrs = append(cfg.Arrs, a)
}
cfg.Arrs = arrCfgs
request.JSONResponse(w, cfg, http.StatusOK)
}
@@ -235,27 +254,43 @@ func (wb *Web) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
// Clear legacy single debrid if using array
}
if len(updatedConfig.Arrs) > 0 {
currentConfig.Arrs = updatedConfig.Arrs
}
// Update Arrs through the service
_store := store.Get()
_arr := _store.Arr()
_arr.Clear() // Clear existing arrs
storage := store.Get()
arrStorage := storage.Arr()
newConfigArrs := make([]config.Arr, 0)
for _, a := range updatedConfig.Arrs {
_arr.AddOrUpdate(&arr.Arr{
Name: a.Name,
Host: a.Host,
Token: a.Token,
Cleanup: a.Cleanup,
SkipRepair: a.SkipRepair,
DownloadUncached: a.DownloadUncached,
SelectedDebrid: a.SelectedDebrid,
})
if a.Name == "" || a.Host == "" || a.Token == "" {
// Skip empty or auto-generated arrs
continue
}
newConfigArrs = append(newConfigArrs, a)
}
currentConfig.Arrs = updatedConfig.Arrs
currentConfig.Arrs = newConfigArrs
// Add config arr into the config
for _, a := range currentConfig.Arrs {
if a.Host == "" || a.Token == "" {
continue // Skip empty arrs
}
existingArr := arrStorage.Get(a.Name)
if existingArr != nil {
// Update existing Arr
existingArr.Host = a.Host
existingArr.Token = a.Token
existingArr.Cleanup = a.Cleanup
existingArr.SkipRepair = a.SkipRepair
existingArr.DownloadUncached = a.DownloadUncached
existingArr.SelectedDebrid = a.SelectedDebrid
existingArr.Source = a.Source
arrStorage.AddOrUpdate(existingArr)
} else {
// Create new Arr if it doesn't exist
newArr := arr.New(a.Name, a.Host, a.Token, a.Cleanup, a.SkipRepair, a.DownloadUncached, a.SelectedDebrid, a.Source)
arrStorage.AddOrUpdate(newArr)
}
}
if err := currentConfig.Save(); err != nil {
http.Error(w, "Error saving config: "+err.Error(), http.StatusInternalServerError)
return

View File

@@ -17,6 +17,37 @@
[data-bs-theme="dark"] .nav-pills .nav-link.active {
color: white !important;
}
.config-item.bg-light {
background-color: var(--bs-gray-100) !important;
border-left: 4px solid var(--bs-info) !important;
}
.config-item input[readonly] {
background-color: var(--bs-gray-200);
opacity: 1;
}
.config-item select[readonly] {
background-color: var(--bs-gray-200);
pointer-events: none;
}
/* Dark mode specific overrides */
[data-bs-theme="dark"] .config-item.bg-light {
background-color: var(--bs-gray-800) !important;
border-left: 4px solid var(--bs-info) !important;
}
[data-bs-theme="dark"] .config-item input[readonly] {
background-color: var(--bs-gray-700);
color: var(--bs-gray-300);
}
[data-bs-theme="dark"] .config-item select[readonly] {
background-color: var(--bs-gray-700);
color: var(--bs-gray-300);
}
</style>
<div class="container mt-4">
<form id="configForm">
@@ -80,7 +111,8 @@
<label>
<!-- Empty label to keep the button aligned -->
</label>
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler();" id="registerMagnetLink">
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler();"
id="registerMagnetLink">
Open Magnet Links in Decypharr
</div>
</div>
@@ -103,7 +135,8 @@
id="bindAddress"
name="bind_address"
placeholder="">
<small class="form-text text-muted">Bind address for the application(default is all interface)</small>
<small class="form-text text-muted">Bind address for the application(default is all
interface)</small>
</div>
</div>
<div class="col-md-2 mt-3">
@@ -150,7 +183,8 @@
id="minFileSize"
name="min_file_size"
placeholder="e.g., 10MB, 1GB">
<small class="form-text text-muted">Minimum file size to download (Empty for no limit)</small>
<small class="form-text text-muted">Minimum file size to download (Empty for no
limit)</small>
</div>
</div>
<div class="col-md-4 mt-3">
@@ -161,7 +195,8 @@
id="maxFileSize"
name="max_file_size"
placeholder="e.g., 50GB, 100MB">
<small class="form-text text-muted">Maximum file size to download (Empty for no limit)</small>
<small class="form-text text-muted">Maximum file size to download (Empty for no
limit)</small>
</div>
</div>
<div class="col-md-4 mt-3">
@@ -172,13 +207,15 @@
id="removeStalledAfter"
name="remove_stalled_after"
placeholder="e.g., 1m, 30s, 1h">
<small class="form-text text-muted">Remove torrents that have been stalled for this duration</small>
<small class="form-text text-muted">Remove torrents that have been stalled for this
duration</small>
</div>
</div>
</div>
</div>
<div class="mt-4 d-flex justify-content-end">
<button type="button" class="btn btn-primary next-step" data-next="2">Next <i class="bi bi-arrow-right"></i></button>
<button type="button" class="btn btn-primary next-step" data-next="2">Next <i
class="bi bi-arrow-right"></i></button>
</div>
</div>
@@ -196,7 +233,8 @@
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="1">
<i class="bi bi-arrow-left"></i> Previous
</button>
<button type="button" class="btn btn-primary next-step" data-next="3">Next <i class="bi bi-arrow-right"></i></button>
<button type="button" class="btn btn-primary next-step" data-next="3">Next <i
class="bi bi-arrow-right"></i></button>
</div>
</div>
@@ -206,23 +244,31 @@
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="qbit.download_folder">Symlink/Download Folder</label>
<input type="text" class="form-control" name="qbit.download_folder" id="qbit.download_folder">
<small class="form-text text-muted">Folder where the downloaded files will be stored</small>
<input type="text" class="form-control" name="qbit.download_folder"
id="qbit.download_folder">
<small class="form-text text-muted">Folder where the downloaded files will be
stored</small>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="qbit.refresh_interval">Refresh Interval (seconds)</label>
<input type="number" class="form-control" name="qbit.refresh_interval" id="qbit.refresh_interval">
<input type="number" class="form-control" name="qbit.refresh_interval"
id="qbit.refresh_interval">
</div>
<div class="col-md-5 mb-3">
<label class="form-label" for="qbit.max_downloads">Maximum Downloads Limit</label>
<input type="number" class="form-control" name="qbit.max_downloads" id="qbit.max_downloads">
<small class="form-text text-muted">Maximum number of simultaneous local downloads across all torrents</small>
<input type="number" class="form-control" name="qbit.max_downloads"
id="qbit.max_downloads">
<small class="form-text text-muted">Maximum number of simultaneous local downloads
across all torrents</small>
</div>
<div class="col mb-3">
<div class="form-check me-3 d-inline-block">
<input type="checkbox" class="form-check-input" name="qbit.skip_pre_cache" id="qbit.skip_pre_cache">
<label class="form-check-label" for="qbit.skip_pre_cache">Disable Pre-Cache On Download</label>
<small class="form-text text-muted">Unchecking this caches a tiny part of your file to speed up import</small>
<input type="checkbox" class="form-check-input" name="qbit.skip_pre_cache"
id="qbit.skip_pre_cache">
<label class="form-check-label" for="qbit.skip_pre_cache">Disable Pre-Cache On
Download</label>
<small class="form-text text-muted">Unchecking this caches a tiny part of your file
to speed up import</small>
</div>
</div>
</div>
@@ -231,7 +277,8 @@
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="2">
<i class="bi bi-arrow-left"></i> Previous
</button>
<button type="button" class="btn btn-primary next-step" data-next="4">Next <i class="bi bi-arrow-right"></i></button>
<button type="button" class="btn btn-primary next-step" data-next="4">Next <i
class="bi bi-arrow-right"></i></button>
</div>
</div>
@@ -249,7 +296,8 @@
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="3">
<i class="bi bi-arrow-left"></i> Previous
</button>
<button type="button" class="btn btn-primary next-step" data-next="5">Next <i class="bi bi-arrow-right"></i></button>
<button type="button" class="btn btn-primary next-step" data-next="5">Next <i
class="bi bi-arrow-right"></i></button>
</div>
</div>
@@ -259,7 +307,8 @@
<div class="row mb-3">
<div class="col">
<div class="form-check me-3 d-inline-block">
<input type="checkbox" class="form-check-input" name="repair.enabled" id="repair.enabled">
<input type="checkbox" class="form-check-input" name="repair.enabled"
id="repair.enabled">
<label class="form-check-label" for="repair.enabled">Enable Scheduled Repair</label>
</div>
</div>
@@ -268,34 +317,43 @@
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="repair.interval">Scheduled Interval</label>
<input type="text" class="form-control" name="repair.interval" id="repair.interval" placeholder="e.g., 24h">
<small class="form-text text-muted">Interval for the repair process(e.g., 24h, 1d, 03:00, or a crontab)</small>
<input type="text" class="form-control" name="repair.interval" id="repair.interval"
placeholder="e.g., 24h">
<small class="form-text text-muted">Interval for the repair process(e.g., 24h, 1d,
03:00, or a crontab)</small>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="repair.workers">Workers</label>
<input type="text" class="form-control" name="repair.workers" id="repair.workers">
<small class="form-text text-muted">Number of workers to use for the repair process</small>
<small class="form-text text-muted">Number of workers to use for the repair
process</small>
</div>
<div class="col-md-5 mb-3">
<label class="form-label" for="repair.zurg_url">Zurg URL</label>
<input type="text" class="form-control" name="repair.zurg_url" id="repair.zurg_url" placeholder="http://zurg:9999">
<small class="form-text text-muted">If you have Zurg running, you can use it to speed up the repair process</small>
<input type="text" class="form-control" name="repair.zurg_url" id="repair.zurg_url"
placeholder="http://zurg:9999">
<small class="form-text text-muted">If you have Zurg running, you can use it to
speed up the repair process</small>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="repair.use_webdav" id="repair.use_webdav">
<input type="checkbox" class="form-check-input" name="repair.use_webdav"
id="repair.use_webdav">
<label class="form-check-label" for="repair.use_webdav">Use Webdav</label>
</div>
<small class="form-text text-muted">Use Internal Webdav for repair(make sure webdav is enabled in the debrid section</small>
<small class="form-text text-muted">Use Internal Webdav for repair(make sure webdav
is enabled in the debrid section</small>
</div>
<div class="col-md-4 mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="repair.auto_process" id="repair.auto_process">
<input type="checkbox" class="form-check-input" name="repair.auto_process"
id="repair.auto_process">
<label class="form-check-label" for="repair.auto_process">Auto Process</label>
</div>
<small class="form-text text-muted">Automatically process the repair job(delete broken symlinks and searches the arr again)</small>
<small class="form-text text-muted">Automatically process the repair job(delete
broken symlinks and searches the arr again)</small>
</div>
</div>
</div>
@@ -335,15 +393,44 @@
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="debrid[${index}].api_key" >API Key</label>
<input type="password" class="form-control" name="debrid[${index}].api_key" id="debrid[${index}].api_key" required>
<div class="password-toggle-container">
<input type="password"
class="form-control has-toggle"
name="debrid[${index}].api_key"
id="debrid[${index}].api_key"
required>
<button type="button"
class="password-toggle-btn"
onclick="togglePassword('debrid[${index}].api_key');">
<i class="bi bi-eye" id="debrid[${index}].api_key_icon"></i>
</button>
</div>
<small class="form-text text-muted">API Key for the debrid service</small>
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="debrid[${index}].download_api_keys">Download API Keys</label>
<div class="password-toggle-container">
<textarea class="form-control has-toggle"
name="debrid[${index}].download_api_keys"
id="debrid[${index}].download_api_keys"
rows="3"
style="font-family: monospace; resize: vertical;"
placeholder="Enter one API key per line&#10;key1&#10;key2&#10;key3"></textarea>
<button type="button"
class="password-toggle-btn"
style="top: 20px;"
onclick="togglePasswordTextarea('debrid[${index}].download_api_keys');">
<i class="bi bi-eye" id="debrid[${index}].download_api_keys_icon"></i>
</button>
</div>
<small class="form-text text-muted">Multiple API keys for download (one per line). If empty, main API key will be used.</small>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].folder">Mount/Rclone Folder</label>
<input type="text" class="form-control" name="debrid[${index}].folder" id="debrid[${index}].folder" placeholder="e.g. /mnt/remote/realdebrid" required>
<small class="form-text text-muted">Path to where you've mounted the debrid files. Usually your rclone path</small>
</div>
<div class="col-md-6 mb-3">
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].rate_limit" >Rate Limit</label>
<input type="text" class="form-control" name="debrid[${index}].rate_limit" id="debrid[${index}].rate_limit" placeholder="e.g., 200/minute" value="250/minute">
<small class="form-text text-muted">Rate limit for the debrid service. Confirm your debrid service rate limit</small>
@@ -432,7 +519,17 @@
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].rc_pass">Rclone RC Password</label>
<input type="password" class="form-control webdav-field" name="debrid[${index}].rc_pass" id="debrid[${index}].rc_pass">
<div class="password-toggle-container">
<input type="password"
class="form-control webdav-field has-toggle"
name="debrid[${index}].rc_pass"
id="debrid[${index}].rc_pass">
<button type="button"
class="password-toggle-btn"
onclick="togglePassword('debrid[${index}].rc_pass');">
<i class="bi bi-eye" id="debrid[${index}].rc_pass_icon"></i>
</button>
</div>
<small class="form-text text-muted">Rclone RC Password for the webdav server</small>
</div>
<div class="col-md-3 mb-3">
@@ -538,7 +635,7 @@
const filterTemplate = (debridIndex, dirIndex, filterIndex, filterType) => {
let placeholder, label;
switch(filterType) {
switch (filterType) {
case 'include':
placeholder = "Text that should be included in filename";
label = "Include";
@@ -632,58 +729,87 @@
`;
};
const arrTemplate = (index) => `
<div class="config-item position-relative mb-3 p-3 border rounded">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-2"
onclick="if(confirm('Are you sure you want to delete this arr?')) this.closest('.config-item').remove();"
title="Delete this arr">
<i class="bi bi-trash"></i>
</button>
<div class="row">
<div class="col-md-4 mb-3">
<label for="arr[${index}].name" class="form-label">Name</label>
<input type="text" class="form-control" name="arr[${index}].name" id="arr[${index}].name" required>
const arrTemplate = (index, data = {}) => `
<div class="config-item position-relative mb-3 p-3 border rounded ${data.source === 'auto' ? 'bg-light' : ''}">
${data.source !== 'auto' ? `
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-2"
onclick="if(confirm('Are you sure you want to delete this arr?')) this.closest('.config-item').remove();"
title="Delete this arr">
<i class="bi bi-trash"></i>
</button>
` : `
<div class="position-absolute top-0 end-0 m-2">
<span class="badge bg-info">Auto-detected</span>
</div>
<div class="col-md-4 mb-3">
<label for="arr[${index}].host" class="form-label">Host</label>
<input type="text" class="form-control" name="arr[${index}].host" id="arr[${index}].host" required>
</div>
<div class="col-md-4 mb-3">
<label for"arr[${index}].token" class="form-label">API Token</label>
<input type="password" class="form-control" name="arr[${index}].token" id="arr[${index}].token" required>
</div>
</div>
<div class="row">
<div class="col-md-4 mb-3">
<select class="form-select" name="arr[${index}].selected_debrid" id="arr[${index}].selected_debrid">
<option value="" selected disabled>Select Arr Debrid</option>
<option value="realdebrid">Real Debrid</option>
<option value="alldebrid">AllDebrid</option>
<option value="debrid_link">Debrid Link</option>
<option value="torbox">Torbox</option>
</select>
</div>
<div class="col-md-2 mb-3">
<div class="form-check">
<label for="arr[${index}].cleanup" class="form-check-label">Cleanup Queue</label>
<input type="checkbox" class="form-check-input" name="arr[${index}].cleanup" id="arr[${index}].cleanup">
`}
<div class="row">
<input type="hidden" name="arr[${index}].source" value="${data.source || ''}">
<div class="col-md-4 mb-3">
<label for="arr[${index}].name" class="form-label">Name</label>
<input type="text"
class="form-control"
name="arr[${index}].name"
id="arr[${index}].name"
${data.source === 'auto' ? 'readonly' : 'required'}>
${data.source === 'auto' ? '<input type="hidden" name="arr[' + index + '].source" value="auto">' : ''}
</div>
<div class="col-md-4 mb-3">
<label for="arr[${index}].host" class="form-label">Host</label>
<input type="text"
class="form-control"
name="arr[${index}].host"
id="arr[${index}].host"
${data.source === 'auto' ? 'readonly' : 'required'}>
</div>
<div class="col-md-4 mb-3">
<label for="arr[${index}].token" class="form-label">API Token</label>
<div class="password-toggle-container">
<input type="password"
class="form-control has-toggle"
name="arr[${index}].token"
id="arr[${index}].token"
${data.source === 'auto' ? 'readonly' : 'required'}>
<button type="button"
class="password-toggle-btn"
onclick="togglePassword('arr[${index}].token');"
${data.source === 'auto' ? 'disabled style="opacity: 0.5;"' : ''}>
<i class="bi bi-eye" id="arr[${index}].token_icon"></i>
</button>
</div>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="form-check">
<label for="arr[${index}].skip_repair" class="form-check-label">Skip Repair</label>
<input type="checkbox" class="form-check-input" name="arr[${index}].skip_repair" id="arr[${index}].skip_repair">
<div class="row">
<div class="col-md-4 mb-3">
<label for="arr[${index}].selected_debrid" class="form-label">Select Arr Debrid</label>
<select class="form-select" name="arr[${index}].selected_debrid" id="arr[${index}].selected_debrid">
<option value="" selected disabled>Select Arr Debrid</option>
<option value="realdebrid">Real Debrid</option>
<option value="alldebrid">AllDebrid</option>
<option value="debrid_link">Debrid Link</option>
<option value="torbox">Torbox</option>
</select>
</div>
</div>
<div class="col-md-2 mb-3">
<div class="form-check">
<label for="arr[${index}].download_uncached" class="form-check-label">Download Uncached</label>
<input type="checkbox" class="form-check-input" name="arr[${index}].download_uncached" id="arr[${index}].download_uncached">
<div class="col-md-2 mb-3">
<div class="form-check">
<label for="arr[${index}].cleanup" class="form-check-label">Cleanup Queue</label>
<input type="checkbox" class="form-check-input" name="arr[${index}].cleanup" id="arr[${index}].cleanup">
</div>
</div>
<div class="col-md-2 mb-3">
<div class="form-check">
<label for="arr[${index}].skip_repair" class="form-check-label">Skip Repair</label>
<input type="checkbox" class="form-check-input" name="arr[${index}].skip_repair" id="arr[${index}].skip_repair">
</div>
</div>
<div class="col-md-2 mb-3">
<div class="form-check">
<label for="arr[${index}].download_uncached" class="form-check-label">Download Uncached</label>
<input type="checkbox" class="form-check-input" name="arr[${index}].download_uncached" id="arr[${index}].download_uncached">
</div>
</div>
</div>
</div>
</div>
`;
`;
const debridDirectoryCounts = {};
const directoryFilterCounts = {};
@@ -739,6 +865,7 @@
debridDirectoryCounts[debridIndex]++;
return dirIndex;
}
function addFilter(debridIndex, dirIndex, filterType, filterValue = "") {
const dirKey = `${debridIndex}-${dirIndex}`;
if (!directoryFilterCounts[dirKey]) {
@@ -771,7 +898,7 @@
}
// Main functionality
document.addEventListener('DOMContentLoaded', function() {
document.addEventListener('DOMContentLoaded', function () {
let debridCount = 0;
let arrCount = 0;
let currentStep = 1;
@@ -785,21 +912,21 @@
// Step navigation
document.querySelectorAll('.nav-link').forEach(navLink => {
navLink.addEventListener('click', function() {
navLink.addEventListener('click', function () {
const stepNumber = parseInt(this.getAttribute('data-step'));
goToStep(stepNumber);
});
});
document.querySelectorAll('.next-step').forEach(button => {
button.addEventListener('click', function() {
button.addEventListener('click', function () {
const nextStep = parseInt(this.getAttribute('data-next'));
goToStep(nextStep);
});
});
document.querySelectorAll('.prev-step').forEach(button => {
button.addEventListener('click', function() {
button.addEventListener('click', function () {
const prevStep = parseInt(this.getAttribute('data-prev'));
goToStep(prevStep);
});
@@ -910,7 +1037,7 @@
addArrConfig();
});
$(document).on('change', '.useWebdav', function() {
$(document).on('change', '.useWebdav', function () {
const webdavConfig = $(this).closest('.config-item').find(`.webdav`);
if (webdavConfig.length === 0) return;
@@ -953,7 +1080,7 @@
// Save config logic
const response = await fetcher('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(config)
});
@@ -1005,7 +1132,7 @@
if (data.use_webdav && data.directories) {
Object.entries(data.directories).forEach(([dirName, dirData]) => {
const dirIndex = addDirectory(debridCount, { name: dirName });
const dirIndex = addDirectory(debridCount, {name: dirName});
// Add filters if available
if (dirData.filters) {
@@ -1015,6 +1142,20 @@
}
});
}
if (data.download_api_keys && Array.isArray(data.download_api_keys)) {
const downloadKeysTextarea = container.querySelector(`[name="debrid[${debridCount}].download_api_keys"]`);
if (downloadKeysTextarea) {
downloadKeysTextarea.value = data.download_api_keys.join('\n');
}
}
}
const downloadKeysTextarea = newDebrid.querySelector(`[name="debrid[${debridCount}].download_api_keys"]`);
if (downloadKeysTextarea) {
downloadKeysTextarea.style.webkitTextSecurity = 'disc';
downloadKeysTextarea.style.textSecurity = 'disc';
downloadKeysTextarea.setAttribute('data-password-visible', 'false');
}
debridCount++;
@@ -1022,11 +1163,10 @@
function addArrConfig(data = {}) {
const container = document.getElementById('arrConfigs');
container.insertAdjacentHTML('beforeend', arrTemplate(arrCount));
container.insertAdjacentHTML('beforeend', arrTemplate(arrCount, data));
// Add a delete button to the new arr
// Don't add delete button for auto-detected arrs since it's already handled in template
const newArr = container.lastElementChild;
addDeleteButton(newArr, `Delete this arr`);
if (data) {
Object.entries(data).forEach(([key, value]) => {
@@ -1051,7 +1191,7 @@
deleteBtn.innerHTML = '<i class="bi bi-trash"></i>';
deleteBtn.title = tooltip;
deleteBtn.addEventListener('click', function() {
deleteBtn.addEventListener('click', function () {
if (confirm('Are you sure you want to delete this item?')) {
element.remove();
}
@@ -1126,7 +1266,7 @@
const nameInput = document.querySelector(`[name="debrid[${i}].directory[${j}].name"]`);
if (nameInput && nameInput.value) {
const dirName = nameInput.value;
debrid.directories[dirName] = { filters: {} };
debrid.directories[dirName] = {filters: {}};
// Get directory key for filter counting
const dirKey = `${i}-${j}`;
@@ -1146,6 +1286,14 @@
}
}
let downloadApiKeysTextarea = document.querySelector(`[name="debrid[${i}].download_api_keys"]`);
if (downloadApiKeysTextarea && downloadApiKeysTextarea.value.trim()) {
debrid.download_api_keys = downloadApiKeysTextarea.value
.split('\n')
.map(key => key.trim())
.filter(key => key.length > 0);
}
if (debrid.name && debrid.api_key) {
config.debrids.push(debrid);
}
@@ -1163,7 +1311,8 @@
cleanup: document.querySelector(`[name="arr[${i}].cleanup"]`).checked,
skip_repair: document.querySelector(`[name="arr[${i}].skip_repair"]`).checked,
download_uncached: document.querySelector(`[name="arr[${i}].download_uncached"]`).checked,
selectedDebrid: document.querySelector(`[name="arr[${i}].selected_debrid"]`).value
selected_debrid: document.querySelector(`[name="arr[${i}].selected_debrid"]`).value,
source: document.querySelector(`[name="arr[${i}].source"]`).value
};
if (arr.name && arr.host) {

View File

@@ -121,6 +121,45 @@
.theme-toggle:hover {
background-color: rgba(128, 128, 128, 0.2);
}
.password-toggle-container {
position: relative;
}
.password-toggle-btn {
position: absolute;
right: 10px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #6c757d;
cursor: pointer;
padding: 0;
z-index: 10;
}
.password-toggle-btn:hover {
color: #495057;
}
.form-control.has-toggle {
padding-right: 35px;
}
textarea.has-toggle {
-webkit-text-security: disc;
text-security: disc;
font-family: monospace !important;
}
textarea.has-toggle[data-password-visible="true"] {
-webkit-text-security: none;
text-security: none;
}
/* Adjust toggle button position for textareas */
.password-toggle-container textarea.has-toggle ~ .password-toggle-btn {
top: 20px;
}
</style>
<script>
(function() {
@@ -297,6 +336,57 @@
});
};
function createPasswordField(name, id, placeholder = "", required = false) {
return `
<div class="password-toggle-container">
<input type="password"
class="form-control has-toggle"
name="${name}"
id="${id}"
placeholder="${placeholder}"
${required ? 'required' : ''}>
<button type="button"
class="password-toggle-btn"
onclick="togglePassword('${id}');">
<i class="bi bi-eye" id="${id}_icon"></i>
</button>
</div>
`;
}
function togglePassword(fieldId) {
const field = document.getElementById(fieldId);
const icon = document.getElementById(fieldId + '_icon');
if (field.type === 'password') {
field.type = 'text';
icon.className = 'bi bi-eye-slash';
} else {
field.type = 'password';
icon.className = 'bi bi-eye';
}
}
// Add this function to handle textarea password toggling
function togglePasswordTextarea(fieldId) {
const field = document.getElementById(fieldId);
const icon = document.getElementById(fieldId + '_icon');
if (field.style.webkitTextSecurity === 'disc' || field.style.webkitTextSecurity === '') {
// Show text
field.style.webkitTextSecurity = 'none';
field.style.textSecurity = 'none'; // For other browsers
field.setAttribute('data-password-visible', 'true');
icon.className = 'bi bi-eye-slash';
} else {
// Hide text
field.style.webkitTextSecurity = 'disc';
field.style.textSecurity = 'disc'; // For other browsers
field.setAttribute('data-password-visible', 'false');
icon.className = 'bi bi-eye';
}
}
// Theme management
const themeToggle = document.getElementById('themeToggle');
const lightIcon = document.getElementById('lightIcon');