- Add shinning UI

- Revamp deployment process
- Fix Alldebrid file node bug
This commit is contained in:
Mukhtar Akere
2025-01-13 20:18:59 +01:00
parent 7cb41a0e8b
commit ea73572557
45 changed files with 1414 additions and 829 deletions

View File

@@ -1,42 +0,0 @@
package server
import (
"goBlack/common"
"goBlack/pkg/qbit/shared"
"net/http"
"path/filepath"
)
func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("v4.3.2"))
}
func (s *Server) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("2.7"))
}
func (s *Server) handlePreferences(w http.ResponseWriter, r *http.Request) {
preferences := shared.NewAppPreferences()
preferences.WebUiUsername = s.qbit.Username
preferences.SavePath = s.qbit.DownloadFolder
preferences.TempPath = filepath.Join(s.qbit.DownloadFolder, "temp")
common.JSONResponse(w, preferences, http.StatusOK)
}
func (s *Server) handleBuildInfo(w http.ResponseWriter, r *http.Request) {
res := shared.BuildInfo{
Bitness: 64,
Boost: "1.75.0",
Libtorrent: "1.2.11.0",
Openssl: "1.1.1i",
Qt: "5.15.2",
Zlib: "1.2.11",
}
common.JSONResponse(w, res, http.StatusOK)
}
func (s *Server) shutdown(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}

View File

@@ -1,7 +0,0 @@
package server
import "net/http"
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Ok."))
}

View File

@@ -1,14 +1,12 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"goBlack/common"
"goBlack/pkg/arr"
"goBlack/pkg/debrid"
"sync"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
"time"
)
@@ -64,12 +62,11 @@ func (i *ImportRequest) Complete() {
i.CompletedAt = time.Now()
}
func (i *ImportRequest) Process(s *Server) (err error) {
func (i *ImportRequest) Process(q *shared.QBit) (err error) {
// Use this for now.
// This sends the torrent to the arr
q := s.qbit
magnet, err := common.GetMagnetFromUrl(i.URI)
torrent := q.CreateTorrentFromMagnet(magnet, i.Arr.Name)
torrent := q.CreateTorrentFromMagnet(magnet, i.Arr.Name, "manual")
debridTorrent, err := debrid.ProcessTorrent(q.Debrid, magnet, i.Arr, i.IsSymlink)
if err != nil || debridTorrent == nil {
if debridTorrent != nil {
@@ -85,94 +82,3 @@ func (i *ImportRequest) Process(s *Server) (err error) {
go q.ProcessFiles(torrent, debridTorrent, i.Arr, i.IsSymlink)
return nil
}
func (i *ImportRequest) BetaProcess(s *Server) (err error) {
// THis actually imports the torrent into the arr. Needs more work
if i.Arr == nil {
return errors.New("invalid arr")
}
q := s.qbit
magnet, err := common.GetMagnetFromUrl(i.URI)
if err != nil {
return fmt.Errorf("error parsing magnet link: %w", err)
}
debridTorrent, err := debrid.ProcessTorrent(q.Debrid, magnet, i.Arr, true)
if err != nil || debridTorrent == nil {
if debridTorrent != nil {
go debridTorrent.Delete()
}
if err == nil {
err = fmt.Errorf("failed to process torrent")
}
return err
}
debridTorrent.Arr = i.Arr
torrentPath, err := q.ProcessSymlink(debridTorrent)
if err != nil {
return fmt.Errorf("failed to process symlink: %w", err)
}
i.Path = torrentPath
body, err := i.Arr.Import(torrentPath, i.SeriesId, i.Seasons)
if err != nil {
return fmt.Errorf("failed to import: %w", err)
}
defer body.Close()
var resp ManualImportResponseSchema
if err := json.NewDecoder(body).Decode(&resp); err != nil {
return fmt.Errorf("failed to decode response: %w", err)
}
if resp.Status != "success" {
return fmt.Errorf("failed to import: %s", resp.Result)
}
i.Complete()
return
}
type ImportStore struct {
Imports map[string]*ImportRequest
mu sync.RWMutex
}
func NewImportStore() *ImportStore {
return &ImportStore{
Imports: make(map[string]*ImportRequest),
}
}
func (s *ImportStore) AddImport(i *ImportRequest) {
s.mu.Lock()
defer s.mu.Unlock()
s.Imports[i.ID] = i
}
func (s *ImportStore) GetImport(id string) *ImportRequest {
s.mu.RLock()
defer s.mu.RUnlock()
return s.Imports[id]
}
func (s *ImportStore) GetAllImports() []*ImportRequest {
s.mu.RLock()
defer s.mu.RUnlock()
var imports []*ImportRequest
for _, i := range s.Imports {
imports = append(imports, i)
}
return imports
}
func (s *ImportStore) DeleteImport(id string) {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.Imports, id)
}
func (s *ImportStore) UpdateImport(i *ImportRequest) {
s.mu.Lock()
defer s.mu.Unlock()
s.Imports[i.ID] = i
}

View File

@@ -1,84 +0,0 @@
package server
import (
"context"
"encoding/base64"
"github.com/go-chi/chi/v5"
"goBlack/pkg/arr"
"net/http"
"strings"
)
func DecodeAuthHeader(header string) (string, string, error) {
encodedTokens := strings.Split(header, " ")
if len(encodedTokens) != 2 {
return "", "", nil
}
encodedToken := encodedTokens[1]
bytes, err := base64.StdEncoding.DecodeString(encodedToken)
if err != nil {
return "", "", err
}
bearer := string(bytes)
colonIndex := strings.LastIndex(bearer, ":")
host := bearer[:colonIndex]
token := bearer[colonIndex+1:]
return host, token, nil
}
func (s *Server) CategoryContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
category := strings.Trim(r.URL.Query().Get("category"), "")
if category == "" {
// Get from form
_ = r.ParseForm()
category = r.Form.Get("category")
if category == "" {
// Get from multipart form
_ = r.ParseMultipartForm(0)
category = r.FormValue("category")
}
}
ctx := r.Context()
ctx = context.WithValue(r.Context(), "category", category)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (s *Server) authContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, token, err := DecodeAuthHeader(r.Header.Get("Authorization"))
category := r.Context().Value("category").(string)
a := &arr.Arr{
Name: category,
}
if err == nil {
a.Host = host
a.Token = token
}
s.qbit.Arrs.AddOrUpdate(a)
ctx := context.WithValue(r.Context(), "arr", a)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func HashesCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_hashes := chi.URLParam(r, "hashes")
var hashes []string
if _hashes != "" {
hashes = strings.Split(_hashes, "|")
}
if hashes == nil {
// Get hashes from form
_ = r.ParseForm()
hashes = r.Form["hashes"]
}
ctx := context.WithValue(r.Context(), "hashes", hashes)
next.ServeHTTP(w, r.WithContext(ctx))
})
}

