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

View File

@@ -5,7 +5,7 @@ tmp_dir = "tmp"
[build] [build]
args_bin = ["--config", "data/"] args_bin = ["--config", "data/"]
bin = "./tmp/main" bin = "./tmp/main"
cmd = "bash -c 'go build -ldflags \"-X github.com/sirrobot01/debrid-blackhole/pkg/version.Version=0.0.0 -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel=beta\" -o ./tmp/main .'" cmd = "bash -c 'go build -ldflags \"-X github.com/sirrobot01/debrid-blackhole/pkg/version.Version=0.0.0 -X github.com/sirrobot01/debrid-blackhole/pkg/version.Channel=nightly\" -o ./tmp/main .'"
delay = 1000 delay = 1000
exclude_dir = ["assets", "tmp", "vendor", "testdata", "data"] exclude_dir = ["assets", "tmp", "vendor", "testdata", "data"]
exclude_file = [] exclude_file = []

View File

@@ -145,3 +145,17 @@
- Fix saving torrents error - Fix saving torrents error
- Fix bugs with the UI - Fix bugs with the UI
- Speed improvements - Speed improvements
#### 0.5.0
- A more refined repair worker(with more control)
- UI Improvements
- Pagination for torrents
- Dark mode
- Ordered torrents table
- Fix Arr API flaky behavior
- Discord Notifications
- Minor bug fixes
- Add Tautulli support
- playback_failed event triggers a repair

View File

