Changelog 0.3.0

This commit is contained in:
Mukhtar Akere
2024-11-30 15:46:58 +01:00
parent df2aa4e361
commit a51364d150
53 changed files with 2019 additions and 679 deletions

View File

@@ -0,0 +1,42 @@
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

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

172
pkg/qbit/server/import.go Normal file
View File

@@ -0,0 +1,172 @@
package server
import (
"encoding/json"
"errors"
"fmt"
"github.com/google/uuid"
"goBlack/common"
"goBlack/pkg/arr"
"goBlack/pkg/debrid"
"sync"
"time"
)
type ImportRequest struct {
ID string `json:"id"`
Path string `json:"path"`
URI string `json:"uri"`
Arr *arr.Arr `json:"arr"`
IsSymlink bool `json:"isSymlink"`
SeriesId int `json:"series"`
Seasons []int `json:"seasons"`
Episodes []string `json:"episodes"`
Failed bool `json:"failed"`
FailedAt time.Time `json:"failedAt"`
Reason string `json:"reason"`
Completed bool `json:"completed"`
CompletedAt time.Time `json:"completedAt"`
Async bool `json:"async"`
}
type ManualImportResponseSchema struct {
Priority string `json:"priority"`
Status string `json:"status"`
Result string `json:"result"`
Queued time.Time `json:"queued"`
Trigger string `json:"trigger"`
SendUpdatesToClient bool `json:"sendUpdatesToClient"`
UpdateScheduledTask bool `json:"updateScheduledTask"`
Id int `json:"id"`
}
func NewImportRequest(uri string, arr *arr.Arr, isSymlink bool) *ImportRequest {
return &ImportRequest{
ID: uuid.NewString(),
URI: uri,
Arr: arr,
Failed: false,
Completed: false,
Async: false,
IsSymlink: isSymlink,
}
}
func (i *ImportRequest) Fail(reason string) {
i.Failed = true
i.FailedAt = time.Now()
i.Reason = reason
}
func (i *ImportRequest) Complete() {
i.Completed = true
i.CompletedAt = time.Now()
}
func (i *ImportRequest) Process(s *Server) (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)
debridTorrent, err := debrid.ProcessTorrent(q.Debrid, magnet, i.Arr, i.IsSymlink)
if err != nil || debridTorrent == nil {
if err == nil {
err = fmt.Errorf("failed to process torrent")
}
return err
}
torrent = q.UpdateTorrentMin(torrent, debridTorrent)
q.Storage.AddOrUpdate(torrent)
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 err == nil {
err = errors.New("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

@@ -0,0 +1,84 @@
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))
})
}

50
pkg/qbit/server/routes.go Normal file
View File

@@ -0,0 +1,50 @@
package server
import (
"github.com/go-chi/chi/v5"
"net/http"
)
func (s *Server) Routes(r chi.Router) http.Handler {
r.Route("/api/v2", func(r chi.Router) {
r.Use(s.CategoryContext)
r.Post("/auth/login", s.handleLogin)
r.Group(func(r chi.Router) {
r.Use(s.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("/pause", s.handleTorrentsPause)
r.Get("/resume", s.handleTorrentsResume)
r.Get("/recheck", s.handleTorrentRecheck)
r.Get("/properties", s.handleTorrentProperties)
r.Get("/files", s.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("/", 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)
})
return r
}

66
pkg/qbit/server/server.go Normal file
View File

@@ -0,0 +1,66 @@
package server
import (
"context"
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"goBlack/common"
"goBlack/pkg/debrid"
"goBlack/pkg/qbit/shared"
"log"
"net/http"
"os"
"os/signal"
"syscall"
)
type Server struct {
qbit *shared.QBit
logger *log.Logger
debug bool
}
func NewServer(config *common.Config, deb *debrid.DebridService, cache *common.Cache) *Server {
logger := common.NewLogger("QBit", os.Stdout)
q := shared.NewQBit(config, deb, cache, logger)
return &Server{
qbit: q,
logger: logger,
debug: config.QBitTorrent.Debug,
}
}
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)
go s.qbit.StartWorker(context.Background())
s.logger.Printf("Starting QBit server on :%s", s.qbit.Port)
port := fmt.Sprintf(":%s", s.qbit.Port)
srv := &http.Server{
Addr: port,
Handler: r,
}
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
fmt.Printf("Error starting server: %v\n", err)
stop()
}
}()
<-ctx.Done()
fmt.Println("Shutting down gracefully...")
return srv.Shutdown(context.Background())
}

