- Fix symlinks % bug

- A cleaner settings page
- More bug fixes
This commit is contained in:
Mukhtar Akere
2025-04-25 12:36:12 +01:00
parent 267430e6fb
commit 07f1d0f28d
22 changed files with 1351 additions and 1140 deletions

View File

@@ -89,7 +89,6 @@ type Config struct {
LogLevel string `json:"log_level,omitempty"`
Debrids []Debrid `json:"debrids,omitempty"`
MaxCacheSize int `json:"max_cache_size,omitempty"`
QBitTorrent QBitTorrent `json:"qbittorrent,omitempty"`
Arrs []Arr `json:"arrs,omitempty"`
Repair Repair `json:"repair,omitempty"`
@@ -118,39 +117,19 @@ func (c *Config) loadConfig() error {
c.Path = configPath
file, err := os.ReadFile(c.JsonFile())
if err != nil {
return err
if os.IsNotExist(err) {
// Create a default config file if it doesn't exist
if err := c.createConfig(c.Path); err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
} else {
return fmt.Errorf("error reading config file: %w", err)
}
} else {
if err := json.Unmarshal(file, &c); err != nil {
return fmt.Errorf("error unmarshaling config: %w", err)
}
for i, debrid := range c.Debrids {
c.Debrids[i] = c.updateDebrid(debrid)
}
if len(c.AllowedExt) == 0 {
c.AllowedExt = getDefaultExtensions()
}
c.Port = cmp.Or(c.Port, c.QBitTorrent.Port)
if c.URLBase == "" {
c.URLBase = "/"
}
// validate url base starts with /
if c.URLBase[0] != '/' {
c.URLBase = "/" + c.URLBase
}
// Load the auth file
c.Auth = c.GetAuth()
//Validate the config
if err := ValidateConfig(c); err != nil {
return err
}
return nil
}
@@ -196,15 +175,15 @@ func ValidateConfig(config *Config) error {
// Run validations concurrently
if err := validateDebrids(config.Debrids); err != nil {
return fmt.Errorf("debrids validation error: %w", err)
return err
}
if err := validateQbitTorrent(&config.QBitTorrent); err != nil {
return fmt.Errorf("qbittorrent validation error: %w", err)
return err
}
if err := validateRepair(&config.Repair); err != nil {
return fmt.Errorf("repair validation error: %w", err)
return err
}
return nil
@@ -287,11 +266,8 @@ func (c *Config) SaveAuth(auth *Auth) error {
return os.WriteFile(c.AuthFile(), data, 0644)
}
func (c *Config) NeedsSetup() bool {
if err := ValidateConfig(c); err != nil {
return true
}
return false
func (c *Config) NeedsSetup() error {
return ValidateConfig(c)
}
func (c *Config) NeedsAuth() bool {
@@ -336,6 +312,26 @@ func (c *Config) updateDebrid(d Debrid) Debrid {
}
func (c *Config) Save() error {
for i, debrid := range c.Debrids {
c.Debrids[i] = c.updateDebrid(debrid)
}
if len(c.AllowedExt) == 0 {
c.AllowedExt = getDefaultExtensions()
}
c.Port = cmp.Or(c.Port, c.QBitTorrent.Port)
if c.URLBase == "" {
c.URLBase = "/"
}
// validate url base starts with /
if c.URLBase[0] != '/' {
c.URLBase = "/" + c.URLBase
}
// Load the auth file
c.Auth = c.GetAuth()
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
@@ -347,6 +343,25 @@ func (c *Config) Save() error {
return nil
}
func (c *Config) createConfig(path string) error {
// Create the directory if it doesn't exist
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
c.Path = path
c.URLBase = "/"
c.Port = "8282"
c.LogLevel = "info"
c.UseAuth = true
c.QBitTorrent = QBitTorrent{
DownloadFolder: filepath.Join(path, "downloads"),
Categories: []string{"sonarr", "radarr"},
RefreshInterval: 15,
}
return c.Save()
}
// Reload forces a reload of the configuration from disk
func Reload() {
instance = nil

View File

@@ -200,7 +200,6 @@ func (as *Storage) cleanupArrsQueue() {
arrs = append(arrs, arr)
}
if len(arrs) > 0 {
as.logger.Trace().Msgf("Cleaning up %d arrs", len(arrs))
for _, arr := range arrs {
if err := arr.CleanupQueue(); err != nil {
as.logger.Error().Err(err).Msgf("Failed to cleanup arr %s", arr.Name)

View File

@@ -64,32 +64,27 @@ func New(dc config.Debrid) *RealDebrid {
"Authorization": fmt.Sprintf("Bearer %s", currentDownloadKey),
}
downloadClient := request.New(
request.WithHeaders(downloadHeaders),
request.WithRateLimiter(rl),
request.WithLogger(_log),
request.WithMaxRetries(5),
request.WithRetryableStatus(429, 447),
request.WithProxy(dc.Proxy),
)
client := request.New(
request.WithHeaders(headers),
request.WithRateLimiter(rl),
request.WithLogger(_log),
request.WithMaxRetries(5),
request.WithRetryableStatus(429),
request.WithProxy(dc.Proxy),
)
return &RealDebrid{
Name: "realdebrid",
Host: "https://api.real-debrid.com/rest/1.0",
APIKey: dc.APIKey,
DownloadKeys: accounts,
DownloadUncached: dc.DownloadUncached,
client: client,
downloadClient: downloadClient,
client: request.New(
request.WithHeaders(headers),
request.WithRateLimiter(rl),
request.WithLogger(_log),
request.WithMaxRetries(5),
request.WithRetryableStatus(429),
request.WithProxy(dc.Proxy),
),
downloadClient: request.New(
request.WithHeaders(downloadHeaders),
request.WithLogger(_log),
request.WithMaxRetries(10),
request.WithRetryableStatus(429, 447),
request.WithProxy(dc.Proxy),
),
currentDownloadKey: currentDownloadKey,
MountPath: dc.Folder,
logger: logger.New(dc.Name),

View File

@@ -177,16 +177,17 @@ func (q *QBit) createSymlinksWebdav(debridTorrent *debrid.Torrent, rclonePath, t
// Check which files exist in this batch
for _, entry := range entries {
if file, exists := remainingFiles[entry.Name()]; exists {
fullFilePath := filepath.Join(rclonePath, file.Name)
filename := entry.Name()
if file, exists := remainingFiles[filename]; exists {
fullFilePath := filepath.Join(rclonePath, filename)
fileSymlinkPath := filepath.Join(symlinkPath, file.Name)
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
} else {
q.logger.Info().Msgf("File is ready: %s", file.Name)
filePaths = append(filePaths, fileSymlinkPath)
delete(remainingFiles, file.Name)
delete(remainingFiles, filename)
q.logger.Info().Msgf("File is ready: %s", file.Name)
}
}
}

View File

@@ -10,7 +10,7 @@ func (q *QBit) Routes() http.Handler {
r.Use(q.CategoryContext)
r.Group(func(r chi.Router) {
r.Use(q.authContext)
r.Post("/auth/login", q.handleLogin)
r.Post("/register/login", q.handleLogin)
r.Route("/torrents", func(r chi.Router) {
r.Use(HashesCtx)
r.Get("/info", q.handleTorrentsInfo)

View File

@@ -250,7 +250,6 @@ func (ts *TorrentStorage) DeleteMultiple(hashes []string, removeFromDebrid bool)
if dbClient == nil {
continue
}
fmt.Println("Deleting torrent from debrid:", id)
err := dbClient.DeleteTorrent(id)
if err != nil {
fmt.Println(err)

View File

@@ -83,10 +83,6 @@ func (s *Server) Start(ctx context.Context) error {
return srv.Shutdown(context.Background())
}
func (s *Server) Mount(pattern string, handler http.Handler) {
s.router.Mount(pattern, handler)
}
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
logFile := logger.GetLogPath()

293
pkg/web/api.go Normal file
View File

@@ -0,0 +1,293 @@
package web
import (
"fmt"
"github.com/go-chi/chi/v5"
"github.com/goccy/go-json"
"github.com/sirrobot01/decypharr/internal/config"
"github.com/sirrobot01/decypharr/internal/request"
"github.com/sirrobot01/decypharr/internal/utils"
"github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/qbit"
"github.com/sirrobot01/decypharr/pkg/service"
"github.com/sirrobot01/decypharr/pkg/version"
"net/http"
"strings"
"time"
)
func (ui *Handler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
svc := service.GetService()
request.JSONResponse(w, svc.Arr.GetAll(), http.StatusOK)
}
func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
svc := service.GetService()
results := make([]*qbit.ImportRequest, 0)
errs := make([]string, 0)
arrName := r.FormValue("arr")
notSymlink := r.FormValue("notSymlink") == "true"
downloadUncached := r.FormValue("downloadUncached") == "true"
_arr := svc.Arr.Get(arrName)
if _arr == nil {
_arr = arr.New(arrName, "", "", false, false, &downloadUncached)
}
// Handle URLs
if urls := r.FormValue("urls"); urls != "" {
var urlList []string
for _, u := range strings.Split(urls, "\n") {
if trimmed := strings.TrimSpace(u); trimmed != "" {
urlList = append(urlList, trimmed)
}
}
for _, url := range urlList {
magnet, err := utils.GetMagnetFromUrl(url)
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to parse URL %s: %v", url, err))
continue
}
importReq := qbit.NewImportRequest(magnet, _arr, !notSymlink, downloadUncached)
if err := importReq.Process(ui.qbit); err != nil {
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
continue
}
results = append(results, importReq)
}
}
// Handle torrent/magnet files
if files := r.MultipartForm.File["files"]; len(files) > 0 {
for _, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to open file %s: %v", fileHeader.Filename, err))
continue
}
magnet, err := utils.GetMagnetFromFile(file, fileHeader.Filename)
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to parse torrent file %s: %v", fileHeader.Filename, err))
continue
}
importReq := qbit.NewImportRequest(magnet, _arr, !notSymlink, downloadUncached)
err = importReq.Process(ui.qbit)
if err != nil {
errs = append(errs, fmt.Sprintf("File %s: %v", fileHeader.Filename, err))
continue
}
results = append(results, importReq)
}
}
request.JSONResponse(w, struct {
Results []*qbit.ImportRequest `json:"results"`
Errors []string `json:"errors,omitempty"`
}{
Results: results,
Errors: errs,
}, http.StatusOK)
}
func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
var req RepairRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
svc := service.GetService()
var arrs []string
if req.ArrName != "" {
_arr := svc.Arr.Get(req.ArrName)
if _arr == nil {
http.Error(w, "No Arrs found to repair", http.StatusNotFound)
return
}
arrs = append(arrs, req.ArrName)
}
if req.Async {
go func() {
if err := svc.Repair.AddJob(arrs, req.MediaIds, req.AutoProcess, false); err != nil {
ui.logger.Error().Err(err).Msg("Failed to repair media")
}
}()
request.JSONResponse(w, "Repair process started", http.StatusOK)
return
}
if err := svc.Repair.AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess, false); err != nil {
http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
return
}
request.JSONResponse(w, "Repair completed", http.StatusOK)
}
func (ui *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
v := version.GetInfo()
request.JSONResponse(w, v, http.StatusOK)
}
func (ui *Handler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
request.JSONResponse(w, ui.qbit.Storage.GetAll("", "", nil), http.StatusOK)
}
func (ui *Handler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
category := r.URL.Query().Get("category")
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
if hash == "" {
http.Error(w, "No hash provided", http.StatusBadRequest)
return
}
ui.qbit.Storage.Delete(hash, category, removeFromDebrid)
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
hashesStr := r.URL.Query().Get("hashes")
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
if hashesStr == "" {
http.Error(w, "No hashes provided", http.StatusBadRequest)
return
}
hashes := strings.Split(hashesStr, ",")
ui.qbit.Storage.DeleteMultiple(hashes, removeFromDebrid)
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
arrCfgs := make([]config.Arr, 0)
svc := service.GetService()
for _, a := range svc.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,
})
}
cfg.Arrs = arrCfgs
request.JSONResponse(w, cfg, http.StatusOK)
}
func (ui *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
// Decode the JSON body
var updatedConfig config.Config
if err := json.NewDecoder(r.Body).Decode(&updatedConfig); err != nil {
ui.logger.Error().Err(err).Msg("Failed to decode config update request")
http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
// Get the current configuration
currentConfig := config.Get()
// Update fields that can be changed
currentConfig.LogLevel = updatedConfig.LogLevel
currentConfig.MinFileSize = updatedConfig.MinFileSize
currentConfig.MaxFileSize = updatedConfig.MaxFileSize
currentConfig.AllowedExt = updatedConfig.AllowedExt
currentConfig.DiscordWebhook = updatedConfig.DiscordWebhook
// Should this be added?
currentConfig.URLBase = updatedConfig.URLBase
currentConfig.BindAddress = updatedConfig.BindAddress
currentConfig.Port = updatedConfig.Port
// Update QBitTorrent config
currentConfig.QBitTorrent = updatedConfig.QBitTorrent
// Update Repair config
currentConfig.Repair = updatedConfig.Repair
// Update Debrids
if len(updatedConfig.Debrids) > 0 {
currentConfig.Debrids = updatedConfig.Debrids
// Clear legacy single debrid if using array
}
// Update Arrs through the service
svc := service.GetService()
svc.Arr.Clear() // Clear existing arrs
for _, a := range updatedConfig.Arrs {
svc.Arr.AddOrUpdate(&arr.Arr{
Name: a.Name,
Host: a.Host,
Token: a.Token,
Cleanup: a.Cleanup,
SkipRepair: a.SkipRepair,
DownloadUncached: a.DownloadUncached,
})
}
if err := currentConfig.Save(); err != nil {
http.Error(w, "Error saving config: "+err.Error(), http.StatusInternalServerError)
return
}
if restartFunc != nil {
go func() {
// Small delay to ensure the response is sent
time.Sleep(500 * time.Millisecond)
restartFunc()
}()
}
// Return success
request.JSONResponse(w, map[string]string{"status": "success"}, http.StatusOK)
}
func (ui *Handler) handleGetRepairJobs(w http.ResponseWriter, r *http.Request) {
svc := service.GetService()
request.JSONResponse(w, svc.Repair.GetJobs(), http.StatusOK)
}
func (ui *Handler) handleProcessRepairJob(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "No job ID provided", http.StatusBadRequest)
return
}
svc := service.GetService()
if err := svc.Repair.ProcessJob(id); err != nil {
ui.logger.Error().Err(err).Msg("Failed to process repair job")
}
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request) {
// Read ids from body
var req struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
http.Error(w, "No job IDs provided", http.StatusBadRequest)
return
}
svc := service.GetService()
svc.Repair.DeleteJobs(req.IDs)
w.WriteHeader(http.StatusOK)
}

34
pkg/web/auth.go Normal file
View File

@@ -0,0 +1,34 @@
package web
import (
"github.com/sirrobot01/decypharr/internal/config"
"golang.org/x/crypto/bcrypt"
"net/http"
)
func (ui *Handler) 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
}
func (ui *Handler) skipAuthHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
cfg.UseAuth = false
if err := cfg.Save(); err != nil {
ui.logger.Error().Err(err).Msg("failed to save config")
http.Error(w, "failed to save config", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}

51
pkg/web/middlewares.go Normal file
View File

@@ -0,0 +1,51 @@
package web
import (
"fmt"
"github.com/sirrobot01/decypharr/internal/config"
"net/http"
)
func (ui *Handler) setupMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
needsAuth := cfg.NeedsSetup()
if needsAuth != nil && r.URL.Path != "/config" && r.URL.Path != "/api/config" {
http.Redirect(w, r, fmt.Sprintf("/config?inco=%s", needsAuth.Error()), http.StatusSeeOther)
return
}
// strip inco from URL
if inco := r.URL.Query().Get("inco"); inco != "" && needsAuth == nil && r.URL.Path == "/config" {
// redirect to the same URL without the inco parameter
http.Redirect(w, r, fmt.Sprintf("/config"), http.StatusSeeOther)
}
next.ServeHTTP(w, r)
})
}
func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if setup is needed
cfg := config.Get()
if !cfg.UseAuth {
next.ServeHTTP(w, r)
return
}
if cfg.NeedsAuth() {
http.Redirect(w, r, "/register", http.StatusSeeOther)
return
}
session, _ := store.Get(r, "auth-session")
auth, ok := session.Values["authenticated"].(bool)
if !ok || !auth {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}

View File

@@ -10,16 +10,19 @@ func (ui *Handler) Routes() http.Handler {
r.Get("/login", ui.LoginHandler)
r.Post("/login", ui.LoginHandler)
r.Get("/auth", ui.SetupHandler)
r.Post("/auth", ui.SetupHandler)
r.Get("/register", ui.RegisterHandler)
r.Post("/register", ui.RegisterHandler)
r.Get("/skip-auth", ui.skipAuthHandler)
r.Get("/version", ui.handleGetVersion)
r.Group(func(r chi.Router) {
r.Use(ui.authMiddleware)
r.Use(ui.setupMiddleware)
r.Get("/", ui.IndexHandler)
r.Get("/download", ui.DownloadHandler)
r.Get("/repair", ui.RepairHandler)
r.Get("/config", ui.ConfigHandler)
r.Route("/internal", func(r chi.Router) {
r.Route("/api", func(r chi.Router) {
r.Get("/arrs", ui.handleGetArrs)
r.Post("/add", ui.handleAddContent)
r.Post("/repair", ui.handleRepairMedia)
@@ -31,7 +34,6 @@ func (ui *Handler) Routes() http.Handler {
r.Delete("/torrents/", ui.handleDeleteTorrents)
r.Get("/config", ui.handleGetConfig)
r.Post("/config", ui.handleUpdateConfig)
r.Get("/version", ui.handleGetVersion)
})
})

View File

@@ -2,25 +2,11 @@ package web
import (
"embed"
"fmt"
"github.com/goccy/go-json"
"github.com/gorilla/sessions"
"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/qbit"
"github.com/sirrobot01/decypharr/pkg/service"
"golang.org/x/crypto/bcrypt"
"html/template"
"net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/pkg/arr"
"github.com/sirrobot01/decypharr/pkg/version"
"github.com/sirrobot01/decypharr/internal/logger"
"github.com/sirrobot01/decypharr/pkg/qbit"
"html/template"
)
var restartFunc func()
@@ -88,7 +74,7 @@ func init() {
"templates/repair.html",
"templates/config.html",
"templates/login.html",
"templates/setup.html",
"templates/register.html",
))
store.Options = &sessions.Options{
@@ -97,461 +83,3 @@ func init() {
HttpOnly: false,
}
}
func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if setup is needed
cfg := config.Get()
if !cfg.UseAuth {
next.ServeHTTP(w, r)
return
}
if cfg.NeedsAuth() && r.URL.Path != "/auth" {
http.Redirect(w, r, "/auth", http.StatusSeeOther)
return
}
// Skip auth check for setup page
if r.URL.Path == "/auth" {
next.ServeHTTP(w, r)
return
}
session, _ := store.Get(r, "auth-session")
auth, ok := session.Values["authenticated"].(bool)
if !ok || !auth {
http.Redirect(w, r, "/login", http.StatusSeeOther)
return
}
next.ServeHTTP(w, r)
})
}
func (ui *Handler) 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
}
func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
if cfg.NeedsAuth() {
http.Redirect(w, r, "/auth", http.StatusSeeOther)
return
}
if r.Method == "GET" {
data := map[string]interface{}{
"Page": "login",
"Title": "Login",
}
_ = templates.ExecuteTemplate(w, "layout", data)
return
}
var credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if ui.verifyAuth(credentials.Username, credentials.Password) {
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = credentials.Username
if err := session.Save(r, w); err != nil {
http.Error(w, "Error saving session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
}
func (ui *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = false
session.Options.MaxAge = -1
err := session.Save(r, w)
if err != nil {
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (ui *Handler) SetupHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
authCfg := cfg.GetAuth()
if !cfg.NeedsAuth() {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
if r.Method == "GET" {
data := map[string]interface{}{
"Page": "auth",
"Title": "Auth Setup",
}
_ = templates.ExecuteTemplate(w, "layout", data)
return
}
// Handle POST (setup attempt)
username := r.FormValue("username")
password := r.FormValue("password")
confirmPassword := r.FormValue("confirmPassword")
if password != confirmPassword {
http.Error(w, "Passwords do not match", http.StatusBadRequest)
return
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Error processing password", http.StatusInternalServerError)
return
}
// Set the credentials
authCfg.Username = username
authCfg.Password = string(hashedPassword)
if err := cfg.SaveAuth(authCfg); err != nil {
http.Error(w, "Error saving credentials", http.StatusInternalServerError)
return
}
// Create a session
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = username
if err := session.Save(r, w); err != nil {
http.Error(w, "Error saving session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (ui *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Page": "index",
"Title": "Torrents",
}
_ = templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) DownloadHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Page": "download",
"Title": "Download",
}
_ = templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) RepairHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Page": "repair",
"Title": "Repair",
}
_ = templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) ConfigHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Page": "config",
"Title": "Config",
}
_ = templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
svc := service.GetService()
request.JSONResponse(w, svc.Arr.GetAll(), http.StatusOK)
}
func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
svc := service.GetService()
results := make([]*qbit.ImportRequest, 0)
errs := make([]string, 0)
arrName := r.FormValue("arr")
notSymlink := r.FormValue("notSymlink") == "true"
downloadUncached := r.FormValue("downloadUncached") == "true"
_arr := svc.Arr.Get(arrName)
if _arr == nil {
_arr = arr.New(arrName, "", "", false, false, &downloadUncached)
}
// Handle URLs
if urls := r.FormValue("urls"); urls != "" {
var urlList []string
for _, u := range strings.Split(urls, "\n") {
if trimmed := strings.TrimSpace(u); trimmed != "" {
urlList = append(urlList, trimmed)
}
}
for _, url := range urlList {
magnet, err := utils.GetMagnetFromUrl(url)
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to parse URL %s: %v", url, err))
continue
}
importReq := qbit.NewImportRequest(magnet, _arr, !notSymlink, downloadUncached)
if err := importReq.Process(ui.qbit); err != nil {
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
continue
}
results = append(results, importReq)
}
}
// Handle torrent/magnet files
if files := r.MultipartForm.File["files"]; len(files) > 0 {
for _, fileHeader := range files {
file, err := fileHeader.Open()
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to open file %s: %v", fileHeader.Filename, err))
continue
}
magnet, err := utils.GetMagnetFromFile(file, fileHeader.Filename)
if err != nil {
errs = append(errs, fmt.Sprintf("Failed to parse torrent file %s: %v", fileHeader.Filename, err))
continue
}
importReq := qbit.NewImportRequest(magnet, _arr, !notSymlink, downloadUncached)
err = importReq.Process(ui.qbit)
if err != nil {
errs = append(errs, fmt.Sprintf("File %s: %v", fileHeader.Filename, err))
continue
}
results = append(results, importReq)
}
}
request.JSONResponse(w, struct {
Results []*qbit.ImportRequest `json:"results"`
Errors []string `json:"errors,omitempty"`
}{
Results: results,
Errors: errs,
}, http.StatusOK)
}
func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
var req RepairRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
svc := service.GetService()
var arrs []string
if req.ArrName != "" {
_arr := svc.Arr.Get(req.ArrName)
if _arr == nil {
http.Error(w, "No Arrs found to repair", http.StatusNotFound)
return
}
arrs = append(arrs, req.ArrName)
}
if req.Async {
go func() {
if err := svc.Repair.AddJob(arrs, req.MediaIds, req.AutoProcess, false); err != nil {
ui.logger.Error().Err(err).Msg("Failed to repair media")
}
}()
request.JSONResponse(w, "Repair process started", http.StatusOK)
return
}
if err := svc.Repair.AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess, false); err != nil {
http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
return
}
request.JSONResponse(w, "Repair completed", http.StatusOK)
}
func (ui *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
v := version.GetInfo()
request.JSONResponse(w, v, http.StatusOK)
}
func (ui *Handler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
request.JSONResponse(w, ui.qbit.Storage.GetAll("", "", nil), http.StatusOK)
}
func (ui *Handler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
category := r.URL.Query().Get("category")
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
if hash == "" {
http.Error(w, "No hash provided", http.StatusBadRequest)
return
}
ui.qbit.Storage.Delete(hash, category, removeFromDebrid)
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
hashesStr := r.URL.Query().Get("hashes")
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
if hashesStr == "" {
http.Error(w, "No hashes provided", http.StatusBadRequest)
return
}
hashes := strings.Split(hashesStr, ",")
ui.qbit.Storage.DeleteMultiple(hashes, removeFromDebrid)
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
arrCfgs := make([]config.Arr, 0)
svc := service.GetService()
for _, a := range svc.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,
})
}
cfg.Arrs = arrCfgs
request.JSONResponse(w, cfg, http.StatusOK)
}
func (ui *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
// Decode the JSON body
var updatedConfig config.Config
if err := json.NewDecoder(r.Body).Decode(&updatedConfig); err != nil {
ui.logger.Error().Err(err).Msg("Failed to decode config update request")
http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
// Get the current configuration
currentConfig := config.Get()
// Update fields that can be changed
currentConfig.LogLevel = updatedConfig.LogLevel
currentConfig.MinFileSize = updatedConfig.MinFileSize
currentConfig.MaxFileSize = updatedConfig.MaxFileSize
currentConfig.AllowedExt = updatedConfig.AllowedExt
currentConfig.DiscordWebhook = updatedConfig.DiscordWebhook
// Update QBitTorrent config
currentConfig.QBitTorrent = updatedConfig.QBitTorrent
// Update Repair config
currentConfig.Repair = updatedConfig.Repair
// Update Debrids
if len(updatedConfig.Debrids) > 0 {
currentConfig.Debrids = updatedConfig.Debrids
// Clear legacy single debrid if using array
}
// Update Arrs through the service
svc := service.GetService()
svc.Arr.Clear() // Clear existing arrs
for _, a := range updatedConfig.Arrs {
svc.Arr.AddOrUpdate(&arr.Arr{
Name: a.Name,
Host: a.Host,
Token: a.Token,
Cleanup: a.Cleanup,
SkipRepair: a.SkipRepair,
DownloadUncached: a.DownloadUncached,
})
}
if err := currentConfig.Save(); err != nil {
http.Error(w, "Error saving config: "+err.Error(), http.StatusInternalServerError)
return
}
if restartFunc != nil {
go func() {
// Small delay to ensure the response is sent
time.Sleep(500 * time.Millisecond)
restartFunc()
}()
}
// Return success
request.JSONResponse(w, map[string]string{"status": "success"}, http.StatusOK)
}
func (ui *Handler) handleGetRepairJobs(w http.ResponseWriter, r *http.Request) {
svc := service.GetService()
request.JSONResponse(w, svc.Repair.GetJobs(), http.StatusOK)
}
func (ui *Handler) handleProcessRepairJob(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "No job ID provided", http.StatusBadRequest)
return
}
svc := service.GetService()
if err := svc.Repair.ProcessJob(id); err != nil {
ui.logger.Error().Err(err).Msg("Failed to process repair job")
}
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request) {
// Read ids from body
var req struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
http.Error(w, "No job IDs provided", http.StatusBadRequest)
return
}
svc := service.GetService()
svc.Repair.DeleteJobs(req.IDs)
w.WriteHeader(http.StatusOK)
}

