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

@@ -12,6 +12,7 @@ This is a Golang implementation go Torrent QbitTorrent with a **Real Debrid & To
- Debrid Link Support
- Multi-Debrid Providers support
- UI for adding torrents directly to *arrs
- Repair Worker for missing files (**NEW**)
The proxy is useful in filtering out un-cached Real Debrid torrents
@@ -117,7 +118,19 @@ Download the binary from the releases page and run it with the config file.
"download_folder": "/media/symlinks/",
"categories": ["sonarr", "radarr"],
"refresh_interval": 5
},
"arrs": [
{
"name": "sonarr",
"host": "http://host:8989",
"token": "arr_key"
},
{
"name": "radarr",
"host": "http://host:7878",
"token": "arr_key"
}
]
}
```
@@ -137,6 +150,12 @@ Download the binary from the releases page and run it with the config file.
- The `download_uncached` bool key is used to download uncached torrents(disabled by default)
- The `check_cached` bool key is used to check if the torrent is cached(disabled by default)
##### Repair Config (**NEW**)
The `repair` key is used to enable the repair worker
- The `enabled` key is used to enable the repair worker
- The `interval` key is the interval in either minutes, seconds, hours, days. Use any of this format, e.g 12:00, 5:00, 1h, 1d, 1m, 1s.
- The `run_on_start` key is used to run the repair worker on start
##### Proxy Config
- The `enabled` key is used to enable the proxy
- The `port` key is the port the proxy will listen on
@@ -151,6 +170,14 @@ Download the binary from the releases page and run it with the config file.
- The `categories` key is used to filter out torrents based on the category. e.g `sonarr`, `radarr`
- The `refresh_interval` key is used to set the interval in minutes to refresh the Arrs Monitored Downloads(it's in seconds). The default value is `5` seconds
##### Arrs Config
This is an array of Arrs(Sonarr, Radarr, etc) that will be used to download the torrents. This is not required if you already set up the Qbittorrent in the Arrs with the host, token.
This is particularly useful if you want to use the Repair tool without using Qbittorent
- The `name` key is the name of the Arr/ Category
- The `host` key is the host of the Arr
- The `token` key is the API token of the Arr
### Proxy
The proxy is useful in filtering out un-cached Real Debrid torrents.
@@ -185,11 +212,25 @@ Setting Up Qbittorrent in Arr
- Test
- Save
### UI for adding torrents
### Repair Worker
The repair worker is a simple worker that checks for missing files in the Arrs(Sonarr, Radarr, etc). It's particularly useful for files either deleted by the Debrid provider or files with bad symlinks.
- Search for broken symlinks/files
- Search for missing files
- Search for deleted/unreadable files
### UI
![UI](./doc/ui.png)
The UI is a simple web interface that allows you to add torrents directly to the Arrs(Sonarr, Radarr, etc)
The UI is a simple web interface that allows you to add torrents directly to the Arrs(Sonarr, Radarr, etc) or trigger the Repair Worker.
UI Features
- Adding new torrents
- Triggering the Repair Worker
### TODO
- [ ] A proper name!!!!

View File

@@ -4,9 +4,12 @@ import (
"cmp"
"context"
"goBlack/common"
"goBlack/pkg/arr"
"goBlack/pkg/debrid"
"goBlack/pkg/proxy"
"goBlack/pkg/qbit"
"goBlack/pkg/repair"
"log"
"sync"
)
@@ -14,6 +17,7 @@ func Start(ctx context.Context, config *common.Config) error {
maxCacheSize := cmp.Or(config.MaxCacheSize, 1000)
deb := debrid.NewDebrid(config.Debrids, maxCacheSize)
arrs := arr.NewStorage(config.Arrs)
var wg sync.WaitGroup
errChan := make(chan error, 2)
@@ -31,12 +35,22 @@ func Start(ctx context.Context, config *common.Config) error {
wg.Add(1)
go func() {
defer wg.Done()
if err := qbit.Start(ctx, config, deb); err != nil {
if err := qbit.Start(ctx, config, deb, arrs); err != nil {
errChan <- err
}
}()
}
if config.Repair.Enabled {
wg.Add(1)
go func() {
defer wg.Done()
if err := repair.Start(ctx, config, arrs); err != nil {
log.Printf("Error during repair: %v", err)
}
}()
}
go func() {
wg.Wait()
close(errChan)

View File

@@ -38,12 +38,26 @@ type QBitTorrentConfig struct {
RefreshInterval int `json:"refresh_interval"`
}
type ArrConfig struct {
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
}
type RepairConfig struct {
Enabled bool `json:"enabled"`
Interval string `json:"interval"`
RunOnStart bool `json:"run_on_start"`
}
type Config struct {
Debrid DebridConfig `json:"debrid"`
Debrids []DebridConfig `json:"debrids"`
Proxy ProxyConfig `json:"proxy"`
MaxCacheSize int `json:"max_cache_size"`
QBitTorrent QBitTorrentConfig `json:"qbittorrent"`
Arrs []ArrConfig `json:"arrs"`
Repair RepairConfig `json:"repair"`
}
func validateDebrids(debrids []DebridConfig) error {

View File

@@ -13,7 +13,6 @@ import (
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"regexp"
"strings"
@@ -251,17 +250,22 @@ func GetInfohashFromURL(url string) (string, error) {
}
func JoinURL(base string, paths ...string) (string, error) {
// Parse the base URL
u, err := url.Parse(base)
// Split the last path component to separate query parameters
lastPath := paths[len(paths)-1]
parts := strings.Split(lastPath, "?")
paths[len(paths)-1] = parts[0]
joined, err := url.JoinPath(base, paths...)
if err != nil {
return "", err
}
// Join the path components
u.Path = path.Join(u.Path, path.Join(paths...))
// Add back query parameters if they exist
if len(parts) > 1 {
return joined + "?" + parts[1], nil
}
// Return the resulting URL as a string
return u.String(), nil
return joined, nil
}
func FileReady(path string) bool {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 197 KiB

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) GetContents() *ContentRequest {
resp, err := a.Request(http.MethodGet, "api/v3/series", nil)
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
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 data *ContentRequest
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
fmt.Printf("Error: %v\n", err)
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 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, err
}
defer resp.Body.Close()
var movies []Movie
if err = json.NewDecoder(resp.Body).Decode(&movies); err != nil {
return nil, err
}
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
}
fmt.Printf("Data: %v\n", data)
//data.Arr = a.Name
return data
}

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