- Revamp decypharr arch \n

- Add callback_ur, download_folder to addContent API \n
- Fix few bugs \n
- More declarative UI keywords
- Speed up repairs
- Few other improvements/bug fixes
This commit is contained in:
Mukhtar Akere
2025-06-02 12:57:36 +01:00
parent 1cd09239f9
commit 9c6c44d785
67 changed files with 1726 additions and 1464 deletions
+70 -49
View File
@@ -2,6 +2,7 @@ package web
import (
"fmt"
"github.com/sirrobot01/decypharr/pkg/store"
"net/http"
"strings"
"time"
@@ -12,34 +13,37 @@ import (
"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"
)
func (ui *Handler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
svc := service.GetService()
request.JSONResponse(w, svc.Arr.GetAll(), http.StatusOK)
func (wb *Web) handleGetArrs(w http.ResponseWriter, r *http.Request) {
_store := store.GetStore()
request.JSONResponse(w, _store.GetArr().GetAll(), http.StatusOK)
}
func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
func (wb *Web) handleAddContent(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if err := r.ParseMultipartForm(32 << 20); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
svc := service.GetService()
_store := store.GetStore()
results := make([]*qbit.ImportRequest, 0)
results := make([]*store.ImportRequest, 0)
errs := make([]string, 0)
arrName := r.FormValue("arr")
notSymlink := r.FormValue("notSymlink") == "true"
downloadUncached := r.FormValue("downloadUncached") == "true"
if arrName == "" {
arrName = "uncategorized"
debridName := r.FormValue("debrid")
callbackUrl := r.FormValue("callbackUrl")
downloadFolder := r.FormValue("downloadFolder")
if downloadFolder == "" {
downloadFolder = config.Get().QBitTorrent.DownloadFolder
}
_arr := svc.Arr.Get(arrName)
downloadUncached := r.FormValue("downloadUncached") == "true"
_arr := _store.GetArr().Get(arrName)
if _arr == nil {
_arr = arr.New(arrName, "", "", false, false, &downloadUncached)
}
@@ -59,8 +63,9 @@ func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
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 {
importReq := store.NewImportRequest(debridName, downloadFolder, magnet, _arr, !notSymlink, downloadUncached, callbackUrl, store.ImportTypeAPI)
if err := _store.AddTorrent(ctx, importReq); err != nil {
errs = append(errs, fmt.Sprintf("URL %s: %v", url, err))
continue
}
@@ -83,8 +88,8 @@ func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
continue
}
importReq := qbit.NewImportRequest(magnet, _arr, !notSymlink, downloadUncached)
err = importReq.Process(ui.qbit)
importReq := store.NewImportRequest(debridName, downloadFolder, magnet, _arr, !notSymlink, downloadUncached, callbackUrl, store.ImportTypeAPI)
err = _store.AddTorrent(ctx, importReq)
if err != nil {
errs = append(errs, fmt.Sprintf("File %s: %v", fileHeader.Filename, err))
continue
@@ -94,27 +99,27 @@ func (ui *Handler) handleAddContent(w http.ResponseWriter, r *http.Request) {
}
request.JSONResponse(w, struct {
Results []*qbit.ImportRequest `json:"results"`
Errors []string `json:"errors,omitempty"`
Results []*store.ImportRequest `json:"results"`
Errors []string `json:"errors,omitempty"`
}{
Results: results,
Errors: errs,
}, http.StatusOK)
}
func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
func (wb *Web) 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()
_store := store.GetStore()
var arrs []string
if req.ArrName != "" {
_arr := svc.Arr.Get(req.ArrName)
_arr := _store.GetArr().Get(req.ArrName)
if _arr == nil {
http.Error(w, "No Arrs found to repair", http.StatusNotFound)
return
@@ -124,15 +129,15 @@ func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
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")
if err := _store.GetRepair().AddJob(arrs, req.MediaIds, req.AutoProcess, false); err != nil {
wb.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 {
if err := _store.GetRepair().AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess, false); err != nil {
http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
return
}
@@ -140,16 +145,16 @@ func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
request.JSONResponse(w, "Repair completed", http.StatusOK)
}
func (ui *Handler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
func (wb *Web) 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.GetAllSorted("", "", nil, "added_on", false), http.StatusOK)
func (wb *Web) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
request.JSONResponse(w, wb.torrents.GetAllSorted("", "", nil, "added_on", false), http.StatusOK)
}
func (ui *Handler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
func (wb *Web) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
category := chi.URLParam(r, "category")
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
@@ -157,11 +162,11 @@ func (ui *Handler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
http.Error(w, "No hash provided", http.StatusBadRequest)
return
}
ui.qbit.Storage.Delete(hash, category, removeFromDebrid)
wb.torrents.Delete(hash, category, removeFromDebrid)
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
func (wb *Web) handleDeleteTorrents(w http.ResponseWriter, r *http.Request) {
hashesStr := r.URL.Query().Get("hashes")
removeFromDebrid := r.URL.Query().Get("removeFromDebrid") == "true"
if hashesStr == "" {
@@ -169,15 +174,15 @@ func (ui *Handler) handleDeleteTorrents(w http.ResponseWriter, r *http.Request)
return
}
hashes := strings.Split(hashesStr, ",")
ui.qbit.Storage.DeleteMultiple(hashes, removeFromDebrid)
wb.torrents.DeleteMultiple(hashes, removeFromDebrid)
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
func (wb *Web) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
arrCfgs := make([]config.Arr, 0)
svc := service.GetService()
for _, a := range svc.Arr.GetAll() {
_store := store.GetStore()
for _, a := range _store.GetArr().GetAll() {
arrCfgs = append(arrCfgs, config.Arr{
Host: a.Host,
Name: a.Name,
@@ -191,11 +196,11 @@ func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
request.JSONResponse(w, cfg, http.StatusOK)
}
func (ui *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
func (wb *Web) 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")
wb.logger.Error().Err(err).Msg("Failed to decode config update request")
http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
return
}
@@ -232,11 +237,12 @@ func (ui *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
}
// Update Arrs through the service
svc := service.GetService()
svc.Arr.Clear() // Clear existing arrs
_store := store.GetStore()
_arr := _store.GetArr()
_arr.Clear() // Clear existing arrs
for _, a := range updatedConfig.Arrs {
svc.Arr.AddOrUpdate(&arr.Arr{
_arr.AddOrUpdate(&arr.Arr{
Name: a.Name,
Host: a.Host,
Token: a.Token,
@@ -263,25 +269,25 @@ func (ui *Handler) handleUpdateConfig(w http.ResponseWriter, r *http.Request) {
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 (wb *Web) handleGetRepairJobs(w http.ResponseWriter, r *http.Request) {
_store := store.GetStore()
request.JSONResponse(w, _store.GetRepair().GetJobs(), http.StatusOK)
}
func (ui *Handler) handleProcessRepairJob(w http.ResponseWriter, r *http.Request) {
func (wb *Web) 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")
_store := store.GetStore()
if err := _store.GetRepair().ProcessJob(id); err != nil {
wb.logger.Error().Err(err).Msg("Failed to process repair job")
}
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request) {
func (wb *Web) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request) {
// Read ids from body
var req struct {
IDs []string `json:"ids"`
@@ -295,7 +301,22 @@ func (ui *Handler) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request)
return
}
svc := service.GetService()
svc.Repair.DeleteJobs(req.IDs)
_store := store.GetStore()
_store.GetRepair().DeleteJobs(req.IDs)
w.WriteHeader(http.StatusOK)
}
func (wb *Web) handleStopRepairJob(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "No job ID provided", http.StatusBadRequest)
return
}
_store := store.GetStore()
if err := _store.GetRepair().StopJob(id); err != nil {
wb.logger.Error().Err(err).Msg("Failed to stop repair job")
http.Error(w, "Failed to stop job: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"net/http"
)
func (ui *Handler) verifyAuth(username, password string) bool {
func (wb *Web) verifyAuth(username, password string) bool {
// If you're storing hashed password, use bcrypt to compare
if username == "" {
return false
@@ -22,11 +22,11 @@ func (ui *Handler) verifyAuth(username, password string) bool {
return err == nil
}
func (ui *Handler) skipAuthHandler(w http.ResponseWriter, r *http.Request) {
func (wb *Web) 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")
wb.logger.Error().Err(err).Msg("failed to save config")
http.Error(w, "failed to save config", http.StatusInternalServerError)
return
}
+3 -3
View File
@@ -6,7 +6,7 @@ import (
"net/http"
)
func (ui *Handler) setupMiddleware(next http.Handler) http.Handler {
func (wb *Web) setupMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
needsAuth := cfg.NeedsSetup()
@@ -24,7 +24,7 @@ func (ui *Handler) setupMiddleware(next http.Handler) http.Handler {
})
}
func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
func (wb *Web) authMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check if setup is needed
cfg := config.Get()
@@ -38,7 +38,7 @@ func (ui *Handler) authMiddleware(next http.Handler) http.Handler {
return
}
session, _ := store.Get(r, "auth-session")
session, _ := wb.cookie.Get(r, "auth-session")
auth, ok := session.Values["authenticated"].(bool)
if !ok || !auth {
+25 -24
View File
@@ -5,35 +5,36 @@ import (
"net/http"
)
func (ui *Handler) Routes() http.Handler {
func (wb *Web) Routes() http.Handler {
r := chi.NewRouter()
r.Get("/login", ui.LoginHandler)
r.Post("/login", ui.LoginHandler)
r.Get("/register", ui.RegisterHandler)
r.Post("/register", ui.RegisterHandler)
r.Get("/skip-auth", ui.skipAuthHandler)
r.Get("/version", ui.handleGetVersion)
r.Get("/login", wb.LoginHandler)
r.Post("/login", wb.LoginHandler)
r.Get("/register", wb.RegisterHandler)
r.Post("/register", wb.RegisterHandler)
r.Get("/skip-auth", wb.skipAuthHandler)
r.Get("/version", wb.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.Use(wb.authMiddleware)
r.Use(wb.setupMiddleware)
r.Get("/", wb.IndexHandler)
r.Get("/download", wb.DownloadHandler)
r.Get("/repair", wb.RepairHandler)
r.Get("/config", wb.ConfigHandler)
r.Route("/api", func(r chi.Router) {
r.Get("/arrs", ui.handleGetArrs)
r.Post("/add", ui.handleAddContent)
r.Post("/repair", ui.handleRepairMedia)
r.Get("/repair/jobs", ui.handleGetRepairJobs)
r.Post("/repair/jobs/{id}/process", ui.handleProcessRepairJob)
r.Delete("/repair/jobs", ui.handleDeleteRepairJob)
r.Get("/torrents", ui.handleGetTorrents)
r.Delete("/torrents/{category}/{hash}", ui.handleDeleteTorrent)
r.Delete("/torrents/", ui.handleDeleteTorrents)
r.Get("/config", ui.handleGetConfig)
r.Post("/config", ui.handleUpdateConfig)
r.Get("/arrs", wb.handleGetArrs)
r.Post("/add", wb.handleAddContent)
r.Post("/repair", wb.handleRepairMedia)
r.Get("/repair/jobs", wb.handleGetRepairJobs)
r.Post("/repair/jobs/{id}/process", wb.handleProcessRepairJob)
r.Post("/repair/jobs/{id}/stop", wb.handleStopRepairJob)
r.Delete("/repair/jobs", wb.handleDeleteRepairJob)
r.Get("/torrents", wb.handleGetTorrents)
r.Delete("/torrents/{category}/{hash}", wb.handleDeleteTorrent)
r.Delete("/torrents/", wb.handleDeleteTorrents)
r.Get("/config", wb.handleGetConfig)
r.Post("/config", wb.handleUpdateConfig)
})
})
+29 -41
View File
@@ -245,43 +245,48 @@
<!-- Step 5: Repair Configuration -->
<div class="setup-step d-none" id="step5">
<div class="section mb-5">
<div class="row mb-2">
<div class="row mb-3">
<div class="col">
<div class="form-check me-3 d-inline-block">
<input type="checkbox" class="form-check-input" name="repair.enabled" id="repair.enabled">
<label class="form-check-label" for="repair.enabled">Enable Repair</label>
<label class="form-check-label" for="repair.enabled">Enable Scheduled Repair</label>
</div>
</div>
</div>
<div id="repairCol" class="d-none">
<div>
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label" for="repair.interval">Interval</label>
<label class="form-label" for="repair.interval">Scheduled Interval</label>
<input type="text" class="form-control" name="repair.interval" id="repair.interval" placeholder="e.g., 24h">
<small class="form-text text-muted">Interval for the repair process(e.g., 24h, 1d, 03:00, or a crontab)</small>
</div>
<div class="col-md-3 mb-3">
<label class="form-label" for="repair.workers">Workers</label>
<input type="text" class="form-control" name="repair.workers" id="repair.workers">
<small class="form-text text-muted">Number of workers to use for the repair process</small>
</div>
<div class="col-md-5 mb-3">
<label class="form-label" for="repair.zurg_url">Zurg URL</label>
<input type="text" class="form-control" name="repair.zurg_url" id="repair.zurg_url" placeholder="http://zurg:9999">
<small class="form-text text-muted">Speeds up the repair process by using Zurg</small>
<small class="form-text text-muted">If you have Zurg running, you can use it to speed up the repair process</small>
</div>
</div>
<div class="row">
<div class="col-md-3 mb-3">
<div class="col-md-4 mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="repair.use_webdav" id="repair.use_webdav">
<label class="form-check-label" for="repair.use_webdav">Use Webdav</label>
</div>
<small class="form-text text-muted">Use Internal Webdav for repair(make sure webdav is enabled in the debrid section</small>
</div>
<div class="col-md-3 mb-3">
<div class="col-md-4 mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="repair.run_on_start" id="repair.run_on_start">
<label class="form-check-label" for="repair.run_on_start">Run on Start</label>
</div>
<small class="form-text text-muted">Run repair on startup</small>
</div>
<div class="col-md-3 mb-3">
<div class="col-md-4 mb-3">
<div class="form-check">
<input type="checkbox" class="form-check-input" name="repair.auto_process" id="repair.auto_process">
<label class="form-check-label" for="repair.auto_process">Auto Process</label>
@@ -340,7 +345,14 @@
<small class="form-text text-muted">Rate limit for the debrid service. Confirm your debrid service rate limit</small>
</div>
</div>
<div class="row">
<div class="row mb-3">
<div class="col-md-3">
<div class="form-check me-3">
<input type="checkbox" class="form-check-input useWebdav" name="debrid[${index}].use_webdav" id="debrid[${index}].use_webdav">
<label class="form-check-label" for="debrid[${index}].use_webdav">Enable WebDav Server</label>
</div>
<small class="form-text text-muted">Create an internal webdav for this debrid</small>
</div>
<div class="col-md-3">
<div class="form-check me-3">
<input type="checkbox" class="form-check-input" name="debrid[${index}].download_uncached" id="debrid[${index}].download_uncached">
@@ -348,13 +360,6 @@
</div>
<small class="form-text text-muted">Download uncached files from the debrid service</small>
</div>
<div class="col-md-3">
<div class="form-check me-3">
<input type="checkbox" class="form-check-input" name="debrid[${index}].check_cached" id="debrid[${index}].check_cached">
<label class="form-check-label" for="debrid[${index}].check_cached" disabled>Check Cached</label>
</div>
<small class="form-text text-muted">Check if the file is cached before downloading(Disabled)</small>
</div>
<div class="col-md-3">
<div class="form-check me-3">
<input type="checkbox" class="form-check-input" name="debrid[${index}].add_samples" id="debrid[${index}].add_samples">
@@ -369,16 +374,10 @@
</div>
<small class="form-text text-muted">Preprocess RARed torrents to allow reading the files inside</small>
</div>
<div class="col-md-4">
<div class="form-check me-3">
<input type="checkbox" class="form-check-input useWebdav" name="debrid[${index}].use_webdav" id="debrid[${index}].use_webdav">
<label class="form-check-label" for="debrid[${index}].use_webdav">Enable WebDav Server</label>
</div>
<small class="form-text text-muted">Create an internal webdav for this debrid</small>
</div>
</div>
<div class="webdav d-none">
<h6 class="pb-2">Webdav</h6>
<div class="webdav d-none mt-1">
<hr/>
<h6 class="pb-2">Webdav Settings</h6>
<div class="row mt-3">
<div class="col-md-3 mb-3">
<label class="form-label" for="debrid[${index}].torrents_refresh_interval">Torrents Refresh Interval</label>
@@ -441,12 +440,12 @@
</div>
</div>
<div class="row mt-3">
<div class="col mt-3">
<h6 class="pb-2">Custom Folders</h6>
<div class="col">
<h6 class="pb-2">Virtual Folders</h6>
<div class="col-12">
<p class="text-muted small">Create virtual directories with filters to organize your content</p>
<div class="directories-container" id="debrid[${index}].directories">
<!-- Dynamic directories will be added here -->
</div>
<button type="button" class="btn btn-secondary mt-2 webdav-field" onclick="addDirectory(${index});">
<i class="bi bi-plus"></i> Add Directory
@@ -842,9 +841,6 @@
// Load Repair config
if (config.repair) {
if (config.repair.enabled) {
document.getElementById('repairCol').classList.remove('d-none');
}
Object.entries(config.repair).forEach(([key, value]) => {
const input = document.querySelector(`[name="repair.${key}"]`);
if (input) {
@@ -921,14 +917,6 @@
}
});
$(document).on('change', 'input[name="repair.enabled"]', function() {
if (this.checked) {
$('#repairCol').removeClass('d-none');
} else {
$('#repairCol').addClass('d-none');
}
});
async function saveConfig(e) {
const submitButton = e.target.querySelector('button[type="submit"]');
submitButton.disabled = true;
@@ -1072,7 +1060,7 @@
debrids: [],
qbittorrent: {
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, 10),
max_downloads: parseInt(document.querySelector('[name="qbit.max_downloads"]').value || '0', 5),
skip_pre_cache: document.querySelector('[name="qbit.skip_pre_cache"]').checked
},
@@ -1082,6 +1070,7 @@
interval: document.querySelector('[name="repair.interval"]').value,
run_on_start: document.querySelector('[name="repair.run_on_start"]').checked,
zurg_url: document.querySelector('[name="repair.zurg_url"]').value,
workers: parseInt(document.querySelector('[name="repair.workers"]').value),
use_webdav: document.querySelector('[name="repair.use_webdav"]').checked,
auto_process: document.querySelector('[name="repair.auto_process"]').checked
}
@@ -1098,7 +1087,6 @@
folder: document.querySelector(`[name="debrid[${i}].folder"]`).value,
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,
unpack_rar: document.querySelector(`[name="debrid[${i}].unpack_rar"]`).checked,
add_samples: document.querySelector(`[name="debrid[${i}].add_samples"]`).checked,
use_webdav: document.querySelector(`[name="debrid[${i}].use_webdav"]`).checked
+36 -8
View File
@@ -17,11 +17,33 @@
<hr />
<div class="mb-3">
<label for="category" class="form-label">Enter Category</label>
<input type="text" class="form-control" id="category" name="arr" placeholder="Enter Category (e.g sonarr, radarr, radarr4k)">
<div class="row mb-3">
<div class="col-md-6">
<label for="downloadFolder" class="form-label">Download Folder</label>
<input type="text" class="form-control" id="downloadFolder" name="downloadFolder" placeholder="Enter Download Folder (e.g /downloads/torrents)">
<small class="text-muted">Default is your qbittorent download_folder</small>
</div>
<div class="col-md-6">
<label for="arr" class="form-label">Arr (if any)</label>
<input type="text" class="form-control" id="arr" name="arr" placeholder="Enter Category (e.g sonarr, radarr, radarr4k)">
<small class="text-muted">Optional, leave empty if not using Arr</small>
</div>
</div>
{{ if .HasMultiDebrid }}
<div class="row mb-3">
<div class="col-md-6">
<label for="debrid" class="form-label">Select Debrid</label>
<select class="form-select" id="debrid" name="debrid">
{{ range $index, $debrid := .Debrids }}
<option value="{{ $debrid }}" {{ if eq $index 0 }}selected{{end}}>{{ $debrid }}</option>
{{ end }}
</select>
<small class="text-muted">Select a debrid service to use for this download</small>
</div>
</div>
{{ end }}
<div class="row mb-3">
<div class="col-md-2 mb-3">
<div class="form-check d-inline-block me-3">
@@ -48,23 +70,27 @@
</div>
<script>
let downloadFolder = '{{ .DownloadFolder }}';
document.addEventListener('DOMContentLoaded', () => {
const loadSavedDownloadOptions = () => {
const savedCategory = localStorage.getItem('downloadCategory');
const savedSymlink = localStorage.getItem('downloadSymlink');
const savedDownloadUncached = localStorage.getItem('downloadUncached');
document.getElementById('category').value = savedCategory || '';
document.getElementById('arr').value = savedCategory || '';
document.getElementById('isSymlink').checked = savedSymlink === 'true';
document.getElementById('downloadUncached').checked = savedDownloadUncached === 'true';
document.getElementById('downloadFolder').value = localStorage.getItem('downloadFolder') || downloadFolder || '';
};
const saveCurrentDownloadOptions = () => {
const category = document.getElementById('category').value;
const arr = document.getElementById('arr').value;
const isSymlink = document.getElementById('isSymlink').checked;
const downloadUncached = document.getElementById('downloadUncached').checked;
localStorage.setItem('downloadCategory', category);
const downloadFolder = document.getElementById('downloadFolder').value;
localStorage.setItem('downloadCategory', arr);
localStorage.setItem('downloadSymlink', isSymlink.toString());
localStorage.setItem('downloadUncached', downloadUncached.toString());
localStorage.setItem('downloadFolder', downloadFolder);
};
// Load the last used download options from local storage
@@ -108,9 +134,11 @@
return;
}
formData.append('arr', document.getElementById('category').value);
formData.append('arr', document.getElementById('arr').value);
formData.append('downloadFolder', document.getElementById('downloadFolder').value);
formData.append('notSymlink', document.getElementById('isSymlink').checked);
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
formData.append('debrid', document.getElementById('debrid') ? document.getElementById('debrid').value : '');
const response = await fetcher('/api/add', {
method: 'POST',
@@ -139,7 +167,7 @@
});
// Save the download options to local storage when they change
document.getElementById('category').addEventListener('change', saveCurrentDownloadOptions);
document.getElementById('arr').addEventListener('change', saveCurrentDownloadOptions);
document.getElementById('isSymlink').addEventListener('change', saveCurrentDownloadOptions);
// Read the URL parameters for a magnet link and add it to the download queue if found
+3 -3
View File
@@ -129,11 +129,11 @@
<td>${torrent.debrid || 'None'}</td>
<td><span class="badge ${getStateColor(torrent.state)}">${torrent.state}</span></td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category}', false)">
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category || ''}', false)">
<i class="bi bi-trash"></i>
</button>
${torrent.debrid && torrent.id ? `
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category}', true)">
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}', '${torrent.category || ''}', true)">
<i class="bi bi-trash"></i> Remove from Debrid
</button>
` : ''}
@@ -485,7 +485,7 @@
}
},
'delete': async (torrent) => {
await deleteTorrent(torrent.hash);
await deleteTorrent(torrent.hash, torrent.category || '', false);
}
};
+30
View File
@@ -36,6 +36,22 @@
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
display: flex;
flex-direction: column;
min-height: 100vh;
}
footer {
background-color: var(--bg-color);
border-top: 1px solid var(--border-color);
}
footer a {
color: var(--text-color);
}
footer a:hover {
color: var(--primary-color);
}
.navbar {
@@ -193,6 +209,20 @@
{{ else }}
{{ end }}
<footer class="mt-auto py-2 text-center border-top">
<div class="container">
<small class="text-muted">
<a href="https://github.com/sirrobot01/decypharr" target="_blank" class="text-decoration-none me-3">
<i class="bi bi-github me-1"></i>GitHub
</a>
<a href="https://sirrobot01.github.io/decypharr" target="_blank" class="text-decoration-none">
<i class="bi bi-book me-1"></i>Documentation
</a>
</small>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<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>
+85 -45
View File
@@ -143,6 +143,9 @@
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="processJobBtn">Process Items</button>
<button type="button" class="btn btn-warning d-none" id="stopJobBtn">
<i class="bi bi-stop-fill me-1"></i>Stop Job
</button>
</div>
</div>
</div>
@@ -218,6 +221,27 @@
}
}
// Return status text and class based on job status
function getStatus(status) {
switch (status) {
case 'started':
return {text: 'In Progress', class: 'text-primary'};
case 'failed':
return {text: 'Failed', class: 'text-danger'};
case 'completed':
return {text: 'Completed', class: 'text-success'};
case 'pending':
return {text: 'Pending', class: 'text-warning'};
case 'cancelled':
return {text: 'Cancelled', class: 'text-secondary'};
case 'processing':
return {text: 'Processing', class: 'text-info'};
default:
// Return status in title case if unknown
return {text: status.charAt(0).toUpperCase() + status.slice(1), class: 'text-secondary'};
}
}
// Render jobs table with pagination
function renderJobsTable(page) {
const tableBody = document.getElementById('jobsTableBody');
@@ -254,24 +278,10 @@
const formattedDate = startedDate.toLocaleString();
// Determine status
let status = 'In Progress';
let statusClass = 'text-primary';
let status = getStatus(job.status);
let canDelete = job.status !== "started";
let totalItems = job.broken_items ? Object.values(job.broken_items).reduce((sum, arr) => sum + arr.length, 0) : 0;
if (job.status === 'failed') {
status = 'Failed';
statusClass = 'text-danger';
} else if (job.status === 'completed') {
status = 'Completed';
statusClass = 'text-success';
} else if (job.status === 'pending') {
status = 'Pending';
statusClass = 'text-warning';
} else if (job.status === "processing") {
status = 'Processing';
statusClass = 'text-info';
}
row.innerHTML = `
<td>
@@ -283,25 +293,31 @@
<td><a href="#" class="text-link view-job" data-id="${job.id}"><small>${job.id.substring(0, 8)}</small></a></td>
<td>${job.arrs.join(', ')}</td>
<td><small>${formattedDate}</small></td>
<td><span class="${statusClass}">${status}</span></td>
<td><span class="${status.class}">${status.text}</span></td>
<td>${totalItems}</td>
<td>
${job.status === "pending" ?
`<button class="btn btn-sm btn-primary process-job" data-id="${job.id}">
<i class="bi bi-play-fill"></i> Process
`<button class="btn btn-sm btn-primary process-job" data-id="${job.id}">
<i class="bi bi-play-fill"></i> Process
</button>` :
`<button class="btn btn-sm btn-primary" disabled>
<i class="bi bi-eye"></i> Process
`<button class="btn btn-sm btn-primary" disabled>
<i class="bi bi-eye"></i> Process
</button>`
}
}
${(job.status === "started" || job.status === "processing") ?
`<button class="btn btn-sm btn-warning stop-job" data-id="${job.id}">
<i class="bi bi-stop-fill"></i> Stop
</button>` :
''
}
${canDelete ?
`<button class="btn btn-sm btn-danger delete-job" data-id="${job.id}">
<i class="bi bi-trash"></i>
</button>` :
`<button class="btn btn-sm btn-danger" disabled>
<i class="bi bi-trash"></i>
</button>`
}
`<button class="btn btn-sm btn-danger delete-job" data-id="${job.id}">
<i class="bi bi-trash"></i>
</button>` :
`<button class="btn btn-sm btn-danger" disabled>
<i class="bi bi-trash"></i>
</button>`
}
</td>
`;
@@ -370,6 +386,13 @@
viewJobDetails(jobId);
});
});
document.querySelectorAll('.stop-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
stopJob(jobId);
});
});
}
document.getElementById('selectAllJobs').addEventListener('change', function() {
@@ -456,6 +479,25 @@
}
}
async function stopJob(jobId) {
if (confirm('Are you sure you want to stop this job?')) {
try {
const response = await fetcher(`/api/repair/jobs/${jobId}/stop`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
});
if (!response.ok) throw new Error(await response.text());
createToast('Job stop requested successfully');
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error stopping job: ${error.message}`, 'error');
}
}
}
// View job details function
function viewJobDetails(jobId) {
// Find the job
@@ -477,24 +519,9 @@
}
// Set status with color
let status = 'In Progress';
let statusClass = 'text-primary';
let status = getStatus(job.status);
if (job.status === 'failed') {
status = 'Failed';
statusClass = 'text-danger';
} else if (job.status === 'completed') {
status = 'Completed';
statusClass = 'text-success';
} else if (job.status === 'pending') {
status = 'Pending';
statusClass = 'text-warning';
} else if (job.status === "processing") {
status = 'Processing';
statusClass = 'text-info';
}
document.getElementById('modalJobStatus').innerHTML = `<span class="${statusClass}">${status}</span>`;
document.getElementById('modalJobStatus').innerHTML = `<span class="${status.class}">${status.text}</span>`;
// Set other job details
document.getElementById('modalJobArrs').textContent = job.arrs.join(', ');
@@ -524,6 +551,19 @@
processBtn.classList.add('d-none');
}
// Stop button visibility
const stopBtn = document.getElementById('stopJobBtn'); // You'll need to add this button to the HTML
if (job.status === 'started' || job.status === 'processing') {
stopBtn.classList.remove('d-none');
stopBtn.onclick = () => {
stopJob(job.id);
const modal = bootstrap.Modal.getInstance(document.getElementById('jobDetailsModal'));
modal.hide();
};
} else {
stopBtn.classList.add('d-none');
}
// Populate broken items table
const brokenItemsTableBody = document.getElementById('brokenItemsTableBody');
const noBrokenItemsMessage = document.getElementById('noBrokenItemsMessage');
+28 -21
View File
@@ -7,7 +7,7 @@ import (
"net/http"
)
func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
func (wb *Web) LoginHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
if cfg.NeedsAuth() {
http.Redirect(w, r, "/register", http.StatusSeeOther)
@@ -19,7 +19,7 @@ func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
"Page": "login",
"Title": "Login",
}
_ = templates.ExecuteTemplate(w, "layout", data)
_ = wb.templates.ExecuteTemplate(w, "layout", data)
return
}
@@ -33,8 +33,8 @@ func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
return
}
if ui.verifyAuth(credentials.Username, credentials.Password) {
session, _ := store.Get(r, "auth-session")
if wb.verifyAuth(credentials.Username, credentials.Password) {
session, _ := wb.cookie.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = credentials.Username
if err := session.Save(r, w); err != nil {
@@ -48,8 +48,8 @@ func (ui *Handler) LoginHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
}
func (ui *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
session, _ := store.Get(r, "auth-session")
func (wb *Web) LogoutHandler(w http.ResponseWriter, r *http.Request) {
session, _ := wb.cookie.Get(r, "auth-session")
session.Values["authenticated"] = false
session.Options.MaxAge = -1
err := session.Save(r, w)
@@ -59,7 +59,7 @@ func (ui *Handler) LogoutHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
func (ui *Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) {
func (wb *Web) RegisterHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
authCfg := cfg.GetAuth()
@@ -69,7 +69,7 @@ func (ui *Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) {
"Page": "register",
"Title": "Register",
}
_ = templates.ExecuteTemplate(w, "layout", data)
_ = wb.templates.ExecuteTemplate(w, "layout", data)
return
}
@@ -99,7 +99,7 @@ func (ui *Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) {
}
// Create a session
session, _ := store.Get(r, "auth-session")
session, _ := wb.cookie.Get(r, "auth-session")
session.Values["authenticated"] = true
session.Values["username"] = username
if err := session.Save(r, w); err != nil {
@@ -110,42 +110,49 @@ func (ui *Handler) RegisterHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func (ui *Handler) IndexHandler(w http.ResponseWriter, r *http.Request) {
func (wb *Web) 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)
_ = wb.templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) DownloadHandler(w http.ResponseWriter, r *http.Request) {
func (wb *Web) DownloadHandler(w http.ResponseWriter, r *http.Request) {
cfg := config.Get()
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "download",
"Title": "Download",
debrids := make([]string, 0)
for _, d := range cfg.Debrids {
debrids = append(debrids, d.Name)
}
_ = templates.ExecuteTemplate(w, "layout", data)
data := map[string]interface{}{
"URLBase": cfg.URLBase,
"Page": "download",
"Title": "Download",
"Debrids": debrids,
"HasMultiDebrid": len(debrids) > 1,
"DownloadFolder": cfg.QBitTorrent.DownloadFolder,
}
_ = wb.templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) RepairHandler(w http.ResponseWriter, r *http.Request) {
func (wb *Web) 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)
_ = wb.templates.ExecuteTemplate(w, "layout", data)
}
func (ui *Handler) ConfigHandler(w http.ResponseWriter, r *http.Request) {
func (wb *Web) 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)
_ = wb.templates.ExecuteTemplate(w, "layout", data)
}
+17 -21
View File
@@ -6,7 +6,7 @@ import (
"github.com/gorilla/sessions"
"github.com/rs/zerolog"
"github.com/sirrobot01/decypharr/internal/logger"
"github.com/sirrobot01/decypharr/pkg/qbit"
"github.com/sirrobot01/decypharr/pkg/store"
"html/template"
"os"
)
@@ -50,26 +50,15 @@ type RepairRequest struct {
//go:embed templates/*
var content embed.FS
type Handler struct {
qbit *qbit.QBit
logger zerolog.Logger
}
func New(qbit *qbit.QBit) *Handler {
return &Handler{
qbit: qbit,
logger: logger.New("ui"),
}
}
var (
secretKey = cmp.Or(os.Getenv("DECYPHARR_SECRET_KEY"), "\"wqj(v%lj*!-+kf@4&i95rhh_!5_px5qnuwqbr%cjrvrozz_r*(\"")
store = sessions.NewCookieStore([]byte(secretKey))
type Web struct {
logger zerolog.Logger
cookie *sessions.CookieStore
templates *template.Template
)
torrents *store.TorrentStorage
}
func init() {
templates = template.Must(template.ParseFS(
func New() *Web {
templates := template.Must(template.ParseFS(
content,
"templates/layout.html",
"templates/index.html",
@@ -79,10 +68,17 @@ func init() {
"templates/login.html",
"templates/register.html",
))
store.Options = &sessions.Options{
secretKey := cmp.Or(os.Getenv("DECYPHARR_SECRET_KEY"), "\"wqj(v%lj*!-+kf@4&i95rhh_!5_px5qnuwqbr%cjrvrozz_r*(\"")
cookieStore := sessions.NewCookieStore([]byte(secretKey))
cookieStore.Options = &sessions.Options{
Path: "/",
MaxAge: 86400 * 7,
HttpOnly: false,
}
return &Web{
logger: logger.New("ui"),
templates: templates,
cookie: cookieStore,
torrents: store.GetStore().GetTorrentStorage(),
}
}