Changelog 0.5.0

This commit is contained in:
Mukhtar Akere
2025-03-05 20:15:10 +01:00
parent 1c06407900
commit a83f3d72ce
30 changed files with 1787 additions and 342 deletions
+47 -11
View File
@@ -2,13 +2,16 @@ package arr
import (
"bytes"
"crypto/tls"
"encoding/json"
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"io"
"net/http"
"strings"
"sync"
"time"
)
// Type is a type of arr
@@ -21,51 +24,84 @@ const (
Readarr Type = "readarr"
)
var (
client *request.RLHTTPClient = request.NewRLHTTPClient(nil, nil)
)
type Arr struct {
Name string `json:"name"`
Host string `json:"host"`
Token string `json:"token"`
Type Type `json:"type"`
Cleanup bool `json:"cleanup"`
client *http.Client
}
func New(name, host, token string, cleanup bool) *Arr {
return &Arr{
Name: name,
Host: host,
Token: token,
Token: strings.TrimSpace(token),
Type: InferType(host, name),
Cleanup: cleanup,
client: &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
},
},
}
}
func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Response, error) {
if a.Token == "" || a.Host == "" {
return nil, nil
return nil, fmt.Errorf("arr not configured")
}
url, err := request.JoinURL(a.Host, endpoint)
if err != nil {
return nil, err
}
var jsonPayload []byte
var body io.Reader
if payload != nil {
jsonPayload, err = json.Marshal(payload)
b, err := json.Marshal(payload)
if err != nil {
return nil, err
}
body = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, bytes.NewBuffer(jsonPayload))
req, err := http.NewRequest(method, url, body)
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", a.Token)
return client.Do(req)
if a.client == nil {
a.client = &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
},
}
}
var resp *http.Response
for attempts := 0; attempts < 5; attempts++ {
resp, err = a.client.Do(req)
if err != nil {
return nil, err
}
// If we got a 401, wait briefly and retry
if resp.StatusCode == http.StatusUnauthorized {
resp.Body.Close() // Don't leak response bodies
if attempts < 4 { // Don't sleep on the last attempt
time.Sleep(time.Duration(attempts+1) * 100 * time.Millisecond)
continue
}
}
return resp, nil
}
return resp, err
}
func (a *Arr) Validate() error {
+31 -11
View File
@@ -7,25 +7,38 @@ import (
"strconv"
)
func (a *Arr) GetMedia(tvId string) ([]Content, error) {
type episode struct {
Id int `json:"id"`
EpisodeFileID int `json:"episodeFileId"`
}
func (a *Arr) GetMedia(mediaId string) ([]Content, error) {
// Get series
resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/series?tvdbId=%s", tvId), nil)
if a.Type == Radarr {
return GetMovies(a, mediaId)
}
// This is likely Sonarr
resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/series?tvdbId=%s", mediaId), nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
// This is likely Radarr
return GetMovies(a, tvId)
return GetMovies(a, mediaId)
}
a.Type = Sonarr
defer resp.Body.Close()
type series struct {
Title string `json:"title"`
Id int `json:"id"`
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get series: %s", resp.Status)
}
var data []series
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
return nil, fmt.Errorf("failed to decode series: %v", err)
}
// Get series files
contents := make([]Content, 0)
@@ -43,11 +56,6 @@ func (a *Arr) GetMedia(tvId string) ([]Content, error) {
Title: d.Title,
Id: d.Id,
}
type episode struct {
Id int `json:"id"`
EpisodeFileID int `json:"episodeFileId"`
}
resp, err = a.Request(http.MethodGet, fmt.Sprintf("api/v3/episode?seriesId=%d", d.Id), nil)
if err != nil {
continue
@@ -67,12 +75,20 @@ func (a *Arr) GetMedia(tvId string) ([]Content, error) {
if !ok {
eId = 0
}
if file.Id == 0 || file.Path == "" {
// Skip files without path
continue
}
files = append(files, ContentFile{
FileId: file.Id,
Path: file.Path,
Id: eId,
})
}
if len(files) == 0 {
// Skip series without files
continue
}
ct.Files = files
contents = append(contents, ct)
}
@@ -92,7 +108,7 @@ func GetMovies(a *Arr, tvId string) ([]Content, error) {
defer resp.Body.Close()
var movies []Movie
if err = json.NewDecoder(resp.Body).Decode(&movies); err != nil {
return nil, err
return nil, fmt.Errorf("failed to decode movies: %v", err)
}
contents := make([]Content, 0)
for _, movie := range movies {
@@ -101,6 +117,10 @@ func GetMovies(a *Arr, tvId string) ([]Content, error) {
Id: movie.Id,
}
files := make([]ContentFile, 0)
if movie.MovieFile.Id == 0 || movie.MovieFile.Path == "" {
// Skip movies without files
continue
}
files = append(files, ContentFile{
FileId: movie.MovieFile.Id,
Id: movie.Id,
-1
View File
@@ -8,7 +8,6 @@ type Movie 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"`
+5 -1
View File
@@ -3,6 +3,7 @@ package torrent
import (
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/cache"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"os"
@@ -66,6 +67,7 @@ func (t *Torrent) GetSymlinkFolder(parent string) string {
}
func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
_log := logger.GetDefaultLogger()
possiblePaths := []string{
t.OriginalFilename,
t.Filename,
@@ -73,7 +75,9 @@ func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
}
for _, path := range possiblePaths {
_, err := os.Stat(filepath.Join(rClonePath, path))
_p := filepath.Join(rClonePath, path)
_log.Trace().Msgf("Checking path: %s", _p)
_, err := os.Stat(_p)
if !os.IsNotExist(err) {
return path, nil
}
+1 -1
View File
@@ -187,7 +187,7 @@ func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debrid.Torrent)
q.logger.Debug().Msgf("Found torrent path: %s", torrentPath)
return torrentPath, err
}
time.Sleep(100 * time.Millisecond)
time.Sleep(10 * time.Millisecond)
}
}
+41 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"os"
"sort"
"sync"
)
@@ -109,7 +110,46 @@ func (ts *TorrentStorage) GetAll(category string, filter string, hashes []string
}
}
}
return filtered
torrents = filtered
}
return torrents
}
func (ts *TorrentStorage) GetAllSorted(category string, filter string, hashes []string, sortBy string, ascending bool) []*Torrent {
torrents := ts.GetAll(category, filter, hashes)
if sortBy != "" {
sort.Slice(torrents, func(i, j int) bool {
// If ascending is false, swap i and j to get descending order
if !ascending {
i, j = j, i
}
switch sortBy {
case "name":
return torrents[i].Name < torrents[j].Name
case "size":
return torrents[i].Size < torrents[j].Size
case "added_on":
return torrents[i].AddedOn < torrents[j].AddedOn
case "completed":
return torrents[i].Completed < torrents[j].Completed
case "progress":
return torrents[i].Progress < torrents[j].Progress
case "state":
return torrents[i].State < torrents[j].State
case "category":
return torrents[i].Category < torrents[j].Category
case "dlspeed":
return torrents[i].Dlspeed < torrents[j].Dlspeed
case "upspeed":
return torrents[i].Upspeed < torrents[j].Upspeed
case "ratio":
return torrents[i].Ratio < torrents[j].Ratio
default:
// Default sort by added_on
return torrents[i].AddedOn < torrents[j].AddedOn
}
})
}
return torrents
}
+11
View File
@@ -4,6 +4,7 @@ import (
"cmp"
"context"
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
db "github.com/sirrobot01/debrid-blackhole/pkg/debrid"
@@ -114,6 +115,11 @@ func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr
}
torrent.TorrentPath = torrentSymlinkPath
q.UpdateTorrent(torrent, debridTorrent)
go func() {
if err := request.SendDiscordMessage("download_complete", "success", torrent.discordContext()); err != nil {
q.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
if err := arr.Refresh(); err != nil {
q.logger.Error().Msgf("Error refreshing arr: %v", err)
}
@@ -122,6 +128,11 @@ func (q *QBit) ProcessFiles(torrent *Torrent, debridTorrent *debrid.Torrent, arr
func (q *QBit) MarkAsFailed(t *Torrent) *Torrent {
t.State = "error"
q.Storage.AddOrUpdate(t)
go func() {
if err := request.SendDiscordMessage("download_failed", "error", t.discordContext()); err != nil {
q.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
return t
}
+12
View File
@@ -1,6 +1,7 @@
package qbit
import (
"fmt"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"sync"
)
@@ -230,6 +231,17 @@ func (t *Torrent) IsReady() bool {
return t.AmountLeft <= 0 && t.TorrentPath != ""
}
func (t *Torrent) discordContext() string {
format := `
**Name:** %s
**Arr:** %s
**Hash:** %s
**MagnetURI:** %s
**Debrid:** %s
`
return fmt.Sprintf(format, t.Name, t.Category, t.Hash, t.MagnetUri, t.Debrid)
}
type TorrentProperties struct {
AdditionDate int64 `json:"addition_date,omitempty"`
Comment string `json:"comment,omitempty"`
+388 -84
View File
@@ -2,20 +2,23 @@ package repair
import (
"context"
"encoding/json"
"fmt"
"github.com/google/uuid"
"github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/pkg/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
"log"
"golang.org/x/sync/errgroup"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"runtime"
"sort"
"strings"
"sync"
"syscall"
@@ -23,57 +26,130 @@ import (
)
type Repair struct {
Jobs []Job `json:"jobs"`
arrs *arr.Storage
deb engine.Service
duration time.Duration
runOnStart bool
ZurgURL string
IsZurg bool
logger zerolog.Logger
Jobs map[string]*Job
arrs *arr.Storage
deb engine.Service
duration time.Duration
runOnStart bool
ZurgURL string
IsZurg bool
autoProcess bool
logger zerolog.Logger
filename string
}
func New(deb *engine.Engine, arrs *arr.Storage) *Repair {
func New(arrs *arr.Storage) *Repair {
cfg := config.GetConfig()
duration, err := parseSchedule(cfg.Repair.Interval)
if err != nil {
duration = time.Hour * 24
}
r := &Repair{
arrs: arrs,
deb: deb.Get(),
logger: logger.NewLogger("repair", cfg.LogLevel, os.Stdout),
duration: duration,
runOnStart: cfg.Repair.RunOnStart,
ZurgURL: cfg.Repair.ZurgURL,
arrs: arrs,
logger: logger.NewLogger("repair", cfg.LogLevel, os.Stdout),
duration: duration,
runOnStart: cfg.Repair.RunOnStart,
ZurgURL: cfg.Repair.ZurgURL,
autoProcess: cfg.Repair.AutoProcess,
filename: filepath.Join(cfg.Path, "repair.json"),
}
if r.ZurgURL != "" {
r.IsZurg = true
}
// Load jobs from file
r.loadFromFile()
return r
}
type JobStatus string
const (
JobStarted JobStatus = "started"
JobPending JobStatus = "pending"
JobFailed JobStatus = "failed"
JobCompleted JobStatus = "completed"
)
type Job struct {
ID string `json:"id"`
Arrs []*arr.Arr `json:"arrs"`
MediaIDs []string `json:"media_ids"`
StartedAt time.Time `json:"created_at"`
CompletedAt time.Time `json:"finished_at"`
FailedAt time.Time `json:"failed_at"`
ID string `json:"id"`
Arrs []*arr.Arr `json:"arrs"`
MediaIDs []string `json:"media_ids"`
OneOff bool `json:"one_off"`
StartedAt time.Time `json:"created_at"`
BrokenItems map[string][]arr.ContentFile `json:"broken_items"`
Status JobStatus `json:"status"`
CompletedAt time.Time `json:"finished_at"`
FailedAt time.Time `json:"failed_at"`
AutoProcess bool `json:"auto_process"`
Error string `json:"error"`
}
func (r *Repair) NewJob(arrs []*arr.Arr, mediaIDs []string) *Job {
func (j *Job) discordContext() string {
format := `
**ID**: %s
**Arrs**: %s
**Media IDs**: %s
**Status**: %s
**Started At**: %s
**Completed At**: %s
`
arrs := make([]string, 0)
for _, a := range j.Arrs {
arrs = append(arrs, a.Name)
}
dateFmt := "2006-01-02 15:04:05"
return fmt.Sprintf(format, j.ID, strings.Join(arrs, ","), strings.Join(j.MediaIDs, ", "), j.Status, j.StartedAt.Format(dateFmt), j.CompletedAt.Format(dateFmt))
}
func (r *Repair) getArrs(arrNames []string) []*arr.Arr {
arrs := make([]*arr.Arr, 0)
if len(arrNames) == 0 {
arrs = r.arrs.GetAll()
} else {
for _, name := range arrNames {
a := r.arrs.Get(name)
if a == nil || a.Host == "" || a.Token == "" {
continue
}
arrs = append(arrs, a)
}
}
return arrs
}
func jobKey(arrNames []string, mediaIDs []string) string {
return fmt.Sprintf("%s-%s", strings.Join(arrNames, ","), strings.Join(mediaIDs, ","))
}
func (r *Repair) reset(j *Job) {
// Update job for rerun
j.Status = JobStarted
j.StartedAt = time.Now()
j.CompletedAt = time.Time{}
j.FailedAt = time.Time{}
j.BrokenItems = nil
j.Error = ""
if j.Arrs == nil {
j.Arrs = r.getArrs([]string{}) // Get new arrs
}
}
func (r *Repair) newJob(arrsNames []string, mediaIDs []string) *Job {
arrs := r.getArrs(arrsNames)
return &Job{
ID: uuid.New().String(),
Arrs: arrs,
MediaIDs: mediaIDs,
StartedAt: time.Now(),
Status: JobStarted,
}
}
func (r *Repair) PreRunChecks() error {
func (r *Repair) preRunChecks() error {
// Check if zurg url is reachable
if !r.IsZurg {
return nil
@@ -90,43 +166,119 @@ func (r *Repair) PreRunChecks() error {
return nil
}
func (r *Repair) Repair(arrs []*arr.Arr, mediaIds []string) error {
func (r *Repair) AddJob(arrsNames []string, mediaIDs []string, autoProcess bool) error {
key := jobKey(arrsNames, mediaIDs)
job, ok := r.Jobs[key]
if !ok {
job = r.newJob(arrsNames, mediaIDs)
}
job.AutoProcess = autoProcess
r.reset(job)
r.Jobs[key] = job
go r.saveToFile()
err := r.repair(job)
go r.saveToFile()
return err
}
j := r.NewJob(arrs, mediaIds)
if err := r.PreRunChecks(); err != nil {
func (r *Repair) repair(job *Job) error {
if err := r.preRunChecks(); err != nil {
return err
}
var wg sync.WaitGroup
errors := make(chan error)
for _, a := range j.Arrs {
wg.Add(1)
go func(a *arr.Arr) {
defer wg.Done()
if len(j.MediaIDs) == 0 {
if err := r.RepairArr(a, ""); err != nil {
log.Printf("Error repairing %s: %v", a.Name, err)
errors <- err
// Create a new error group with context
g, ctx := errgroup.WithContext(context.Background())
// Use a mutex to protect concurrent access to brokenItems
var mu sync.Mutex
brokenItems := map[string][]arr.ContentFile{}
for _, a := range job.Arrs {
a := a // Capture range variable
g.Go(func() error {
var items []arr.ContentFile
var err error
if len(job.MediaIDs) == 0 {
items, err = r.repairArr(job, a, "")
if err != nil {
r.logger.Error().Err(err).Msgf("Error repairing %s", a.Name)
return err
}
} else {
for _, id := range j.MediaIDs {
if err := r.RepairArr(a, id); err != nil {
log.Printf("Error repairing %s: %v", a.Name, err)
errors <- err
for _, id := range job.MediaIDs {
// Check if any other goroutine has failed
select {
case <-ctx.Done():
return ctx.Err()
default:
}
someItems, err := r.repairArr(job, a, id)
if err != nil {
r.logger.Error().Err(err).Msgf("Error repairing %s with ID %s", a.Name, id)
return err
}
items = append(items, someItems...)
}
}
}(a)
// Safely append the found items to the shared slice
if len(items) > 0 {
mu.Lock()
brokenItems[a.Name] = items
mu.Unlock()
}
return nil
})
}
wg.Wait()
close(errors)
err := <-errors
if err != nil {
j.FailedAt = time.Now()
j.Error = err.Error()
// Wait for all goroutines to complete and check for errors
if err := g.Wait(); err != nil {
job.FailedAt = time.Now()
job.Error = err.Error()
job.Status = JobFailed
job.CompletedAt = time.Now()
go func() {
if err := request.SendDiscordMessage("repair_failed", "error", job.discordContext()); err != nil {
r.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
return err
}
j.CompletedAt = time.Now()
if len(brokenItems) == 0 {
job.CompletedAt = time.Now()
job.Status = JobCompleted
go func() {
if err := request.SendDiscordMessage("repair_complete", "success", job.discordContext()); err != nil {
r.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
return nil
}
job.BrokenItems = brokenItems
if job.AutoProcess {
// Job is already processed
job.CompletedAt = time.Now() // Mark as completed
job.Status = JobCompleted
go func() {
if err := request.SendDiscordMessage("repair_complete", "success", job.discordContext()); err != nil {
r.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
} else {
job.Status = JobPending
go func() {
if err := request.SendDiscordMessage("repair_pending", "pending", job.discordContext()); err != nil {
r.logger.Error().Msgf("Error sending discord message: %v", err)
}
}()
}
return nil
}
@@ -138,8 +290,8 @@ func (r *Repair) Start(ctx context.Context) error {
if r.runOnStart {
r.logger.Info().Msgf("Running initial repair")
go func() {
if err := r.Repair(r.arrs.GetAll(), []string{}); err != nil {
r.logger.Info().Msgf("Error during initial repair: %v", err)
if err := r.AddJob([]string{}, []string{}, r.autoProcess); err != nil {
r.logger.Error().Err(err).Msg("Error running initial repair")
}
}()
}
@@ -156,9 +308,8 @@ func (r *Repair) Start(ctx context.Context) error {
return nil
case t := <-ticker.C:
r.logger.Info().Msgf("Running repair at %v", t.Format("15:04:05"))
err := r.Repair(r.arrs.GetAll(), []string{})
if err != nil {
r.logger.Info().Msgf("Error during repair: %v", err)
if err := r.AddJob([]string{}, []string{}, r.autoProcess); err != nil {
r.logger.Error().Err(err).Msg("Error running repair")
}
// If using time-of-day schedule, reset the ticker for next day
@@ -171,55 +322,78 @@ func (r *Repair) Start(ctx context.Context) error {
}
}
func (r *Repair) RepairArr(a *arr.Arr, tmdbId string) error {
cfg := config.GetConfig()
func (r *Repair) repairArr(j *Job, a *arr.Arr, tmdbId string) ([]arr.ContentFile, error) {
brokenItems := make([]arr.ContentFile, 0)
r.logger.Info().Msgf("Starting repair for %s", a.Name)
media, err := a.GetMedia(tmdbId)
if err != nil {
r.logger.Info().Msgf("Failed to get %s media: %v", a.Name, err)
return err
return brokenItems, err
}
r.logger.Info().Msgf("Found %d %s media", len(media), a.Name)
if len(media) == 0 {
r.logger.Info().Msgf("No %s media found", a.Name)
return nil
return brokenItems, nil
}
// Check first media to confirm mounts are accessible
if !r.isMediaAccessible(media[0]) {
r.logger.Info().Msgf("Skipping repair. Parent directory not accessible for. Check your mounts")
return nil
return brokenItems, nil
}
semaphore := make(chan struct{}, runtime.NumCPU()*4)
totalBrokenItems := 0
var wg sync.WaitGroup
// Create a new error group
g, ctx := errgroup.WithContext(context.Background())
// Limit concurrent goroutines
g.SetLimit(runtime.NumCPU() * 4)
// Mutex for brokenItems
var mu sync.Mutex
for _, m := range media {
wg.Add(1)
semaphore <- struct{}{}
go func(m arr.Content) {
defer wg.Done()
defer func() { <-semaphore }()
brokenItems := r.getBrokenFiles(m)
if brokenItems != nil {
r.logger.Debug().Msgf("Found %d broken files for %s", len(brokenItems), m.Title)
if !cfg.Repair.SkipDeletion {
if err := a.DeleteFiles(brokenItems); err != nil {
r.logger.Info().Msgf("Failed to delete broken items for %s: %v", m.Title, err)
m := m // Create a new variable scoped to the loop iteration
g.Go(func() error {
// Check if context was canceled
select {
case <-ctx.Done():
return ctx.Err()
default:
}
items := r.getBrokenFiles(m)
if items != nil {
r.logger.Debug().Msgf("Found %d broken files for %s", len(items), m.Title)
if j.AutoProcess {
r.logger.Info().Msgf("Auto processing %d broken items for %s", len(items), m.Title)
// Delete broken items
if err := a.DeleteFiles(items); err != nil {
r.logger.Debug().Msgf("Failed to delete broken items for %s: %v", m.Title, err)
}
// Search for missing items
if err := a.SearchMissing(items); err != nil {
r.logger.Debug().Msgf("Failed to search missing items for %s: %v", m.Title, err)
}
}
if err := a.SearchMissing(brokenItems); err != nil {
r.logger.Info().Msgf("Failed to search missing items for %s: %v", m.Title, err)
}
totalBrokenItems += len(brokenItems)
mu.Lock()
brokenItems = append(brokenItems, items...)
mu.Unlock()
}
}(m)
return nil
})
}
wg.Wait()
r.logger.Info().Msgf("Repair completed for %s. %d broken items found", a.Name, totalBrokenItems)
return nil
if err := g.Wait(); err != nil {
return brokenItems, err
}
r.logger.Info().Msgf("Repair completed for %s. %d broken items found", a.Name, len(brokenItems))
return brokenItems, nil
}
func (r *Repair) isMediaAccessible(m arr.Content) bool {
@@ -328,9 +502,13 @@ func (r *Repair) getZurgBrokenFiles(media arr.Content) []arr.ContentFile {
brokenFiles = append(brokenFiles, f...)
continue
}
resp.Body.Close()
err = resp.Body.Close()
if err != nil {
return nil
}
if resp.StatusCode != http.StatusOK {
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
resp.Body.Close()
brokenFiles = append(brokenFiles, f...)
continue
}
@@ -350,3 +528,129 @@ func (r *Repair) getZurgBrokenFiles(media arr.Content) []arr.ContentFile {
r.logger.Debug().Msgf("%d broken files found for %s", len(brokenFiles), media.Title)
return brokenFiles
}
func (r *Repair) GetJob(id string) *Job {
for _, job := range r.Jobs {
if job.ID == id {
return job
}
}
return nil
}
func (r *Repair) GetJobs() []*Job {
jobs := make([]*Job, 0)
for _, job := range r.Jobs {
jobs = append(jobs, job)
}
sort.Slice(jobs, func(i, j int) bool {
return jobs[i].StartedAt.After(jobs[j].StartedAt)
})
return jobs
}
func (r *Repair) ProcessJob(id string) error {
job := r.GetJob(id)
if job == nil {
return fmt.Errorf("job %s not found", id)
}
if job.Status != JobPending {
return fmt.Errorf("job %s not pending", id)
}
if job.StartedAt.IsZero() {
return fmt.Errorf("job %s not started", id)
}
if !job.CompletedAt.IsZero() {
return fmt.Errorf("job %s already completed", id)
}
if !job.FailedAt.IsZero() {
return fmt.Errorf("job %s already failed", id)
}
brokenItems := job.BrokenItems
if len(brokenItems) == 0 {
r.logger.Info().Msgf("No broken items found for job %s", id)
job.CompletedAt = time.Now()
job.Status = JobCompleted
return nil
}
// Create a new error group
g := new(errgroup.Group)
for arrName, items := range brokenItems {
items := items
arrName := arrName
g.Go(func() error {
a := r.arrs.Get(arrName)
if a == nil {
r.logger.Error().Msgf("Arr %s not found", arrName)
return nil
}
if err := a.DeleteFiles(items); err != nil {
r.logger.Error().Err(err).Msgf("Failed to delete broken items for %s", arrName)
return nil
}
// Search for missing items
if err := a.SearchMissing(items); err != nil {
r.logger.Error().Err(err).Msgf("Failed to search missing items for %s", arrName)
return nil
}
return nil
})
}
if err := g.Wait(); err != nil {
job.FailedAt = time.Now()
job.Error = err.Error()
job.CompletedAt = time.Now()
job.Status = JobFailed
return err
}
job.CompletedAt = time.Now()
job.Status = JobCompleted
return nil
}
func (r *Repair) saveToFile() {
// Save jobs to file
data, err := json.Marshal(r.Jobs)
if err != nil {
r.logger.Debug().Err(err).Msg("Failed to marshal jobs")
}
err = os.WriteFile(r.filename, data, 0644)
}
func (r *Repair) loadFromFile() {
data, err := os.ReadFile(r.filename)
if err != nil && os.IsNotExist(err) {
r.Jobs = make(map[string]*Job)
return
}
jobs := make(map[string]*Job)
err = json.Unmarshal(data, &jobs)
if err != nil {
r.logger.Debug().Err(err).Msg("Failed to unmarshal jobs")
}
r.Jobs = jobs
}
func (r *Repair) DeleteJobs(ids []string) {
for _, id := range ids {
if id == "" {
continue
}
for k, job := range r.Jobs {
if job.ID == id {
delete(r.Jobs, k)
}
}
}
go r.saveToFile()
}
+4
View File
@@ -37,6 +37,10 @@ func New() *Server {
func (s *Server) Start(ctx context.Context) error {
cfg := config.GetConfig()
// Register routes
// Register webhooks
s.router.Post("/webhooks/tautulli", s.handleTautulli)
// Register logs
s.router.Get("/logs", s.getLogs)
port := fmt.Sprintf(":%s", cfg.QBitTorrent.Port)
s.logger.Info().Msgf("Starting server on %s", port)
+54
View File
@@ -0,0 +1,54 @@
package server
import (
"cmp"
"encoding/json"
"github.com/sirrobot01/debrid-blackhole/pkg/service"
"net/http"
)
func (s *Server) handleTautulli(w http.ResponseWriter, r *http.Request) {
// Verify it's a POST request
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
// Parse the JSON body from Tautulli
var payload struct {
Type string `json:"type"`
TvdbID string `json:"tvdb_id"`
TmdbID string `json:"tmdb_id"`
Topic string `json:"topic"`
AutoProcess bool `json:"autoProcess"`
}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
s.logger.Error().Err(err).Msg("Failed to parse webhook body")
http.Error(w, "Failed to parse webhook body: "+err.Error(), http.StatusBadRequest)
return
}
if payload.Topic != "tautulli" {
http.Error(w, "Invalid topic", http.StatusBadRequest)
return
}
if payload.TmdbID == "" && payload.TvdbID == "" {
http.Error(w, "Invalid ID", http.StatusBadRequest)
return
}
svc := service.GetService()
repair := svc.Repair
mediaId := cmp.Or(payload.TmdbID, payload.TvdbID)
if repair == nil {
http.Error(w, "Repair service is not enabled", http.StatusInternalServerError)
return
}
if err := repair.AddJob([]string{}, []string{mediaId}, payload.AutoProcess); err != nil {
http.Error(w, "Failed to add job: "+err.Error(), http.StatusInternalServerError)
return
}
}
+2 -2
View File
@@ -24,7 +24,7 @@ func New() *Service {
arrs := arr.NewStorage()
deb := debrid.New()
instance = &Service{
Repair: repair.New(deb, arrs),
Repair: repair.New(arrs),
Arr: arrs,
Debrid: deb,
}
@@ -44,7 +44,7 @@ func Update() *Service {
arrs := arr.NewStorage()
deb := debrid.New()
instance = &Service{
Repair: repair.New(deb, arrs),
Repair: repair.New(arrs),
Arr: arrs,
Debrid: deb,
}
+3
View File
@@ -23,6 +23,9 @@ func (ui *Handler) Routes() http.Handler {
r.Get("/arrs", ui.handleGetArrs)
r.Post("/add", ui.handleAddContent)
r.Post("/repair", ui.handleRepairMedia)
r.Get("/repair/jobs", ui.handleGetRepairJobs)
r.Post("/repair/jobs/{id}/process", ui.handleProcessRepairJob)
r.Delete("/repair/jobs", ui.handleDeleteRepairJob)
r.Get("/torrents", ui.handleGetTorrents)
r.Delete("/torrents/{category}/{hash}", ui.handleDeleteTorrent)
r.Delete("/torrents/", ui.handleDeleteTorrents)
+44 -5
View File
@@ -46,9 +46,10 @@ type ContentResponse struct {
}
type RepairRequest struct {
ArrName string `json:"arr"`
MediaIds []string `json:"mediaIds"`
Async bool `json:"async"`
ArrName string `json:"arr"`
MediaIds []string `json:"mediaIds"`
Async bool `json:"async"`
AutoProcess bool `json:"autoProcess"`
}
//go:embed web/*
@@ -383,7 +384,7 @@ func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
if req.Async {
go func() {
if err := svc.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil {
if err := svc.Repair.AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess); err != nil {
ui.logger.Error().Err(err).Msg("Failed to repair media")
}
}()
@@ -391,7 +392,7 @@ func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
return
}
if err := svc.Repair.Repair([]*arr.Arr{_arr}, req.MediaIds); err != nil {
if err := svc.Repair.AddJob([]string{req.ArrName}, req.MediaIds, req.AutoProcess); err != nil {
http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
return
@@ -441,3 +442,41 @@ func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg.Arrs = arrCfgs
request.JSONResponse(w, cfg, http.StatusOK)
}
func (ui *Handler) handleGetRepairJobs(w http.ResponseWriter, r *http.Request) {
svc := service.GetService()
request.JSONResponse(w, svc.Repair.GetJobs(), http.StatusOK)
}
func (ui *Handler) handleProcessRepairJob(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if id == "" {
http.Error(w, "No job ID provided", http.StatusBadRequest)
return
}
svc := service.GetService()
if err := svc.Repair.ProcessJob(id); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (ui *Handler) handleDeleteRepairJob(w http.ResponseWriter, r *http.Request) {
// Read ids from body
var req struct {
IDs []string `json:"ids"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if len(req.IDs) == 0 {
http.Error(w, "No job IDs provided", http.StatusBadRequest)
return
}
svc := service.GetService()
svc.Repair.DeleteJobs(req.IDs)
w.WriteHeader(http.StatusOK)
}
+21 -5
View File
@@ -27,14 +27,27 @@
<!-- Empty label to keep the button aligned -->
</label>
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler()" id="registerMagnetLink">
Open Magnet Links in DecyphArr
Open Magnet Links in Decypharr
</div>
</div>
<div class="col-12 mt-3">
<div class="col-md-6 mt-3">
<div class="form-group">
<label for="discordWebhookUrl">Discord Webhook URL</label>
<div class="input-group">
<textarea type="text"
class="form-control"
id="discordWebhookUrl"
name="discord_webhook_url"
disabled
placeholder="https://discord..."></textarea>
</div>
</div>
</div>
<div class="col-md-6 mt-3">
<div class="form-group">
<label for="allowedExtensions">Allowed File Extensions</label>
<div class="input-group">
<textarea type="text"
<textarea
class="form-control"
id="allowedExtensions"
name="allowed_file_types"
@@ -145,8 +158,8 @@
<label class="form-check-label" for="repairOnStart">Run on Start</label>
</div>
<div class="form-check d-inline-block">
<input type="checkbox" disabled class="form-check-input" name="repair.skip_deletion" id="skipDeletion">
<label class="form-check-label" for="skipDeletion">Run on Start</label>
<input type="checkbox" disabled class="form-check-input" name="repair.auto_process" id="autoProcess">
<label class="form-check-label" for="autoProcess">Auto Process(Scheduled jobs will be processed automatically)</label>
</div>
</div>
</div>
@@ -280,6 +293,9 @@
if (config.max_file_size) {
document.querySelector('[name="max_file_size"]').value = config.max_file_size;
}
if (config.discord_webhook_url) {
document.querySelector('[name="discord_webhook_url"]').value = config.discord_webhook_url;
}
});
+172 -2
View File
@@ -12,13 +12,23 @@
</button>
<select class="form-select form-select-sm d-inline-block w-auto me-2" id="stateFilter" style="flex-shrink: 0;">
<option value="">All States</option>
<option value="pausedup">Completed</option>
<option value="downloading">Downloading</option>
<option value="pausedup">Paused</option>
<option value="error">Error</option>
</select>
<select class="form-select form-select-sm d-inline-block w-auto" id="categoryFilter">
<option value="">All Categories</option>
</select>
<select class="form-select form-select-sm d-inline-block w-auto" id="sortSelector" style="flex-shrink: 0;">
<option value="added_on" selected>Date Added (Newest First)</option>
<option value="added_on_asc">Date Added (Oldest First)</option>
<option value="name_asc">Name (A-Z)</option>
<option value="name_desc">Name (Z-A)</option>
<option value="size_desc">Size (Largest First)</option>
<option value="size_asc">Size (Smallest First)</option>
<option value="progress_desc">Progress (Most First)</option>
<option value="progress_asc">Progress (Least First)</option>
</select>
</div>
</div>
<div class="card-body p-0">
@@ -43,6 +53,14 @@
</tbody>
</table>
</div>
<div class="d-flex justify-content-between align-items-center p-3 border-top">
<div class="pagination-info">
<span id="paginationInfo">Showing 0-0 of 0 torrents</span>
</div>
<nav aria-label="Torrents pagination">
<ul class="pagination pagination-sm m-0" id="paginationControls"></ul>
</nav>
</div>
</div>
</div>
</div>
@@ -51,9 +69,12 @@
torrentsList: document.getElementById('torrentsList'),
categoryFilter: document.getElementById('categoryFilter'),
stateFilter: document.getElementById('stateFilter'),
sortSelector: document.getElementById('sortSelector'),
selectAll: document.getElementById('selectAll'),
batchDeleteBtn: document.getElementById('batchDeleteBtn'),
refreshBtn: document.getElementById('refreshBtn'),
paginationControls: document.getElementById('paginationControls'),
paginationInfo: document.getElementById('paginationInfo')
};
let state = {
torrents: [],
@@ -62,6 +83,9 @@
states: new Set('downloading', 'pausedup', 'error'),
selectedCategory: refs.categoryFilter?.value || '',
selectedState: refs.stateFilter?.value || '',
sortBy: refs.sortSelector?.value || 'added_on',
itemsPerPage: 20,
currentPage: 1
};
const torrentRowTemplate = (torrent) => `
@@ -124,8 +148,19 @@
filteredTorrents = filteredTorrents.filter(t => t.state === state.selectedState);
}
// Sort the filtered torrents
filteredTorrents = sortTorrents(filteredTorrents, state.sortBy);
const totalPages = Math.ceil(filteredTorrents.length / state.itemsPerPage);
if (state.currentPage > totalPages && totalPages > 0) {
state.currentPage = totalPages;
}
const paginatedTorrents = paginateTorrents(filteredTorrents);
// Update the torrents list table
refs.torrentsList.innerHTML = filteredTorrents.map(torrent => torrentRowTemplate(torrent)).join('');
refs.torrentsList.innerHTML = paginatedTorrents.map(torrent => torrentRowTemplate(torrent)).join('');
// Update the category filter dropdown
const currentCategories = Array.from(state.categories).sort();
@@ -162,6 +197,56 @@
}
}
function sortTorrents(torrents, sortBy) {
// Create a copy of the array to avoid mutating the original
const result = [...torrents];
// Parse the sort value to determine field and direction
const [field, direction] = sortBy.includes('_asc') || sortBy.includes('_desc')
? [sortBy.split('_').slice(0, -1).join('_'), sortBy.endsWith('_asc') ? 'asc' : 'desc']
: [sortBy, 'desc']; // Default to descending if not specified
result.sort((a, b) => {
let valueA, valueB;
// Get values based on field
switch (field) {
case 'name':
valueA = a.name?.toLowerCase() || '';
valueB = b.name?.toLowerCase() || '';
break;
case 'size':
valueA = a.size || 0;
valueB = b.size || 0;
break;
case 'progress':
valueA = a.progress || 0;
valueB = b.progress || 0;
break;
case 'added_on':
valueA = a.added_on || 0;
valueB = b.added_on || 0;
break;
default:
valueA = a[field] || 0;
valueB = b[field] || 0;
}
// Compare based on type
if (typeof valueA === 'string') {
return direction === 'asc'
? valueA.localeCompare(valueB)
: valueB.localeCompare(valueA);
} else {
return direction === 'asc'
? valueA - valueB
: valueB - valueA;
}
});
return result;
}
async function deleteTorrent(hash, category) {
if (!confirm('Are you sure you want to delete this torrent?')) return;
@@ -194,6 +279,83 @@
}
}
function paginateTorrents(torrents) {
const totalItems = torrents.length;
const totalPages = Math.ceil(totalItems / state.itemsPerPage);
const startIndex = (state.currentPage - 1) * state.itemsPerPage;
const endIndex = Math.min(startIndex + state.itemsPerPage, totalItems);
// Update pagination info text
refs.paginationInfo.textContent =
`Showing ${totalItems > 0 ? startIndex + 1 : 0}-${endIndex} of ${totalItems} torrents`;
// Generate pagination controls
refs.paginationControls.innerHTML = '';
if (totalPages <= 1) {
return torrents.slice(startIndex, endIndex);
}
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${state.currentPage === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `
<a class="page-link" href="#" aria-label="Previous" ${state.currentPage === 1 ? 'tabindex="-1" aria-disabled="true"' : ''}>
<span aria-hidden="true">&laquo;</span>
</a>
`;
if (state.currentPage > 1) {
prevLi.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
state.currentPage--;
updateUI();
});
}
refs.paginationControls.appendChild(prevLi);
// Page numbers
const maxPageButtons = 5;
let startPage = Math.max(1, state.currentPage - Math.floor(maxPageButtons / 2));
let endPage = Math.min(totalPages, startPage + maxPageButtons - 1);
if (endPage - startPage + 1 < maxPageButtons) {
startPage = Math.max(1, endPage - maxPageButtons + 1);
}
for (let i = startPage; i <= endPage; i++) {
const pageLi = document.createElement('li');
pageLi.className = `page-item ${i === state.currentPage ? 'active' : ''}`;
pageLi.innerHTML = `<a class="page-link" href="#">${i}</a>`;
pageLi.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
state.currentPage = i;
updateUI();
});
refs.paginationControls.appendChild(pageLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${state.currentPage === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `
<a class="page-link" href="#" aria-label="Next" ${state.currentPage === totalPages ? 'tabindex="-1" aria-disabled="true"' : ''}>
<span aria-hidden="true">&raquo;</span>
</a>
`;
if (state.currentPage < totalPages) {
nextLi.querySelector('a').addEventListener('click', (e) => {
e.preventDefault();
state.currentPage++;
updateUI();
});
}
refs.paginationControls.appendChild(nextLi);
return torrents.slice(startIndex, endIndex);
}
document.addEventListener('DOMContentLoaded', () => {
loadTorrents();
const refreshInterval = setInterval(loadTorrents, 5000);
@@ -230,11 +392,19 @@
refs.categoryFilter.addEventListener('change', (e) => {
state.selectedCategory = e.target.value;
state.currentPage = 1; // Reset to first page
updateUI();
});
refs.stateFilter.addEventListener('change', (e) => {
state.selectedState = e.target.value;
state.currentPage = 1; // Reset to first page
updateUI();
});
refs.sortSelector.addEventListener('change', (e) => {
state.sortBy = e.target.value;
state.currentPage = 1; // Reset to first page
updateUI();
});
+280 -163
View File
@@ -1,146 +1,212 @@
{{ define "layout" }}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DecyphArr - {{.Title}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css"/>
<style>
:root {
--primary-color: #2563eb;
--secondary-color: #1e40af;
}
<!DOCTYPE html>
<html lang="en" data-bs-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DecyphArr - {{.Title}}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.7.2/font/bootstrap-icons.css" rel="stylesheet">
<link href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css" rel="stylesheet"/>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2-bootstrap-5-theme@1.3.0/dist/select2-bootstrap-5-theme.min.css"/>
<style>
:root {
--primary-color: #2563eb;
--secondary-color: #1e40af;
--bg-color: #f8fafc;
--card-bg: #ffffff;
--text-color: #333333;
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
--nav-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
--border-color: #e5e7eb;
}
body {
background-color: #f8fafc;
}
[data-bs-theme="dark"] {
--primary-color: #3b82f6;
--secondary-color: #60a5fa;
--bg-color: #1e293b;
--card-bg: #283548;
--text-color: #e5e7eb;
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
--nav-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
--border-color: #4b5563;
}
.navbar {
padding: 1rem 0;
background: #fff !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
body {
background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
}
.navbar-brand {
color: var(--primary-color) !important;
font-weight: 700;
font-size: 1.5rem;
}
.navbar {
padding: 1rem 0;
background: var(--card-bg) !important;
box-shadow: var(--nav-shadow);
border-bottom: 1px solid var(--border-color);
}
.card {
border: none;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.navbar-brand {
color: var(--primary-color) !important;
font-weight: 700;
font-size: 1.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
color: #4b5563;
}
.card {
border: none;
border-radius: 10px;
box-shadow: var(--card-shadow);
background-color: var(--card-bg);
}
.nav-link.active {
color: var(--primary-color) !important;
font-weight: 500;
}
.nav-link {
padding: 0.5rem 1rem;
color: var(--text-color);
}
.badge#channel-badge {
background-color: #0d6efd;
}
.nav-link.active {
color: var(--primary-color) !important;
font-weight: 500;
}
.badge#channel-badge.beta {
background-color: #fd7e14;
}
</style>
</head>
<body>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<!-- Toast messages will be created dynamically here -->
</div>
<nav class="navbar navbar-expand-lg navbar-light mb-4">
<div class="container">
<a class="navbar-brand" href="/">
<i class="bi bi-cloud-download me-2"></i>DecyphArr
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{if eq .Page "index"}}active{{end}}" href="/">
<i class="bi bi-table me-1"></i>Torrents
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "download"}}active{{end}}" href="/download">
<i class="bi bi-cloud-download me-1"></i>Download
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "repair"}}active{{end}}" href="/repair">
<i class="bi bi-tools me-1"></i>Repair
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="/config">
<i class="bi bi-gear me-1"></i>Config
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/logs" target="_blank">
.badge#channel-badge {
background-color: #0d6efd;
}
.badge#channel-badge.beta {
background-color: #fd7e14;
}
.badge#channel-badge.nightly {
background-color: #6c757d;
}
.table {
color: var(--text-color);
}
/* Dark mode specific overrides */
[data-bs-theme="dark"] .navbar-light .navbar-toggler-icon {
filter: invert(1);
}
[data-bs-theme="dark"] .form-control,
[data-bs-theme="dark"] .form-select {
background-color: #374151;
color: #e5e7eb;
border-color: #4b5563;
}
[data-bs-theme="dark"] .form-control:focus,
[data-bs-theme="dark"] .form-select:focus {
border-color: var(--primary-color);
}
/* Theme toggle button styles */
.theme-toggle {
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
width: 38px;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
transition: background-color 0.3s;
}
.theme-toggle:hover {
background-color: rgba(128, 128, 128, 0.2);
}
</style>
</head>
<body>
<div class="toast-container position-fixed bottom-0 end-0 p-3">
<!-- Toast messages will be created dynamically here -->
</div>
<nav class="navbar navbar-expand-lg navbar-light mb-4">
<div class="container">
<a class="navbar-brand" href="/">
<i class="bi bi-cloud-download me-2"></i>DecyphArr
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {{if eq .Page "index"}}active{{end}}" href="/">
<i class="bi bi-table me-1"></i>Torrents
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "download"}}active{{end}}" href="/download">
<i class="bi bi-cloud-download me-1"></i>Download
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "repair"}}active{{end}}" href="/repair">
<i class="bi bi-tools me-1"></i>Repair
</a>
</li>
<li class="nav-item">
<a class="nav-link {{if eq .Page "config"}}active{{end}}" href="/config">
<i class="bi bi-gear me-1"></i>Config
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/logs" target="_blank">
<i class="bi bi-journal me-1"></i>Logs
</a>
</li>
</ul>
<div class="d-flex align-items-center">
<span class="badge me-2" id="channel-badge">Loading...</span>
<span class="badge bg-primary" id="version-badge">Loading...</span>
</a>
</li>
</ul>
<div class="d-flex align-items-center">
<div class="theme-toggle me-3" id="themeToggle" title="Toggle dark mode">
<i class="bi bi-sun-fill" id="lightIcon"></i>
<i class="bi bi-moon-fill d-none" id="darkIcon"></i>
</div>
<span class="badge me-2" id="channel-badge">Loading...</span>
<span class="badge bg-primary" id="version-badge">Loading...</span>
</div>
</div>
</nav>
</div>
</nav>
{{ if eq .Page "index" }}
{{ template "index" . }}
{{ else if eq .Page "download" }}
{{ template "download" . }}
{{ else if eq .Page "repair" }}
{{ template "repair" . }}
{{ else if eq .Page "config" }}
{{ template "config" . }}
{{ else if eq .Page "login" }}
{{ template "login" . }}
{{ else if eq .Page "setup" }}
{{ template "setup" . }}
{{ else }}
{{ end }}
{{ if eq .Page "index" }}
{{ template "index" . }}
{{ else if eq .Page "download" }}
{{ template "download" . }}
{{ else if eq .Page "repair" }}
{{ template "repair" . }}
{{ else if eq .Page "config" }}
{{ template "config" . }}
{{ else if eq .Page "login" }}
{{ template "login" . }}
{{ else if eq .Page "setup" }}
{{ template "setup" . }}
{{ else }}
{{ end }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
/**
* Create a toast message
* @param {string} message - The message to display
* @param {string} [type='success'] - The type of toast (success, warning, error)
*/
const createToast = (message, type = 'success') => {
type = ['success', 'warning', 'error'].includes(type) ? type : 'success';
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script>
/**
* Create a toast message
* @param {string} message - The message to display
* @param {string} [type='success'] - The type of toast (success, warning, error)
*/
const createToast = (message, type = 'success') => {
type = ['success', 'warning', 'error'].includes(type) ? type : 'success';
const toastTimeouts = {
success: 5000,
warning: 10000,
error: 15000
}
const toastTimeouts = {
success: 5000,
warning: 10000,
error: 15000
};
const toastContainer = document.querySelector('.toast-container');
const toastId = `toast-${Date.now()}`;
const toastHtml = `
const toastContainer = document.querySelector('.toast-container');
const toastId = `toast-${Date.now()}`;
const toastHtml = `
<div id="${toastId}" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
<div class="toast-header ${type === 'error' ? 'bg-danger text-white' : type === 'warning' ? 'bg-warning text-dark' : 'bg-success text-white'}">
<strong class="me-auto">
@@ -153,44 +219,95 @@
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: toastTimeouts[type]
});
toast.show();
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
};
document.addEventListener('DOMContentLoaded', function() {
fetch('/internal/version')
.then(response => response.json())
.then(data => {
const versionBadge = document.getElementById('version-badge');
const channelBadge = document.getElementById('channel-badge');
toastContainer.insertAdjacentHTML('beforeend', toastHtml);
// Add url to version badge
versionBadge.innerHTML = `<a href="https://github.com/sirrobot01/debrid-blackhole/releases/tag/${data.version}" target="_blank" class="text-white">${data.version}</a>`;
channelBadge.textContent = data.channel.charAt(0).toUpperCase() + data.channel.slice(1);
if (data.channel === 'beta') {
channelBadge.classList.add('beta');
}
})
.catch(error => {
console.error('Error fetching version:', error);
document.getElementById('version-badge').textContent = 'Unknown';
document.getElementById('channel-badge').textContent = 'Unknown';
});
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, {
autohide: true,
delay: toastTimeouts[type]
});
</script>
</body>
</html>
toast.show();
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
};
// Theme management
const themeToggle = document.getElementById('themeToggle');
const lightIcon = document.getElementById('lightIcon');
const darkIcon = document.getElementById('darkIcon');
const htmlElement = document.documentElement;
// Function to set the theme
function setTheme(theme) {
htmlElement.setAttribute('data-bs-theme', theme);
localStorage.setItem('theme', theme);
if (theme === 'dark') {
lightIcon.classList.add('d-none');
darkIcon.classList.remove('d-none');
} else {
lightIcon.classList.remove('d-none');
darkIcon.classList.add('d-none');
}
}
// Check for saved theme preference or use system preference
const savedTheme = localStorage.getItem('theme');
if (savedTheme) {
setTheme(savedTheme);
} else {
// Check for system preference
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
setTheme('dark');
} else {
setTheme('light');
}
}
// Toggle theme when button is clicked
themeToggle.addEventListener('click', () => {
const currentTheme = htmlElement.getAttribute('data-bs-theme');
setTheme(currentTheme === 'dark' ? 'light' : 'dark');
});
// Listen for system theme changes
if (window.matchMedia) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', e => {
if (!localStorage.getItem('theme')) {
setTheme(e.matches ? 'dark' : 'light');
}
});
}
document.addEventListener('DOMContentLoaded', function() {
fetch('/internal/version')
.then(response => response.json())
.then(data => {
const versionBadge = document.getElementById('version-badge');
const channelBadge = document.getElementById('channel-badge');
// Add url to version badge
versionBadge.innerHTML = `<a href="https://github.com/sirrobot01/debrid-blackhole/releases/tag/${data.version}" target="_blank" class="text-white">${data.version}</a>`;
channelBadge.textContent = data.channel.charAt(0).toUpperCase() + data.channel.slice(1);
if (data.channel === 'beta') {
channelBadge.classList.add('beta');
} else if (data.channel === 'nightly') {
channelBadge.classList.add('nightly');
}
})
.catch(error => {
console.error('Error fetching version:', error);
document.getElementById('version-badge').textContent = 'Unknown';
document.getElementById('channel-badge').textContent = 'Unknown';
});
});
</script>
</body>
</html>
{{ end }}
+483 -3
View File
@@ -20,11 +20,20 @@
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
</div>
<div class="mb-3">
<div class="mb-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="isAsync" checked>
<label class="form-check-label" for="isAsync">
Run repair in background
Run in background
</label>
</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="autoProcess" checked>
<label class="form-check-label" for="autoProcess">
Auto Process(this will delete and re-search broken media)
</label>
</div>
</div>
@@ -35,7 +44,111 @@
</form>
</div>
</div>
<!-- Jobs Table Section -->
<div class="card mt-4">
<div class="card-header d-flex justify-content-between align-items-center">
<h4 class="mb-0"><i class="bi bi-list-task me-2"></i>Repair Jobs</h4>
<div>
<button id="deleteSelectedJobs" class="btn btn-sm btn-danger me-2" disabled>
<i class="bi bi-trash me-1"></i>Delete Selected
</button>
<button id="refreshJobs" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-arrow-clockwise me-1"></i>Refresh
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-striped table-hover" id="jobsTable">
<thead>
<tr>
<th>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="selectAllJobs">
</div>
</th>
<th>ID</th>
<th>Arr Instances</th>
<th>Started</th>
<th>Status</th>
<th>Broken Items</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="jobsTableBody">
<!-- Jobs will be loaded here -->
</tbody>
</table>
</div>
<!-- Pagination -->
<nav aria-label="Jobs pagination" class="mt-3">
<ul class="pagination justify-content-center" id="jobsPagination">
<!-- Pagination will be generated here -->
</ul>
</nav>
<div id="noJobsMessage" class="text-center py-3 d-none">
<p class="text-muted">No repair jobs found</p>
</div>
</div>
</div>
<!-- Job Details Modal -->
<div class="modal fade" id="jobDetailsModal" tabindex="-1" aria-labelledby="jobDetailsModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="jobDetailsModalLabel">Job Details</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="row mb-3">
<div class="col-md-6">
<p><strong>Job ID:</strong> <span id="modalJobId"></span></p>
<p><strong>Status:</strong> <span id="modalJobStatus"></span></p>
<p><strong>Started:</strong> <span id="modalJobStarted"></span></p>
<p><strong>Completed:</strong> <span id="modalJobCompleted"></span></p>
</div>
<div class="col-md-6">
<p><strong>Arrs:</strong> <span id="modalJobArrs"></span></p>
<p><strong>Media IDs:</strong> <span id="modalJobMediaIds"></span></p>
<p><strong>Auto Process:</strong> <span id="modalJobAutoProcess"></span></p>
</div>
</div>
<div id="errorContainer" class="alert alert-danger mb-3 d-none">
<strong>Error:</strong> <span id="modalJobError"></span>
</div>
<h6>Broken Items</h6>
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Arr</th>
<th>Path</th>
</tr>
</thead>
<tbody id="brokenItemsTableBody">
<!-- Broken items will be loaded here -->
</tbody>
</table>
</div>
<div id="noBrokenItemsMessage" class="text-center py-2 d-none">
<p class="text-muted">No broken items found</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
<button type="button" class="btn btn-primary" id="processJobBtn">Process Items</button>
</div>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', () => {
// Load Arr instances
@@ -76,12 +189,14 @@
body: JSON.stringify({
arr: document.getElementById('arrSelect').value,
mediaIds: mediaIds,
async: document.getElementById('isAsync').checked
async: document.getElementById('isAsync').checked,
autoProcess: document.getElementById('autoProcess').checked,
})
});
if (!response.ok) throw new Error(await response.text());
createToast('Repair process initiated successfully!');
loadJobs(1); // Refresh jobs after submission
} catch (error) {
createToast(`Error starting repair: ${error.message}`, 'error');
} finally {
@@ -89,6 +204,371 @@
submitBtn.innerHTML = originalText;
}
});
// Jobs table pagination variables
let currentPage = 1;
const itemsPerPage = 10;
let allJobs = [];
// Load jobs function
async function loadJobs(page) {
try {
const response = await fetch('/internal/repair/jobs');
if (!response.ok) throw new Error('Failed to fetch jobs');
allJobs = await response.json();
renderJobsTable(page);
} catch (error) {
console.error('Error loading jobs:', error);
createToast(`Error loading jobs: ${error.message}`, 'error');
}
}
// Render jobs table with pagination
function renderJobsTable(page) {
const tableBody = document.getElementById('jobsTableBody');
const paginationElement = document.getElementById('jobsPagination');
const noJobsMessage = document.getElementById('noJobsMessage');
const deleteSelectedBtn = document.getElementById('deleteSelectedJobs');
// Clear previous content
tableBody.innerHTML = '';
paginationElement.innerHTML = '';
document.getElementById('selectAllJobs').checked = false;
deleteSelectedBtn.disabled = true;
if (allJobs.length === 0) {
noJobsMessage.classList.remove('d-none');
return;
}
noJobsMessage.classList.add('d-none');
// Calculate pagination
const totalPages = Math.ceil(allJobs.length / itemsPerPage);
const startIndex = (page - 1) * itemsPerPage;
const endIndex = Math.min(startIndex + itemsPerPage, allJobs.length);
// Display jobs for current page
for (let i = startIndex; i < endIndex; i++) {
const job = allJobs[i];
const row = document.createElement('tr');
// Format date
const startedDate = new Date(job.created_at);
const formattedDate = startedDate.toLocaleString();
// Determine status
let status = 'In Progress';
let statusClass = 'text-primary';
let canDelete = false;
let totalItems = job.broken_items ? Object.values(job.broken_items).reduce((sum, arr) => sum + arr.length, 0) : 0;
if (job.status === 'failed') {
status = 'Failed';
statusClass = 'text-danger';
canDelete = true;
} else if (job.status === 'completed') {
status = 'Completed';
statusClass = 'text-success';
canDelete = true;
} else if (job.status === 'pending') {
status = 'Pending';
statusClass = 'text-warning';
}
row.innerHTML = `
<td>
<div class="form-check">
<input class="form-check-input job-checkbox" type="checkbox" value="${job.id}"
${canDelete ? '' : 'disabled'} data-can-delete="${canDelete}">
</div>
</td>
<td><a href="#" class="text-link view-job" data-id="${job.id}"><small>${job.id.substring(0, 8)}</small></a></td>
<td>${job.arrs.map(a => a.name).join(', ')}</td>
<td><small>${formattedDate}</small></td>
<td><span class="${statusClass}">${status}</span></td>
<td>${totalItems}</td>
<td>
${job.status === "pending" ?
`<button class="btn btn-sm btn-primary process-job" data-id="${job.id}">
<i class="bi bi-play-fill"></i> Process
</button>` :
`<button class="btn btn-sm btn-primary" disabled>
<i class="bi bi-eye"></i> Process
</button>`
}
${canDelete ?
`<button class="btn btn-sm btn-danger delete-job" data-id="${job.id}">
<i class="bi bi-trash"></i>
</button>` :
`<button class="btn btn-sm btn-danger" disabled>
<i class="bi bi-trash"></i>
</button>`
}
</td>
`;
tableBody.appendChild(row);
}
// Create pagination
if (totalPages > 1) {
// Previous button
const prevLi = document.createElement('li');
prevLi.className = `page-item ${page === 1 ? 'disabled' : ''}`;
prevLi.innerHTML = `<a class="page-link" href="#" aria-label="Previous" ${page !== 1 ? `data-page="${page - 1}"` : ''}>
<span aria-hidden="true">&laquo;</span>
</a>`;
paginationElement.appendChild(prevLi);
// Page numbers
for (let i = 1; i <= totalPages; i++) {
const pageLi = document.createElement('li');
pageLi.className = `page-item ${i === page ? 'active' : ''}`;
pageLi.innerHTML = `<a class="page-link" href="#" data-page="${i}">${i}</a>`;
paginationElement.appendChild(pageLi);
}
// Next button
const nextLi = document.createElement('li');
nextLi.className = `page-item ${page === totalPages ? 'disabled' : ''}`;
nextLi.innerHTML = `<a class="page-link" href="#" aria-label="Next" ${page !== totalPages ? `data-page="${page + 1}"` : ''}>
<span aria-hidden="true">&raquo;</span>
</a>`;
paginationElement.appendChild(nextLi);
}
// Add event listeners to pagination
document.querySelectorAll('#jobsPagination a[data-page]').forEach(link => {
link.addEventListener('click', (e) => {
e.preventDefault();
const newPage = parseInt(e.currentTarget.dataset.page);
currentPage = newPage;
renderJobsTable(newPage);
});
});
document.querySelectorAll('.job-checkbox').forEach(checkbox => {
checkbox.addEventListener('change', updateDeleteButtonState);
});
document.querySelectorAll('.delete-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
deleteJob(jobId);
});
});
// Add event listeners to action buttons
document.querySelectorAll('.process-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
processJob(jobId);
});
});
document.querySelectorAll('.view-job').forEach(button => {
button.addEventListener('click', (e) => {
const jobId = e.currentTarget.dataset.id;
viewJobDetails(jobId);
});
});
}
document.getElementById('selectAllJobs').addEventListener('change', function() {
const isChecked = this.checked;
document.querySelectorAll('.job-checkbox:not(:disabled)').forEach(checkbox => {
checkbox.checked = isChecked;
});
updateDeleteButtonState();
});
// Function to update delete button state
function updateDeleteButtonState() {
const deleteBtn = document.getElementById('deleteSelectedJobs');
const selectedCheckboxes = document.querySelectorAll('.job-checkbox:checked');
deleteBtn.disabled = selectedCheckboxes.length === 0;
}
// Delete selected jobs
document.getElementById('deleteSelectedJobs').addEventListener('click', async () => {
const selectedIds = Array.from(
document.querySelectorAll('.job-checkbox:checked')
).map(checkbox => checkbox.value);
if (!selectedIds.length) return;
if (confirm(`Are you sure you want to delete ${selectedIds.length} job(s)?`)) {
await deleteMultipleJobs(selectedIds);
}
});
async function deleteJob(jobId) {
if (confirm('Are you sure you want to delete this job?')) {
try {
const response = await fetch(`/internal/repair/jobs`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: [jobId] })
});
if (!response.ok) throw new Error(await response.text());
createToast('Job deleted successfully');
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error deleting job: ${error.message}`, 'error');
}
}
}
async function deleteMultipleJobs(jobIds) {
try {
const response = await fetch(`/internal/repair/jobs`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids: jobIds })
});
if (!response.ok) throw new Error(await response.text());
createToast(`${jobIds.length} job(s) deleted successfully`);
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error deleting jobs: ${error.message}`, 'error');
}
}
// Process job function
async function processJob(jobId) {
try {
const response = await fetch(`/internal/repair/jobs/${jobId}/process`, {
method: 'POST'
});
if (!response.ok) throw new Error(await response.text());
createToast('Job processing started successfully');
await loadJobs(currentPage); // Refresh the jobs list
} catch (error) {
createToast(`Error processing job: ${error.message}`, 'error');
}
}
// View job details function
function viewJobDetails(jobId) {
// Find the job
const job = allJobs.find(j => j.id === jobId);
if (!job) return;
// Prepare modal data
document.getElementById('modalJobId').textContent = job.id.substring(0, 8);
// Format dates
const startedDate = new Date(job.created_at);
document.getElementById('modalJobStarted').textContent = startedDate.toLocaleString();
if (job.finished_at) {
const completedDate = new Date(job.finished_at);
document.getElementById('modalJobCompleted').textContent = completedDate.toLocaleString();
} else {
document.getElementById('modalJobCompleted').textContent = 'N/A';
}
// Set status with color
let status = 'In Progress';
let statusClass = 'text-primary';
if (job.status === 'failed') {
status = 'Failed';
statusClass = 'text-danger';
} else if (job.status === 'completed') {
status = 'Completed';
statusClass = 'text-success';
} else if (job.status === 'pending') {
status = 'Pending';
statusClass = 'text-warning';
}
document.getElementById('modalJobStatus').innerHTML = `<span class="${statusClass}">${status}</span>`;
// Set other job details
document.getElementById('modalJobArrs').textContent = job.arrs.map(a => a.name).join(', ');
document.getElementById('modalJobMediaIds').textContent = job.media_ids && job.media_ids.length > 0 ?
job.media_ids.join(', ') : 'All';
document.getElementById('modalJobAutoProcess').textContent = job.auto_process ? 'Yes' : 'No';
// Show/hide error message
const errorContainer = document.getElementById('errorContainer');
if (job.error) {
document.getElementById('modalJobError').textContent = job.error;
errorContainer.classList.remove('d-none');
} else {
errorContainer.classList.add('d-none');
}
// Process button visibility
const processBtn = document.getElementById('processJobBtn');
if (job.status === 'pending') {
processBtn.classList.remove('d-none');
processBtn.onclick = () => {
processJob(job.id);
const modal = bootstrap.Modal.getInstance(document.getElementById('jobDetailsModal'));
modal.hide();
};
} else {
processBtn.classList.add('d-none');
}
// Populate broken items table
const brokenItemsTableBody = document.getElementById('brokenItemsTableBody');
const noBrokenItemsMessage = document.getElementById('noBrokenItemsMessage');
brokenItemsTableBody.innerHTML = '';
let hasBrokenItems = false;
// Check if broken_items exists and has entries
if (job.broken_items && Object.entries(job.broken_items).length > 0) {
hasBrokenItems = true;
// Loop through each Arr's broken items
for (const [arrName, items] of Object.entries(job.broken_items)) {
if (items && items.length > 0) {
// Add each item to the table
items.forEach(item => {
const row = document.createElement('tr');
row.innerHTML = `
<td>${arrName}</td>
<td><small class="text-muted">${item.path}</small></td>
`;
brokenItemsTableBody.appendChild(row);
});
}
}
}
// Show/hide no items message
if (hasBrokenItems) {
noBrokenItemsMessage.classList.add('d-none');
} else {
noBrokenItemsMessage.classList.remove('d-none');
}
// Show the modal
const modal = new bootstrap.Modal(document.getElementById('jobDetailsModal'));
modal.show();
}
// Add event listener for refresh button
document.getElementById('refreshJobs').addEventListener('click', () => {
loadJobs(currentPage);
});
// Load jobs on page load
loadJobs(1);
});
</script>
{{ end }}
+1 -1
View File
@@ -100,7 +100,7 @@ func cleanUpQueues() {
if !a.Cleanup {
continue
}
_logger.Debug().Msgf("Cleaning up queue for %s", a.Name)
_logger.Trace().Msgf("Cleaning up queue for %s", a.Name)
if err := a.CleanupQueue(); err != nil {
_logger.Debug().Err(err).Msg("Error cleaning up queue")
}