Add repair worker
This commit is contained in:
45
README.md
45
README.md
@@ -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
|
||||
|
||||

|
||||
|
||||
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!!!!
|
||||
|
||||
16
cmd/main.go
16
cmd/main.go
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
BIN
doc/ui.png
BIN
doc/ui.png
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB After Width: | Height: | Size: 197 KiB |
@@ -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) 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
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>');
|
||||
@@ -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