View File

@@ -1,16 +1,67 @@
{{ define "config" }}
<style>
/* Custom styles for the stepper navigation */
.nav-pills .nav-link {
color: var(--text-color);
background-color: var(--card-bg);
margin: 0 3px;
}
.nav-pills .nav-link.active {
background-color: var(--primary-color);
color: white !important;
font-weight: 500 !important;
}
/* For dark mode */
[data-bs-theme="dark"] .nav-pills .nav-link.active {
color: white !important;
}
</style>
<div class="container mt-4">
<form id="configForm">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center mb-2">
<h4 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h4>
<button type="submit" class="btn btn-primary px-4">
<h4 class="mb-0"><i class="bi bi-gear me-2"></i>Settings</h4>
<button type="submit" class="btn btn-success px-4">
<i class="bi bi-save"></i> Save
</button>
</div>
<div class="card-body">
<!-- Stepper navigation -->
<div class="stepper-nav mb-4">
<ul class="nav nav-pills nav-justified">
<li class="nav-item">
<button type="button" class="nav-link active fw-medium" data-step="1">
<i class="bi bi-1-circle me-1"></i>General
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link fw-medium" data-step="2">
<i class="bi bi-2-circle me-1"></i>Debrid
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link fw-medium" data-step="3">
<i class="bi bi-3-circle me-1"></i>QBitTorrent
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link fw-medium" data-step="4">
<i class="bi bi-4-circle me-1"></i>Arr Config
</button>
</li>
<li class="nav-item">
<button type="button" class="nav-link fw-medium" data-step="5">
<i class="bi bi-5-circle me-1"></i>Repair
</button>
</li>
</ul>
</div>
<!-- Step 1: General Configuration -->
<div class="setup-step" id="step1">
<div class="section mb-5">
<h5 class="border-bottom pb-2">General Configuration</h5>
<div class="row">
<div class="col-md-6">
<div class="form-group">
@@ -29,7 +80,7 @@
<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>
@@ -37,7 +88,6 @@
<div class="form-group">
<label for="urlBase">URL Base</label>
<input type="text"
disabled
class="form-control"
id="urlBase"
name="url_base"
@@ -49,7 +99,6 @@
<div class="form-group">
<label for="bindAddress">Bind Address</label>
<input type="text"
disabled
class="form-control"
id="bindAddress"
name="bind_address"
@@ -61,7 +110,6 @@
<div class="form-group">
<label for="port">Port</label>
<input type="text"
disabled
class="form-control"
id="port"
name="port"
@@ -102,7 +150,7 @@
id="minFileSize"
name="min_file_size"
placeholder="e.g., 10MB, 1GB">
<small class="form-text text-muted">Minimum file size to download (0 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-6 mt-3">
@@ -113,14 +161,19 @@
id="maxFileSize"
name="max_file_size"
placeholder="e.g., 50GB, 100MB">
<small class="form-text text-muted">Maximum file size to download (0 for no limit)</small>
<small class="form-text text-muted">Maximum file size to download (Empty for no limit)</small>
</div>
</div>
</div>
</div>
<!-- Debrid Configuration -->
<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>
</div>
</div>
<!-- Step 2: Debrid Configuration -->
<div class="setup-step d-none" id="step2">
<div class="section mb-5">
<h5 class="border-bottom pb-2">Debrids</h5>
<div id="debridConfigs"></div>
<div class="mb-3">
<button type="button" id="addDebridBtn" class="btn btn-secondary">
@@ -128,19 +181,18 @@
</button>
</div>
</div>
<div class="mt-4 d-flex justify-content-between">
<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>
</div>
</div>
<!-- QBitTorrent Configuration -->
<!-- Step 3: QBitTorrent Configuration -->
<div class="setup-step d-none" id="step3">
<div class="section mb-5">
<h5 class="border-bottom pb-2">QBitTorrent</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="qbit.username">Username</label>
<input type="text" class="form-control" name="qbit.username" id="qbit.username">
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="qbit.password">Password</label>
<input type="password" class="form-control" name="qbit.password" id="qbit.password">
</div>
<div class="col-md-6 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">
@@ -157,10 +209,17 @@
</div>
</div>
</div>
<div class="mt-4 d-flex justify-content-between">
<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>
</div>
</div>
<!-- Arr Configurations -->
<!-- Step 4: Arr Configuration -->
<div class="setup-step d-none" id="step4">
<div class="section mb-5">
<h5 class="border-bottom pb-2">Arr Configurations</h5>
<div id="arrConfigs"></div>
<div class="mb-3">
<button type="button" id="addArrBtn" class="btn btn-secondary">
@@ -168,10 +227,17 @@
</button>
</div>
</div>
<div class="mt-4 d-flex justify-content-between">
<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>
</div>
</div>
<!-- Repair Configuration -->
<!-- Step 5: Repair Configuration -->
<div class="setup-step d-none" id="step5">
<div class="section mb-5">
<h5 class="border-bottom pb-2">Repair Configuration</h5>
<div class="row mb-2">
<div class="col">
<div class="form-check me-3 d-inline-block">
@@ -219,15 +285,20 @@
</div>
</div>
</div>
<div class="text-end mt-3">
<button type="submit" class="btn btn-primary px-4">
<div class="mt-4 d-flex justify-content-between">
<button type="button" class="btn btn-outline-secondary prev-step" data-prev="4">
<i class="bi bi-arrow-left"></i> Previous
</button>
<button type="submit" class="btn btn-success">
<i class="bi bi-save"></i> Save
</button>
</div>
</div>
</div>
</div>
</form>
</div>
<script>
// Templates for dynamic elements
const debridTemplate = (index) => `
@@ -240,7 +311,12 @@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label" for="debrid[${index}].name" >Name</label>
<input type="text" class="form-control" name="debrid[${index}].name" id="debrid[${index}].name" required>
<select class="form-select" name="debrid[${index}].name" id="debrid[${index}].name" required>
<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-6 mb-3">
<label class="form-label" for="debrid[${index}].api_key" >API Key</label>
@@ -248,11 +324,11 @@
</div>
<div class="col-md-6 mb-3">
<label class="form-label" for="debrid[${index}].folder" >Mount Folder</label>
<input type="text" class="form-control" name="debrid[${index}].folder" id="debrid[${index}].folder">
<input type="text" class="form-control" name="debrid[${index}].folder" id="debrid[${index}].folder" placeholder="e.g. /mnt/remote/realdebrid" required>
</div>
<div class="col-md-6 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">
<input type="text" class="form-control" name="debrid[${index}].rate_limit" id="debrid[${index}].rate_limit" placeholder="e.g., 200/minute" value="250/minute">
</div>
<div class="col-12">
<div class="form-check me-3 d-inline-block">
@@ -273,42 +349,42 @@
<h6 class="pb-2">Webdav</h6>
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].torrents_refresh_interval">Torrents Refresh Interval</label>
<input type="text" class="form-control" name="debrid[${index}].torrents_refresh_interval" id="debrid[${index}].torrents_refresh_interval" placeholder="15s" required>
<input type="text" class="form-control webdav-field" name="debrid[${index}].torrents_refresh_interval" id="debrid[${index}].torrents_refresh_interval" placeholder="15s" value="15s">
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].download_links_refresh_interval">Download Links Refresh Interval</label>
<input type="text" class="form-control" name="debrid[${index}].download_links_refresh_interval" id="debrid[${index}].download_links_refresh_interval" placeholder="24h" required>
<input type="text" class="form-control webdav-field" name="debrid[${index}].download_links_refresh_interval" id="debrid[${index}].download_links_refresh_interval" placeholder="40m" value="40m">
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].auto_expire_links_after">Expire Links After</label>
<input type="text" class="form-control" name="debrid[${index}].auto_expire_links_after" id="debrid[${index}].auto_expire_links_after" placeholder="24h" required>
<input type="text" class="form-control webdav-field" name="debrid[${index}].auto_expire_links_after" id="debrid[${index}].auto_expire_links_after" placeholder="3d" value="3d">
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].folder_naming">Folder Naming Structure</label>
<select class="form-select" name="debrid[${index}].folder_naming" id="debrid[${index}].folder_naming">
<select class="form-select webdav-field" name="debrid[${index}].folder_naming" id="debrid[${index}].folder_naming">
<option value="original_no_ext" selected>Original name with No Ext</option>
<option value="original">Original name</option>
<option value="filename">File name</option>
<option value="filename_no_ext">File name with No Ext</option>
<option value="original">Original name</option>
<option value="original_no_ext">Original name with No Ext</option>
<option value="id">Use ID</option>
<option value="infohash">Use Infohash</option>
</select>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].workers">Number of Workers</label>
<input type="text" class="form-control" name="debrid[${index}].workers" id="debrid[${index}].workers" required placeholder="e.g., 20">
<input type="text" class="form-control webdav-field" name="debrid[${index}].workers" id="debrid[${index}].workers" placeholder="e.g., 50" value="50">
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].rc_url">Rclone RC URL</label>
<input type="text" class="form-control" name="debrid[${index}].rc_url" id="debrid[${index}].rc_url" placeholder="e.g., http://localhost:9990">
<input type="text" class="form-control webdav-field" name="debrid[${index}].rc_url" id="debrid[${index}].rc_url" placeholder="e.g., http://localhost:9990">
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].rc_user">Rclone RC User</label>
<input type="text" class="form-control" name="debrid[${index}].rc_user" id="debrid[${index}].rc_user">
<input type="text" class="form-control webdav-field" name="debrid[${index}].rc_user" id="debrid[${index}].rc_user">
</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" name="debrid[${index}].rc_pass" id="debrid[${index}].rc_pass">
<input type="password" class="form-control webdav-field" name="debrid[${index}].rc_pass" id="debrid[${index}].rc_pass">
</div>
</div>
</div>
@@ -362,9 +438,58 @@
document.addEventListener('DOMContentLoaded', function() {
let debridCount = 0;
let arrCount = 0;
let currentStep = 1;
// Check query parameters for incomplete config
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.has('inco')) {
const errMsg = urlParams.get('inco');
createToast(`Incomplete configuration: ${errMsg}`, 'warning');
}
// Step navigation
document.querySelectorAll('.nav-link').forEach(navLink => {
navLink.addEventListener('click', function() {
const stepNumber = parseInt(this.getAttribute('data-step'));
goToStep(stepNumber);
});
});
document.querySelectorAll('.next-step').forEach(button => {
button.addEventListener('click', function() {
const nextStep = parseInt(this.getAttribute('data-next'));
goToStep(nextStep);
});
});
document.querySelectorAll('.prev-step').forEach(button => {
button.addEventListener('click', function() {
const prevStep = parseInt(this.getAttribute('data-prev'));
goToStep(prevStep);
});
});
function goToStep(stepNumber) {
// Hide all steps
document.querySelectorAll('.setup-step').forEach(step => {
step.classList.add('d-none');
});
// Show the target step
document.getElementById(`step${stepNumber}`).classList.remove('d-none');
// Update nav pills
document.querySelectorAll('.nav-link').forEach(link => {
link.classList.remove('active');
});
document.querySelector(`.nav-link[data-step="${stepNumber}"]`).classList.add('active');
// Update current step
currentStep = stepNumber;
}
// Load existing configuration
fetch('/internal/config')
fetcher('/api/config')
.then(response => response.json())
.then(config => {
// Load Debrid configs
@@ -409,7 +534,6 @@
}
// Load general config
const logLevel = document.getElementById('log-level');
logLevel.value = config.log_level;
if (config.allowed_file_types && Array.isArray(config.allowed_file_types)) {
@@ -435,6 +559,7 @@
}
})
.catch(error => {
console.log(error);
console.error('Error loading configuration:', error);
createToast(`Error loading configuration: ${error.message}`, 'error');
});
@@ -455,10 +580,20 @@
$(document).on('change', '.useWebdav', function() {
const webdavConfig = $(this).closest('.config-item').find(`.webdav`);
if (webdavConfig.length === 0) return;
const webdavFields = webdavConfig.find('.webdav-field');
if (this.checked) {
webdavConfig.removeClass('d-none');
// Add required attribute to key fields
webdavConfig.find('input[name$=".torrents_refresh_interval"]').prop('required', true);
webdavConfig.find('input[name$=".download_links_refresh_interval"]').prop('required', true);
webdavConfig.find('input[name$=".auto_expire_links_after"]').prop('required', true);
webdavConfig.find('input[name$=".workers"]').prop('required', true);
} else {
webdavConfig.addClass('d-none');
// Remove required attribute from all fields
webdavFields.prop('required', false);
}
});
@@ -470,9 +605,6 @@
}
});
// In your JavaScript for the config page:
async function saveConfig(e) {
const submitButton = e.target.querySelector('button[type="submit"]');
submitButton.disabled = true;
@@ -494,7 +626,7 @@
try {
const config = collectFormData();
// Save config logic
const response = await fetch('/internal/config', {
const response = await fetcher('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
@@ -528,7 +660,6 @@
addDeleteButton(newDebrid, `Delete this debrid`);
if (data) {
if (data.use_webdav) {
let _webCfg = newDebrid.querySelector(`.webdav`);
if (_webCfg) {
@@ -591,8 +722,6 @@
}
function collectFormData() {
const formEl = document.getElementById('configForm');
// Create the config object
const config = {
log_level: document.getElementById('log-level').value,
@@ -600,12 +729,14 @@
allowed_file_types: document.getElementById('allowedExtensions').value.split(',').map(ext => ext.trim()).filter(Boolean),
min_file_size: document.getElementById('minFileSize').value,
max_file_size: document.getElementById('maxFileSize').value,
url_base: document.getElementById('urlBase').value,
bind_address: document.getElementById('bindAddress').value,
port: document.getElementById('port').value,
debrids: [],
qbittorrent: {
username: document.querySelector('[name="qbit.username"]').value,
password: document.querySelector('[name="qbit.password"]').value,
download_folder: document.querySelector('[name="qbit.download_folder"]').value,
refresh_interval: parseInt(document.querySelector('[name="qbit.refresh_interval"]').value || '0', 10)
refresh_interval: parseInt(document.querySelector('[name="qbit.refresh_interval"]').value || '0', 10),
skip_pre_cache: document.querySelector('[name="qbit.skip_pre_cache"]').checked
},
arrs: [],
repair: {
@@ -631,17 +762,21 @@
rate_limit: document.querySelector(`[name="debrid[${i}].rate_limit"]`).value,
download_uncached: document.querySelector(`[name="debrid[${i}].download_uncached"]`).checked,
check_cached: document.querySelector(`[name="debrid[${i}].check_cached"]`).checked,
use_webdav: document.querySelector(`[name="debrid[${i}].use_webdav"]`).checked,
torrents_refresh_interval: document.querySelector(`[name="debrid[${i}].torrents_refresh_interval"]`).value,
download_links_refresh_interval: document.querySelector(`[name="debrid[${i}].download_links_refresh_interval"]`).value,
auto_expire_links_after: document.querySelector(`[name="debrid[${i}].auto_expire_links_after"]`).value,
folder_naming: document.querySelector(`[name="debrid[${i}].folder_naming"]`).value,
workers: parseInt(document.querySelector(`[name="debrid[${i}].workers"]`).value),
rc_url: document.querySelector(`[name="debrid[${i}].rc_url"]`).value,
rc_user: document.querySelector(`[name="debrid[${i}].rc_user"]`).value,
rc_pass: document.querySelector(`[name="debrid[${i}].rc_pass"]`).value
use_webdav: document.querySelector(`[name="debrid[${i}].use_webdav"]`).checked
};
// Add WebDAV specific properties if enabled
if (debrid.use_webdav) {
debrid.torrents_refresh_interval = document.querySelector(`[name="debrid[${i}].torrents_refresh_interval"]`).value;
debrid.download_links_refresh_interval = document.querySelector(`[name="debrid[${i}].download_links_refresh_interval"]`).value;
debrid.auto_expire_links_after = document.querySelector(`[name="debrid[${i}].auto_expire_links_after"]`).value;
debrid.folder_naming = document.querySelector(`[name="debrid[${i}].folder_naming"]`).value;
debrid.workers = parseInt(document.querySelector(`[name="debrid[${i}].workers"]`).value);
debrid.rc_url = document.querySelector(`[name="debrid[${i}].rc_url"]`).value;
debrid.rc_user = document.querySelector(`[name="debrid[${i}].rc_user"]`).value;
debrid.rc_pass = document.querySelector(`[name="debrid[${i}].rc_pass"]`).value;
}
if (debrid.name && debrid.api_key) {
config.debrids.push(debrid);
}
@@ -668,8 +803,8 @@
return config;
}
});
// Register magnet link handler
function registerMagnetLinkHandler() {
if ('registerProtocolHandler' in navigator) {

View File

@@ -112,7 +112,7 @@
formData.append('notSymlink', document.getElementById('isSymlink').checked);
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
const response = await fetch('/internal/add', {
const response = await fetcher('/api/add', {
method: 'POST',
body: formData
});

View File

@@ -190,7 +190,7 @@
async function loadTorrents() {
try {
const response = await fetch('/internal/torrents');
const response = await fetcher('/api/torrents');
const torrents = await response.json();
state.torrents = torrents;
@@ -256,7 +256,7 @@
if (!confirm('Are you sure you want to delete this torrent?')) return;
try {
await fetch(`/internal/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
await fetcher(`/api/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
method: 'DELETE'
});
await loadTorrents();
@@ -273,7 +273,7 @@
try {
// COmma separated list of hashes
const hashes = Array.from(state.selectedTorrents).join(',');
await fetch(`/internal/torrents/?hashes=${encodeURIComponent(hashes)}`, {
await fetcher(`/api/torrents/?hashes=${encodeURIComponent(hashes)}`, {
method: 'DELETE'
});
await loadTorrents();

View File

@@ -197,8 +197,8 @@
{{ template "config" . }}
{{ else if eq .Page "login" }}
{{ template "login" . }}
{{ else if eq .Page "auth" }}
{{ template "auth" . }}
{{ else if eq .Page "register" }}
{{ template "register" . }}
{{ else }}
{{ end }}
@@ -206,6 +206,30 @@
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
window.urlBase = "{{.URLBase}}";
function joinUrl (base, path) {
if (path.substring(0, 1) === "/") {
// path starts with `/`. Thus it is absolute.
return path;
}
if (base.substring(base.length-1) === "/") {
// base ends with `/`
return base + path;
}
return base + "/" + path;
}
function fetcher(endpoint, options = {}) {
// Use the global urlBase or default to empty string
let baseUrl = window.urlBase || '';
let url = joinUrl(baseUrl, endpoint);
// Return the regular fetcher with the complete URL
return fetch(url, options);
}
/**
* Create a toast message
* @param {string} message - The message to display
@@ -243,6 +267,7 @@
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: toastTimeouts[type]
});
toast.show();
@@ -302,7 +327,7 @@
}
document.addEventListener('DOMContentLoaded', function() {
fetch('/internal/version')
fetcher('/version')
.then(response => response.json())
.then(data => {
const versionBadge = document.getElementById('version-badge');

View File

@@ -36,7 +36,7 @@
};
try {
const response = await fetch('/login', {
const response = await fetcher('/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -56,76 +56,3 @@
});
</script>
{{ end }}
{{ define "auth" }}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0 text-center">First Time Setup</h4>
</div>
<div class="card-body">
<form id="authForm">
<div class="mb-3">
<label for="username" class="form-label">Choose Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Choose Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Set Credentials</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.getElementById('authForm').addEventListener('submit', async (e) => {
e.preventDefault();
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
createToast('Passwords do not match', 'error');
return;
}
const formData = {
username: document.getElementById('username').value,
password: password,
confirmPassword: confirmPassword
};
try {
const response = await fetch('/auth', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
window.location.href = '/';
} else {
const error = await response.text();
createToast(error, 'error');
}
} catch (error) {
console.error('Setup error:', error);
createToast('Setup failed', 'error');
}
});
</script>
{{ end }}

View File

@@ -0,0 +1,92 @@
{{ define "register" }}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0 text-center">First Time Auth Setup</h4>
</div>
<div class="card-body">
<form id="authForm">
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary mb-2">Save</button>
<button type="button" id="skipAuthBtn" class="btn btn-secondary">Skip</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const authForm = document.getElementById('authForm');
const skipAuthBtn = document.getElementById('skipAuthBtn');
authForm.addEventListener('submit', async function (e) {
e.preventDefault();
// Validate passwords match
const password = document.getElementById('password').value;
const confirmPassword = document.getElementById('confirmPassword').value;
if (password !== confirmPassword) {
alert('Passwords do not match!');
return;
}
// Collect form data
let formData = new FormData();
formData.append('username', document.getElementById('username').value);
formData.append('password', password);
formData.append('confirmPassword', confirmPassword);
await fetcher('/register', {
method: 'POST',
body: formData
})
.then(response => {
if (!response.ok) {
return response.text().then(errorText => {
// Throw an error with the response text
createToast(errorText || 'Registration failed', 'error');
});
} else {
window.location.href = joinUrl(window.urlBase, '/');
}
})
.catch(error => {
alert('Registration failed: ' + error.message);
});
});
// Handle skip auth button
skipAuthBtn.addEventListener('click', function() {
fetcher('/skip-auth', { method: 'GET' })
.then(response => {
if (response.ok) {
window.location.href = joinUrl(window.urlBase, '/');
} else {
throw new Error('Failed to skip authentication');
}
})
.catch(error => {
alert('Error: ' + error.message);
});
});
});
</script>
{{ end }}

View File

@@ -152,7 +152,7 @@
<script>
document.addEventListener('DOMContentLoaded', () => {
// Load Arr instances
fetch('/internal/arrs')
fetcher('/api/arrs')
.then(response => response.json())
.then(arrs => {
const select = document.getElementById('arrSelect');
@@ -175,7 +175,7 @@
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
let arr = document.getElementById('arrSelect').value;
try {
const response = await fetch('/internal/repair', {
const response = await fetcher('/api/repair', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
@@ -207,8 +207,8 @@
// Load jobs function
async function loadJobs(page) {
try {
const response = await fetch('/internal/repair/jobs');
if (!response.ok) throw new Error('Failed to fetch jobs');
const response = await fetcher('/api/repair/jobs');
if (!response.ok) throw new Error('Failed to fetcher jobs');
allJobs = await response.json();
renderJobsTable(page);
@@ -403,7 +403,7 @@
async function deleteJob(jobId) {
if (confirm('Are you sure you want to delete this job?')) {
try {
const response = await fetch(`/internal/repair/jobs`, {
const response = await fetcher(`/api/repair/jobs`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
@@ -422,7 +422,7 @@
async function deleteMultipleJobs(jobIds) {
try {
const response = await fetch(`/internal/repair/jobs`, {
const response = await fetcher(`/api/repair/jobs`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
@@ -441,7 +441,7 @@
// Process job function
async function processJob(jobId) {
try {
const response = await fetch(`/internal/repair/jobs/${jobId}/process`, {
const response = await fetcher(`/api/repair/jobs/${jobId}/process`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'

View File

@@ -1,32 +0,0 @@
{{ define "auth" }}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0 text-center">First Time Setup</h4>
</div>
<div class="card-body">
<form id="authForm" method="POST" action="/auth">
<div class="mb-3">
<label for="username" class="form-label">Choose Username</label>
<input type="text" class="form-control" id="username" name="username" required>
</div>
<div class="mb-3">
<label for="password" class="form-label">Choose Password</label>
<input type="password" class="form-control" id="password" name="password" required>
</div>
<div class="mb-3">
<label for="confirmPassword" class="form-label">Confirm Password</label>
<input type="password" class="form-control" id="confirmPassword" name="confirmPassword" required>
</div>
<div class="d-grid">
<button type="submit" class="btn btn-primary">Set Credentials</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{{ end }}

151
pkg/web/ui.go Normal file
View File

@@ -0,0 +1,151 @@
package web
import (
"github.com/goccy/go-json"
"github.com/sirrobot01/decypharr/internal/config"
"golang.org/x/crypto/bcrypt"
"net/http"
)
func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
if cfg.NeedsAuth() {
http.Redirect(w, r, "/register", http.StatusSeeOther)
return
}
if r.Method == "GET" {
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "login",
"Title": "Login",
}
_ = templates.ExecuteTemplate(w, "layout", data)
return
}
var credentials struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&credentials); err != nil {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
if ui.verifyAuth(credentials.Username, credentials.Password) {
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = credentials.Username
if err := session.Save(r, w); err != nil {
http.Error(w, "Error saving session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
}
func (ui *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = false
session.Options.MaxAge = -1
err := session.Save(r, w)
if err != nil {
return
}
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (ui *Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
authCfg := cfg.GetAuth()
if r.Method == "GET" {
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "register",
"Title": "Register",
}
_ = templates.ExecuteTemplate(w, "layout", data)
return
}
username := r.FormValue("username")
password := r.FormValue("password")
confirmPassword := r.FormValue("confirmPassword")
if password != confirmPassword {
http.Error(w, "Passwords do not match", http.StatusBadRequest)
return
}
// Hash the password
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
http.Error(w, "Error processing password", http.StatusInternalServerError)
return
}
// Set the credentials
authCfg.Username = username
authCfg.Password = string(hashedPassword)
if err := cfg.SaveAuth(authCfg); err != nil {
http.Error(w, "Error saving credentials", http.StatusInternalServerError)
return
}
// Create a session
session, _ := store.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = username
if err := session.Save(r, w); err != nil {
http.Error(w, "Error saving session", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (ui *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "index",
"Title": "Torrents",
}
_ = templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) DownloadHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "download",
"Title": "Download",
}
_ = templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) RepairHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "repair",
"Title": "Repair",
}
_ = templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) ConfigHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "config",
"Title": "Config",
}
_ = templates.ExecuteTemplate(w, "layout", data)
}

View File

@@ -140,7 +140,7 @@ func (h *Handler) OpenFile(ctx context.Context, name string, flag int, perm os.F
folderName := strings.TrimPrefix(name, rootDir)
folderName = strings.TrimPrefix(folderName, "/")
// Only fetch the torrent folders once
// Only fetcher the torrent folders once
children := h.getTorrentsFolders()
return &File{