- 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"`
|
LogLevel string `json:"log_level,omitempty"`
|
||||||
Debrids []Debrid `json:"debrids,omitempty"`
|
Debrids []Debrid `json:"debrids,omitempty"`
|
||||||
MaxCacheSize int `json:"max_cache_size,omitempty"`
|
|
||||||
QBitTorrent QBitTorrent `json:"qbittorrent,omitempty"`
|
QBitTorrent QBitTorrent `json:"qbittorrent,omitempty"`
|
||||||
Arrs []Arr `json:"arrs,omitempty"`
|
Arrs []Arr `json:"arrs,omitempty"`
|
||||||
Repair Repair `json:"repair,omitempty"`
|
Repair Repair `json:"repair,omitempty"`
|
||||||
@@ -118,39 +117,19 @@ func (c *Config) loadConfig() error {
|
|||||||
c.Path = configPath
|
c.Path = configPath
|
||||||
file, err := os.ReadFile(c.JsonFile())
|
file, err := os.ReadFile(c.JsonFile())
|
||||||
if err != nil {
|
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 {
|
if err := json.Unmarshal(file, &c); err != nil {
|
||||||
return fmt.Errorf("error unmarshaling config: %w", err)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,15 +175,15 @@ func ValidateConfig(config *Config) error {
|
|||||||
// Run validations concurrently
|
// Run validations concurrently
|
||||||
|
|
||||||
if err := validateDebrids(config.Debrids); err != nil {
|
if err := validateDebrids(config.Debrids); err != nil {
|
||||||
return fmt.Errorf("debrids validation error: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateQbitTorrent(&config.QBitTorrent); err != nil {
|
if err := validateQbitTorrent(&config.QBitTorrent); err != nil {
|
||||||
return fmt.Errorf("qbittorrent validation error: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := validateRepair(&config.Repair); err != nil {
|
if err := validateRepair(&config.Repair); err != nil {
|
||||||
return fmt.Errorf("repair validation error: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -287,11 +266,8 @@ func (c *Config) SaveAuth(auth *Auth) error {
|
|||||||
return os.WriteFile(c.AuthFile(), data, 0644)
|
return os.WriteFile(c.AuthFile(), data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) NeedsSetup() bool {
|
func (c *Config) NeedsSetup() error {
|
||||||
if err := ValidateConfig(c); err != nil {
|
return ValidateConfig(c)
|
||||||
return true
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) NeedsAuth() bool {
|
func (c *Config) NeedsAuth() bool {
|
||||||
@@ -336,6 +312,26 @@ func (c *Config) updateDebrid(d Debrid) Debrid {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) Save() error {
|
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, "", " ")
|
data, err := json.MarshalIndent(c, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -347,6 +343,25 @@ func (c *Config) Save() error {
|
|||||||
return nil
|
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
|
// Reload forces a reload of the configuration from disk
|
||||||
func Reload() {
|
func Reload() {
|
||||||
instance = nil
|
instance = nil
|
||||||
|
|||||||
@@ -200,7 +200,6 @@ func (as *Storage) cleanupArrsQueue() {
|
|||||||
arrs = append(arrs, arr)
|
arrs = append(arrs, arr)
|
||||||
}
|
}
|
||||||
if len(arrs) > 0 {
|
if len(arrs) > 0 {
|
||||||
as.logger.Trace().Msgf("Cleaning up %d arrs", len(arrs))
|
|
||||||
for _, arr := range arrs {
|
for _, arr := range arrs {
|
||||||
if err := arr.CleanupQueue(); err != nil {
|
if err := arr.CleanupQueue(); err != nil {
|
||||||
as.logger.Error().Err(err).Msgf("Failed to cleanup arr %s", arr.Name)
|
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),
|
"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{
|
return &RealDebrid{
|
||||||
Name: "realdebrid",
|
Name: "realdebrid",
|
||||||
Host: "https://api.real-debrid.com/rest/1.0",
|
Host: "https://api.real-debrid.com/rest/1.0",
|
||||||
APIKey: dc.APIKey,
|
APIKey: dc.APIKey,
|
||||||
DownloadKeys: accounts,
|
DownloadKeys: accounts,
|
||||||
DownloadUncached: dc.DownloadUncached,
|
DownloadUncached: dc.DownloadUncached,
|
||||||
client: client,
|
client: request.New(
|
||||||
downloadClient: downloadClient,
|
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,
|
currentDownloadKey: currentDownloadKey,
|
||||||
MountPath: dc.Folder,
|
MountPath: dc.Folder,
|
||||||
logger: logger.New(dc.Name),
|
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
|
// Check which files exist in this batch
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
if file, exists := remainingFiles[entry.Name()]; exists {
|
filename := entry.Name()
|
||||||
fullFilePath := filepath.Join(rclonePath, file.Name)
|
if file, exists := remainingFiles[filename]; exists {
|
||||||
|
fullFilePath := filepath.Join(rclonePath, filename)
|
||||||
fileSymlinkPath := filepath.Join(symlinkPath, file.Name)
|
fileSymlinkPath := filepath.Join(symlinkPath, file.Name)
|
||||||
|
|
||||||
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
if err := os.Symlink(fullFilePath, fileSymlinkPath); err != nil && !os.IsExist(err) {
|
||||||
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
q.logger.Debug().Msgf("Failed to create symlink: %s: %v", fileSymlinkPath, err)
|
||||||
} else {
|
} else {
|
||||||
q.logger.Info().Msgf("File is ready: %s", file.Name)
|
|
||||||
filePaths = append(filePaths, fileSymlinkPath)
|
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.Use(q.CategoryContext)
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(q.authContext)
|
r.Use(q.authContext)
|
||||||
r.Post("/auth/login", q.handleLogin)
|
r.Post("/register/login", q.handleLogin)
|
||||||
r.Route("/torrents", func(r chi.Router) {
|
r.Route("/torrents", func(r chi.Router) {
|
||||||
r.Use(HashesCtx)
|
r.Use(HashesCtx)
|
||||||
r.Get("/info", q.handleTorrentsInfo)
|
r.Get("/info", q.handleTorrentsInfo)
|
||||||
|
|||||||
@@ -250,7 +250,6 @@ func (ts *TorrentStorage) DeleteMultiple(hashes []string, removeFromDebrid bool)
|
|||||||
if dbClient == nil {
|
if dbClient == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Println("Deleting torrent from debrid:", id)
|
|
||||||
err := dbClient.DeleteTorrent(id)
|
err := dbClient.DeleteTorrent(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ func (s *Server) Start(ctx context.Context) error {
|
|||||||
return srv.Shutdown(context.Background())
|
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) {
|
func (s *Server) getLogs(w http.ResponseWriter, r *http.Request) {
|
||||||
logFile := logger.GetLogPath()
|
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.Get("/login", ui.LoginHandler)
|
||||||
r.Post("/login", ui.LoginHandler)
|
r.Post("/login", ui.LoginHandler)
|
||||||
r.Get("/auth", ui.SetupHandler)
|
r.Get("/register", ui.RegisterHandler)
|
||||||
r.Post("/auth", ui.SetupHandler)
|
r.Post("/register", ui.RegisterHandler)
|
||||||
|
r.Get("/skip-auth", ui.skipAuthHandler)
|
||||||
|
r.Get("/version", ui.handleGetVersion)
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(ui.authMiddleware)
|
r.Use(ui.authMiddleware)
|
||||||
|
r.Use(ui.setupMiddleware)
|
||||||
r.Get("/", ui.IndexHandler)
|
r.Get("/", ui.IndexHandler)
|
||||||
r.Get("/download", ui.DownloadHandler)
|
r.Get("/download", ui.DownloadHandler)
|
||||||
r.Get("/repair", ui.RepairHandler)
|
r.Get("/repair", ui.RepairHandler)
|
||||||
r.Get("/config", ui.ConfigHandler)
|
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.Get("/arrs", ui.handleGetArrs)
|
||||||
r.Post("/add", ui.handleAddContent)
|
r.Post("/add", ui.handleAddContent)
|
||||||
r.Post("/repair", ui.handleRepairMedia)
|
r.Post("/repair", ui.handleRepairMedia)
|
||||||
@@ -31,7 +34,6 @@ func (ui *Handler) Routes() http.Handler {
|
|||||||
r.Delete("/torrents/", ui.handleDeleteTorrents)
|
r.Delete("/torrents/", ui.handleDeleteTorrents)
|
||||||
r.Get("/config", ui.handleGetConfig)
|
r.Get("/config", ui.handleGetConfig)
|
||||||
r.Post("/config", ui.handleUpdateConfig)
|
r.Post("/config", ui.handleUpdateConfig)
|
||||||
r.Get("/version", ui.handleGetVersion)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,25 +2,11 @@ package web
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
|
||||||
"github.com/goccy/go-json"
|
|
||||||
"github.com/gorilla/sessions"
|
"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/rs/zerolog"
|
||||||
"github.com/sirrobot01/decypharr/pkg/arr"
|
"github.com/sirrobot01/decypharr/internal/logger"
|
||||||
"github.com/sirrobot01/decypharr/pkg/version"
|
"github.com/sirrobot01/decypharr/pkg/qbit"
|
||||||
|
"html/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
var restartFunc func()
|
var restartFunc func()
|
||||||
@@ -88,7 +74,7 @@ func init() {
|
|||||||
"templates/repair.html",
|
"templates/repair.html",
|
||||||
"templates/config.html",
|
"templates/config.html",
|
||||||
"templates/login.html",
|
"templates/login.html",
|
||||||
"templates/setup.html",
|
"templates/register.html",
|
||||||
))
|
))
|
||||||
|
|
||||||
store.Options = &sessions.Options{
|
store.Options = &sessions.Options{
|
||||||
@@ -97,461 +83,3 @@ func init() {
|
|||||||
HttpOnly: false,
|
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" }}
|
{{ 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">
|
<div class="container mt-4">
|
||||||
<form id="configForm">
|
<form id="configForm">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center mb-2">
|
<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>
|
<h4 class="mb-0"><i class="bi bi-gear me-2"></i>Settings</h4>
|
||||||
<button type="submit" class="btn btn-primary px-4">
|
<button type="submit" class="btn btn-success px-4">
|
||||||
<i class="bi bi-save"></i> Save
|
<i class="bi bi-save"></i> Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<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">
|
<div class="section mb-5">
|
||||||
<h5 class="border-bottom pb-2">General Configuration</h5>
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -29,7 +80,7 @@
|
|||||||
<label>
|
<label>
|
||||||
<!-- Empty label to keep the button aligned -->
|
<!-- Empty label to keep the button aligned -->
|
||||||
</label>
|
</label>
|
||||||
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler()" id="registerMagnetLink">
|
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler();" id="registerMagnetLink">
|
||||||
Open Magnet Links in Decypharr
|
Open Magnet Links in Decypharr
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +88,6 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="urlBase">URL Base</label>
|
<label for="urlBase">URL Base</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
disabled
|
|
||||||
class="form-control"
|
class="form-control"
|
||||||
id="urlBase"
|
id="urlBase"
|
||||||
name="url_base"
|
name="url_base"
|
||||||
@@ -49,7 +99,6 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="bindAddress">Bind Address</label>
|
<label for="bindAddress">Bind Address</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
disabled
|
|
||||||
class="form-control"
|
class="form-control"
|
||||||
id="bindAddress"
|
id="bindAddress"
|
||||||
name="bind_address"
|
name="bind_address"
|
||||||
@@ -61,7 +110,6 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="port">Port</label>
|
<label for="port">Port</label>
|
||||||
<input type="text"
|
<input type="text"
|
||||||
disabled
|
|
||||||
class="form-control"
|
class="form-control"
|
||||||
id="port"
|
id="port"
|
||||||
name="port"
|
name="port"
|
||||||
@@ -102,7 +150,7 @@
|
|||||||
id="minFileSize"
|
id="minFileSize"
|
||||||
name="min_file_size"
|
name="min_file_size"
|
||||||
placeholder="e.g., 10MB, 1GB">
|
placeholder="e.g., 10MB, 1GB">
|
||||||
<small class="form-text text-muted">Minimum file size to download (0 for no limit)</small>
|
<small class="form-text text-muted">Minimum file size to download (Empty for no limit)</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mt-3">
|
<div class="col-md-6 mt-3">
|
||||||
@@ -113,14 +161,19 @@
|
|||||||
id="maxFileSize"
|
id="maxFileSize"
|
||||||
name="max_file_size"
|
name="max_file_size"
|
||||||
placeholder="e.g., 50GB, 100MB">
|
placeholder="e.g., 50GB, 100MB">
|
||||||
<small class="form-text text-muted">Maximum file size to download (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>
|
||||||
</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">
|
<div class="section mb-5">
|
||||||
<h5 class="border-bottom pb-2">Debrids</h5>
|
|
||||||
<div id="debridConfigs"></div>
|
<div id="debridConfigs"></div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button type="button" id="addDebridBtn" class="btn btn-secondary">
|
<button type="button" id="addDebridBtn" class="btn btn-secondary">
|
||||||
@@ -128,19 +181,18 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section mb-5">
|
||||||
<h5 class="border-bottom pb-2">QBitTorrent</h5>
|
|
||||||
<div class="row">
|
<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">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="qbit.download_folder">Symlink/Download Folder</label>
|
<label class="form-label" for="qbit.download_folder">Symlink/Download Folder</label>
|
||||||
<input type="text" class="form-control" name="qbit.download_folder" id="qbit.download_folder">
|
<input type="text" class="form-control" name="qbit.download_folder" id="qbit.download_folder">
|
||||||
@@ -157,10 +209,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="section mb-5">
|
||||||
<h5 class="border-bottom pb-2">Arr Configurations</h5>
|
|
||||||
<div id="arrConfigs"></div>
|
<div id="arrConfigs"></div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<button type="button" id="addArrBtn" class="btn btn-secondary">
|
<button type="button" id="addArrBtn" class="btn btn-secondary">
|
||||||
@@ -168,10 +227,17 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section mb-5">
|
||||||
<h5 class="border-bottom pb-2">Repair Configuration</h5>
|
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="form-check me-3 d-inline-block">
|
<div class="form-check me-3 d-inline-block">
|
||||||
@@ -219,15 +285,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end mt-3">
|
<div class="mt-4 d-flex justify-content-between">
|
||||||
<button type="submit" class="btn btn-primary px-4">
|
<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
|
<i class="bi bi-save"></i> Save
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Templates for dynamic elements
|
// Templates for dynamic elements
|
||||||
const debridTemplate = (index) => `
|
const debridTemplate = (index) => `
|
||||||
@@ -240,7 +311,12 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].name" >Name</label>
|
<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>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].api_key" >API Key</label>
|
<label class="form-label" for="debrid[${index}].api_key" >API Key</label>
|
||||||
@@ -248,11 +324,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].folder" >Mount Folder</label>
|
<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>
|
||||||
<div class="col-md-6 mb-3">
|
<div class="col-md-6 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].rate_limit" >Rate Limit</label>
|
<label class="form-label" for="debrid[${index}].rate_limit" >Rate Limit</label>
|
||||||
<input type="text" class="form-control" name="debrid[${index}].rate_limit" id="debrid[${index}].rate_limit" placeholder="e.g., 200/minute">
|
<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>
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="form-check me-3 d-inline-block">
|
<div class="form-check me-3 d-inline-block">
|
||||||
@@ -273,42 +349,42 @@
|
|||||||
<h6 class="pb-2">Webdav</h6>
|
<h6 class="pb-2">Webdav</h6>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].torrents_refresh_interval">Torrents Refresh Interval</label>
|
<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>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].download_links_refresh_interval">Download Links Refresh Interval</label>
|
<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>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].auto_expire_links_after">Expire Links After</label>
|
<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>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].folder_naming">Folder Naming Structure</label>
|
<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">File name</option>
|
||||||
<option value="filename_no_ext">File name with No Ext</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="id">Use ID</option>
|
||||||
<option value="infohash">Use Infohash</option>
|
<option value="infohash">Use Infohash</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].workers">Number of Workers</label>
|
<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>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].rc_url">Rclone RC URL</label>
|
<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>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].rc_user">Rclone RC User</label>
|
<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>
|
||||||
<div class="col-md-3 mb-3">
|
<div class="col-md-3 mb-3">
|
||||||
<label class="form-label" for="debrid[${index}].rc_pass">Rclone RC Password</label>
|
<label class="form-label" for="debrid[${index}].rc_pass">Rclone RC Password</label>
|
||||||
<input type="password" class="form-control" 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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -362,9 +438,58 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
let debridCount = 0;
|
let debridCount = 0;
|
||||||
let arrCount = 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
|
// Load existing configuration
|
||||||
fetch('/internal/config')
|
fetcher('/api/config')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(config => {
|
.then(config => {
|
||||||
// Load Debrid configs
|
// Load Debrid configs
|
||||||
@@ -409,7 +534,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load general config
|
// Load general config
|
||||||
|
|
||||||
const logLevel = document.getElementById('log-level');
|
const logLevel = document.getElementById('log-level');
|
||||||
logLevel.value = config.log_level;
|
logLevel.value = config.log_level;
|
||||||
if (config.allowed_file_types && Array.isArray(config.allowed_file_types)) {
|
if (config.allowed_file_types && Array.isArray(config.allowed_file_types)) {
|
||||||
@@ -435,6 +559,7 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
console.log(error);
|
||||||
console.error('Error loading configuration:', error);
|
console.error('Error loading configuration:', error);
|
||||||
createToast(`Error loading configuration: ${error.message}`, 'error');
|
createToast(`Error loading configuration: ${error.message}`, 'error');
|
||||||
});
|
});
|
||||||
@@ -455,10 +580,20 @@
|
|||||||
$(document).on('change', '.useWebdav', function() {
|
$(document).on('change', '.useWebdav', function() {
|
||||||
const webdavConfig = $(this).closest('.config-item').find(`.webdav`);
|
const webdavConfig = $(this).closest('.config-item').find(`.webdav`);
|
||||||
if (webdavConfig.length === 0) return;
|
if (webdavConfig.length === 0) return;
|
||||||
|
|
||||||
|
const webdavFields = webdavConfig.find('.webdav-field');
|
||||||
|
|
||||||
if (this.checked) {
|
if (this.checked) {
|
||||||
webdavConfig.removeClass('d-none');
|
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 {
|
} else {
|
||||||
webdavConfig.addClass('d-none');
|
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) {
|
async function saveConfig(e) {
|
||||||
const submitButton = e.target.querySelector('button[type="submit"]');
|
const submitButton = e.target.querySelector('button[type="submit"]');
|
||||||
submitButton.disabled = true;
|
submitButton.disabled = true;
|
||||||
@@ -494,7 +626,7 @@
|
|||||||
try {
|
try {
|
||||||
const config = collectFormData();
|
const config = collectFormData();
|
||||||
// Save config logic
|
// Save config logic
|
||||||
const response = await fetch('/internal/config', {
|
const response = await fetcher('/api/config', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(config)
|
body: JSON.stringify(config)
|
||||||
@@ -528,7 +660,6 @@
|
|||||||
addDeleteButton(newDebrid, `Delete this debrid`);
|
addDeleteButton(newDebrid, `Delete this debrid`);
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
|
|
||||||
if (data.use_webdav) {
|
if (data.use_webdav) {
|
||||||
let _webCfg = newDebrid.querySelector(`.webdav`);
|
let _webCfg = newDebrid.querySelector(`.webdav`);
|
||||||
if (_webCfg) {
|
if (_webCfg) {
|
||||||
@@ -591,8 +722,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function collectFormData() {
|
function collectFormData() {
|
||||||
const formEl = document.getElementById('configForm');
|
|
||||||
|
|
||||||
// Create the config object
|
// Create the config object
|
||||||
const config = {
|
const config = {
|
||||||
log_level: document.getElementById('log-level').value,
|
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),
|
allowed_file_types: document.getElementById('allowedExtensions').value.split(',').map(ext => ext.trim()).filter(Boolean),
|
||||||
min_file_size: document.getElementById('minFileSize').value,
|
min_file_size: document.getElementById('minFileSize').value,
|
||||||
max_file_size: document.getElementById('maxFileSize').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: [],
|
debrids: [],
|
||||||
qbittorrent: {
|
qbittorrent: {
|
||||||
username: document.querySelector('[name="qbit.username"]').value,
|
|
||||||
password: document.querySelector('[name="qbit.password"]').value,
|
|
||||||
download_folder: document.querySelector('[name="qbit.download_folder"]').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: [],
|
arrs: [],
|
||||||
repair: {
|
repair: {
|
||||||
@@ -631,17 +762,21 @@
|
|||||||
rate_limit: document.querySelector(`[name="debrid[${i}].rate_limit"]`).value,
|
rate_limit: document.querySelector(`[name="debrid[${i}].rate_limit"]`).value,
|
||||||
download_uncached: document.querySelector(`[name="debrid[${i}].download_uncached"]`).checked,
|
download_uncached: document.querySelector(`[name="debrid[${i}].download_uncached"]`).checked,
|
||||||
check_cached: document.querySelector(`[name="debrid[${i}].check_cached"]`).checked,
|
check_cached: document.querySelector(`[name="debrid[${i}].check_cached"]`).checked,
|
||||||
use_webdav: document.querySelector(`[name="debrid[${i}].use_webdav"]`).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
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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) {
|
if (debrid.name && debrid.api_key) {
|
||||||
config.debrids.push(debrid);
|
config.debrids.push(debrid);
|
||||||
}
|
}
|
||||||
@@ -668,8 +803,8 @@
|
|||||||
|
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Register magnet link handler
|
// Register magnet link handler
|
||||||
function registerMagnetLinkHandler() {
|
function registerMagnetLinkHandler() {
|
||||||
if ('registerProtocolHandler' in navigator) {
|
if ('registerProtocolHandler' in navigator) {
|
||||||
|
|||||||
@@ -112,7 +112,7 @@
|
|||||||
formData.append('notSymlink', document.getElementById('isSymlink').checked);
|
formData.append('notSymlink', document.getElementById('isSymlink').checked);
|
||||||
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
|
formData.append('downloadUncached', document.getElementById('downloadUncached').checked);
|
||||||
|
|
||||||
const response = await fetch('/internal/add', {
|
const response = await fetcher('/api/add', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -190,7 +190,7 @@
|
|||||||
|
|
||||||
async function loadTorrents() {
|
async function loadTorrents() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/internal/torrents');
|
const response = await fetcher('/api/torrents');
|
||||||
const torrents = await response.json();
|
const torrents = await response.json();
|
||||||
|
|
||||||
state.torrents = torrents;
|
state.torrents = torrents;
|
||||||
@@ -256,7 +256,7 @@
|
|||||||
if (!confirm('Are you sure you want to delete this torrent?')) return;
|
if (!confirm('Are you sure you want to delete this torrent?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/internal/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
|
await fetcher(`/api/torrents/${category}/${hash}?removeFromDebrid=${removeFromDebrid}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
await loadTorrents();
|
await loadTorrents();
|
||||||
@@ -273,7 +273,7 @@
|
|||||||
try {
|
try {
|
||||||
// COmma separated list of hashes
|
// COmma separated list of hashes
|
||||||
const hashes = Array.from(state.selectedTorrents).join(',');
|
const hashes = Array.from(state.selectedTorrents).join(',');
|
||||||
await fetch(`/internal/torrents/?hashes=${encodeURIComponent(hashes)}`, {
|
await fetcher(`/api/torrents/?hashes=${encodeURIComponent(hashes)}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
});
|
});
|
||||||
await loadTorrents();
|
await loadTorrents();
|
||||||
|
|||||||
@@ -197,8 +197,8 @@
|
|||||||
{{ template "config" . }}
|
{{ template "config" . }}
|
||||||
{{ else if eq .Page "login" }}
|
{{ else if eq .Page "login" }}
|
||||||
{{ template "login" . }}
|
{{ template "login" . }}
|
||||||
{{ else if eq .Page "auth" }}
|
{{ else if eq .Page "register" }}
|
||||||
{{ template "auth" . }}
|
{{ template "register" . }}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
@@ -206,6 +206,30 @@
|
|||||||
<script src="https://code.jquery.com/jquery-3.6.0.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>
|
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
|
||||||
<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
|
* Create a toast message
|
||||||
* @param {string} message - The message to display
|
* @param {string} message - The message to display
|
||||||
@@ -243,6 +267,7 @@
|
|||||||
const toast = new bootstrap.Toast(toastElement, {
|
const toast = new bootstrap.Toast(toastElement, {
|
||||||
autohide: true,
|
autohide: true,
|
||||||
delay: toastTimeouts[type]
|
delay: toastTimeouts[type]
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.show();
|
toast.show();
|
||||||
@@ -302,7 +327,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
fetch('/internal/version')
|
fetcher('/version')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
const versionBadge = document.getElementById('version-badge');
|
const versionBadge = document.getElementById('version-badge');
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/login', {
|
const response = await fetcher('/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -56,76 +56,3 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{{ end }}
|
{{ 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>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Load Arr instances
|
// Load Arr instances
|
||||||
fetch('/internal/arrs')
|
fetcher('/api/arrs')
|
||||||
.then(response => response.json())
|
.then(response => response.json())
|
||||||
.then(arrs => {
|
.then(arrs => {
|
||||||
const select = document.getElementById('arrSelect');
|
const select = document.getElementById('arrSelect');
|
||||||
@@ -175,7 +175,7 @@
|
|||||||
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
|
let mediaIds = document.getElementById('mediaIds').value.split(',').map(id => id.trim());
|
||||||
let arr = document.getElementById('arrSelect').value;
|
let arr = document.getElementById('arrSelect').value;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/internal/repair', {
|
const response = await fetcher('/api/repair', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -207,8 +207,8 @@
|
|||||||
// Load jobs function
|
// Load jobs function
|
||||||
async function loadJobs(page) {
|
async function loadJobs(page) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/internal/repair/jobs');
|
const response = await fetcher('/api/repair/jobs');
|
||||||
if (!response.ok) throw new Error('Failed to fetch jobs');
|
if (!response.ok) throw new Error('Failed to fetcher jobs');
|
||||||
|
|
||||||
allJobs = await response.json();
|
allJobs = await response.json();
|
||||||
renderJobsTable(page);
|
renderJobsTable(page);
|
||||||
@@ -403,7 +403,7 @@
|
|||||||
async function deleteJob(jobId) {
|
async function deleteJob(jobId) {
|
||||||
if (confirm('Are you sure you want to delete this job?')) {
|
if (confirm('Are you sure you want to delete this job?')) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/internal/repair/jobs`, {
|
const response = await fetcher(`/api/repair/jobs`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -422,7 +422,7 @@
|
|||||||
|
|
||||||
async function deleteMultipleJobs(jobIds) {
|
async function deleteMultipleJobs(jobIds) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/internal/repair/jobs`, {
|
const response = await fetcher(`/api/repair/jobs`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'Content-Type': 'application/json'
|
||||||
@@ -441,7 +441,7 @@
|
|||||||
// Process job function
|
// Process job function
|
||||||
async function processJob(jobId) {
|
async function processJob(jobId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/internal/repair/jobs/${jobId}/process`, {
|
const response = await fetcher(`/api/repair/jobs/${jobId}/process`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
'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(name, rootDir)
|
||||||
folderName = strings.TrimPrefix(folderName, "/")
|
folderName = strings.TrimPrefix(folderName, "/")
|
||||||
|
|
||||||
// Only fetch the torrent folders once
|
// Only fetcher the torrent folders once
|
||||||
children := h.getTorrentsFolders()
|
children := h.getTorrentsFolders()
|
||||||
|
|
||||||
return &File{
|
return &File{
|
||||||
|
|||||||
Reference in New Issue
Block a user