Changelog 0.3.0
This commit is contained in:
42
pkg/qbit/server/app_handlers.go
Normal file
42
pkg/qbit/server/app_handlers.go
Normal 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)
|
||||
}
|
||||
7
pkg/qbit/server/auth_handlers.go
Normal file
7
pkg/qbit/server/auth_handlers.go
Normal 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
172
pkg/qbit/server/import.go
Normal 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
|
||||
}
|
||||
84
pkg/qbit/server/middleware.go
Normal file
84
pkg/qbit/server/middleware.go
Normal 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
50
pkg/qbit/server/routes.go
Normal 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
66
pkg/qbit/server/server.go
Normal 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())
|
||||
}
|
||||
334
pkg/qbit/server/static/index.html
Normal file
334
pkg/qbit/server/static/index.html
Normal 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>
|
||||
177
pkg/qbit/server/torrent_handlers.go
Normal file
177
pkg/qbit/server/torrent_handlers.go
Normal 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)
|
||||
}
|
||||
114
pkg/qbit/server/ui_handlers.go
Normal file
114
pkg/qbit/server/ui_handlers.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user