Add repair worker

This commit is contained in:
Mukhtar Akere
2025-01-09 19:44:38 +01:00
parent 28e5342c66
commit 03c9657945
19 changed files with 783 additions and 235 deletions

View File

@@ -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,
}

View File

@@ -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
View 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
View 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
View File

@@ -0,0 +1,5 @@
package arr
func Readfile(path string) error {
return nil
}

View File

@@ -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)
}

View File

@@ -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
}

View File

@@ -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,

View File

@@ -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>

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
View 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
View 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)
}
}