@@ -48,20 +48,35 @@ The proxy is useful for filtering out un-cached Debrid torrents
### Installation ### Installation
##### Docker Compose ##### Docker
###### Registry
You can use either hub.docker.com or ghcr.io to pull the image. The image is available on both platforms.
- Docker Hub: `cy01/blackhole:latest`
- GitHub Container Registry: `ghcr.io/sirrobot01/decypharr:latest`
###### Tags
- `latest`: The latest stable release
- `beta`: The latest beta release
- `vX.Y.Z`: A specific version (e.g `v0.1.0`)
- `nightly`: The latest nightly build. This is highly unstable
```yaml ```yaml
version: '3.7' version: '3.7'
services: services:
blackhole: decypharr:
image: cy01/blackhole:latest # or cy01/blackhole:beta image: cy01/blackhole:latest # or cy01/blackhole:beta
container_name: blackhole container_name: decypharr
ports: ports:
- "8282:8282" # qBittorrent - "8282:8282" # qBittorrent
- "8181:8181" # Proxy - "8181:8181" # Proxy
user: "1000:1000" user: "1000:1000"
volumes: volumes:
- /mnt/:/mnt - /mnt/:/mnt
- ~/plex/configs/blackhole/:/app # config.json must be in this directory - ~/plex/configs/decypharr/:/app # config.json must be in this directory
environment: environment:
- PUID=1000 - PUID=1000
- PGID=1000 - PGID=1000
@@ -78,7 +93,7 @@ services:
Download the binary from the releases page and run it with the config file. Download the binary from the releases page and run it with the config file.
```bash ```bash
./blackhole --config /app ./decypharr --config /app
``` ```
### Usage ### Usage
@@ -116,7 +131,7 @@ This is the default config file. You can create a `config.json` file in the root
} }
], ],
"proxy": { "proxy": {
"enabled": true, "enabled": false,
"port": "8100", "port": "8100",
"username": "username", "username": "username",
"password": "password" "password": "password"
@@ -124,7 +139,8 @@ This is the default config file. You can create a `config.json` file in the root
"qbittorrent": { "qbittorrent": {
"port": "8282", "port": "8282",
"download_folder": "/mnt/symlinks/", "download_folder": "/mnt/symlinks/",
"categories": ["sonarr", "radarr"] "categories": ["sonarr", "radarr"],
"log_level": "info"
}, },
"repair": { "repair": {
"enabled": false, "enabled": false,
@@ -147,6 +163,7 @@ Full config are [here](doc/config.full.json)
- The `max_cache_size` key is used to set the maximum number of infohashes that can be stored in the availability cache. This is used to prevent round trip to the debrid provider when using the proxy/Qbittorrent. The default value is `1000` - The `max_cache_size` key is used to set the maximum number of infohashes that can be stored in the availability cache. This is used to prevent round trip to the debrid provider when using the proxy/Qbittorrent. The default value is `1000`
- The `allowed_file_types` key is an array of allowed file types that can be downloaded. By default, all movie, tv show and music file types are allowed - The `allowed_file_types` key is an array of allowed file types that can be downloaded. By default, all movie, tv show and music file types are allowed
- The `use_auth` is used to enable basic authentication for the UI. The default value is `false` - The `use_auth` is used to enable basic authentication for the UI. The default value is `false`
- The `discord_webhook_url` is used to send notifications to discord
##### Debrid Config ##### Debrid Config
- The `debrids` key is an array of debrid providers - The `debrids` key is an array of debrid providers
@@ -164,7 +181,7 @@ The `repair` 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 `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 - The `run_on_start` key is used to run the repair worker on start
- The `zurg_url` is the url of the zurg server. Typically `http://localhost:9999` or `http://zurg:9999` - The `zurg_url` is the url of the zurg server. Typically `http://localhost:9999` or `http://zurg:9999`
- The `skip_deletion`: true if you don't want to delete the files - The `auto_process` is used to automatically process the repair worker. This will delete broken symlinks and re-search for missing files
##### Proxy Config ##### Proxy Config
- The `enabled` key is used to enable the proxy - The `enabled` key is used to enable the proxy
@@ -191,15 +208,6 @@ This is particularly useful if you want to use the Repair tool without using Qbi
</details> </details>
### Proxy
**Note**: Proxy has stopped working for Real Debrid, Debrid Link, and All Debrid. It still works for Torbox. This is due to the changes in the API of the Debrid Providers.
The proxy is useful in filtering out un-cached Debrid torrents.
The proxy is a simple HTTP proxy that requires basic authentication. The proxy can be enabled by setting the `proxy.enabled` to `true` in the config file.
The proxy listens on the port `8181` by default. The username and password can be set in the config file.
### Repair Worker ### 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. 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.
@@ -211,6 +219,14 @@ The repair worker is a simple worker that checks for missing files in the Arrs(S
- Search for deleted/unreadable files - Search for deleted/unreadable files
### Proxy
#### **Note**: Proxy has stopped working for Real Debrid, Debrid Link, and All Debrid. It still works for Torbox. This is due to the changes in the API of the Debrid Providers.
The proxy is useful in filtering out un-cached Debrid torrents.
The proxy is a simple HTTP proxy that requires basic authentication. The proxy can be enabled by setting the `proxy.enabled` to `true` in the config file.
The proxy listens on the port `8181` by default. The username and password can be set in the config file.
### Changelog ### Changelog
- View the [CHANGELOG.md](CHANGELOG.md) for the latest changes - View the [CHANGELOG.md](CHANGELOG.md) for the latest changes

View File

@@ -20,7 +20,7 @@ func Start(ctx context.Context) error {
var wg sync.WaitGroup var wg sync.WaitGroup
errChan := make(chan error) errChan := make(chan error)
_log := logger.GetLogger(cfg.LogLevel) _log := logger.GetDefaultLogger()
_log.Info().Msgf("Version: %s", version.GetInfo().String()) _log.Info().Msgf("Version: %s", version.GetInfo().String())
_log.Debug().Msgf("Config Loaded: %s", cfg.JsonFile()) _log.Debug().Msgf("Config Loaded: %s", cfg.JsonFile())

View File

@@ -58,7 +58,7 @@
"name": "sonarr", "name": "sonarr",
"host": "http://host:8989", "host": "http://host:8989",
"token": "arr_key", "token": "arr_key",
"cleanup": false "cleanup": true
}, },
{ {
"name": "radarr", "name": "radarr",
@@ -72,11 +72,12 @@
"interval": "12h", "interval": "12h",
"run_on_start": false, "run_on_start": false,
"zurg_url": "http://zurg:9999", "zurg_url": "http://zurg:9999",
"skip_deletion": false "auto_process": false
}, },
"log_level": "info", "log_level": "info",
"min_file_size": "", "min_file_size": "",
"max_file_size": "", "max_file_size": "",
"allowed_file_types": [], "allowed_file_types": [],
"use_auth": false "use_auth": false,
"discord_webhook_url": "https://discord.com/api/webhooks/...",
} }

1
go.mod
View File

@@ -16,6 +16,7 @@ require (
github.com/valyala/fastjson v1.6.4 github.com/valyala/fastjson v1.6.4
golang.org/x/crypto v0.33.0 golang.org/x/crypto v0.33.0
golang.org/x/net v0.33.0 golang.org/x/net v0.33.0
golang.org/x/sync v0.11.0
golang.org/x/time v0.8.0 golang.org/x/time v0.8.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
) )

2
go.sum
View File

@@ -243,6 +243,8 @@ golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w=
golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=

View File

@@ -56,7 +56,7 @@ type Repair struct {
Interval string `json:"interval"` Interval string `json:"interval"`
RunOnStart bool `json:"run_on_start"` RunOnStart bool `json:"run_on_start"`
ZurgURL string `json:"zurg_url"` ZurgURL string `json:"zurg_url"`
SkipDeletion bool `json:"skip_deletion"` AutoProcess bool `json:"auto_process"`
} }
type Auth struct { type Auth struct {
@@ -79,6 +79,7 @@ type Config struct {
Path string `json:"-"` // Path to save the config file Path string `json:"-"` // Path to save the config file
UseAuth bool `json:"use_auth"` UseAuth bool `json:"use_auth"`
Auth *Auth `json:"-"` Auth *Auth `json:"-"`
DiscordWebhook string `json:"discord_webhook_url"`
} }
func (c *Config) JsonFile() string { func (c *Config) JsonFile() string {
@@ -207,10 +208,7 @@ func GetConfig() *Config {
once.Do(func() { once.Do(func() {
instance = &Config{} // Initialize instance first instance = &Config{} // Initialize instance first
if err := instance.loadConfig(); err != nil { if err := instance.loadConfig(); err != nil {
_, err := fmt.Fprintf(os.Stderr, "configuration Error: %v\n", err) fmt.Fprintf(os.Stderr, "configuration Error: %v\n", err)
if err != nil {
return
}
os.Exit(1) os.Exit(1)
} }
}) })

View File

@@ -85,9 +85,10 @@ func NewLogger(prefix string, level string, output *os.File) zerolog.Logger {
return logger return logger
} }
func GetLogger(level string) zerolog.Logger { func GetDefaultLogger() zerolog.Logger {
once.Do(func() { once.Do(func() {
logger = NewLogger("decypharr", level, os.Stdout) cfg := config.GetConfig()
logger = NewLogger("decypharr", cfg.LogLevel, os.Stdout)
}) })
return logger return logger
} }

100
internal/request/discord.go Normal file
View File

@@ -0,0 +1,100 @@
package request
import (
"bytes"
"encoding/json"
"fmt"
"github.com/sirrobot01/debrid-blackhole/internal/config"
"io"
"net/http"
"strings"
)
type DiscordEmbed struct {
Title string `json:"title"`
Description string `json:"description"`
Color int `json:"color"`
}
type DiscordWebhook struct {
Embeds []DiscordEmbed `json:"embeds"`
}
func getDiscordColor(status string) int {
switch status {
case "success":
return 3066993
case "error":
return 15158332
case "warning":
return 15844367
case "pending":
return 3447003
default:
return 0
}
}
func getDiscordHeader(event string) string {
switch event {
case "download_complete":
return "[Decypharr] Download Completed"
case "download_failed":
return "[Decypharr] Download Failed"
case "repair_pending":
return "[Decypharr] Repair Completed, Awaiting action"
case "repair_complete":
return "[Decypharr] Repair Complete"
default:
// split the event string and capitalize the first letter of each word
evs := strings.Split(event, "_")
for i, ev := range evs {
evs[i] = strings.ToTitle(ev)
}
return "[Decypharr] %s" + strings.Join(evs, " ")
}
}
func SendDiscordMessage(event string, status string, message string) error {
cfg := config.GetConfig()
webhookURL := cfg.DiscordWebhook
if webhookURL == "" {
return nil
}
// Create the proper Discord webhook structure
webhook := DiscordWebhook{
Embeds: []DiscordEmbed{
{
Title: getDiscordHeader(event),
Description: message,
Color: getDiscordColor(status),
},
},
}
payload, err := json.Marshal(webhook)
if err != nil {
return fmt.Errorf("failed to marshal discord payload: %v", err)
}
req, err := http.NewRequest(http.MethodPost, webhookURL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("failed to create discord request: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send discord message: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("discord returned error status code: %s, body: %s", resp.Status, string(bodyBytes))
}
return nil
}

View File

@@ -119,14 +119,17 @@ func (c *RLHTTPClient) MakeRequest(req *http.Request) ([]byte, error) {
func NewRLHTTPClient(rl *rate.Limiter, headers map[string]string) *RLHTTPClient { func NewRLHTTPClient(rl *rate.Limiter, headers map[string]string) *RLHTTPClient {
tr := &http.Transport{ tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
Proxy: http.ProxyFromEnvironment,
} }
c := &RLHTTPClient{ c := &RLHTTPClient{
client: &http.Client{ client: &http.Client{
Transport: tr, Transport: tr,
}, },
Ratelimiter: rl, }
Headers: headers, if rl != nil {
c.Ratelimiter = rl
}
if headers != nil {
c.Headers = headers
} }
return c return c
} }

View File

@@ -2,13 +2,16 @@ package arr
import ( import (
"bytes" "bytes"
"crypto/tls"
"encoding/json" "encoding/json"
"fmt" "fmt"
"github.com/sirrobot01/debrid-blackhole/internal/config" "github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/request" "github.com/sirrobot01/debrid-blackhole/internal/request"
"io"
"net/http" "net/http"
"strings" "strings"
"sync" "sync"
"time"
) )
// Type is a type of arr // Type is a type of arr
@@ -21,51 +24,84 @@ const (
Readarr Type = "readarr" Readarr Type = "readarr"
) )
var (
client *request.RLHTTPClient = request.NewRLHTTPClient(nil, nil)
)
type Arr struct { type Arr struct {
Name string `json:"name"` Name string `json:"name"`
Host string `json:"host"` Host string `json:"host"`
Token string `json:"token"` Token string `json:"token"`
Type Type `json:"type"` Type Type `json:"type"`
Cleanup bool `json:"cleanup"` Cleanup bool `json:"cleanup"`
client *http.Client
} }
func New(name, host, token string, cleanup bool) *Arr { func New(name, host, token string, cleanup bool) *Arr {
return &Arr{ return &Arr{
Name: name, Name: name,
Host: host, Host: host,
Token: token, Token: strings.TrimSpace(token),
Type: InferType(host, name), Type: InferType(host, name),
Cleanup: cleanup, 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) { func (a *Arr) Request(method, endpoint string, payload interface{}) (*http.Response, error) {
if a.Token == "" || a.Host == "" { if a.Token == "" || a.Host == "" {
return nil, nil return nil, fmt.Errorf("arr not configured")
} }
url, err := request.JoinURL(a.Host, endpoint) url, err := request.JoinURL(a.Host, endpoint)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var jsonPayload []byte var body io.Reader
if payload != nil { if payload != nil {
jsonPayload, err = json.Marshal(payload) b, err := json.Marshal(payload)
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-Api-Key", a.Token) 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 { func (a *Arr) Validate() error {

View File

@@ -7,25 +7,38 @@ import (
"strconv" "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 // 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 { if err != nil {
return nil, err return nil, err
} }
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
// This is likely Radarr // This is likely Radarr
return GetMovies(a, tvId) return GetMovies(a, mediaId)
} }
a.Type = Sonarr a.Type = Sonarr
defer resp.Body.Close()
type series struct { type series struct {
Title string `json:"title"` Title string `json:"title"`
Id int `json:"id"` Id int `json:"id"`
} }
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get series: %s", resp.Status)
}
var data []series var data []series
if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { 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 // Get series files
contents := make([]Content, 0) contents := make([]Content, 0)
@@ -43,11 +56,6 @@ func (a *Arr) GetMedia(tvId string) ([]Content, error) {
Title: d.Title, Title: d.Title,
Id: d.Id, 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) resp, err = a.Request(http.MethodGet, fmt.Sprintf("api/v3/episode?seriesId=%d", d.Id), nil)
if err != nil { if err != nil {
continue continue
@@ -67,12 +75,20 @@ func (a *Arr) GetMedia(tvId string) ([]Content, error) {
if !ok { if !ok {
eId = 0 eId = 0
} }
if file.Id == 0 || file.Path == "" {
// Skip files without path
continue
}
files = append(files, ContentFile{ files = append(files, ContentFile{
FileId: file.Id, FileId: file.Id,
Path: file.Path, Path: file.Path,
Id: eId, Id: eId,
}) })
} }
if len(files) == 0 {
// Skip series without files
continue
}
ct.Files = files ct.Files = files
contents = append(contents, ct) contents = append(contents, ct)
} }
@@ -92,7 +108,7 @@ func GetMovies(a *Arr, tvId string) ([]Content, error) {
defer resp.Body.Close() defer resp.Body.Close()
var movies []Movie var movies []Movie
if err = json.NewDecoder(resp.Body).Decode(&movies); err != nil { 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) contents := make([]Content, 0)
for _, movie := range movies { for _, movie := range movies {
@@ -101,6 +117,10 @@ func GetMovies(a *Arr, tvId string) ([]Content, error) {
Id: movie.Id, Id: movie.Id,
} }
files := make([]ContentFile, 0) files := make([]ContentFile, 0)
if movie.MovieFile.Id == 0 || movie.MovieFile.Path == "" {
// Skip movies without files
continue
}
files = append(files, ContentFile{ files = append(files, ContentFile{
FileId: movie.MovieFile.Id, FileId: movie.MovieFile.Id,
Id: movie.Id, Id: movie.Id,

View File

@@ -8,7 +8,6 @@ type Movie struct {
MovieId int `json:"movieId"` MovieId int `json:"movieId"`
RelativePath string `json:"relativePath"` RelativePath string `json:"relativePath"`
Path string `json:"path"` Path string `json:"path"`
Size int `json:"size"`
Id int `json:"id"` Id int `json:"id"`
} `json:"movieFile"` } `json:"movieFile"`
Id int `json:"id"` Id int `json:"id"`

View File

@@ -3,6 +3,7 @@ package torrent
import ( import (
"fmt" "fmt"
"github.com/sirrobot01/debrid-blackhole/internal/cache" "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/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/arr" "github.com/sirrobot01/debrid-blackhole/pkg/arr"
"os" "os"
@@ -66,6 +67,7 @@ func (t *Torrent) GetSymlinkFolder(parent string) string {
} }
func (t *Torrent) GetMountFolder(rClonePath string) (string, error) { func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
_log := logger.GetDefaultLogger()
possiblePaths := []string{ possiblePaths := []string{
t.OriginalFilename, t.OriginalFilename,
t.Filename, t.Filename,
@@ -73,7 +75,9 @@ func (t *Torrent) GetMountFolder(rClonePath string) (string, error) {
} }
for _, path := range possiblePaths { 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) { if !os.IsNotExist(err) {
return path, nil return path, nil
} }

View File

@@ -187,7 +187,7 @@ func (q *QBit) getTorrentPath(rclonePath string, debridTorrent *debrid.Torrent)
q.logger.Debug().Msgf("Found torrent path: %s", torrentPath) q.logger.Debug().Msgf("Found torrent path: %s", torrentPath)
return torrentPath, err return torrentPath, err
} }
time.Sleep(100 * time.Millisecond) time.Sleep(10 * time.Millisecond)
} }
} }

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"sort"
"sync" "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 return torrents
} }

View File

@@ -4,6 +4,7 @@ import (
"cmp" "cmp"
"context" "context"
"fmt" "fmt"
"github.com/sirrobot01/debrid-blackhole/internal/request"
"github.com/sirrobot01/debrid-blackhole/internal/utils" "github.com/sirrobot01/debrid-blackhole/internal/utils"
"github.com/sirrobot01/debrid-blackhole/pkg/arr" "github.com/sirrobot01/debrid-blackhole/pkg/arr"
db "github.com/sirrobot01/debrid-blackhole/pkg/debrid" 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 torrent.TorrentPath = torrentSymlinkPath
q.UpdateTorrent(torrent, debridTorrent) 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 { if err := arr.Refresh(); err != nil {
q.logger.Error().Msgf("Error refreshing arr: %v", err) 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 { func (q *QBit) MarkAsFailed(t *Torrent) *Torrent {
t.State = "error" t.State = "error"
q.Storage.AddOrUpdate(t) 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 return t
} }

View File

@@ -1,6 +1,7 @@
package qbit package qbit
import ( import (
"fmt"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent" "github.com/sirrobot01/debrid-blackhole/pkg/debrid/torrent"
"sync" "sync"
) )
@@ -230,6 +231,17 @@ func (t *Torrent) IsReady() bool {
return t.AmountLeft <= 0 && t.TorrentPath != "" 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 { type TorrentProperties struct {
AdditionDate int64 `json:"addition_date,omitempty"` AdditionDate int64 `json:"addition_date,omitempty"`
Comment string `json:"comment,omitempty"` Comment string `json:"comment,omitempty"`

View File

@@ -2,20 +2,23 @@ package repair
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/rs/zerolog" "github.com/rs/zerolog"
"github.com/sirrobot01/debrid-blackhole/internal/config" "github.com/sirrobot01/debrid-blackhole/internal/config"
"github.com/sirrobot01/debrid-blackhole/internal/logger" "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/arr"
"github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine" "github.com/sirrobot01/debrid-blackhole/pkg/debrid/engine"
"log" "golang.org/x/sync/errgroup"
"net/http" "net/http"
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"path/filepath" "path/filepath"
"runtime" "runtime"
"sort"
"strings" "strings"
"sync" "sync"
"syscall" "syscall"
@@ -23,17 +26,19 @@ import (
) )
type Repair struct { type Repair struct {
Jobs []Job `json:"jobs"` Jobs map[string]*Job
arrs *arr.Storage arrs *arr.Storage
deb engine.Service deb engine.Service
duration time.Duration duration time.Duration
runOnStart bool runOnStart bool
ZurgURL string ZurgURL string
IsZurg bool IsZurg bool
autoProcess bool
logger zerolog.Logger logger zerolog.Logger
filename string
} }
func New(deb *engine.Engine, arrs *arr.Storage) *Repair { func New(arrs *arr.Storage) *Repair {
cfg := config.GetConfig() cfg := config.GetConfig()
duration, err := parseSchedule(cfg.Repair.Interval) duration, err := parseSchedule(cfg.Repair.Interval)
if err != nil { if err != nil {
@@ -41,39 +46,110 @@ func New(deb *engine.Engine, arrs *arr.Storage) *Repair {
} }
r := &Repair{ r := &Repair{
arrs: arrs, arrs: arrs,
deb: deb.Get(),
logger: logger.NewLogger("repair", cfg.LogLevel, os.Stdout), logger: logger.NewLogger("repair", cfg.LogLevel, os.Stdout),
duration: duration, duration: duration,
runOnStart: cfg.Repair.RunOnStart, runOnStart: cfg.Repair.RunOnStart,
ZurgURL: cfg.Repair.ZurgURL, ZurgURL: cfg.Repair.ZurgURL,
autoProcess: cfg.Repair.AutoProcess,
filename: filepath.Join(cfg.Path, "repair.json"),
} }
if r.ZurgURL != "" { if r.ZurgURL != "" {
r.IsZurg = true r.IsZurg = true
} }
// Load jobs from file
r.loadFromFile()
return r return r
} }
type JobStatus string
const (
JobStarted JobStatus = "started"
JobPending JobStatus = "pending"
JobFailed JobStatus = "failed"
JobCompleted JobStatus = "completed"
)
type Job struct { type Job struct {
ID string `json:"id"` ID string `json:"id"`
Arrs []*arr.Arr `json:"arrs"` Arrs []*arr.Arr `json:"arrs"`
MediaIDs []string `json:"media_ids"` MediaIDs []string `json:"media_ids"`
OneOff bool `json:"one_off"`
StartedAt time.Time `json:"created_at"` StartedAt time.Time `json:"created_at"`
BrokenItems map[string][]arr.ContentFile `json:"broken_items"`
Status JobStatus `json:"status"`
CompletedAt time.Time `json:"finished_at"` CompletedAt time.Time `json:"finished_at"`
FailedAt time.Time `json:"failed_at"` FailedAt time.Time `json:"failed_at"`
AutoProcess bool `json:"auto_process"`
Error string `json:"error"` 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{ return &Job{
ID: uuid.New().String(), ID: uuid.New().String(),
Arrs: arrs, Arrs: arrs,
MediaIDs: mediaIDs, MediaIDs: mediaIDs,
StartedAt: time.Now(), StartedAt: time.Now(),
Status: JobStarted,
} }
} }
func (r *Repair) PreRunChecks() error { func (r *Repair) preRunChecks() error {
// Check if zurg url is reachable // Check if zurg url is reachable
if !r.IsZurg { if !r.IsZurg {
return nil return nil
@@ -90,43 +166,119 @@ func (r *Repair) PreRunChecks() error {
return nil 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) func (r *Repair) repair(job *Job) error {
if err := r.preRunChecks(); err != nil {
if err := r.PreRunChecks(); err != nil {
return err return err
} }
var wg sync.WaitGroup
errors := make(chan error) // Create a new error group with context
for _, a := range j.Arrs { g, ctx := errgroup.WithContext(context.Background())
wg.Add(1)
go func(a *arr.Arr) { // Use a mutex to protect concurrent access to brokenItems
defer wg.Done() var mu sync.Mutex
if len(j.MediaIDs) == 0 { brokenItems := map[string][]arr.ContentFile{}
if err := r.RepairArr(a, ""); err != nil {
log.Printf("Error repairing %s: %v", a.Name, err) for _, a := range job.Arrs {
errors <- err 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 { } else {
for _, id := range j.MediaIDs { for _, id := range job.MediaIDs {
if err := r.RepairArr(a, id); err != nil { // Check if any other goroutine has failed
log.Printf("Error repairing %s: %v", a.Name, err) select {
errors <- err case <-ctx.Done():
return ctx.Err()
default:
} }
}
} someItems, err := r.repairArr(job, a, id)
}(a)
}
wg.Wait()
close(errors)
err := <-errors
if err != nil { if err != nil {
j.FailedAt = time.Now() r.logger.Error().Err(err).Msgf("Error repairing %s with ID %s", a.Name, id)
j.Error = err.Error()
return err return err
} }
j.CompletedAt = time.Now() items = append(items, someItems...)
}
}
// Safely append the found items to the shared slice
if len(items) > 0 {
mu.Lock()
brokenItems[a.Name] = items
mu.Unlock()
}
return nil
})
}
// 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
}
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 return nil
} }
@@ -138,8 +290,8 @@ func (r *Repair) Start(ctx context.Context) error {
if r.runOnStart { if r.runOnStart {
r.logger.Info().Msgf("Running initial repair") r.logger.Info().Msgf("Running initial repair")
go func() { go func() {
if err := r.Repair(r.arrs.GetAll(), []string{}); err != nil { if err := r.AddJob([]string{}, []string{}, r.autoProcess); err != nil {
r.logger.Info().Msgf("Error during initial repair: %v", err) r.logger.Error().Err(err).Msg("Error running initial repair")
} }
}() }()
} }
@@ -156,9 +308,8 @@ func (r *Repair) Start(ctx context.Context) error {
return nil return nil
case t := <-ticker.C: case t := <-ticker.C:
r.logger.Info().Msgf("Running repair at %v", t.Format("15:04:05")) r.logger.Info().Msgf("Running repair at %v", t.Format("15:04:05"))
err := r.Repair(r.arrs.GetAll(), []string{}) if err := r.AddJob([]string{}, []string{}, r.autoProcess); err != nil {
if err != nil { r.logger.Error().Err(err).Msg("Error running repair")
r.logger.Info().Msgf("Error during repair: %v", err)
} }
// If using time-of-day schedule, reset the ticker for next day // 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 { func (r *Repair) repairArr(j *Job, a *arr.Arr, tmdbId string) ([]arr.ContentFile, error) {
brokenItems := make([]arr.ContentFile, 0)
cfg := config.GetConfig()
r.logger.Info().Msgf("Starting repair for %s", a.Name) r.logger.Info().Msgf("Starting repair for %s", a.Name)
media, err := a.GetMedia(tmdbId) media, err := a.GetMedia(tmdbId)
if err != nil { if err != nil {
r.logger.Info().Msgf("Failed to get %s media: %v", a.Name, err) 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) r.logger.Info().Msgf("Found %d %s media", len(media), a.Name)
if len(media) == 0 { if len(media) == 0 {
r.logger.Info().Msgf("No %s media found", a.Name) r.logger.Info().Msgf("No %s media found", a.Name)
return nil return brokenItems, nil
} }
// Check first media to confirm mounts are accessible // Check first media to confirm mounts are accessible
if !r.isMediaAccessible(media[0]) { if !r.isMediaAccessible(media[0]) {
r.logger.Info().Msgf("Skipping repair. Parent directory not accessible for. Check your mounts") 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) // Create a new error group
totalBrokenItems := 0 g, ctx := errgroup.WithContext(context.Background())
var wg sync.WaitGroup
// Limit concurrent goroutines
g.SetLimit(runtime.NumCPU() * 4)
// Mutex for brokenItems
var mu sync.Mutex
for _, m := range media { for _, m := range media {
wg.Add(1) m := m // Create a new variable scoped to the loop iteration
semaphore <- struct{}{} g.Go(func() error {
go func(m arr.Content) { // Check if context was canceled
defer wg.Done() select {
defer func() { <-semaphore }() case <-ctx.Done():
brokenItems := r.getBrokenFiles(m) return ctx.Err()
if brokenItems != nil { default:
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 { items := r.getBrokenFiles(m)
r.logger.Info().Msgf("Failed to delete broken items for %s: %v", m.Title, err) 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) mu.Lock()
brokenItems = append(brokenItems, items...)
mu.Unlock()
} }
totalBrokenItems += len(brokenItems)
}
}(m)
}
wg.Wait()
r.logger.Info().Msgf("Repair completed for %s. %d broken items found", a.Name, totalBrokenItems)
return nil 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 { 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...) brokenFiles = append(brokenFiles, f...)
continue continue
} }
resp.Body.Close() err = resp.Body.Close()
if err != nil {
return nil
}
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
r.logger.Debug().Msgf("Failed to get download url for %s", fullURL) r.logger.Debug().Msgf("Failed to get download url for %s", fullURL)
resp.Body.Close()
brokenFiles = append(brokenFiles, f...) brokenFiles = append(brokenFiles, f...)
continue 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) r.logger.Debug().Msgf("%d broken files found for %s", len(brokenFiles), media.Title)
return brokenFiles 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()
}

View File

@@ -37,6 +37,10 @@ func New() *Server {
func (s *Server) Start(ctx context.Context) error { func (s *Server) Start(ctx context.Context) error {
cfg := config.GetConfig() cfg := config.GetConfig()
// Register routes // Register routes
// Register webhooks
s.router.Post("/webhooks/tautulli", s.handleTautulli)
// Register logs
s.router.Get("/logs", s.getLogs) s.router.Get("/logs", s.getLogs)
port := fmt.Sprintf(":%s", cfg.QBitTorrent.Port) port := fmt.Sprintf(":%s", cfg.QBitTorrent.Port)
s.logger.Info().Msgf("Starting server on %s", port) s.logger.Info().Msgf("Starting server on %s", port)

54
pkg/server/webhook.go Normal file
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
}
}

View File

@@ -24,7 +24,7 @@ func New() *Service {
arrs := arr.NewStorage() arrs := arr.NewStorage()
deb := debrid.New() deb := debrid.New()
instance = &Service{ instance = &Service{
Repair: repair.New(deb, arrs), Repair: repair.New(arrs),
Arr: arrs, Arr: arrs,
Debrid: deb, Debrid: deb,
} }
@@ -44,7 +44,7 @@ func Update() *Service {
arrs := arr.NewStorage() arrs := arr.NewStorage()
deb := debrid.New() deb := debrid.New()
instance = &Service{ instance = &Service{
Repair: repair.New(deb, arrs), Repair: repair.New(arrs),
Arr: arrs, Arr: arrs,
Debrid: deb, Debrid: deb,
} }

View File

@@ -23,6 +23,9 @@ func (ui *Handler) Routes() http.Handler {
r.Get("/arrs", ui.handleGetArrs) r.Get("/arrs", ui.handleGetArrs)
r.Post("/add", ui.handleAddContent) r.Post("/add", ui.handleAddContent)
r.Post("/repair", ui.handleRepairMedia) 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.Get("/torrents", ui.handleGetTorrents)
r.Delete("/torrents/{category}/{hash}", ui.handleDeleteTorrent) r.Delete("/torrents/{category}/{hash}", ui.handleDeleteTorrent)
r.Delete("/torrents/", ui.handleDeleteTorrents) r.Delete("/torrents/", ui.handleDeleteTorrents)

View File

@@ -49,6 +49,7 @@ type RepairRequest struct {
ArrName string `json:"arr"` ArrName string `json:"arr"`
MediaIds []string `json:"mediaIds"` MediaIds []string `json:"mediaIds"`
Async bool `json:"async"` Async bool `json:"async"`
AutoProcess bool `json:"autoProcess"`
} }
//go:embed web/* //go:embed web/*
@@ -383,7 +384,7 @@ func (ui *Handler) handleRepairMedia(w http.ResponseWriter, r *http.Request) {
if req.Async { if req.Async {
go func() { 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") 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 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) http.Error(w, fmt.Sprintf("Failed to repair: %v", err), http.StatusInternalServerError)
return return
@@ -441,3 +442,41 @@ func (ui *Handler) handleGetConfig(w http.ResponseWriter, r *http.Request) {
cfg.Arrs = arrCfgs cfg.Arrs = arrCfgs
request.JSONResponse(w, cfg, http.StatusOK) 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)
}

View File

@@ -27,14 +27,27 @@
<!-- Empty label to keep the button aligned --> <!-- Empty label to keep the button aligned -->
</label> </label>
<div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler()" id="registerMagnetLink"> <div class="btn btn-primary w-100" onclick="registerMagnetLinkHandler()" id="registerMagnetLink">
Open Magnet Links in DecyphArr Open Magnet Links in Decypharr
</div> </div>
</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"> <div class="form-group">
<label for="allowedExtensions">Allowed File Extensions</label> <label for="allowedExtensions">Allowed File Extensions</label>
<div class="input-group"> <div class="input-group">
<textarea type="text" <textarea
class="form-control" class="form-control"
id="allowedExtensions" id="allowedExtensions"
name="allowed_file_types" name="allowed_file_types"
@@ -145,8 +158,8 @@
<label class="form-check-label" for="repairOnStart">Run on Start</label> <label class="form-check-label" for="repairOnStart">Run on Start</label>
</div> </div>
<div class="form-check d-inline-block"> <div class="form-check d-inline-block">
<input type="checkbox" disabled class="form-check-input" name="repair.skip_deletion" id="skipDeletion"> <input type="checkbox" disabled class="form-check-input" name="repair.auto_process" id="autoProcess">
<label class="form-check-label" for="skipDeletion">Run on Start</label> <label class="form-check-label" for="autoProcess">Auto Process(Scheduled jobs will be processed automatically)</label>
</div> </div>
</div> </div>
</div> </div>
@@ -280,6 +293,9 @@
if (config.max_file_size) { if (config.max_file_size) {
document.querySelector('[name="max_file_size"]').value = 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;
}
}); });

View File

@@ -12,13 +12,23 @@
</button> </button>
<select class="form-select form-select-sm d-inline-block w-auto me-2" id="stateFilter" style="flex-shrink: 0;"> <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="">All States</option>
<option value="pausedup">Completed</option>
<option value="downloading">Downloading</option> <option value="downloading">Downloading</option>
<option value="pausedup">Paused</option>
<option value="error">Error</option> <option value="error">Error</option>
</select> </select>
<select class="form-select form-select-sm d-inline-block w-auto" id="categoryFilter"> <select class="form-select form-select-sm d-inline-block w-auto" id="categoryFilter">
<option value="">All Categories</option> <option value="">All Categories</option>
</select> </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> </div>
<div class="card-body p-0"> <div class="card-body p-0">
@@ -43,6 +53,14 @@
</tbody> </tbody>
</table> </table>
</div> </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> </div>
</div> </div>
@@ -51,9 +69,12 @@
torrentsList: document.getElementById('torrentsList'), torrentsList: document.getElementById('torrentsList'),
categoryFilter: document.getElementById('categoryFilter'), categoryFilter: document.getElementById('categoryFilter'),
stateFilter: document.getElementById('stateFilter'), stateFilter: document.getElementById('stateFilter'),
sortSelector: document.getElementById('sortSelector'),
selectAll: document.getElementById('selectAll'), selectAll: document.getElementById('selectAll'),
batchDeleteBtn: document.getElementById('batchDeleteBtn'), batchDeleteBtn: document.getElementById('batchDeleteBtn'),
refreshBtn: document.getElementById('refreshBtn'), refreshBtn: document.getElementById('refreshBtn'),
paginationControls: document.getElementById('paginationControls'),
paginationInfo: document.getElementById('paginationInfo')
}; };
let state = { let state = {
torrents: [], torrents: [],
@@ -62,6 +83,9 @@
states: new Set('downloading', 'pausedup', 'error'), states: new Set('downloading', 'pausedup', 'error'),
selectedCategory: refs.categoryFilter?.value || '', selectedCategory: refs.categoryFilter?.value || '',
selectedState: refs.stateFilter?.value || '', selectedState: refs.stateFilter?.value || '',
sortBy: refs.sortSelector?.value || 'added_on',
itemsPerPage: 20,
currentPage: 1
}; };
const torrentRowTemplate = (torrent) => ` const torrentRowTemplate = (torrent) => `
@@ -124,8 +148,19 @@
filteredTorrents = filteredTorrents.filter(t => t.state === state.selectedState); 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 // 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 // Update the category filter dropdown
const currentCategories = Array.from(state.categories).sort(); 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) { async function deleteTorrent(hash, category) {
if (!confirm('Are you sure you want to delete this torrent?')) return; 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', () => { document.addEventListener('DOMContentLoaded', () => {
loadTorrents(); loadTorrents();
const refreshInterval = setInterval(loadTorrents, 5000); const refreshInterval = setInterval(loadTorrents, 5000);
@@ -230,11 +392,19 @@
refs.categoryFilter.addEventListener('change', (e) => { refs.categoryFilter.addEventListener('change', (e) => {
state.selectedCategory = e.target.value; state.selectedCategory = e.target.value;
state.currentPage = 1; // Reset to first page
updateUI(); updateUI();
}); });
refs.stateFilter.addEventListener('change', (e) => { refs.stateFilter.addEventListener('change', (e) => {
state.selectedState = e.target.value; 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(); updateUI();
}); });

View File

@@ -1,7 +1,7 @@
{{ define "layout" }} {{ define "layout" }}
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-bs-theme="light">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>DecyphArr - {{.Title}}</title> <title>DecyphArr - {{.Title}}</title>
@@ -13,16 +13,36 @@
:root { :root {
--primary-color: #2563eb; --primary-color: #2563eb;
--secondary-color: #1e40af; --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;
}
[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;
} }
body { body {
background-color: #f8fafc; background-color: var(--bg-color);
color: var(--text-color);
transition: background-color 0.3s ease, color 0.3s ease;
} }
.navbar { .navbar {
padding: 1rem 0; padding: 1rem 0;
background: #fff !important; background: var(--card-bg) !important;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: var(--nav-shadow);
border-bottom: 1px solid var(--border-color);
} }
.navbar-brand { .navbar-brand {
@@ -34,12 +54,13 @@
.card { .card {
border: none; border: none;
border-radius: 10px; border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); box-shadow: var(--card-shadow);
background-color: var(--card-bg);
} }
.nav-link { .nav-link {
padding: 0.5rem 1rem; padding: 0.5rem 1rem;
color: #4b5563; color: var(--text-color);
} }
.nav-link.active { .nav-link.active {
@@ -54,13 +75,54 @@
.badge#channel-badge.beta { .badge#channel-badge.beta {
background-color: #fd7e14; 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> </style>
</head> </head>
<body> <body>
<div class="toast-container position-fixed bottom-0 end-0 p-3"> <div class="toast-container position-fixed bottom-0 end-0 p-3">
<!-- Toast messages will be created dynamically here --> <!-- Toast messages will be created dynamically here -->
</div> </div>
<nav class="navbar navbar-expand-lg navbar-light mb-4"> <nav class="navbar navbar-expand-lg navbar-light mb-4">
<div class="container"> <div class="container">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
<i class="bi bi-cloud-download me-2"></i>DecyphArr <i class="bi bi-cloud-download me-2"></i>DecyphArr
@@ -97,32 +159,36 @@
</li> </li>
</ul> </ul>
<div class="d-flex align-items-center"> <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 me-2" id="channel-badge">Loading...</span>
<span class="badge bg-primary" id="version-badge">Loading...</span> <span class="badge bg-primary" id="version-badge">Loading...</span>
</div> </div>
</div> </div>
</div> </div>
</nav> </nav>
{{ if eq .Page "index" }} {{ if eq .Page "index" }}
{{ template "index" . }} {{ template "index" . }}
{{ else if eq .Page "download" }} {{ else if eq .Page "download" }}
{{ template "download" . }} {{ template "download" . }}
{{ else if eq .Page "repair" }} {{ else if eq .Page "repair" }}
{{ template "repair" . }} {{ template "repair" . }}
{{ else if eq .Page "config" }} {{ else if eq .Page "config" }}
{{ template "config" . }} {{ template "config" . }}
{{ else if eq .Page "login" }} {{ else if eq .Page "login" }}
{{ template "login" . }} {{ template "login" . }}
{{ else if eq .Page "setup" }} {{ else if eq .Page "setup" }}
{{ template "setup" . }} {{ template "setup" . }}
{{ else }} {{ else }}
{{ end }} {{ end }}
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script> <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://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 src="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js"></script>
<script> <script>
/** /**
* Create a toast message * Create a toast message
* @param {string} message - The message to display * @param {string} message - The message to display
@@ -135,7 +201,7 @@
success: 5000, success: 5000,
warning: 10000, warning: 10000,
error: 15000 error: 15000
} };
const toastContainer = document.querySelector('.toast-container'); const toastContainer = document.querySelector('.toast-container');
const toastId = `toast-${Date.now()}`; const toastId = `toast-${Date.now()}`;
@@ -169,6 +235,55 @@
}); });
}; };
// 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() { document.addEventListener('DOMContentLoaded', function() {
fetch('/internal/version') fetch('/internal/version')
.then(response => response.json()) .then(response => response.json())
@@ -182,6 +297,8 @@
if (data.channel === 'beta') { if (data.channel === 'beta') {
channelBadge.classList.add('beta'); channelBadge.classList.add('beta');
} else if (data.channel === 'nightly') {
channelBadge.classList.add('nightly');
} }
}) })
.catch(error => { .catch(error => {
@@ -190,7 +307,7 @@
document.getElementById('channel-badge').textContent = 'Unknown'; document.getElementById('channel-badge').textContent = 'Unknown';
}); });
}); });
</script> </script>
</body> </body>
</html> </html>
{{ end }} {{ end }}

View File

@@ -20,11 +20,20 @@
<small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small> <small class="text-muted">Enter TV DB ids for Sonarr, TM DB ids for Radarr</small>
</div> </div>
<div class="mb-3"> <div class="mb-2">
<div class="form-check"> <div class="form-check">
<input class="form-check-input" type="checkbox" id="isAsync" checked> <input class="form-check-input" type="checkbox" id="isAsync" checked>
<label class="form-check-label" for="isAsync"> <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> </label>
</div> </div>
</div> </div>
@@ -35,7 +44,111 @@
</form> </form>
</div> </div>
</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> </div>
<script> <script>
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
// Load Arr instances // Load Arr instances
@@ -76,12 +189,14 @@
body: JSON.stringify({ body: JSON.stringify({
arr: document.getElementById('arrSelect').value, arr: document.getElementById('arrSelect').value,
mediaIds: mediaIds, 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()); if (!response.ok) throw new Error(await response.text());
createToast('Repair process initiated successfully!'); createToast('Repair process initiated successfully!');
loadJobs(1); // Refresh jobs after submission
} catch (error) { } catch (error) {
createToast(`Error starting repair: ${error.message}`, 'error'); createToast(`Error starting repair: ${error.message}`, 'error');
} finally { } finally {
@@ -89,6 +204,371 @@
submitBtn.innerHTML = originalText; 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> </script>
{{ end }} {{ end }}

View File

@@ -100,7 +100,7 @@ func cleanUpQueues() {
if !a.Cleanup { if !a.Cleanup {
continue 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 { if err := a.CleanupQueue(); err != nil {
_logger.Debug().Err(err).Msg("Error cleaning up queue") _logger.Debug().Err(err).Msg("Error cleaning up queue")
} }