- Fix symlinks % bug
- A cleaner settings page - More bug fixes
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
293
pkg/web/api.go
Normal 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
34
pkg/web/auth.go
Normal 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
51
pkg/web/middlewares.go
Normal 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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 }}
|
||||
92
pkg/web/templates/register.html
Normal file
92
pkg/web/templates/register.html
Normal 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 }}
|
||||
@@ -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'
|
||||
|
||||
@@ -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
151
pkg/web/ui.go
Normal 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)
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user