View File

@@ -0,0 +1,334 @@
<!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">
<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 class="col-md-6">-->
<!-- <div class="mb-3 d-none">-->
<!-- <select class="form-select mb-3 select2-ajax" id="selectContent">-->
<!-- <option></option>-->
<!-- </select>-->
<!-- </div>-->
<!-- <div class="mb-3 d-none">-->
<!-- <select class="form-select mb-3 select2-multi" id="selectSeason" multiple-->
<!-- style="width: 100%; display: none;">-->
<!-- <option value="all">Select All</option>-->
<!-- </select>-->
<!-- </div>-->
<!-- <div class="mb-4 d-none">-->
<!-- <select class="form-select mb-3 select2-multi" id="selectEpisode" multiple-->
<!-- style="width: 100%; display: none;">-->
<!-- <option value="all">Select All</option>-->
<!-- </select>-->
<!-- </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 $selectContent = $('#selectContent');
let $selectSeason = $('#selectSeason');
let $selectEpisode = $('#selectEpisode');
let $addBtn = $('#addToArr');
const $contentSearch = $('#contentSearch');
const $searchResults = $('#searchResults');
let isSonarr = true;
let searchTimeout;
let selectedArr, selectedContent, selectedSeasons, selectedEpisodes;
// Initially show only selectArr, hide others
$selectSeason.hide().closest('.mb-3').hide();
$selectEpisode.hide().closest('.mb-3').hide();
// Initialize Select2
$('.select2-multi').select2({
theme: 'bootstrap-5',
width: '100%',
placeholder: 'Select options',
allowClear: true
});
// Also hide the Select2 containers
$('.select2-container--bootstrap-5').hide();
$selectContent.select2({
theme: 'bootstrap-5',
width: '100%',
placeholder: 'Search shows/movies...',
allowClear: true,
minimumInputLength: 2,
ajax: {
url: '/internal/search',
dataType: 'json',
delay: 250,
data: function (params) {
return {
term: params.term
};
},
processResults: function (data) {
return {
results: data.map(function (item) {
return {
id: item.id,
text: item.media_type === 'movie' ? item.title : item.name,
media_type: item.media_type,
poster: item.poster_path ?
'https://image.tmdb.org/t/p/w92' + item.poster_path : null,
year: item.media_type === 'movie' ?
(item.release_date ? item.release_date.substring(0, 4) : '') :
(item.first_air_date ? item.first_air_date.substring(0, 4) : '')
};
})
};
},
cache: true
},
templateResult: formatResult,
templateSelection: formatSelection
});
function formatResult(item) {
if (!item.id) return item.text;
return $(`
<div class="select2-result d-flex align-items-center gap-2">
${item.poster ?
`<img src="${item.poster}" style="width: 45px; height: 68px; object-fit: cover;">` :
'<div style="width: 45px; height: 68px; background: #eee;"></div>'
}
<div>
<div class="fw-bold">${item.text}</div>
<small class="text-muted">
${item.year}${item.media_type === 'movie' ? 'Movie' : 'TV Series'}
</small>
</div>
</div>
`);
}
function formatSelection(item) {
if (!item.id) return item.text;
return item.text + (item.year ? ` (${item.year})` : '');
}
// Handle selection
$selectContent.on('select2:select', function (e) {
selectedContent = e.params.data.id;
const mediaType = e.params.data.media_type;
if (mediaType === 'tv') {
$selectSeason.show().closest('.mb-3').show();
$selectSeason.next('.select2-container--bootstrap-5').show();
// Fetch seasons (your existing seasons fetch code)
fetch(`/internal/seasons/${selectedContent}`)
.then(response => response.json())
.then(seasons => {
$selectSeason.empty().append('<option value="all">Select All</option>');
seasons.forEach(season => {
$selectSeason.append(`<option value="${season}">Season ${season}</option>`);
});
$selectSeason.trigger('change.select2');
})
.catch(error => console.error('Error fetching seasons:', error));
} else {
// For movies, show the Add to Arr button directly
$selectSeason.hide().closest('.mb-3').hide();
$selectSeason.next('.select2-container--bootstrap-5').hide();
$selectEpisode.hide().closest('.mb-3').hide();
$selectEpisode.next('.select2-container--bootstrap-5').hide();
$addBtn.show();
}
});
// Fetch Arrs
function fetchArrs() {
fetch('/internal/arrs')
.then(response => response.json())
.then(arrs => {
$selectArr.empty().append('<option value="">Select Arr</option>');
arrs.forEach(arr => {
$selectArr.append(`<option value="${arr.name}">${arr.name}</option>`);
});
})
.catch(error => console.error('Error fetching arrs:', error));
}
// Handle content selection
$selectContent.change(function () {
selectedContent = $(this).val();
selectedArr = $selectArr.val();
if (!selectedContent) {
$selectSeason.hide().closest('.mb-3').hide();
$selectSeason.next('.select2-container--bootstrap-5').hide();
$selectEpisode.hide().closest('.mb-3').hide();
$selectEpisode.next('.select2-container--bootstrap-5').hide();
return;
}
if (isSonarr) {
$selectSeason.show().closest('.mb-3').show();
$selectSeason.next('.select2-container--bootstrap-5').show();
// Fetch seasons
fetch(`/internal/seasons/${selectedContent}`)
.then(response => response.json())
.then(seasons => {
$selectSeason.empty().append('<option value="all">Select All</option>');
seasons.forEach(season => {
$selectSeason.append(`<option value="${season}">Season ${season}</option>`);
});
$selectSeason.trigger('change.select2');
})
.catch(error => console.error('Error fetching seasons:', error));
} else {
// For Radarr, show the Add to Arr button directly
$selectSeason.hide().closest('.mb-3').hide();
$selectSeason.next('.select2-container--bootstrap-5').hide();
$selectEpisode.hide().closest('.mb-3').hide();
$selectEpisode.next('.select2-container--bootstrap-5').hide();
$addBtn.show();
}
});
// Handle season selection
$selectSeason.change(function () {
selectedSeasons = $(this).val();
console.log('Selected seasons:', selectedSeasons);
if (!selectedSeasons || selectedSeasons.includes('all')) {
$selectEpisode.hide().closest('.mb-3').hide();
$selectEpisode.next('.select2-container--bootstrap-5').hide();
$addBtn.show();
return;
}
$selectEpisode.show().closest('.mb-3').show();
$selectEpisode.next('.select2-container--bootstrap-5').show();
fetch(`/internal/episodes/${selectedContent}?seasons=${selectedSeasons.join(',')}`)
.then(response => response.json())
.then(episodes => {
$selectEpisode.empty().append('<option value="all">Select All</option>');
episodes.forEach(episode => {
$selectEpisode.append(`<option value="${episode}">Episode ${episode}</option>`);
});
$selectEpisode.trigger('change.select2');
})
.catch(error => console.error('Error fetching episodes:', error));
$addBtn.show();
});
$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}`);
});
});
// Initial fetch of Arrs
//fetchArrs();
});
</script>
</body>
</html>

View File

@@ -0,0 +1,177 @@
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")
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
}
}
if contentType == "multipart/form-data" {
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
}
}
}
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

@@ -0,0 +1,114 @@
package server
import (
"embed"
"encoding/json"
"goBlack/common"
"goBlack/pkg/arr"
"html/template"
"net/http"
)
type AddRequest struct {
Url string `json:"url"`
Arr string `json:"arr"`
File string `json:"file"`
NotSymlink bool `json:"notSymlink"`
Content string `json:"content"`
Seasons []string `json:"seasons"`
Episodes []string `json:"episodes"`
}
type ArrResponse struct {
Name string `json:"name"`
Url string `json:"url"`
}
type ContentResponse struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
ArrID string `json:"arr"`
}
//go:embed static/index.html
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
}
err = tmpl.Execute(w, nil)
if 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)
return
}
contents := _arr.GetContents()
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)
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 (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"}
w.Header().Set("Content-Type", "application/json")
common.JSONResponse(w, episodes, http.StatusOK)
}
func (s *Server) 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)
if _arr == nil {
_arr = arr.NewArr(req.Arr, "", "", arr.Sonarr)
}
importReq := NewImportRequest(req.Url, _arr, !req.NotSymlink)
err := importReq.Process(s)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
common.JSONResponse(w, importReq, http.StatusOK)
}