View File

@@ -0,0 +1,306 @@
package server
import (
"context"
"encoding/base64"
"github.com/go-chi/chi/v5"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
"log"
"net/http"
"path/filepath"
"strings"
)
type qbitHandler struct {
qbit *shared.QBit
logger *log.Logger
debug bool
}
func decodeAuthHeader(header string) (string, string, error) {
encodedTokens := strings.Split(header, " ")
if len(encodedTokens) != 2 {
return "", "", nil
}
encodedToken := encodedTokens[1]
bytes, err := base64.StdEncoding.DecodeString(encodedToken)
if err != nil {
return "", "", err
}
bearer := string(bytes)
colonIndex := strings.LastIndex(bearer, ":")
host := bearer[:colonIndex]
token := bearer[colonIndex+1:]
return host, token, nil
}
func (q *qbitHandler) CategoryContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
category := strings.Trim(r.URL.Query().Get("category"), "")
if category == "" {
// Get from form
_ = r.ParseForm()
category = r.Form.Get("category")
if category == "" {
// Get from multipart form
_ = r.ParseMultipartForm(0)
category = r.FormValue("category")
}
}
ctx := r.Context()
ctx = context.WithValue(r.Context(), "category", category)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (q *qbitHandler) authContext(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
host, token, err := decodeAuthHeader(r.Header.Get("Authorization"))
category := r.Context().Value("category").(string)
a := &arr.Arr{
Name: category,
}
if err == nil {
a.Host = host
a.Token = token
}
q.qbit.Arrs.AddOrUpdate(a)
ctx := context.WithValue(r.Context(), "arr", a)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func HashesCtx(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_hashes := chi.URLParam(r, "hashes")
var hashes []string
if _hashes != "" {
hashes = strings.Split(_hashes, "|")
}
if hashes == nil {
// Get hashes from form
_ = r.ParseForm()
hashes = r.Form["hashes"]
}
ctx := context.WithValue(r.Context(), "hashes", hashes)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func (q *qbitHandler) handleLogin(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("Ok."))
}
func (q *qbitHandler) handleVersion(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("v4.3.2"))
}
func (q *qbitHandler) handleWebAPIVersion(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("2.7"))
}
func (q *qbitHandler) handlePreferences(w http.ResponseWriter, r *http.Request) {
preferences := shared.NewAppPreferences()
preferences.WebUiUsername = q.qbit.Username
preferences.SavePath = q.qbit.DownloadFolder
preferences.TempPath = filepath.Join(q.qbit.DownloadFolder, "temp")
common.JSONResponse(w, preferences, http.StatusOK)
}
func (q *qbitHandler) handleBuildInfo(w http.ResponseWriter, r *http.Request) {
res := shared.BuildInfo{
Bitness: 64,
Boost: "1.75.0",
Libtorrent: "1.2.11.0",
Openssl: "1.1.1i",
Qt: "5.15.2",
Zlib: "1.2.11",
}
common.JSONResponse(w, res, http.StatusOK)
}
func (q *qbitHandler) shutdown(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (q *qbitHandler) handleTorrentsInfo(w http.ResponseWriter, r *http.Request) {
//log all url params
ctx := r.Context()
category := ctx.Value("category").(string)
filter := strings.Trim(r.URL.Query().Get("filter"), "")
hashes, _ := ctx.Value("hashes").([]string)
torrents := q.qbit.Storage.GetAll(category, filter, hashes)
common.JSONResponse(w, torrents, http.StatusOK)
}
func (q *qbitHandler) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0]
switch contentType {
case "multipart/form-data":
err := r.ParseMultipartForm(32 << 20) // 32MB max memory
if err != nil {
q.logger.Printf("Error parsing form: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
case "application/x-www-form-urlencoded":
err := r.ParseForm()
if err != nil {
q.logger.Printf("Error parsing form: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
q.logger.Printf("isSymlink: %v\n", isSymlink)
urls := r.FormValue("urls")
category := r.FormValue("category")
atleastOne := false
var urlList []string
if urls != "" {
urlList = strings.Split(urls, "\n")
}
ctx = context.WithValue(ctx, "isSymlink", isSymlink)
for _, url := range urlList {
if err := q.qbit.AddMagnet(ctx, url, category); err != nil {
q.logger.Printf("Error adding magnet: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
atleastOne = true
}
if contentType == "multipart/form-data" && len(r.MultipartForm.File["torrents"]) > 0 {
files := r.MultipartForm.File["torrents"]
for _, fileHeader := range files {
if err := q.qbit.AddTorrent(ctx, fileHeader, category); err != nil {
q.logger.Printf("Error adding torrent: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
atleastOne = true
}
}
if !atleastOne {
http.Error(w, "No valid URLs or torrents provided", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
func (q *qbitHandler) handleTorrentsDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
if len(hashes) == 0 {
http.Error(w, "No hashes provided", http.StatusBadRequest)
return
}
for _, hash := range hashes {
q.qbit.Storage.Delete(hash)
}
w.WriteHeader(http.StatusOK)
}
func (q *qbitHandler) handleTorrentsPause(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
for _, hash := range hashes {
torrent := q.qbit.Storage.Get(hash)
if torrent == nil {
continue
}
go q.qbit.PauseTorrent(torrent)
}
w.WriteHeader(http.StatusOK)
}
func (q *qbitHandler) handleTorrentsResume(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
for _, hash := range hashes {
torrent := q.qbit.Storage.Get(hash)
if torrent == nil {
continue
}
go q.qbit.ResumeTorrent(torrent)
}
w.WriteHeader(http.StatusOK)
}
func (q *qbitHandler) handleTorrentRecheck(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
for _, hash := range hashes {
torrent := q.qbit.Storage.Get(hash)
if torrent == nil {
continue
}
go q.qbit.RefreshTorrent(torrent)
}
w.WriteHeader(http.StatusOK)
}
func (q *qbitHandler) handleCategories(w http.ResponseWriter, r *http.Request) {
var categories = map[string]shared.TorrentCategory{}
for _, cat := range q.qbit.Categories {
path := filepath.Join(q.qbit.DownloadFolder, cat)
categories[cat] = shared.TorrentCategory{
Name: cat,
SavePath: path,
}
}
common.JSONResponse(w, categories, http.StatusOK)
}
func (q *qbitHandler) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
name := r.Form.Get("category")
if name == "" {
http.Error(w, "No name provided", http.StatusBadRequest)
return
}
q.qbit.Categories = append(q.qbit.Categories, name)
common.JSONResponse(w, nil, http.StatusOK)
}
func (q *qbitHandler) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
hash := r.URL.Query().Get("hash")
torrent := q.qbit.Storage.Get(hash)
properties := q.qbit.GetTorrentProperties(torrent)
common.JSONResponse(w, properties, http.StatusOK)
}
func (q *qbitHandler) handleTorrentFiles(w http.ResponseWriter, r *http.Request) {
hash := r.URL.Query().Get("hash")
torrent := q.qbit.Storage.Get(hash)
if torrent == nil {
return
}
files := q.qbit.GetTorrentFiles(torrent)
common.JSONResponse(w, files, http.StatusOK)
}

View File

@@ -2,51 +2,68 @@ package server
import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"net/http"
)
func (s *Server) Routes(r chi.Router) http.Handler {
func (q *qbitHandler) Routes(r chi.Router) http.Handler {
r.Route("/api/v2", func(r chi.Router) {
r.Use(s.CategoryContext)
r.Post("/auth/login", s.handleLogin)
if q.debug {
r.Use(middleware.Logger)
}
r.Use(q.CategoryContext)
r.Post("/auth/login", q.handleLogin)
r.Group(func(r chi.Router) {
r.Use(s.authContext)
r.Use(q.authContext)
r.Route("/torrents", func(r chi.Router) {
r.Use(HashesCtx)
r.Get("/info", s.handleTorrentsInfo)
r.Post("/add", s.handleTorrentsAdd)
r.Post("/delete", s.handleTorrentsDelete)
r.Get("/categories", s.handleCategories)
r.Post("/createCategory", s.handleCreateCategory)
r.Get("/info", q.handleTorrentsInfo)
r.Post("/add", q.handleTorrentsAdd)
r.Post("/delete", q.handleTorrentsDelete)
r.Get("/categories", q.handleCategories)
r.Post("/createCategory", q.handleCreateCategory)
r.Get("/pause", s.handleTorrentsPause)
r.Get("/resume", s.handleTorrentsResume)
r.Get("/recheck", s.handleTorrentRecheck)
r.Get("/properties", s.handleTorrentProperties)
r.Get("/files", s.handleTorrentFiles)
r.Get("/pause", q.handleTorrentsPause)
r.Get("/resume", q.handleTorrentsResume)
r.Get("/recheck", q.handleTorrentRecheck)
r.Get("/properties", q.handleTorrentProperties)
r.Get("/files", q.handleTorrentFiles)
})
r.Route("/app", func(r chi.Router) {
r.Get("/version", s.handleVersion)
r.Get("/webapiVersion", s.handleWebAPIVersion)
r.Get("/preferences", s.handlePreferences)
r.Get("/buildInfo", s.handleBuildInfo)
r.Get("/shutdown", s.shutdown)
r.Get("/version", q.handleVersion)
r.Get("/webapiVersion", q.handleWebAPIVersion)
r.Get("/preferences", q.handlePreferences)
r.Get("/buildInfo", q.handleBuildInfo)
r.Get("/shutdown", q.shutdown)
})
})
})
r.Get("/", s.handleHome)
r.Route("/internal", func(r chi.Router) {
r.Get("/arrs", s.handleGetArrs)
r.Get("/content", s.handleContent)
r.Get("/seasons/{contentId}", s.handleSeasons)
r.Get("/episodes/{contentId}", s.handleEpisodes)
r.Post("/add", s.handleAddContent)
r.Get("/search", s.handleSearch)
r.Get("/cached", s.handleCheckCached)
r.Post("/repair", s.handleRepair)
})
return r
}
func (u *uiHandler) Routes(r chi.Router) http.Handler {
r.Group(func(r chi.Router) {
if u.debug {
r.Use(middleware.Logger)
}
r.Get("/", u.IndexHandler)
r.Get("/download", u.DownloadHandler)
r.Get("/repair", u.RepairHandler)
r.Get("/config", u.ConfigHandler)
r.Route("/internal", func(r chi.Router) {
r.Get("/arrs", u.handleGetArrs)
r.Post("/add", u.handleAddContent)
r.Get("/cached", u.handleCheckCached)
r.Post("/repair", u.handleRepairMedia)
r.Get("/torrents", u.handleGetTorrents)
r.Delete("/torrents/{hash}", u.handleDeleteTorrent)
r.Get("/config", u.handleGetConfig)
r.Get("/version", u.handleGetVersion)
})
})
return r
}

View File

@@ -6,10 +6,10 @@ import (
"fmt"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"goBlack/common"
"goBlack/pkg/arr"
"goBlack/pkg/debrid"
"goBlack/pkg/qbit/shared"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
"log"
"net/http"
"os"
@@ -35,12 +35,14 @@ func NewServer(config *common.Config, deb *debrid.DebridService, arrs *arr.Stora
func (s *Server) Start(ctx context.Context) error {
r := chi.NewRouter()
if s.debug {
r.Use(middleware.Logger)
}
r.Use(middleware.Recoverer)
r.Handle("/static/*", http.StripPrefix("/static/", http.FileServer(http.Dir("static"))))
s.Routes(r)
q := qbitHandler{qbit: s.qbit, logger: s.logger}
ui := uiHandler{qbit: s.qbit, logger: common.NewLogger("UI", os.Stdout)}
// Register routes
q.Routes(r)
ui.Routes(r)
go s.qbit.StartWorker(context.Background())

View File

@@ -1,215 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Debrid Manager</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Bootstrap Icons -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
<!-- Select2 Bootstrap 5 Theme CSS -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css"/>
<style>
.select2-container--bootstrap-5 .select2-results__option {
padding: 0.5rem;
}
.select2-result img {
border-radius: 4px;
}
.select2-container--bootstrap-5 .select2-results__option--highlighted {
background-color: #f8f9fa !important;
color: #000 !important;
}
.select2-container--bootstrap-5 .select2-results__option--selected {
background-color: #e9ecef !important;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<span class="navbar-brand">Debrid Manager</span>
</div>
</nav>
<div class="container mt-4">
<div class="row mb-5">
<div class="col-md-8">
<div class="mb-3">
<label for="magnetURI" class="form-label">Magnet Link</label>
<textarea class="form-control" id="magnetURI" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="selectArr" class="form-label">Enter Category</label>
<input type="email" class="form-control" id="selectArr"
placeholder="Enter Category(e.g sonarr, radarr, radarr4k)">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="isSymlink">
<label class="form-check-label" for="isSymlink">
Not Symlink(Download real files instead of symlinks from Debrid)
</label>
</div>
<div class="mt-3">
<button class="btn btn-primary" id="addToArr">
Add to Arr
</button>
</div>
</div>
</div>
<hr class="mb-4">
<div class="row">
<div class="col-md-8">
<h4>Repair</h4>
<div class="mb-3">
<label for="selectRepairArr" class="form-label">Select ARR</label>
<select class="form-select" id="selectRepairArr">
<option value="">Select ARR</option>
</select>
</div>
<div class="mb-3">
<label for="tvids" class="form-label">TV IDs</label>
<input type="text" class="form-control" id="tvids" placeholder="Enter TV IDs (comma-separated)">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="" id="isAsync" checked>
<label class="form-check-label" for="isAsync">
Repair Asynchronously(run in background)
</label>
</div>
<div class="mt-3">
<button class="btn btn-primary" id="repairBtn">
Repair
</button>
</div>
</div>
</div>
</div>
<!-- Bootstrap JS and Popper.js -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<!-- jQuery -->
<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>
$(document).ready(function () {
let $selectArr = $('#selectArr');
let $selectRepairArr = $('#selectRepairArr');
let $addBtn = $('#addToArr');
let $repairBtn = $('#repairBtn');
// Initialize Select2
$('.select2-multi').select2({
theme: 'bootstrap-5',
width: '100%',
placeholder: 'Select options',
allowClear: true
});
function fetchArrs() {
fetch('/internal/arrs')
.then(response => response.json())
.then(arrs => {
// Populate both selects
$selectArr.empty().append('<option value="">Select Arr</option>');
$selectRepairArr.empty().append('<option value="">Select Arr</option>');
arrs.forEach(arr => {
$selectArr.append(`<option value="${arr.name}">${arr.name}</option>`);
$selectRepairArr.append(`<option value="${arr.name}">${arr.name}</option>`);
});
})
.catch(error => console.error('Error fetching arrs:', error));
}
$addBtn.click(function () {
let oldText = $(this).text();
$(this).prop('disabled', true).prepend('<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>');
let magnet = $('#magnetURI').val();
if (!magnet) {
$(this).prop('disabled', false).text(oldText);
alert('Please provide a magnet link or upload a torrent file!');
return;
}
let data = {
arr: $selectArr.val(),
url: magnet,
notSymlink: $('#isSymlink').is(':checked'),
};
console.log('Adding to Arr:', data);
fetch('/internal/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(async response => {
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}
return response.json();
})
.then(result => {
console.log('Added to Arr:', result);
$(this).prop('disabled', false).text(oldText);
alert('Added to Arr successfully!');
})
.catch(error => {
$(this).prop('disabled', false).text(oldText);
alert(`Error adding to Arr: ${error.message || error}`);
});
});
fetchArrs();
$repairBtn.click(function () {
let oldText = $(this).text();
$(this).prop('disabled', true)
.prepend('<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>');
let selectedArr = $selectRepairArr.val();
let tvids = $('#tvids').val();
let data = {
arr: selectedArr,
tvids: tvids,
async: $('#isAsync').is(':checked'),
};
fetch('/internal/repair', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
})
.then(async response => {
if (!response.ok) {
const errorText = await response.text();
throw new Error(errorText);
}
return response.json();
})
.then(result => {
$(this).prop('disabled', false).text(oldText);
alert(result);
})
.catch(error => {
$(this).prop('disabled', false).text(oldText);
alert(`Error repairing: ${error.message || error}`);
});
});
});
</script>
</body>
</html>

View File

@@ -0,0 +1,282 @@
{{ define "config" }}
<div class="container mt-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0"><i class="bi bi-gear me-2"></i>Configuration</h4>
</div>
<div class="card-body">
<form id="configForm">
<!-- Debrid Configuration -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">Debrid Configuration</h5>
<div id="debridConfigs"></div>
</div>
<!-- QBitTorrent Configuration -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">QBitTorrent Configuration</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Username</label>
<input type="text" disabled class="form-control" name="qbit.username">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Password</label>
<input type="password" disabled class="form-control" name="qbit.password">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Port</label>
<input type="text" disabled class="form-control" name="qbit.port">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Symlink/Download Folder</label>
<input type="text" disabled class="form-control" name="qbit.download_folder">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Refresh Interval (seconds)</label>
<input type="number" class="form-control" name="qbit.refresh_interval">
</div>
<div class="col-12 mb-3">
<div class="form-check">
<input type="checkbox" disabled class="form-check-input" name="qbit.debug" id="qbitDebug">
<label class="form-check-label" for="qbitDebug">Enable Debug Mode</label>
</div>
</div>
</div>
</div>
<!-- Arr Configurations -->
<div class="section mb-5">
<h5 class="border-bottom pb-2">Arr Configurations</h5>
<div id="arrConfigs"></div>
</div>
<!-- Repair Configuration -->
<div class="section">
<h5 class="border-bottom pb-2">Repair Configuration</h5>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Interval</label>
<input type="text" disabled class="form-control" name="repair.interval" placeholder="e.g., 24h">
</div>
<div class="col-12">
<div class="form-check mb-2">
<input type="checkbox" disabled class="form-check-input" name="repair.enabled" id="repairEnabled">
<label class="form-check-label" for="repairEnabled">Enable Repair</label>
</div>
<div class="form-check">
<input type="checkbox" disabled class="form-check-input" name="repair.run_on_start" id="repairOnStart">
<label class="form-check-label" for="repairOnStart">Run on Start</label>
</div>
</div>
</div>
</div>
</form>
</div>
</div>
</div>
<script>
// Templates for dynamic elements
const debridTemplate = (index) => `
<div class="config-item position-relative mb-3 p-3 border rounded">
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">Name</label>
<input type="text" disabled class="form-control" name="debrid[${index}].name" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Host</label>
<input type="text" disabled class="form-control" name="debrid[${index}].host" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">API Key</label>
<input type="password" disabled class="form-control" name="debrid[${index}].api_key" required>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Mount Folder</label>
<input type="text" disabled class="form-control" name="debrid[${index}].folder">
</div>
<div class="col-md-6 mb-3">
<label class="form-label">Rate Limit</label>
<input type="text" disabled class="form-control" name="debrid[${index}].rate_limit" placeholder="e.g., 200/minute">
</div>
<div class="col-12">
<div class="form-check me-3 d-inline-block">
<input type="checkbox" disabled class="form-check-input" name="debrid[${index}].download_uncached">
<label class="form-check-label">Download Uncached</label>
</div>
<div class="form-check d-inline-block">
<input type="checkbox" disabled class="form-check-input" name="debrid[${index}].check_cached">
<label class="form-check-label">Check Cached</label>
</div>
</div>
</div>
</div>
`;
const arrTemplate = (index) => `
<div class="config-item position-relative mb-3 p-3 border rounded">
<div class="row">
<div class="col-md-4 mb-3">
<label class="form-label">Name</label>
<input type="text" disabled class="form-control" name="arr[${index}].name" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">Host</label>
<input type="text" disabled class="form-control" name="arr[${index}].host" required>
</div>
<div class="col-md-4 mb-3">
<label class="form-label">API Token</label>
<input type="password" disabled class="form-control" name="arr[${index}].token" required>
</div>
</div>
</div>
`;
// Main functionality
document.addEventListener('DOMContentLoaded', function() {
let debridCount = 0;
let arrCount = 0;
// Load existing configuration
fetch('/internal/config')
.then(response => response.json())
.then(config => {
console.log(config)
// Load Debrid configs
config.debrids?.forEach(debrid => {
addDebridConfig(debrid);
});
// Load QBitTorrent config
if (config.qbittorrent) {
Object.entries(config.qbittorrent).forEach(([key, value]) => {
const input = document.querySelector(`[name="qbit.${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
});
}
// Load Arr configs
config.arrs?.forEach(arr => {
addArrConfig(arr);
});
// Load Repair config
if (config.repair) {
Object.entries(config.repair).forEach(([key, value]) => {
const input = document.querySelector(`[name="repair.${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
});
}
});
// Handle form submission
document.getElementById('configForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
const config = {
debrids: [],
qbittorrent: {},
arrs: [],
repair: {}
};
// Process form data
for (let [key, value] of formData.entries()) {
if (key.startsWith('debrid[')) {
const match = key.match(/debrid\[(\d+)\]\.(.+)/);
if (match) {
const [_, index, field] = match;
if (!config.debrids[index]) config.debrids[index] = {};
config.debrids[index][field] = value;
}
} else if (key.startsWith('qbit.')) {
config.qbittorrent[key.replace('qbit.', '')] = value;
} else if (key.startsWith('arr[')) {
const match = key.match(/arr\[(\d+)\]\.(.+)/);
if (match) {
const [_, index, field] = match;
if (!config.arrs[index]) config.arrs[index] = {};
config.arrs[index][field] = value;
}
} else if (key.startsWith('repair.')) {
config.repair[key.replace('repair.', '')] = value;
}
}
// Clean up arrays (remove empty entries)
config.debrids = config.debrids.filter(Boolean);
config.arrs = config.arrs.filter(Boolean);
try {
const response = await fetch('/internal/config', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(config)
});
if (!response.ok) throw new Error(await response.text());
alert('Configuration saved successfully!');
} catch (error) {
alert(`Error saving configuration: ${error.message}`);
}
});
// Helper functions
function addDebridConfig(data = {}) {
const container = document.getElementById('debridConfigs');
container.insertAdjacentHTML('beforeend', debridTemplate(debridCount));
if (data) {
Object.entries(data).forEach(([key, value]) => {
const input = container.querySelector(`[name="debrid[${debridCount}].${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
});
}
debridCount++;
}
function addArrConfig(data = {}) {
const container = document.getElementById('arrConfigs');
container.insertAdjacentHTML('beforeend', arrTemplate(arrCount));
if (data) {
Object.entries(data).forEach(([key, value]) => {
const input = container.querySelector(`[name="arr[${arrCount}].${key}"]`);
if (input) {
if (input.type === 'checkbox') {
input.checked = value;
} else {
input.value = value;
}
}
});
}
arrCount++;
}
});
</script>
{{ end }}

View File

@@ -0,0 +1,74 @@
{{ define "download" }}
<div class="container mt-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0"><i class="bi bi-cloud-download me-2"></i>Add New Download</h4>
</div>
<div class="card-body">
<form id="downloadForm">
<div class="mb-3">
<label for="magnetURI" class="form-label">Magnet Link or Torrent URL</label>
<textarea class="form-control" id="magnetURI" rows="3" placeholder="Paste your magnet link here..."></textarea>
</div>
<div class="mb-3">
<label for="category" class="form-label">Enter Category</label>
<input type="text" class="form-control" id="category" placeholder="Enter Category(e.g sonarr, radarr, radarr4k)">
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isSymlink">
<label class="form-check-label" for="isSymlink">
Download real files instead of symlinks
</label>
</div>
</div>
<button type="submit" class="btn btn-primary" id="submitDownload">
<i class="bi bi-cloud-upload me-2"></i>Add to Download Queue
</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Handle form submission
document.getElementById('downloadForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submitDownload');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding...';
try {
const response = await fetch('/internal/add', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url: document.getElementById('magnetURI').value,
arr: document.getElementById('category').value,
notSymlink: document.getElementById('isSymlink').checked
})
});
if (!response.ok) throw new Error(await response.text());
alert('Download added successfully!');
document.getElementById('magnetURI').value = '';
} catch (error) {
alert(`Error adding download: ${error.message}`);
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
});
</script>
{{ end }}

View File

@@ -0,0 +1,143 @@
{{ define "index" }}
<div class="container mt-4">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-table me-2"></i>Active Torrents</h4>
<div>
<button class="btn btn-outline-secondary btn-sm me-2" id="refreshBtn">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
<select class="form-select form-select-sm d-inline-block w-auto" id="categoryFilter">
<option value="">All Categories</option>
</select>
</div>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th>Name</th>
<th>Size</th>
<th>Progress</th>
<th>Speed</th>
<th>Category</th>
<th>State</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="torrentsList">
<!-- Will be populated by JavaScript -->
</tbody>
</table>
</div>
</div>
</div>
</div>
<script>
const torrentRowTemplate = (torrent) => `
<tr>
<td class="text-break">${torrent.name}</td>
<td>${formatBytes(torrent.size)}</td>
<td style="min-width: 150px;">
<div class="progress" style="height: 8px;">
<div class="progress-bar" role="progressbar"
style="width: ${(torrent.progress * 100).toFixed(1)}%"
aria-valuenow="${(torrent.progress * 100).toFixed(1)}"
aria-valuemin="0"
aria-valuemax="100"></div>
</div>
<small class="text-muted">${(torrent.progress * 100).toFixed(1)}%</small>
</td>
<td>${formatSpeed(torrent.dlspeed)}</td>
<td><span class="badge bg-secondary">${torrent.category || 'None'}</span></td>
<td><span class="badge ${getStateColor(torrent.state)}">${torrent.state}</span></td>
<td>
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}')">
<i class="bi bi-trash"></i>
</button>
</td>
</tr>
`;
function formatBytes(bytes) {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
function formatSpeed(speed) {
return `${formatBytes(speed)}/s`;
}
function getStateColor(state) {
const stateColors = {
'downloading': 'bg-primary',
'pausedUP': 'bg-success',
'error': 'bg-danger',
};
return stateColors[state?.toLowerCase()] || 'bg-secondary';
}
let refreshInterval;
async function loadTorrents() {
try {
const response = await fetch('/internal/torrents');
const torrents = await response.json();
const tbody = document.getElementById('torrentsList');
tbody.innerHTML = torrents.map(torrent => torrentRowTemplate(torrent)).join('');
// Update category filter options
updateCategoryFilter(torrents);
} catch (error) {
console.error('Error loading torrents:', error);
}
}
function updateCategoryFilter(torrents) {
const categories = [...new Set(torrents.map(t => t.category).filter(Boolean))];
const select = document.getElementById('categoryFilter');
const currentValue = select.value;
select.innerHTML = '<option value="">All Categories</option>' +
categories.map(cat => `<option value="${cat}" ${cat === currentValue ? 'selected' : ''}>${cat}</option>`).join('');
}
async function deleteTorrent(hash) {
if (!confirm('Are you sure you want to delete this torrent?')) return;
try {
await fetch(`/internal/torrents/${hash}`, {
method: 'DELETE'
});
await loadTorrents();
} catch (error) {
console.error('Error deleting torrent:', error);
alert('Failed to delete torrent');
}
}
document.addEventListener('DOMContentLoaded', () => {
loadTorrents();
refreshInterval = setInterval(loadTorrents, 5000); // Refresh every 5 seconds
document.getElementById('refreshBtn').addEventListener('click', loadTorrents);
document.getElementById('categoryFilter').addEventListener('change', (e) => {
const category = e.target.value;
document.querySelectorAll('#torrentsList tr').forEach(row => {
const rowCategory = row.querySelector('td:nth-child(5)').textContent;
row.style.display = (!category || rowCategory.includes(category)) ? '' : 'none';
});
});
});
window.addEventListener('beforeunload', () => {
clearInterval(refreshInterval);
});
</script>
{{ end }}

View File

@@ -0,0 +1,136 @@
{{ define "layout" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DebridArr - {{.Title}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css"/>
<style>
:root {
--primary-color: #2563eb;
--secondary-color: #1e40af;
}
body {
background-color: #f8fafc;
}
.navbar {
padding: 1rem 0;
background: #fff !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
color: var(--primary-color) !important;
font-weight: 700;
font-size: 1.5rem;
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.nav-link {
padding: 0.5rem 1rem;
color: #4b5563;
}
.nav-link.active {
color: var(--primary-color) !important;
font-weight: 500;
}
.badge#channel-badge {
background-color: #0d6efd;
}
.badge#channel-badge.beta {
background-color: #fd7e14;
}
</style>
</head>
<body>
<nav class="navbar navbar-expand-lg navbar-light mb-4">
<div class="container">
<a class="navbar-brand" href="/">
<i class="bi bi-cloud-download me-2"></i>DebridArr
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{if eq .Page "index"}}active{{end}}" href="/">
<i class="bi bi-table me-1"></i>Torrents
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "download"}}active{{end}}" href="/download">
<i class="bi bi-cloud-download me-1"></i>Download
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "repair"}}active{{end}}" href="/repair">
<i class="bi bi-tools me-1"></i>Repair
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="/config">
<i class="bi bi-gear me-1"></i>Config
</a>
</li>
</ul>
<div class="d-flex align-items-center">
<span class="badge me-2" id="channel-badge">Loading...</span>
<span class="badge bg-primary" id="version-badge">Loading...</span>
</div>
</div>
</div>
</nav>
{{ if eq .Page "index" }}
{{ template "index" . }}
{{ else if eq .Page "download" }}
{{ template "download" . }}
{{ else if eq .Page "repair" }}
{{ template "repair" . }}
{{ else if eq .Page "config" }}
{{ template "config" . }}
{{ end }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
fetch('/internal/version')
.then(response => response.json())
.then(data => {
const versionBadge = document.getElementById('version-badge');
const channelBadge = document.getElementById('channel-badge');
versionBadge.textContent = data.version;
channelBadge.textContent = data.channel;
if (data.channel === 'beta') {
channelBadge.classList.add('beta');
}
})
.catch(error => {
console.error('Error fetching version:', error);
document.getElementById('version-badge').textContent = 'Unknown';
document.getElementById('channel-badge').textContent = 'Unknown';
});
});
</script>
</body>
</html>
{{ end }}

View File

@@ -0,0 +1,90 @@
{{ define "repair" }}
<div class="container mt-4">
<div class="card">
<div class="card-header">
<h4 class="mb-0"><i class="bi bi-tools me-2"></i>Repair Media</h4>
</div>
<div class="card-body">
<form id="repairForm">
<div class="mb-3">
<label for="arrSelect" class="form-label">Select Arr Instance</label>
<select class="form-select" id="arrSelect" required>
<option value="">Select an Arr instance</option>
</select>
</div>
<div class="mb-3">
<label for="mediaIds" class="form-label">Media IDs</label>
<input type="text" class="form-control" id="mediaIds"
placeholder="Enter IDs (comma-separated)">
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isAsync" checked>
<label class="form-check-label" for="isAsync">
Run repair in background
</label>
</div>
</div>
<button type="submit" class="btn btn-primary" id="submitRepair">
<i class="bi bi-wrench me-2"></i>Start Repair
</button>
</form>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Load Arr instances
fetch('/internal/arrs')
.then(response => response.json())
.then(arrs => {
const select = document.getElementById('arrSelect');
arrs.forEach(arr => {
const option = document.createElement('option');
option.value = arr.name;
option.textContent = arr.name;
select.appendChild(option);
});
});
// Handle form submission
document.getElementById('repairForm').addEventListener('submit', async (e) => {
e.preventDefault();
const submitBtn = document.getElementById('submitRepair');
const originalText = submitBtn.innerHTML;
submitBtn.disabled = true;
submitBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Repairing...';
try {
const response = await fetch('/internal/repair', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
arr: document.getElementById('arrSelect').value,
mediaIds: document.getElementById('mediaIds').value.split(',').map(id => id.trim()),
async: document.getElementById('isAsync').checked
})
});
if (!response.ok) throw new Error(await response.text());
const result = await response.json();
alert('Repair process initiated successfully!');
document.getElementById('mediaIds').value = '';
} catch (error) {
alert(`Error starting repair: ${error.message}`);
} finally {
submitBtn.disabled = false;
submitBtn.innerHTML = originalText;
}
});
});
</script>
{{ end }}

View File

@@ -1,184 +0,0 @@
package server
import (
"context"
"goBlack/common"
"goBlack/pkg/qbit/shared"
"net/http"
"path/filepath"
"strings"
)
func (s *Server) handleTorrentsInfo(w http.ResponseWriter, r *http.Request) {
//log all url params
ctx := r.Context()
category := ctx.Value("category").(string)
filter := strings.Trim(r.URL.Query().Get("filter"), "")
hashes, _ := ctx.Value("hashes").([]string)
torrents := s.qbit.Storage.GetAll(category, filter, hashes)
common.JSONResponse(w, torrents, http.StatusOK)
}
func (s *Server) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0]
switch contentType {
case "multipart/form-data":
err := r.ParseMultipartForm(32 << 20) // 32MB max memory
if err != nil {
s.logger.Printf("Error parsing form: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
case "application/x-www-form-urlencoded":
err := r.ParseForm()
if err != nil {
s.logger.Printf("Error parsing form: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
}
isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
s.logger.Printf("isSymlink: %v\n", isSymlink)
urls := r.FormValue("urls")
category := r.FormValue("category")
atleastOne := false
var urlList []string
if urls != "" {
urlList = strings.Split(urls, "\n")
}
ctx = context.WithValue(ctx, "isSymlink", isSymlink)
for _, url := range urlList {
if err := s.qbit.AddMagnet(ctx, url, category); err != nil {
s.logger.Printf("Error adding magnet: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
atleastOne = true
}
if contentType == "multipart/form-data" && len(r.MultipartForm.File["torrents"]) > 0 {
files := r.MultipartForm.File["torrents"]
for _, fileHeader := range files {
if err := s.qbit.AddTorrent(ctx, fileHeader, category); err != nil {
s.logger.Printf("Error adding torrent: %v\n", err)
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
atleastOne = true
}
}
if !atleastOne {
http.Error(w, "No valid URLs or torrents provided", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleTorrentsDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
if len(hashes) == 0 {
http.Error(w, "No hashes provided", http.StatusBadRequest)
return
}
for _, hash := range hashes {
s.qbit.Storage.Delete(hash)
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleTorrentsPause(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
for _, hash := range hashes {
torrent := s.qbit.Storage.Get(hash)
if torrent == nil {
continue
}
go s.qbit.PauseTorrent(torrent)
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleTorrentsResume(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
for _, hash := range hashes {
torrent := s.qbit.Storage.Get(hash)
if torrent == nil {
continue
}
go s.qbit.ResumeTorrent(torrent)
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleTorrentRecheck(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
hashes, _ := ctx.Value("hashes").([]string)
for _, hash := range hashes {
torrent := s.qbit.Storage.Get(hash)
if torrent == nil {
continue
}
go s.qbit.RefreshTorrent(torrent)
}
w.WriteHeader(http.StatusOK)
}
func (s *Server) handleCategories(w http.ResponseWriter, r *http.Request) {
var categories = map[string]shared.TorrentCategory{}
for _, cat := range s.qbit.Categories {
path := filepath.Join(s.qbit.DownloadFolder, cat)
categories[cat] = shared.TorrentCategory{
Name: cat,
SavePath: path,
}
}
common.JSONResponse(w, categories, http.StatusOK)
}
func (s *Server) handleCreateCategory(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil {
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
return
}
name := r.Form.Get("category")
if name == "" {
http.Error(w, "No name provided", http.StatusBadRequest)
return
}
s.qbit.Categories = append(s.qbit.Categories, name)
common.JSONResponse(w, nil, http.StatusOK)
}
func (s *Server) handleTorrentProperties(w http.ResponseWriter, r *http.Request) {
hash := r.URL.Query().Get("hash")
torrent := s.qbit.Storage.Get(hash)
properties := s.qbit.GetTorrentProperties(torrent)
common.JSONResponse(w, properties, http.StatusOK)
}
func (s *Server) handleTorrentFiles(w http.ResponseWriter, r *http.Request) {
hash := r.URL.Query().Get("hash")
torrent := s.qbit.Storage.Get(hash)
if torrent == nil {
return
}
files := s.qbit.GetTorrentFiles(torrent)
common.JSONResponse(w, files, http.StatusOK)
}

View File

@@ -5,10 +5,14 @@ import (
"encoding/json"
"errors"
"fmt"
"goBlack/common"
"goBlack/pkg/arr"
"goBlack/pkg/debrid"
"github.com/go-chi/chi/v5"
"github.com/sirrobot01/debrid-blackhole/common"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid"
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
"github.com/sirrobot01/debrid-blackhole/pkg/version"
"html/template"
"log"
"net/http"
"strings"
)
@@ -41,81 +45,89 @@ type RepairRequest struct {
Async bool `json:"async"`
}
//go:embed static/index.html
//go:embed templates/*
var content embed.FS
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
tmpl, err := template.ParseFS(content, "static/index.html")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
type uiHandler struct {
qbit *shared.QBit
logger *log.Logger
debug bool
}
err = tmpl.Execute(w, nil)
if err != nil {
var templates *template.Template
func init() {
currentDir := "pkg/qbit/server"
templates = template.Must(template.ParseFiles(
currentDir+"/templates/layout.html",
currentDir+"/templates/index.html",
currentDir+"/templates/download.html",
currentDir+"/templates/repair.html",
currentDir+"/templates/config.html",
))
}
func (u *uiHandler) IndexHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Page": "index",
"Title": "Torrents",
}
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (s *Server) handleGetArrs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, s.qbit.Arrs.GetAll(), http.StatusOK)
}
func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) {
arrName := r.URL.Query().Get("arr")
_arr := s.qbit.Arrs.Get(arrName)
if _arr == nil {
http.Error(w, "Invalid arr", http.StatusBadRequest)
func (u *uiHandler) DownloadHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Page": "download",
"Title": "Download",
}
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
contents, _ := _arr.GetMedia("")
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, contents, http.StatusOK)
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
// arrName := r.URL.Query().Get("arr")
term := r.URL.Query().Get("term")
results, err := arr.SearchTMDB(term)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
func (u *uiHandler) RepairHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Page": "repair",
"Title": "Repair",
}
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, results.Results, http.StatusOK)
}
func (s *Server) handleSeasons(w http.ResponseWriter, r *http.Request) {
// arrId := r.URL.Query().Get("arrId")
// contentId := chi.URLParam(r, "contentId")
seasons := []string{"Season 1", "Season 2", "Season 3", "Season 4", "Season 5"}
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, seasons, http.StatusOK)
func (u *uiHandler) ConfigHandler(w http.ResponseWriter, r *http.Request) {
data := map[string]interface{}{
"Page": "config",
"Title": "Config",
}
if err := templates.ExecuteTemplate(w, "layout", data); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (s *Server) handleEpisodes(w http.ResponseWriter, r *http.Request) {
// arrId := r.URL.Query().Get("arrId")
// contentId := chi.URLParam(r, "contentId")
// seasonIds := strings.Split(r.URL.Query().Get("seasons"), ",")
episodes := []string{"Episode 1", "Episode 2", "Episode 3", "Episode 4", "Episode 5"}
func (u *uiHandler) handleGetArrs(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, episodes, http.StatusOK)
common.JSONResponse(w, u.qbit.Arrs.GetAll(), http.StatusOK)
}
func (s *Server) handleAddContent(w http.ResponseWriter, r *http.Request) {
func (u *uiHandler) handleAddContent(w http.ResponseWriter, r *http.Request) {
var req AddRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
_arr := s.qbit.Arrs.Get(req.Arr)
_arr := u.qbit.Arrs.Get(req.Arr)
if _arr == nil {
_arr = arr.NewArr(req.Arr, "", "", arr.Sonarr)
}
importReq := NewImportRequest(req.Url, _arr, !req.NotSymlink)
err := importReq.Process(s)
err := importReq.Process(u.qbit)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -123,7 +135,7 @@ func (s *Server) handleAddContent(w http.ResponseWriter, r *http.Request) {
common.JSONResponse(w, importReq, http.StatusOK)
}
func (s *Server) handleCheckCached(w http.ResponseWriter, r *http.Request) {
func (u *uiHandler) handleCheckCached(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_hashes := r.URL.Query().Get("hash")
if _hashes == "" {
@@ -139,9 +151,9 @@ func (s *Server) handleCheckCached(w http.ResponseWriter, r *http.Request) {
var deb debrid.Service
if db == "" {
// use the first debrid
deb = s.qbit.Debrid.Get()
deb = u.qbit.Debrid.Get()
} else {
deb = s.qbit.Debrid.GetByName(db)
deb = u.qbit.Debrid.GetByName(db)
}
if deb == nil {
http.Error(w, "Invalid debrid", http.StatusBadRequest)
@@ -156,7 +168,7 @@ func (s *Server) handleCheckCached(w http.ResponseWriter, r *http.Request) {
common.JSONResponse(w, result, http.StatusOK)
}
func (s *Server) handleRepair(w http.ResponseWriter, r *http.Request) {
func (u *uiHandler) 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)
@@ -167,12 +179,12 @@ func (s *Server) handleRepair(w http.ResponseWriter, r *http.Request) {
tvids = strings.Split(req.TVIds, ",")
}
_arr := s.qbit.Arrs.Get(req.ArrName)
_arr := u.qbit.Arrs.Get(req.ArrName)
arrs := make([]*arr.Arr, 0)
if _arr != nil {
arrs = append(arrs, _arr)
} else {
arrs = s.qbit.Arrs.GetAll()
arrs = u.qbit.Arrs.GetAll()
}
if len(arrs) == 0 {
@@ -183,7 +195,12 @@ func (s *Server) handleRepair(w http.ResponseWriter, r *http.Request) {
if req.Async {
for _, a := range arrs {
for _, tvId := range tvids {
go a.Repair(tvId)
go func() {
err := a.Repair(tvId)
if err != nil {
u.logger.Printf("Failed to repair: %v", err)
}
}()
}
}
common.JSONResponse(w, "Repair process started", http.StatusOK)
@@ -207,3 +224,29 @@ func (s *Server) handleRepair(w http.ResponseWriter, r *http.Request) {
common.JSONResponse(w, "Repair completed", http.StatusOK)
}
func (u *uiHandler) handleGetVersion(w http.ResponseWriter, r *http.Request) {
v := version.GetInfo()
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, v, http.StatusOK)
}
func (u *uiHandler) handleGetTorrents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, u.qbit.Storage.GetAll("", "", nil), http.StatusOK)
}
func (u *uiHandler) handleDeleteTorrent(w http.ResponseWriter, r *http.Request) {
hash := chi.URLParam(r, "hash")
if hash == "" {
http.Error(w, "No hash provided", http.StatusBadRequest)
return
}
u.qbit.Storage.Delete(hash)
w.WriteHeader(http.StatusOK)
}
func (u *uiHandler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, common.CONFIG, http.StatusOK)
}