Add repair worker
This commit is contained in:
@@ -4,9 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"goBlack/common"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -23,7 +21,6 @@ const (
|
||||
|
||||
var (
|
||||
client *common.RLHTTPClient = common.NewRLHTTPClient(nil, nil)
|
||||
logger *log.Logger = common.NewLogger("QBit", os.Stdout)
|
||||
)
|
||||
|
||||
type Arr struct {
|
||||
@@ -87,11 +84,12 @@ func inferType(host, name string) Type {
|
||||
}
|
||||
}
|
||||
|
||||
func NewStorage() *Storage {
|
||||
func NewStorage(cfg []common.ArrConfig) *Storage {
|
||||
arrs := make(map[string]*Arr)
|
||||
//for name, arrCfg := range cfg {
|
||||
// arrs[name] = NewArr(name, arrCfg.Host, arrCfg.Token, inferType(arrCfg.Host, name))
|
||||
//}
|
||||
for _, a := range cfg {
|
||||
name := a.Name
|
||||
arrs[name] = NewArr(name, a.Host, a.Token, inferType(a.Host, name))
|
||||
}
|
||||
return &Storage{
|
||||
Arrs: arrs,
|
||||
}
|
||||
|
||||
@@ -3,27 +3,102 @@ package arr
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ContentRequest struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"name"`
|
||||
Arr string `json:"arr"`
|
||||
func (a *Arr) GetMedia(tvId string) ([]Content, error) {
|
||||
// Get series
|
||||
resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/series?tvdbId=%s", tvId), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
// This is Radarr
|
||||
log.Printf("Radarr detected\n")
|
||||
a.Type = Radarr
|
||||
return GetMovies(a, tvId)
|
||||
}
|
||||
a.Type = Sonarr
|
||||
defer resp.Body.Close()
|
||||
type series struct {
|
||||
Title string `json:"title"`
|
||||
Id int `json:"id"`
|
||||
}
|
||||
var data []series
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Get series files
|
||||
contents := make([]Content, 0)
|
||||
for _, d := range data {
|
||||
resp, err = a.Request(http.MethodGet, fmt.Sprintf("api/v3/episodefile?seriesId=%d", d.Id), nil)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var seriesFiles []seriesFile
|
||||
if err = json.NewDecoder(resp.Body).Decode(&seriesFiles); err != nil {
|
||||
continue
|
||||
}
|
||||
ct := Content{
|
||||
Title: d.Title,
|
||||
Id: d.Id,
|
||||
}
|
||||
files := make([]contentFile, 0)
|
||||
for _, file := range seriesFiles {
|
||||
files = append(files, contentFile{
|
||||
Id: file.Id,
|
||||
Path: file.Path,
|
||||
})
|
||||
}
|
||||
ct.Files = files
|
||||
contents = append(contents, ct)
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func (a *Arr) GetContents() *ContentRequest {
|
||||
resp, err := a.Request(http.MethodGet, "api/v3/series", nil)
|
||||
func GetMovies(a *Arr, tvId string) ([]Content, error) {
|
||||
resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/movie?tmdbId=%s", tvId), nil)
|
||||
if err != nil {
|
||||
return nil
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var data *ContentRequest
|
||||
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
||||
fmt.Printf("Error: %v\n", err)
|
||||
return nil
|
||||
var movies []Movie
|
||||
if err = json.NewDecoder(resp.Body).Decode(&movies); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fmt.Printf("Data: %v\n", data)
|
||||
//data.Arr = a.Name
|
||||
return data
|
||||
contents := make([]Content, 0)
|
||||
for _, movie := range movies {
|
||||
ct := Content{
|
||||
Title: movie.Title,
|
||||
Id: movie.Id,
|
||||
}
|
||||
files := make([]contentFile, 0)
|
||||
files = append(files, contentFile{
|
||||
Id: movie.MovieFile.Id,
|
||||
Path: movie.MovieFile.Path,
|
||||
})
|
||||
ct.Files = files
|
||||
contents = append(contents, ct)
|
||||
}
|
||||
return contents, nil
|
||||
}
|
||||
|
||||
func (a *Arr) DeleteFile(id int) error {
|
||||
switch a.Type {
|
||||
case Sonarr:
|
||||
_, err := a.Request(http.MethodDelete, fmt.Sprintf("api/v3/episodefile/%d", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case Radarr:
|
||||
_, err := a.Request(http.MethodDelete, fmt.Sprintf("api/v3/moviefile/%d", id), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown arr type: %s", a.Type)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
270
pkg/arr/repair.go
Normal file
270
pkg/arr/repair.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package arr
|
||||
|
||||
import (
|
||||
"goBlack/common"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
repairLogger *log.Logger = common.NewLogger("Repair", os.Stdout)
|
||||
)
|
||||
|
||||
func (a *Arr) SearchMissing(id int) {
|
||||
var payload interface{}
|
||||
|
||||
switch a.Type {
|
||||
case Sonarr:
|
||||
payload = struct {
|
||||
Name string `json:"name"`
|
||||
SeriesId int `json:"seriesId"`
|
||||
}{
|
||||
Name: "SeriesSearch",
|
||||
SeriesId: id,
|
||||
}
|
||||
case Radarr:
|
||||
payload = struct {
|
||||
Name string `json:"name"`
|
||||
MovieId int `json:"movieId"`
|
||||
}{
|
||||
Name: "MoviesSearch",
|
||||
MovieId: id,
|
||||
}
|
||||
default:
|
||||
repairLogger.Printf("Unknown arr type: %s\n", a.Type)
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := a.Request(http.MethodPost, "api/v3/command", payload)
|
||||
if err != nil {
|
||||
repairLogger.Printf("Failed to search missing: %v\n", err)
|
||||
return
|
||||
}
|
||||
if statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'; !statusOk {
|
||||
repairLogger.Printf("Failed to search missing: %s\n", resp.Status)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Arr) Repair(tmdbId string) error {
|
||||
|
||||
repairLogger.Printf("Starting repair for %s\n", a.Name)
|
||||
media, err := a.GetMedia(tmdbId)
|
||||
if err != nil {
|
||||
repairLogger.Printf("Failed to get %s media: %v\n", a.Type, err)
|
||||
return err
|
||||
}
|
||||
repairLogger.Printf("Found %d %s media\n", len(media), a.Type)
|
||||
|
||||
brokenMedia := a.processMedia(media)
|
||||
repairLogger.Printf("Found %d %s broken media files\n", len(brokenMedia), a.Type)
|
||||
|
||||
// Automatic search for missing files
|
||||
for _, m := range brokenMedia {
|
||||
a.SearchMissing(m.Id)
|
||||
}
|
||||
repairLogger.Printf("Search missing completed for %s\n", a.Name)
|
||||
repairLogger.Printf("Repair completed for %s\n", a.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Arr) processMedia(media []Content) []Content {
|
||||
if len(media) <= 1 {
|
||||
var brokenMedia []Content
|
||||
for _, m := range media {
|
||||
if a.checkMediaFiles(m) {
|
||||
brokenMedia = append(brokenMedia, m)
|
||||
}
|
||||
}
|
||||
return brokenMedia
|
||||
}
|
||||
|
||||
workerCount := runtime.NumCPU() * 4
|
||||
if len(media) < workerCount {
|
||||
workerCount = len(media)
|
||||
}
|
||||
|
||||
jobs := make(chan Content)
|
||||
results := make(chan Content)
|
||||
var brokenMedia []Content
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < workerCount; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for m := range jobs {
|
||||
if a.checkMediaFilesParallel(m) {
|
||||
results <- m
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
for _, m := range media {
|
||||
jobs <- m
|
||||
}
|
||||
close(jobs)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(results)
|
||||
}()
|
||||
|
||||
for m := range results {
|
||||
brokenMedia = append(brokenMedia, m)
|
||||
}
|
||||
|
||||
return brokenMedia
|
||||
}
|
||||
|
||||
func (a *Arr) checkMediaFilesParallel(m Content) bool {
|
||||
if len(m.Files) <= 1 {
|
||||
return a.checkMediaFiles(m)
|
||||
}
|
||||
|
||||
fileWorkers := runtime.NumCPU() * 2
|
||||
if len(m.Files) < fileWorkers {
|
||||
fileWorkers = len(m.Files)
|
||||
}
|
||||
|
||||
fileJobs := make(chan contentFile)
|
||||
brokenFiles := make(chan bool, len(m.Files))
|
||||
|
||||
var fileWg sync.WaitGroup
|
||||
for i := 0; i < fileWorkers; i++ {
|
||||
fileWg.Add(1)
|
||||
go func() {
|
||||
defer fileWg.Done()
|
||||
for f := range fileJobs {
|
||||
isBroken := false
|
||||
if fileIsSymlinked(f.Path) {
|
||||
if !fileIsCorrectSymlink(f.Path) {
|
||||
isBroken = true
|
||||
if err := a.DeleteFile(f.Id); err != nil {
|
||||
repairLogger.Printf("Failed to delete file: %s %d: %v\n", f.Path, f.Id, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !fileIsReadable(f.Path) {
|
||||
isBroken = true
|
||||
if err := a.DeleteFile(f.Id); err != nil {
|
||||
repairLogger.Printf("Failed to delete file: %s %d: %v\n", f.Path, f.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
brokenFiles <- isBroken
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
go func() {
|
||||
for _, f := range m.Files {
|
||||
fileJobs <- f
|
||||
}
|
||||
close(fileJobs)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
fileWg.Wait()
|
||||
close(brokenFiles)
|
||||
}()
|
||||
|
||||
isBroken := false
|
||||
for broken := range brokenFiles {
|
||||
if broken {
|
||||
isBroken = true
|
||||
}
|
||||
}
|
||||
|
||||
return isBroken
|
||||
}
|
||||
|
||||
func (a *Arr) checkMediaFiles(m Content) bool {
|
||||
isBroken := false
|
||||
for _, f := range m.Files {
|
||||
if fileIsSymlinked(f.Path) {
|
||||
if !fileIsCorrectSymlink(f.Path) {
|
||||
isBroken = true
|
||||
if err := a.DeleteFile(f.Id); err != nil {
|
||||
repairLogger.Printf("Failed to delete file: %s %d: %v\n", f.Path, f.Id, err)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if !fileIsReadable(f.Path) {
|
||||
isBroken = true
|
||||
if err := a.DeleteFile(f.Id); err != nil {
|
||||
repairLogger.Printf("Failed to delete file: %s %d: %v\n", f.Path, f.Id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return isBroken
|
||||
}
|
||||
|
||||
func fileIsSymlinked(file string) bool {
|
||||
info, err := os.Lstat(file)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return info.Mode()&os.ModeSymlink != 0
|
||||
}
|
||||
|
||||
func fileIsCorrectSymlink(file string) bool {
|
||||
target, err := os.Readlink(file)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !filepath.IsAbs(target) {
|
||||
dir := filepath.Dir(file)
|
||||
target = filepath.Join(dir, target)
|
||||
}
|
||||
|
||||
return fileIsReadable(target)
|
||||
}
|
||||
|
||||
func fileIsReadable(filePath string) bool {
|
||||
// First check if file exists and is accessible
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if it's a regular file
|
||||
if !info.Mode().IsRegular() {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to read the first 1024 bytes
|
||||
err = checkFileStart(filePath)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func checkFileStart(filePath string) error {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
buffer := make([]byte, 1024)
|
||||
_, err = io.ReadAtLeast(f, buffer, 1024)
|
||||
if err != nil && err != io.EOF {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
34
pkg/arr/structs.go
Normal file
34
pkg/arr/structs.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package arr
|
||||
|
||||
type Movie struct {
|
||||
Title string `json:"title"`
|
||||
OriginalTitle string `json:"originalTitle"`
|
||||
Path string `json:"path"`
|
||||
MovieFile struct {
|
||||
MovieId int `json:"movieId"`
|
||||
RelativePath string `json:"relativePath"`
|
||||
Path string `json:"path"`
|
||||
Size int `json:"size"`
|
||||
Id int `json:"id"`
|
||||
} `json:"movieFile"`
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
type contentFile struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
Id int `json:"id"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
Title string `json:"title"`
|
||||
Id int `json:"id"`
|
||||
Files []contentFile `json:"files"`
|
||||
}
|
||||
|
||||
type seriesFile struct {
|
||||
SeriesId int `json:"seriesId"`
|
||||
SeasonNumber int `json:"seasonNumber"`
|
||||
Path string `json:"path"`
|
||||
Id int `json:"id"`
|
||||
}
|
||||
5
pkg/arr/utils.go
Normal file
5
pkg/arr/utils.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package arr
|
||||
|
||||
func Readfile(path string) error {
|
||||
return nil
|
||||
}
|
||||
@@ -4,12 +4,13 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/arr"
|
||||
"goBlack/pkg/debrid"
|
||||
"goBlack/pkg/qbit/server"
|
||||
)
|
||||
|
||||
func Start(ctx context.Context, config *common.Config, deb *debrid.DebridService) error {
|
||||
srv := server.NewServer(config, deb)
|
||||
func Start(ctx context.Context, config *common.Config, deb *debrid.DebridService, arrs *arr.Storage) error {
|
||||
srv := server.NewServer(config, deb, arrs)
|
||||
if err := srv.Start(ctx); err != nil {
|
||||
return fmt.Errorf("failed to start qbit server: %w", err)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ func (s *Server) Routes(r chi.Router) http.Handler {
|
||||
r.Post("/add", s.handleAddContent)
|
||||
r.Get("/search", s.handleSearch)
|
||||
r.Get("/cached", s.handleCheckCached)
|
||||
r.Post("/repair", s.handleRepair)
|
||||
})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"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"
|
||||
"log"
|
||||
@@ -22,9 +23,9 @@ type Server struct {
|
||||
debug bool
|
||||
}
|
||||
|
||||
func NewServer(config *common.Config, deb *debrid.DebridService) *Server {
|
||||
func NewServer(config *common.Config, deb *debrid.DebridService, arrs *arr.Storage) *Server {
|
||||
logger := common.NewLogger("QBit", os.Stdout)
|
||||
q := shared.NewQBit(config, deb, logger)
|
||||
q := shared.NewQBit(config, deb, logger, arrs)
|
||||
return &Server{
|
||||
qbit: q,
|
||||
logger: logger,
|
||||
|
||||
@@ -35,11 +35,12 @@
|
||||
<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="row mb-5">
|
||||
<div class="col-md-8">
|
||||
<div class="mb-3">
|
||||
<label for="magnetURI" class="form-label">Magnet Link</label>
|
||||
@@ -47,7 +48,8 @@
|
||||
</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)">
|
||||
<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">
|
||||
@@ -61,27 +63,33 @@
|
||||
</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>
|
||||
<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>
|
||||
|
||||
@@ -93,19 +101,9 @@
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
let $selectArr = $('#selectArr');
|
||||
let $selectContent = $('#selectContent');
|
||||
let $selectSeason = $('#selectSeason');
|
||||
let $selectEpisode = $('#selectEpisode');
|
||||
let $selectRepairArr = $('#selectRepairArr');
|
||||
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();
|
||||
let $repairBtn = $('#repairBtn');
|
||||
|
||||
// Initialize Select2
|
||||
$('.select2-multi').select2({
|
||||
@@ -115,176 +113,22 @@
|
||||
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 => {
|
||||
// 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));
|
||||
}
|
||||
|
||||
// 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>');
|
||||
@@ -292,7 +136,7 @@
|
||||
if (!magnet) {
|
||||
$(this).prop('disabled', false).text(oldText);
|
||||
alert('Please provide a magnet link or upload a torrent file!');
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
let data = {
|
||||
@@ -326,8 +170,45 @@
|
||||
});
|
||||
});
|
||||
|
||||
// Initial fetch of Arrs
|
||||
//fetchArrs();
|
||||
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>
|
||||
|
||||
@@ -43,6 +43,7 @@ func (s *Server) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
||||
s.logger.Printf("isSymlink: %v\n", isSymlink)
|
||||
urls := r.FormValue("urls")
|
||||
category := r.FormValue("category")
|
||||
atleastOne := false
|
||||
|
||||
var urlList []string
|
||||
if urls != "" {
|
||||
@@ -56,6 +57,7 @@ func (s *Server) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
atleastOne = true
|
||||
}
|
||||
|
||||
if contentType == "multipart/form-data" && len(r.MultipartForm.File["torrents"]) > 0 {
|
||||
@@ -66,9 +68,15 @@ func (s *Server) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ package server
|
||||
import (
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/arr"
|
||||
"goBlack/pkg/debrid"
|
||||
@@ -33,6 +35,12 @@ type ContentResponse struct {
|
||||
ArrID string `json:"arr"`
|
||||
}
|
||||
|
||||
type RepairRequest struct {
|
||||
ArrName string `json:"arr"`
|
||||
TVIds string `json:"tvIds"`
|
||||
Async bool `json:"async"`
|
||||
}
|
||||
|
||||
//go:embed static/index.html
|
||||
var content embed.FS
|
||||
|
||||
@@ -62,7 +70,7 @@ func (s *Server) handleContent(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "Invalid arr", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
contents := _arr.GetContents()
|
||||
contents, _ := _arr.GetMedia("")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
common.JSONResponse(w, contents, http.StatusOK)
|
||||
}
|
||||
@@ -147,3 +155,55 @@ 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) {
|
||||
var req RepairRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
tvids := []string{""}
|
||||
if req.TVIds != "" {
|
||||
tvids = strings.Split(req.TVIds, ",")
|
||||
}
|
||||
|
||||
_arr := s.qbit.Arrs.Get(req.ArrName)
|
||||
arrs := make([]*arr.Arr, 0)
|
||||
if _arr != nil {
|
||||
arrs = append(arrs, _arr)
|
||||
} else {
|
||||
arrs = s.qbit.Arrs.GetAll()
|
||||
}
|
||||
|
||||
if len(arrs) == 0 {
|
||||
http.Error(w, "No arrays found to repair", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
if req.Async {
|
||||
for _, a := range arrs {
|
||||
for _, tvId := range tvids {
|
||||
go a.Repair(tvId)
|
||||
}
|
||||
}
|
||||
common.JSONResponse(w, "Repair process started", http.StatusOK)
|
||||
return
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, a := range arrs {
|
||||
for _, tvId := range tvids {
|
||||
if err := a.Repair(tvId); err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
combinedErr := errors.Join(errs...)
|
||||
http.Error(w, fmt.Sprintf("Failed to repair: %v", combinedErr), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
common.JSONResponse(w, "Repair completed", http.StatusOK)
|
||||
}
|
||||
|
||||
@@ -23,11 +23,10 @@ type QBit struct {
|
||||
RefreshInterval int
|
||||
}
|
||||
|
||||
func NewQBit(config *common.Config, deb *debrid.DebridService, logger *log.Logger) *QBit {
|
||||
func NewQBit(config *common.Config, deb *debrid.DebridService, logger *log.Logger, arrs *arr.Storage) *QBit {
|
||||
cfg := config.QBitTorrent
|
||||
port := cmp.Or(cfg.Port, os.Getenv("QBIT_PORT"), "8182")
|
||||
refreshInterval := cmp.Or(cfg.RefreshInterval, 10)
|
||||
arrs := arr.NewStorage()
|
||||
return &QBit{
|
||||
Username: cfg.Username,
|
||||
Password: cfg.Password,
|
||||
|
||||
72
pkg/repair/repair.go
Normal file
72
pkg/repair/repair.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package repair
|
||||
|
||||
import (
|
||||
"context"
|
||||
"goBlack/common"
|
||||
"goBlack/pkg/arr"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func Start(ctx context.Context, config *common.Config, arrs *arr.Storage) error {
|
||||
ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
|
||||
logger := common.NewLogger("Repair", os.Stdout)
|
||||
defer stop()
|
||||
|
||||
duration, err := parseSchedule(config.Repair.Interval)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to parse schedule: %v", err)
|
||||
}
|
||||
|
||||
if config.Repair.RunOnStart {
|
||||
logger.Printf("Running initial repair")
|
||||
if err := repair(arrs); err != nil {
|
||||
log.Printf("Error during initial repair: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(duration)
|
||||
defer ticker.Stop()
|
||||
|
||||
if strings.Contains(config.Repair.Interval, ":") {
|
||||
logger.Printf("Starting repair worker, scheduled daily at %s", config.Repair.Interval)
|
||||
} else {
|
||||
logger.Printf("Starting repair worker with %v interval", duration)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
logger.Println("Repair worker stopped")
|
||||
return nil
|
||||
case t := <-ticker.C:
|
||||
logger.Printf("Running repair at %v", t.Format("15:04:05"))
|
||||
if err := repair(arrs); err != nil {
|
||||
logger.Printf("Error during repair: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// If using time-of-day schedule, reset the ticker for next day
|
||||
if strings.Contains(config.Repair.Interval, ":") {
|
||||
nextDuration, err := parseSchedule(config.Repair.Interval)
|
||||
if err != nil {
|
||||
logger.Printf("Error calculating next schedule: %v", err)
|
||||
return err
|
||||
}
|
||||
ticker.Reset(nextDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func repair(arrs *arr.Storage) error {
|
||||
for _, a := range arrs.GetAll() {
|
||||
go a.Repair("")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
70
pkg/repair/utils.go
Normal file
70
pkg/repair/utils.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package repair
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func parseSchedule(schedule string) (time.Duration, error) {
|
||||
if schedule == "" {
|
||||
return time.Hour, nil // default 60m
|
||||
}
|
||||
|
||||
// Check if it's a time-of-day format (HH:MM)
|
||||
if strings.Contains(schedule, ":") {
|
||||
return parseTimeOfDay(schedule)
|
||||
}
|
||||
|
||||
// Otherwise treat as duration interval
|
||||
return parseDurationInterval(schedule)
|
||||
}
|
||||
|
||||
func parseTimeOfDay(schedule string) (time.Duration, error) {
|
||||
now := time.Now()
|
||||
scheduledTime, err := time.Parse("15:04", schedule)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid time format: %s. Use HH:MM in 24-hour format", schedule)
|
||||
}
|
||||
|
||||
// Convert scheduled time to today
|
||||
scheduleToday := time.Date(
|
||||
now.Year(), now.Month(), now.Day(),
|
||||
scheduledTime.Hour(), scheduledTime.Minute(), 0, 0,
|
||||
now.Location(),
|
||||
)
|
||||
|
||||
if scheduleToday.Before(now) {
|
||||
scheduleToday = scheduleToday.Add(24 * time.Hour)
|
||||
}
|
||||
|
||||
return scheduleToday.Sub(now), nil
|
||||
}
|
||||
|
||||
func parseDurationInterval(interval string) (time.Duration, error) {
|
||||
if len(interval) < 2 {
|
||||
return 0, fmt.Errorf("invalid interval format: %s", interval)
|
||||
}
|
||||
|
||||
numStr := interval[:len(interval)-1]
|
||||
unit := interval[len(interval)-1]
|
||||
|
||||
num, err := strconv.Atoi(numStr)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid number in interval: %s", numStr)
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case 'm':
|
||||
return time.Duration(num) * time.Minute, nil
|
||||
case 'h':
|
||||
return time.Duration(num) * time.Hour, nil
|
||||
case 'd':
|
||||
return time.Duration(num) * 24 * time.Hour, nil
|
||||
case 's':
|
||||
return time.Duration(num) * time.Second, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("invalid unit in interval: %c", unit)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user