Finalize v0.4.0
This commit is contained in:
@@ -272,3 +272,17 @@ func FileReady(path string) bool {
|
|||||||
_, err := os.Stat(path)
|
_, err := os.Stat(path)
|
||||||
return !os.IsNotExist(err) // Returns true if the file exists
|
return !os.IsNotExist(err) // Returns true if the file exists
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Remove[S ~[]E, E comparable](s S, values ...E) S {
|
||||||
|
result := make(S, 0, len(s))
|
||||||
|
outer:
|
||||||
|
for _, item := range s {
|
||||||
|
for _, v := range values {
|
||||||
|
if item == v {
|
||||||
|
continue outer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
@@ -8,6 +9,7 @@ import (
|
|||||||
"github.com/sirrobot01/debrid-blackhole/common"
|
"github.com/sirrobot01/debrid-blackhole/common"
|
||||||
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
|
||||||
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
|
"github.com/sirrobot01/debrid-blackhole/pkg/qbit/shared"
|
||||||
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -49,12 +51,12 @@ func (q *qbitHandler) CategoryContext(next http.Handler) http.Handler {
|
|||||||
category = r.Form.Get("category")
|
category = r.Form.Get("category")
|
||||||
if category == "" {
|
if category == "" {
|
||||||
// Get from multipart form
|
// Get from multipart form
|
||||||
_ = r.ParseMultipartForm(0)
|
_ = r.ParseMultipartForm(32 << 20)
|
||||||
category = r.FormValue("category")
|
category = r.FormValue("category")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
ctx = context.WithValue(r.Context(), "category", category)
|
ctx = context.WithValue(r.Context(), "category", strings.TrimSpace(category))
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -67,8 +69,8 @@ func (q *qbitHandler) authContext(next http.Handler) http.Handler {
|
|||||||
Name: category,
|
Name: category,
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
a.Host = host
|
a.Host = strings.TrimSpace(host)
|
||||||
a.Token = token
|
a.Token = strings.TrimSpace(token)
|
||||||
}
|
}
|
||||||
q.qbit.Arrs.AddOrUpdate(a)
|
q.qbit.Arrs.AddOrUpdate(a)
|
||||||
ctx := context.WithValue(r.Context(), "arr", a)
|
ctx := context.WithValue(r.Context(), "arr", a)
|
||||||
@@ -88,6 +90,9 @@ func HashesCtx(next http.Handler) http.Handler {
|
|||||||
_ = r.ParseForm()
|
_ = r.ParseForm()
|
||||||
hashes = r.Form["hashes"]
|
hashes = r.Form["hashes"]
|
||||||
}
|
}
|
||||||
|
for i, hash := range hashes {
|
||||||
|
hashes[i] = strings.TrimSpace(hash)
|
||||||
|
}
|
||||||
ctx := context.WithValue(r.Context(), "hashes", hashes)
|
ctx := context.WithValue(r.Context(), "hashes", hashes)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
})
|
})
|
||||||
@@ -143,50 +148,49 @@ func (q *qbitHandler) handleTorrentsInfo(w http.ResponseWriter, r *http.Request)
|
|||||||
|
|
||||||
func (q *qbitHandler) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
func (q *qbitHandler) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
contentType := strings.Split(r.Header.Get("Content-Type"), ";")[0]
|
|
||||||
switch contentType {
|
body, _ := io.ReadAll(r.Body)
|
||||||
case "multipart/form-data":
|
q.logger.Debug().Msgf("Raw request body: %s", string(body))
|
||||||
err := r.ParseMultipartForm(32 << 20) // 32MB max memory
|
r.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||||
if err != nil {
|
|
||||||
q.logger.Info().Msgf("Error parsing form: %v", err)
|
// Parse form based on content type
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
contentType := r.Header.Get("Content-Type")
|
||||||
return
|
if strings.Contains(contentType, "multipart/form-data") {
|
||||||
}
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
||||||
case "application/x-www-form-urlencoded":
|
q.logger.Info().Msgf("Error parsing multipart form: %v", err)
|
||||||
err := r.ParseForm()
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
if err != nil {
|
return
|
||||||
|
}
|
||||||
|
} else if strings.Contains(contentType, "application/x-www-form-urlencoded") {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
q.logger.Info().Msgf("Error parsing form: %v", err)
|
q.logger.Info().Msgf("Error parsing form: %v", err)
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
http.Error(w, "Invalid content type", http.StatusBadRequest)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
q.logger.Debug().Msgf("All form values: %+v", r.Form)
|
||||||
|
q.logger.Debug().Msgf("URLs value: %q", r.FormValue("urls"))
|
||||||
|
q.logger.Debug().Msgf("Content-Type: %s", r.Header.Get("Content-Type"))
|
||||||
|
|
||||||
isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
|
isSymlink := strings.ToLower(r.FormValue("sequentialDownload")) != "true"
|
||||||
q.logger.Info().Msgf("isSymlink: %v", isSymlink)
|
|
||||||
urls := r.FormValue("urls")
|
|
||||||
category := r.FormValue("category")
|
category := r.FormValue("category")
|
||||||
atleastOne := false
|
atleastOne := false
|
||||||
|
|
||||||
var urlList []string
|
// Handle magnet URLs
|
||||||
if urls != "" {
|
if urls := r.FormValue("urls"); urls != "" {
|
||||||
urlList = strings.Split(urls, "\n")
|
var urlList []string
|
||||||
}
|
for _, u := range strings.Split(urls, "\n") {
|
||||||
|
urlList = append(urlList, strings.TrimSpace(u))
|
||||||
ctx = context.WithValue(ctx, "isSymlink", isSymlink)
|
|
||||||
for _, url := range urlList {
|
|
||||||
if err := q.qbit.AddMagnet(ctx, url, category); err != nil {
|
|
||||||
q.logger.Info().Msgf("Error adding magnet: %v", err)
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
atleastOne = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if contentType == "multipart/form-data" && len(r.MultipartForm.File["torrents"]) > 0 {
|
ctx = context.WithValue(ctx, "isSymlink", isSymlink)
|
||||||
files := r.MultipartForm.File["torrents"]
|
for _, url := range urlList {
|
||||||
for _, fileHeader := range files {
|
if err := q.qbit.AddMagnet(ctx, url, category); err != nil {
|
||||||
if err := q.qbit.AddTorrent(ctx, fileHeader, category); err != nil {
|
q.logger.Info().Msgf("Error adding magnet: %v", err)
|
||||||
q.logger.Info().Msgf("Error adding torrent: %v", err)
|
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,6 +198,20 @@ func (q *qbitHandler) handleTorrentsAdd(w http.ResponseWriter, r *http.Request)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle torrent files
|
||||||
|
if r.MultipartForm != nil && r.MultipartForm.File != nil {
|
||||||
|
if files := r.MultipartForm.File["torrents"]; len(files) > 0 {
|
||||||
|
for _, fileHeader := range files {
|
||||||
|
if err := q.qbit.AddTorrent(ctx, fileHeader, category); err != nil {
|
||||||
|
q.logger.Info().Msgf("Error adding torrent: %v", err)
|
||||||
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atleastOne = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if !atleastOne {
|
if !atleastOne {
|
||||||
http.Error(w, "No valid URLs or torrents provided", http.StatusBadRequest)
|
http.Error(w, "No valid URLs or torrents provided", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -304,3 +322,72 @@ func (q *qbitHandler) handleTorrentFiles(w http.ResponseWriter, r *http.Request)
|
|||||||
files := q.qbit.GetTorrentFiles(torrent)
|
files := q.qbit.GetTorrentFiles(torrent)
|
||||||
common.JSONResponse(w, files, http.StatusOK)
|
common.JSONResponse(w, files, http.StatusOK)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (q *qbitHandler) handleSetCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
category := ctx.Value("category").(string)
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
torrents := q.qbit.Storage.GetAll("", "", hashes)
|
||||||
|
for _, torrent := range torrents {
|
||||||
|
torrent.Category = category
|
||||||
|
q.qbit.Storage.AddOrUpdate(torrent)
|
||||||
|
}
|
||||||
|
common.JSONResponse(w, nil, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *qbitHandler) handleAddTorrentTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
tags := strings.Split(r.FormValue("tags"), ",")
|
||||||
|
for i, tag := range tags {
|
||||||
|
tags[i] = strings.TrimSpace(tag)
|
||||||
|
}
|
||||||
|
torrents := q.qbit.Storage.GetAll("", "", hashes)
|
||||||
|
for _, t := range torrents {
|
||||||
|
q.qbit.SetTorrentTags(t, tags)
|
||||||
|
}
|
||||||
|
common.JSONResponse(w, nil, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *qbitHandler) handleRemoveTorrentTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx := r.Context()
|
||||||
|
hashes, _ := ctx.Value("hashes").([]string)
|
||||||
|
tags := strings.Split(r.FormValue("tags"), ",")
|
||||||
|
for i, tag := range tags {
|
||||||
|
tags[i] = strings.TrimSpace(tag)
|
||||||
|
}
|
||||||
|
torrents := q.qbit.Storage.GetAll("", "", hashes)
|
||||||
|
for _, torrent := range torrents {
|
||||||
|
q.qbit.RemoveTorrentTags(torrent, tags)
|
||||||
|
|
||||||
|
}
|
||||||
|
common.JSONResponse(w, nil, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *qbitHandler) handleGetTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
common.JSONResponse(w, q.qbit.Tags, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *qbitHandler) handleCreateTags(w http.ResponseWriter, r *http.Request) {
|
||||||
|
err := r.ParseForm()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "Failed to parse form data", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tags := strings.Split(r.FormValue("tags"), ",")
|
||||||
|
for i, tag := range tags {
|
||||||
|
tags[i] = strings.TrimSpace(tag)
|
||||||
|
}
|
||||||
|
q.qbit.AddTags(tags)
|
||||||
|
common.JSONResponse(w, nil, http.StatusOK)
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ func (q *qbitHandler) Routes(r chi.Router) http.Handler {
|
|||||||
r.Post("/delete", q.handleTorrentsDelete)
|
r.Post("/delete", q.handleTorrentsDelete)
|
||||||
r.Get("/categories", q.handleCategories)
|
r.Get("/categories", q.handleCategories)
|
||||||
r.Post("/createCategory", q.handleCreateCategory)
|
r.Post("/createCategory", q.handleCreateCategory)
|
||||||
|
r.Post("/setCategory", q.handleSetCategory)
|
||||||
|
r.Post("/addTags", q.handleAddTorrentTags)
|
||||||
|
r.Post("/removeTags", q.handleRemoveTorrentTags)
|
||||||
|
r.Post("/createTags", q.handleCreateTags)
|
||||||
|
r.Get("/tags", q.handleGetTags)
|
||||||
r.Get("/pause", q.handleTorrentsPause)
|
r.Get("/pause", q.handleTorrentsPause)
|
||||||
r.Get("/resume", q.handleTorrentsResume)
|
r.Get("/resume", q.handleTorrentsResume)
|
||||||
r.Get("/recheck", q.handleTorrentRecheck)
|
r.Get("/recheck", q.handleTorrentRecheck)
|
||||||
@@ -42,24 +46,3 @@ func (q *qbitHandler) Routes(r chi.Router) http.Handler {
|
|||||||
})
|
})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u *uiHandler) Routes(r chi.Router) http.Handler {
|
|
||||||
r.Group(func(r chi.Router) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td>${formatSpeed(torrent.dlspeed)}</td>
|
<td>${formatSpeed(torrent.dlspeed)}</td>
|
||||||
<td><span class="badge bg-secondary">${torrent.category || 'None'}</span></td>
|
<td><span class="badge bg-secondary">${torrent.category || 'None'}</span></td>
|
||||||
<td><span class="badge bg-secondary">${torrent.debrid || 'None'}</span></td>
|
<td>${torrent.debrid || 'None'}</td>
|
||||||
<td><span class="badge ${getStateColor(torrent.state)}">${torrent.state}</span></td>
|
<td><span class="badge ${getStateColor(torrent.state)}">${torrent.state}</span></td>
|
||||||
<td>
|
<td>
|
||||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}')">
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteTorrent('${torrent.hash}')">
|
||||||
@@ -93,6 +93,11 @@
|
|||||||
tbody.innerHTML = torrents.map(torrent => torrentRowTemplate(torrent)).join('');
|
tbody.innerHTML = torrents.map(torrent => torrentRowTemplate(torrent)).join('');
|
||||||
|
|
||||||
// Update category filter options
|
// Update category filter options
|
||||||
|
let category = document.getElementById('categoryFilter').value;
|
||||||
|
document.querySelectorAll('#torrentsList tr').forEach(row => {
|
||||||
|
const rowCategory = row.querySelector('td:nth-child(5)').textContent;
|
||||||
|
row.style.display = (!category || rowCategory.includes(category)) ? '' : 'none';
|
||||||
|
});
|
||||||
updateCategoryFilter(torrents);
|
updateCategoryFilter(torrents);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading torrents:', error);
|
console.error('Error loading torrents:', error);
|
||||||
|
|||||||
27
pkg/qbit/server/ui_routes.go
Normal file
27
pkg/qbit/server/ui_routes.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (u *uiHandler) Routes(r chi.Router) http.Handler {
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ type QBit struct {
|
|||||||
debug bool
|
debug bool
|
||||||
logger zerolog.Logger
|
logger zerolog.Logger
|
||||||
Arrs *arr.Storage
|
Arrs *arr.Storage
|
||||||
|
Tags []string
|
||||||
RefreshInterval int
|
RefreshInterval int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -266,12 +267,55 @@ func (q *QBit) GetTorrentFiles(t *Torrent) []*TorrentFile {
|
|||||||
if t.DebridTorrent == nil {
|
if t.DebridTorrent == nil {
|
||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
for index, file := range t.DebridTorrent.Files {
|
for _, file := range t.DebridTorrent.Files {
|
||||||
files = append(files, &TorrentFile{
|
files = append(files, &TorrentFile{
|
||||||
Index: index,
|
Name: file.Path,
|
||||||
Name: file.Path,
|
Size: file.Size,
|
||||||
Size: file.Size,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return files
|
return files
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (q *QBit) SetTorrentTags(t *Torrent, tags []string) bool {
|
||||||
|
torrentTags := strings.Split(t.Tags, ",")
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(torrentTags, tag) {
|
||||||
|
torrentTags = append(torrentTags, tag)
|
||||||
|
}
|
||||||
|
if !slices.Contains(q.Tags, tag) {
|
||||||
|
q.Tags = append(q.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Tags = strings.Join(torrentTags, ",")
|
||||||
|
q.Storage.Update(t)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) RemoveTorrentTags(t *Torrent, tags []string) bool {
|
||||||
|
torrentTags := strings.Split(t.Tags, ",")
|
||||||
|
newTorrentTags := common.Remove(torrentTags, tags...)
|
||||||
|
q.Tags = common.Remove(q.Tags, tags...)
|
||||||
|
t.Tags = strings.Join(newTorrentTags, ",")
|
||||||
|
q.Storage.Update(t)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) AddTags(tags []string) bool {
|
||||||
|
for _, tag := range tags {
|
||||||
|
if tag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !slices.Contains(q.Tags, tag) {
|
||||||
|
q.Tags = append(q.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (q *QBit) RemoveTags(tags []string) bool {
|
||||||
|
q.Tags = common.Remove(q.Tags, tags...)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user