Improve Arr integerations
This commit is contained in:
@@ -54,6 +54,7 @@ type Arr struct {
|
|||||||
SkipRepair bool `json:"skip_repair,omitempty"`
|
SkipRepair bool `json:"skip_repair,omitempty"`
|
||||||
DownloadUncached *bool `json:"download_uncached,omitempty"`
|
DownloadUncached *bool `json:"download_uncached,omitempty"`
|
||||||
SelectedDebrid string `json:"selected_debrid,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 {
|
type Repair struct {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package arr
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
@@ -19,6 +20,13 @@ import (
|
|||||||
// Type is a type of arr
|
// Type is a type of arr
|
||||||
type Type string
|
type Type string
|
||||||
|
|
||||||
|
var sharedClient = &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
||||||
|
},
|
||||||
|
Timeout: 60 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Sonarr Type = "sonarr"
|
Sonarr Type = "sonarr"
|
||||||
Radarr Type = "radarr"
|
Radarr Type = "radarr"
|
||||||
@@ -35,10 +43,10 @@ type Arr struct {
|
|||||||
SkipRepair bool `json:"skip_repair"`
|
SkipRepair bool `json:"skip_repair"`
|
||||||
DownloadUncached *bool `json:"download_uncached"`
|
DownloadUncached *bool `json:"download_uncached"`
|
||||||
SelectedDebrid string `json:"selected_debrid,omitempty"` // The debrid service selected for this arr
|
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{
|
return &Arr{
|
||||||
Name: name,
|
Name: name,
|
||||||
Host: host,
|
Host: host,
|
||||||
@@ -47,8 +55,8 @@ func New(name, host, token string, cleanup, skipRepair bool, downloadUncached *b
|
|||||||
Cleanup: cleanup,
|
Cleanup: cleanup,
|
||||||
SkipRepair: skipRepair,
|
SkipRepair: skipRepair,
|
||||||
DownloadUncached: downloadUncached,
|
DownloadUncached: downloadUncached,
|
||||||
client: request.New(),
|
|
||||||
SelectedDebrid: selectedDebrid,
|
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("Content-Type", "application/json")
|
||||||
req.Header.Set("X-Api-Key", a.Token)
|
req.Header.Set("X-Api-Key", a.Token)
|
||||||
if a.client == nil {
|
|
||||||
a.client = request.New()
|
|
||||||
}
|
|
||||||
|
|
||||||
var resp *http.Response
|
var resp *http.Response
|
||||||
|
|
||||||
for attempts := 0; attempts < 5; attempts++ {
|
for attempts := 0; attempts < 5; attempts++ {
|
||||||
resp, err = a.client.Do(req)
|
resp, err = sharedClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -104,7 +109,7 @@ func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Respo
|
|||||||
|
|
||||||
func (a *Arr) Validate() error {
|
func (a *Arr) Validate() error {
|
||||||
if a.Token == "" || a.Host == "" {
|
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)
|
resp, err := a.Request("GET", "/api/v3/health", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -146,8 +151,11 @@ func InferType(host, name string) Type {
|
|||||||
func NewStorage() *Storage {
|
func NewStorage() *Storage {
|
||||||
arrs := make(map[string]*Arr)
|
arrs := make(map[string]*Arr)
|
||||||
for _, a := range config.Get().Arrs {
|
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
|
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{
|
return &Storage{
|
||||||
Arrs: arrs,
|
Arrs: arrs,
|
||||||
@@ -158,7 +166,7 @@ func NewStorage() *Storage {
|
|||||||
func (s *Storage) AddOrUpdate(arr *Arr) {
|
func (s *Storage) AddOrUpdate(arr *Arr) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
if arr.Name == "" {
|
if arr.Host == "" || arr.Token == "" || arr.Name == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.Arrs[arr.Name] = arr
|
s.Arrs[arr.Name] = arr
|
||||||
@@ -175,19 +183,11 @@ func (s *Storage) GetAll() []*Arr {
|
|||||||
defer s.mu.Unlock()
|
defer s.mu.Unlock()
|
||||||
arrs := make([]*Arr, 0, len(s.Arrs))
|
arrs := make([]*Arr, 0, len(s.Arrs))
|
||||||
for _, arr := range s.Arrs {
|
for _, arr := range s.Arrs {
|
||||||
if arr.Host != "" && arr.Token != "" {
|
|
||||||
arrs = append(arrs, arr)
|
arrs = append(arrs, arr)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return arrs
|
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 {
|
func (s *Storage) StartSchedule(ctx context.Context) error {
|
||||||
|
|
||||||
ticker := time.NewTicker(10 * time.Second)
|
ticker := time.NewTicker(10 * time.Second)
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ package qbit
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
"github.com/sirrobot01/decypharr/pkg/arr"
|
||||||
"github.com/sirrobot01/decypharr/pkg/store"
|
"github.com/sirrobot01/decypharr/pkg/store"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,6 +20,45 @@ const (
|
|||||||
arrKey contextKey = "arr"
|
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 {
|
func getCategory(ctx context.Context) string {
|
||||||
if category, ok := ctx.Value(categoryKey).(string); ok {
|
if category, ok := ctx.Value(categoryKey).(string); ok {
|
||||||
return category
|
return category
|
||||||
@@ -32,7 +73,7 @@ func getHashes(ctx context.Context) []string {
|
|||||||
return nil
|
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 {
|
if a, ok := ctx.Value(arrKey).(*arr.Arr); ok {
|
||||||
return a
|
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 {
|
func (q *QBit) authContext(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
host, token, err := decodeAuthHeader(r.Header.Get("Authorization"))
|
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
|
// Check if arr exists
|
||||||
a := arrs.Get(category)
|
a := arrs.Get(category)
|
||||||
if a == nil {
|
if a == nil {
|
||||||
|
// Arr is not configured, create a new one
|
||||||
downloadUncached := false
|
downloadUncached := false
|
||||||
a = arr.New(category, "", "", false, false, &downloadUncached, "")
|
a = arr.New(category, "", "", false, false, &downloadUncached, "", "auto")
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
host = strings.TrimSpace(host)
|
host = strings.TrimSpace(host)
|
||||||
@@ -99,7 +145,11 @@ func (q *QBit) authContext(next http.Handler) http.Handler {
|
|||||||
a.Token = token
|
a.Token = token
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
a.Source = "auto"
|
||||||
|
if err := validateServiceURL(a.Host); err != nil {
|
||||||
|
// Return silently, no need to raise a problem. Just do not add the Arr to the context/config.json
|
||||||
|
return
|
||||||
|
}
|
||||||
arrs.AddOrUpdate(a)
|
arrs.AddOrUpdate(a)
|
||||||
ctx := context.WithValue(r.Context(), arrKey, a)
|
ctx := context.WithValue(r.Context(), arrKey, a)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
|
|||||||
@@ -10,14 +10,15 @@ import (
|
|||||||
|
|
||||||
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
|
func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
_arr := getArr(ctx)
|
_arr := getArrFromContext(ctx)
|
||||||
if _arr == nil {
|
if _arr == nil {
|
||||||
// No arr
|
// Arr not in context, return OK
|
||||||
_, _ = w.Write([]byte("Ok."))
|
_, _ = w.Write([]byte("Ok."))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := _arr.Validate(); err != nil {
|
if err := _arr.Validate(); err != nil {
|
||||||
q.logger.Error().Err(err).Msgf("Error validating arr")
|
q.logger.Error().Err(err).Msgf("Error validating arr")
|
||||||
|
http.Error(w, "Invalid arr configuration", http.StatusBadRequest)
|
||||||
}
|
}
|
||||||
_, _ = w.Write([]byte("Ok."))
|
_, _ = w.Write([]byte("Ok."))
|
||||||
}
|
}
|
||||||
@@ -94,9 +95,10 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
debridName := r.FormValue("debrid")
|
debridName := r.FormValue("debrid")
|
||||||
category := r.FormValue("category")
|
category := r.FormValue("category")
|
||||||
_arr := getArr(ctx)
|
_arr := getArrFromContext(ctx)
|
||||||
if _arr == nil {
|
if _arr == nil {
|
||||||
_arr = arr.New(category, "", "", false, false, nil, "")
|
// Arr is not in context
|
||||||
|
_arr = arr.New(category, "", "", false, false, nil, "", "")
|
||||||
}
|
}
|
||||||
atleastOne := false
|
atleastOne := false
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
_arr := _store.Arr().Get(arrName)
|
_arr := _store.Arr().Get(arrName)
|
||||||
if _arr == nil {
|
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
|
// 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) {
|
func (wb *Web) handleGetConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Merge config arrs, with arr Storage
|
||||||
|
unique := map[string]config.Arr{}
|
||||||
cfg := config.Get()
|
cfg := config.Get()
|
||||||
arrCfgs := make([]config.Arr, 0)
|
arrStorage := store.Get().Arr()
|
||||||
_store := store.Get()
|
|
||||||
for _, a := range _store.Arr().GetAll() {
|
// Add existing Arrs from storage
|
||||||
arrCfgs = append(arrCfgs, config.Arr{
|
for _, a := range arrStorage.GetAll() {
|
||||||
Host: a.Host,
|
if _, ok := unique[a.Name]; !ok {
|
||||||
|
// Only add if not already in the unique map
|
||||||
|
unique[a.Name] = config.Arr{
|
||||||
Name: a.Name,
|
Name: a.Name,
|
||||||
|
Host: a.Host,
|
||||||
Token: a.Token,
|
Token: a.Token,
|
||||||
Cleanup: a.Cleanup,
|
Cleanup: a.Cleanup,
|
||||||
SkipRepair: a.SkipRepair,
|
SkipRepair: a.SkipRepair,
|
||||||
DownloadUncached: a.DownloadUncached,
|
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)
|
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
|
// Clear legacy single debrid if using array
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(updatedConfig.Arrs) > 0 {
|
|
||||||
currentConfig.Arrs = updatedConfig.Arrs
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update Arrs through the service
|
// Update Arrs through the service
|
||||||
_store := store.Get()
|
storage := store.Get()
|
||||||
_arr := _store.Arr()
|
arrStorage := storage.Arr()
|
||||||
_arr.Clear() // Clear existing arrs
|
|
||||||
|
|
||||||
|
newConfigArrs := make([]config.Arr, 0)
|
||||||
for _, a := range updatedConfig.Arrs {
|
for _, a := range updatedConfig.Arrs {
|
||||||
_arr.AddOrUpdate(&arr.Arr{
|
if a.Name == "" || a.Host == "" || a.Token == "" {
|
||||||
Name: a.Name,
|
// Skip empty or auto-generated arrs
|
||||||
Host: a.Host,
|
continue
|
||||||
Token: a.Token,
|
|
||||||
Cleanup: a.Cleanup,
|
|
||||||
SkipRepair: a.SkipRepair,
|
|
||||||
DownloadUncached: a.DownloadUncached,
|
|
||||||
SelectedDebrid: a.SelectedDebrid,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
currentConfig.Arrs = updatedConfig.Arrs
|
newConfigArrs = append(newConfigArrs, a)
|
||||||
|
}
|
||||||
|
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 {
|
if err := currentConfig.Save(); err != nil {
|
||||||
http.Error(w, "Error saving config: "+err.Error(), http.StatusInternalServerError)
|
http.Error(w, "Error saving config: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -17,6 +17,37 @@
|
|||||||
[data-bs-theme="dark"] .nav-pills .nav-link.active {
|
[data-bs-theme="dark"] .nav-pills .nav-link.active {
|
||||||
color: white !important;
|
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>
|
</style>
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<form id="configForm">
|
<form id="configForm">
|
||||||
@@ -80,7 +111,8 @@
|
|||||||
<label>
|
<label>
|
||||||
<!-- Empty label to keep the button aligned -->
|
<!-- Empty label to keep the button aligned -->
|
||||||
</label>
|
</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
|
Open Magnet Links in Decypharr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +135,8 @@
|
|||||||
id="bindAddress"
|
id="bindAddress"
|
||||||
name="bind_address"
|
name="bind_address"
|
||||||
placeholder="">
|
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>
|
</div>
|
||||||
<div class="col-md-2 mt-3">
|
<div class="col-md-2 mt-3">
|
||||||
@@ -150,7 +183,8 @@
|
|||||||
id="minFileSize"
|
id="minFileSize"
|
||||||
name="min_file_size"
|
name="min_file_size"
|
||||||
placeholder="e.g., 10MB, 1GB">
|
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>
|
</div>
|
||||||
<div class="col-md-4 mt-3">
|
<div class="col-md-4 mt-3">
|
||||||
@@ -161,7 +195,8 @@
|
|||||||
id="maxFileSize"
|
id="maxFileSize"
|
||||||
name="max_file_size"
|
name="max_file_size"
|
||||||
placeholder="e.g., 50GB, 100MB">
|
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>
|
</div>
|
||||||
<div class="col-md-4 mt-3">
|
<div class="col-md-4 mt-3">
|
||||||
@@ -172,13 +207,15 @@
|
|||||||
id="removeStalledAfter"
|
id="removeStalledAfter"
|
||||||
name="remove_stalled_after"
|
name="remove_stalled_after"
|
||||||
placeholder="e.g., 1m, 30s, 1h">
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 d-flex justify-content-end">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -196,7 +233,8 @@
|
|||||||
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="1">
|
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="1">
|
||||||
<i class="bi bi-arrow-left"></i> Previous
|
<i class="bi bi-arrow-left"></i> Previous
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -206,23 +244,31 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label class="form-label" for="qbit.download_folder">Symlink/Download Folder</label>
|
<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">
|
<input type="text" class="form-control" name="qbit.download_folder"
|
||||||
<small class="form-text text-muted">Folder where the downloaded files will be stored</small>
|
id="qbit.download_folder">
|
||||||
|
<small class="form-text text-muted">Folder where the downloaded files will be
|
||||||
|
stored</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="qbit.refresh_interval">Refresh Interval (seconds)</label>
|
<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>
|
||||||
<div class="col-md-5 mb-3">
|
<div class="col-md-5 mb-3">
|
||||||
<label class="form-label" for="qbit.max_downloads">Maximum Downloads Limit</label>
|
<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">
|
<input type="number" class="form-control" name="qbit.max_downloads"
|
||||||
<small class="form-text text-muted">Maximum number of simultaneous local downloads across all torrents</small>
|
id="qbit.max_downloads">
|
||||||
|
<small class="form-text text-muted">Maximum number of simultaneous local downloads
|
||||||
|
across all torrents</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<div class="form-check me-3 d-inline-block">
|
<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">
|
<input type="checkbox" class="form-check-input" name="qbit.skip_pre_cache"
|
||||||
<label class="form-check-label" for="qbit.skip_pre_cache">Disable Pre-Cache On Download</label>
|
id="qbit.skip_pre_cache">
|
||||||
<small class="form-text text-muted">Unchecking this caches a tiny part of your file to speed up import</small>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,7 +277,8 @@
|
|||||||
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="2">
|
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="2">
|
||||||
<i class="bi bi-arrow-left"></i> Previous
|
<i class="bi bi-arrow-left"></i> Previous
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -249,7 +296,8 @@
|
|||||||
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="3">
|
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="3">
|
||||||
<i class="bi bi-arrow-left"></i> Previous
|
<i class="bi bi-arrow-left"></i> Previous
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -259,7 +307,8 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="form-check me-3 d-inline-block">
|
<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>
|
<label class="form-check-label" for="repair.enabled">Enable Scheduled Repair</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -268,34 +317,43 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label class="form-label" for="repair.interval">Scheduled Interval</label>
|
<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">
|
<input type="text" class="form-control" name="repair.interval" id="repair.interval"
|
||||||
<small class="form-text text-muted">Interval for the repair process(e.g., 24h, 1d, 03:00, or a crontab)</small>
|
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>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="repair.workers">Workers</label>
|
<label class="form-label" for="repair.workers">Workers</label>
|
||||||
<input type="text" class="form-control" name="repair.workers" id="repair.workers">
|
<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>
|
||||||
<div class="col-md-5 mb-3">
|
<div class="col-md-5 mb-3">
|
||||||
<label class="form-label" for="repair.zurg_url">Zurg URL</label>
|
<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">
|
<input type="text" class="form-control" name="repair.zurg_url" id="repair.zurg_url"
|
||||||
<small class="form-text text-muted">If you have Zurg running, you can use it to speed up the repair process</small>
|
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>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<div class="form-check">
|
<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>
|
<label class="form-check-label" for="repair.use_webdav">Use Webdav</label>
|
||||||
</div>
|
</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>
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<div class="form-check">
|
<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>
|
<label class="form-check-label" for="repair.auto_process">Auto Process</label>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -335,15 +393,44 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].api_key" >API Key</label>
|
<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>
|
<small class="form-text text-muted">API Key for the debrid service</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<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 key1 key2 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>
|
<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>
|
<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>
|
<small class="form-text text-muted">Path to where you've mounted the debrid files. Usually your rclone path</small>
|
||||||
</div>
|
</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>
|
<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">
|
<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>
|
<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>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].rc_pass">Rclone RC Password</label>
|
<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>
|
<small class="form-text text-muted">Rclone RC Password for the webdav server</small>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
@@ -538,7 +635,7 @@
|
|||||||
const filterTemplate = (debridIndex, dirIndex, filterIndex, filterType) => {
|
const filterTemplate = (debridIndex, dirIndex, filterIndex, filterType) => {
|
||||||
let placeholder, label;
|
let placeholder, label;
|
||||||
|
|
||||||
switch(filterType) {
|
switch (filterType) {
|
||||||
case 'include':
|
case 'include':
|
||||||
placeholder = "Text that should be included in filename";
|
placeholder = "Text that should be included in filename";
|
||||||
label = "Include";
|
label = "Include";
|
||||||
@@ -632,29 +729,58 @@
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const arrTemplate = (index) => `
|
const arrTemplate = (index, data = {}) => `
|
||||||
<div class="config-item position-relative mb-3 p-3 border rounded">
|
<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"
|
<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();"
|
onclick="if(confirm('Are you sure you want to delete this arr?')) this.closest('.config-item').remove();"
|
||||||
title="Delete this arr">
|
title="Delete this arr">
|
||||||
<i class="bi bi-trash"></i>
|
<i class="bi bi-trash"></i>
|
||||||
</button>
|
</button>
|
||||||
|
` : `
|
||||||
|
<div class="position-absolute top-0 end-0 m-2">
|
||||||
|
<span class="badge bg-info">Auto-detected</span>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
<input type="hidden" name="arr[${index}].source" value="${data.source || ''}">
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label for="arr[${index}].name" class="form-label">Name</label>
|
<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>
|
<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>
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label for="arr[${index}].host" class="form-label">Host</label>
|
<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>
|
<input type="text"
|
||||||
|
class="form-control"
|
||||||
|
name="arr[${index}].host"
|
||||||
|
id="arr[${index}].host"
|
||||||
|
${data.source === 'auto' ? 'readonly' : 'required'}>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 mb-3">
|
<div class="col-md-4 mb-3">
|
||||||
<label for"arr[${index}].token" class="form-label">API Token</label>
|
<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 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>
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-4 mb-3">
|
<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">
|
<select class="form-select" name="arr[${index}].selected_debrid" id="arr[${index}].selected_debrid">
|
||||||
<option value="" selected disabled>Select Arr Debrid</option>
|
<option value="" selected disabled>Select Arr Debrid</option>
|
||||||
<option value="realdebrid">Real Debrid</option>
|
<option value="realdebrid">Real Debrid</option>
|
||||||
@@ -739,6 +865,7 @@
|
|||||||
debridDirectoryCounts[debridIndex]++;
|
debridDirectoryCounts[debridIndex]++;
|
||||||
return dirIndex;
|
return dirIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
function addFilter(debridIndex, dirIndex, filterType, filterValue = "") {
|
function addFilter(debridIndex, dirIndex, filterType, filterValue = "") {
|
||||||
const dirKey = `${debridIndex}-${dirIndex}`;
|
const dirKey = `${debridIndex}-${dirIndex}`;
|
||||||
if (!directoryFilterCounts[dirKey]) {
|
if (!directoryFilterCounts[dirKey]) {
|
||||||
@@ -771,7 +898,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Main functionality
|
// Main functionality
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
let debridCount = 0;
|
let debridCount = 0;
|
||||||
let arrCount = 0;
|
let arrCount = 0;
|
||||||
let currentStep = 1;
|
let currentStep = 1;
|
||||||
@@ -785,21 +912,21 @@
|
|||||||
|
|
||||||
// Step navigation
|
// Step navigation
|
||||||
document.querySelectorAll('.nav-link').forEach(navLink => {
|
document.querySelectorAll('.nav-link').forEach(navLink => {
|
||||||
navLink.addEventListener('click', function() {
|
navLink.addEventListener('click', function () {
|
||||||
const stepNumber = parseInt(this.getAttribute('data-step'));
|
const stepNumber = parseInt(this.getAttribute('data-step'));
|
||||||
goToStep(stepNumber);
|
goToStep(stepNumber);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.next-step').forEach(button => {
|
document.querySelectorAll('.next-step').forEach(button => {
|
||||||
button.addEventListener('click', function() {
|
button.addEventListener('click', function () {
|
||||||
const nextStep = parseInt(this.getAttribute('data-next'));
|
const nextStep = parseInt(this.getAttribute('data-next'));
|
||||||
goToStep(nextStep);
|
goToStep(nextStep);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll('.prev-step').forEach(button => {
|
document.querySelectorAll('.prev-step').forEach(button => {
|
||||||
button.addEventListener('click', function() {
|
button.addEventListener('click', function () {
|
||||||
const prevStep = parseInt(this.getAttribute('data-prev'));
|
const prevStep = parseInt(this.getAttribute('data-prev'));
|
||||||
goToStep(prevStep);
|
goToStep(prevStep);
|
||||||
});
|
});
|
||||||
@@ -910,7 +1037,7 @@
|
|||||||
addArrConfig();
|
addArrConfig();
|
||||||
});
|
});
|
||||||
|
|
||||||
$(document).on('change', '.useWebdav', function() {
|
$(document).on('change', '.useWebdav', function () {
|
||||||
const webdavConfig = $(this).closest('.config-item').find(`.webdav`);
|
const webdavConfig = $(this).closest('.config-item').find(`.webdav`);
|
||||||
if (webdavConfig.length === 0) return;
|
if (webdavConfig.length === 0) return;
|
||||||
|
|
||||||
@@ -953,7 +1080,7 @@
|
|||||||
// Save config logic
|
// Save config logic
|
||||||
const response = await fetcher('/api/config', {
|
const response = await fetcher('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config)
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1005,7 +1132,7 @@
|
|||||||
|
|
||||||
if (data.use_webdav && data.directories) {
|
if (data.use_webdav && data.directories) {
|
||||||
Object.entries(data.directories).forEach(([dirName, dirData]) => {
|
Object.entries(data.directories).forEach(([dirName, dirData]) => {
|
||||||
const dirIndex = addDirectory(debridCount, { name: dirName });
|
const dirIndex = addDirectory(debridCount, {name: dirName});
|
||||||
|
|
||||||
// Add filters if available
|
// Add filters if available
|
||||||
if (dirData.filters) {
|
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++;
|
debridCount++;
|
||||||
@@ -1022,11 +1163,10 @@
|
|||||||
|
|
||||||
function addArrConfig(data = {}) {
|
function addArrConfig(data = {}) {
|
||||||
const container = document.getElementById('arrConfigs');
|
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;
|
const newArr = container.lastElementChild;
|
||||||
addDeleteButton(newArr, `Delete this arr`);
|
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
Object.entries(data).forEach(([key, value]) => {
|
Object.entries(data).forEach(([key, value]) => {
|
||||||
@@ -1051,7 +1191,7 @@
|
|||||||
deleteBtn.innerHTML = '<i class="bi bi-trash"></i>';
|
deleteBtn.innerHTML = '<i class="bi bi-trash"></i>';
|
||||||
deleteBtn.title = tooltip;
|
deleteBtn.title = tooltip;
|
||||||
|
|
||||||
deleteBtn.addEventListener('click', function() {
|
deleteBtn.addEventListener('click', function () {
|
||||||
if (confirm('Are you sure you want to delete this item?')) {
|
if (confirm('Are you sure you want to delete this item?')) {
|
||||||
element.remove();
|
element.remove();
|
||||||
}
|
}
|
||||||
@@ -1126,7 +1266,7 @@
|
|||||||
const nameInput = document.querySelector(`[name="debrid[${i}].directory[${j}].name"]`);
|
const nameInput = document.querySelector(`[name="debrid[${i}].directory[${j}].name"]`);
|
||||||
if (nameInput && nameInput.value) {
|
if (nameInput && nameInput.value) {
|
||||||
const dirName = nameInput.value;
|
const dirName = nameInput.value;
|
||||||
debrid.directories[dirName] = { filters: {} };
|
debrid.directories[dirName] = {filters: {}};
|
||||||
|
|
||||||
// Get directory key for filter counting
|
// Get directory key for filter counting
|
||||||
const dirKey = `${i}-${j}`;
|
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) {
|
if (debrid.name && debrid.api_key) {
|
||||||
config.debrids.push(debrid);
|
config.debrids.push(debrid);
|
||||||
}
|
}
|
||||||
@@ -1163,7 +1311,8 @@
|
|||||||
cleanup: document.querySelector(`[name="arr[${i}].cleanup"]`).checked,
|
cleanup: document.querySelector(`[name="arr[${i}].cleanup"]`).checked,
|
||||||
skip_repair: document.querySelector(`[name="arr[${i}].skip_repair"]`).checked,
|
skip_repair: document.querySelector(`[name="arr[${i}].skip_repair"]`).checked,
|
||||||
download_uncached: document.querySelector(`[name="arr[${i}].download_uncached"]`).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) {
|
if (arr.name && arr.host) {
|
||||||
|
|||||||
@@ -121,6 +121,45 @@
|
|||||||
.theme-toggle:hover {
|
.theme-toggle:hover {
|
||||||
background-color: rgba(128, 128, 128, 0.2);
|
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>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
(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
|
// Theme management
|
||||||
const themeToggle = document.getElementById('themeToggle');
|
const themeToggle = document.getElementById('themeToggle');
|
||||||
const lightIcon = document.getElementById('lightIcon');
|
const lightIcon = document.getElementById('lightIcon');
|
||||||
|
|||||||
Reference in New Issue
Block a user