diff --git a/README.md b/README.md index c61b7e1..a66e922 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ This is a Golang implementation go Torrent QbitTorrent with a **Real Debrid & To - Debrid Link Support - Multi-Debrid Providers support - UI for adding torrents directly to *arrs +- Repair Worker for missing files (**NEW**) The proxy is useful in filtering out un-cached Real Debrid torrents @@ -117,7 +118,19 @@ Download the binary from the releases page and run it with the config file. "download_folder": "/media/symlinks/", "categories": ["sonarr", "radarr"], "refresh_interval": 5 - } + }, + "arrs": [ + { + "name": "sonarr", + "host": "http://host:8989", + "token": "arr_key" + }, + { + "name": "radarr", + "host": "http://host:7878", + "token": "arr_key" + } + ] } ``` @@ -137,6 +150,12 @@ Download the binary from the releases page and run it with the config file. - The `download_uncached` bool key is used to download uncached torrents(disabled by default) - The `check_cached` bool key is used to check if the torrent is cached(disabled by default) +##### Repair Config (**NEW**) +The `repair` key is used to enable the repair worker +- The `enabled` key is used to enable the repair worker +- The `interval` key is the interval in either minutes, seconds, hours, days. Use any of this format, e.g 12:00, 5:00, 1h, 1d, 1m, 1s. +- The `run_on_start` key is used to run the repair worker on start + ##### Proxy Config - The `enabled` key is used to enable the proxy - The `port` key is the port the proxy will listen on @@ -151,6 +170,14 @@ Download the binary from the releases page and run it with the config file. - The `categories` key is used to filter out torrents based on the category. e.g `sonarr`, `radarr` - The `refresh_interval` key is used to set the interval in minutes to refresh the Arrs Monitored Downloads(it's in seconds). The default value is `5` seconds + +##### Arrs Config +This is an array of Arrs(Sonarr, Radarr, etc) that will be used to download the torrents. This is not required if you already set up the Qbittorrent in the Arrs with the host, token. +This is particularly useful if you want to use the Repair tool without using Qbittorent +- The `name` key is the name of the Arr/ Category +- The `host` key is the host of the Arr +- The `token` key is the API token of the Arr + ### Proxy The proxy is useful in filtering out un-cached Real Debrid torrents. @@ -185,11 +212,25 @@ Setting Up Qbittorrent in Arr - Test - Save -### UI for adding torrents +### Repair Worker + +The repair worker is a simple worker that checks for missing files in the Arrs(Sonarr, Radarr, etc). It's particularly useful for files either deleted by the Debrid provider or files with bad symlinks. + +- Search for broken symlinks/files +- Search for missing files +- Search for deleted/unreadable files + + +### UI  -The UI is a simple web interface that allows you to add torrents directly to the Arrs(Sonarr, Radarr, etc) +The UI is a simple web interface that allows you to add torrents directly to the Arrs(Sonarr, Radarr, etc) or trigger the Repair Worker. + +UI Features + +- Adding new torrents +- Triggering the Repair Worker ### TODO - [ ] A proper name!!!! diff --git a/cmd/main.go b/cmd/main.go index 63bb785..be42592 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -4,9 +4,12 @@ import ( "cmp" "context" "goBlack/common" + "goBlack/pkg/arr" "goBlack/pkg/debrid" "goBlack/pkg/proxy" "goBlack/pkg/qbit" + "goBlack/pkg/repair" + "log" "sync" ) @@ -14,6 +17,7 @@ func Start(ctx context.Context, config *common.Config) error { maxCacheSize := cmp.Or(config.MaxCacheSize, 1000) deb := debrid.NewDebrid(config.Debrids, maxCacheSize) + arrs := arr.NewStorage(config.Arrs) var wg sync.WaitGroup errChan := make(chan error, 2) @@ -31,12 +35,22 @@ func Start(ctx context.Context, config *common.Config) error { wg.Add(1) go func() { defer wg.Done() - if err := qbit.Start(ctx, config, deb); err != nil { + if err := qbit.Start(ctx, config, deb, arrs); err != nil { errChan <- err } }() } + if config.Repair.Enabled { + wg.Add(1) + go func() { + defer wg.Done() + if err := repair.Start(ctx, config, arrs); err != nil { + log.Printf("Error during repair: %v", err) + } + }() + } + go func() { wg.Wait() close(errChan) diff --git a/common/config.go b/common/config.go index 89f4b9d..b91db91 100644 --- a/common/config.go +++ b/common/config.go @@ -38,12 +38,26 @@ type QBitTorrentConfig struct { RefreshInterval int `json:"refresh_interval"` } +type ArrConfig struct { + Name string `json:"name"` + Host string `json:"host"` + Token string `json:"token"` +} + +type RepairConfig struct { + Enabled bool `json:"enabled"` + Interval string `json:"interval"` + RunOnStart bool `json:"run_on_start"` +} + type Config struct { Debrid DebridConfig `json:"debrid"` Debrids []DebridConfig `json:"debrids"` Proxy ProxyConfig `json:"proxy"` MaxCacheSize int `json:"max_cache_size"` QBitTorrent QBitTorrentConfig `json:"qbittorrent"` + Arrs []ArrConfig `json:"arrs"` + Repair RepairConfig `json:"repair"` } func validateDebrids(debrids []DebridConfig) error { diff --git a/common/utils.go b/common/utils.go index 44acfc0..b30b1a5 100644 --- a/common/utils.go +++ b/common/utils.go @@ -13,7 +13,6 @@ import ( "net/http" "net/url" "os" - "path" "path/filepath" "regexp" "strings" @@ -251,17 +250,22 @@ func GetInfohashFromURL(url string) (string, error) { } func JoinURL(base string, paths ...string) (string, error) { - // Parse the base URL - u, err := url.Parse(base) + // Split the last path component to separate query parameters + lastPath := paths[len(paths)-1] + parts := strings.Split(lastPath, "?") + paths[len(paths)-1] = parts[0] + + joined, err := url.JoinPath(base, paths...) if err != nil { return "", err } - // Join the path components - u.Path = path.Join(u.Path, path.Join(paths...)) + // Add back query parameters if they exist + if len(parts) > 1 { + return joined + "?" + parts[1], nil + } - // Return the resulting URL as a string - return u.String(), nil + return joined, nil } func FileReady(path string) bool { diff --git a/doc/ui.png b/doc/ui.png index 85c722b..9984f50 100644 Binary files a/doc/ui.png and b/doc/ui.png differ diff --git a/pkg/arr/arr.go b/pkg/arr/arr.go index ddfae29..ed2ba47 100644 --- a/pkg/arr/arr.go +++ b/pkg/arr/arr.go @@ -4,9 +4,7 @@ import ( "bytes" "encoding/json" "goBlack/common" - "log" "net/http" - "os" "strings" "sync" ) @@ -23,7 +21,6 @@ const ( var ( client *common.RLHTTPClient = common.NewRLHTTPClient(nil, nil) - logger *log.Logger = common.NewLogger("QBit", os.Stdout) ) type Arr struct { @@ -87,11 +84,12 @@ func inferType(host, name string) Type { } } -func NewStorage() *Storage { +func NewStorage(cfg []common.ArrConfig) *Storage { arrs := make(map[string]*Arr) - //for name, arrCfg := range cfg { - // arrs[name] = NewArr(name, arrCfg.Host, arrCfg.Token, inferType(arrCfg.Host, name)) - //} + for _, a := range cfg { + name := a.Name + arrs[name] = NewArr(name, a.Host, a.Token, inferType(a.Host, name)) + } return &Storage{ Arrs: arrs, } diff --git a/pkg/arr/content.go b/pkg/arr/content.go index 5dfb34a..dc66d4e 100644 --- a/pkg/arr/content.go +++ b/pkg/arr/content.go @@ -3,27 +3,102 @@ package arr import ( "encoding/json" "fmt" + "log" "net/http" ) -type ContentRequest struct { - ID string `json:"id"` - Title string `json:"name"` - Arr string `json:"arr"` +func (a *Arr) GetMedia(tvId string) ([]Content, error) { + // Get series + resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/series?tvdbId=%s", tvId), nil) + if err != nil { + return nil, err + } + if resp.StatusCode == http.StatusNotFound { + // This is Radarr + log.Printf("Radarr detected\n") + a.Type = Radarr + return GetMovies(a, tvId) + } + a.Type = Sonarr + defer resp.Body.Close() + type series struct { + Title string `json:"title"` + Id int `json:"id"` + } + var data []series + if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + // Get series files + contents := make([]Content, 0) + for _, d := range data { + resp, err = a.Request(http.MethodGet, fmt.Sprintf("api/v3/episodefile?seriesId=%d", d.Id), nil) + if err != nil { + continue + } + defer resp.Body.Close() + var seriesFiles []seriesFile + if err = json.NewDecoder(resp.Body).Decode(&seriesFiles); err != nil { + continue + } + ct := Content{ + Title: d.Title, + Id: d.Id, + } + files := make([]contentFile, 0) + for _, file := range seriesFiles { + files = append(files, contentFile{ + Id: file.Id, + Path: file.Path, + }) + } + ct.Files = files + contents = append(contents, ct) + } + return contents, nil } -func (a *Arr) GetContents() *ContentRequest { - resp, err := a.Request(http.MethodGet, "api/v3/series", nil) +func GetMovies(a *Arr, tvId string) ([]Content, error) { + resp, err := a.Request(http.MethodGet, fmt.Sprintf("api/v3/movie?tmdbId=%s", tvId), nil) if err != nil { - return nil + return nil, err } defer resp.Body.Close() - var data *ContentRequest - if err = json.NewDecoder(resp.Body).Decode(&data); err != nil { - fmt.Printf("Error: %v\n", err) - return nil + var movies []Movie + if err = json.NewDecoder(resp.Body).Decode(&movies); err != nil { + return nil, err } - fmt.Printf("Data: %v\n", data) - //data.Arr = a.Name - return data + contents := make([]Content, 0) + for _, movie := range movies { + ct := Content{ + Title: movie.Title, + Id: movie.Id, + } + files := make([]contentFile, 0) + files = append(files, contentFile{ + Id: movie.MovieFile.Id, + Path: movie.MovieFile.Path, + }) + ct.Files = files + contents = append(contents, ct) + } + return contents, nil +} + +func (a *Arr) DeleteFile(id int) error { + switch a.Type { + case Sonarr: + _, err := a.Request(http.MethodDelete, fmt.Sprintf("api/v3/episodefile/%d", id), nil) + if err != nil { + return err + } + case Radarr: + _, err := a.Request(http.MethodDelete, fmt.Sprintf("api/v3/moviefile/%d", id), nil) + if err != nil { + return err + } + default: + return fmt.Errorf("unknown arr type: %s", a.Type) + } + return nil } diff --git a/pkg/arr/repair.go b/pkg/arr/repair.go new file mode 100644 index 0000000..ecba5ba --- /dev/null +++ b/pkg/arr/repair.go @@ -0,0 +1,270 @@ +package arr + +import ( + "goBlack/common" + "io" + "log" + "net/http" + "os" + "path/filepath" + "runtime" + "strconv" + "sync" +) + +var ( + repairLogger *log.Logger = common.NewLogger("Repair", os.Stdout) +) + +func (a *Arr) SearchMissing(id int) { + var payload interface{} + + switch a.Type { + case Sonarr: + payload = struct { + Name string `json:"name"` + SeriesId int `json:"seriesId"` + }{ + Name: "SeriesSearch", + SeriesId: id, + } + case Radarr: + payload = struct { + Name string `json:"name"` + MovieId int `json:"movieId"` + }{ + Name: "MoviesSearch", + MovieId: id, + } + default: + repairLogger.Printf("Unknown arr type: %s\n", a.Type) + return + } + + resp, err := a.Request(http.MethodPost, "api/v3/command", payload) + if err != nil { + repairLogger.Printf("Failed to search missing: %v\n", err) + return + } + if statusOk := strconv.Itoa(resp.StatusCode)[0] == '2'; !statusOk { + repairLogger.Printf("Failed to search missing: %s\n", resp.Status) + return + } +} + +func (a *Arr) Repair(tmdbId string) error { + + repairLogger.Printf("Starting repair for %s\n", a.Name) + media, err := a.GetMedia(tmdbId) + if err != nil { + repairLogger.Printf("Failed to get %s media: %v\n", a.Type, err) + return err + } + repairLogger.Printf("Found %d %s media\n", len(media), a.Type) + + brokenMedia := a.processMedia(media) + repairLogger.Printf("Found %d %s broken media files\n", len(brokenMedia), a.Type) + + // Automatic search for missing files + for _, m := range brokenMedia { + a.SearchMissing(m.Id) + } + repairLogger.Printf("Search missing completed for %s\n", a.Name) + repairLogger.Printf("Repair completed for %s\n", a.Name) + return nil +} + +func (a *Arr) processMedia(media []Content) []Content { + if len(media) <= 1 { + var brokenMedia []Content + for _, m := range media { + if a.checkMediaFiles(m) { + brokenMedia = append(brokenMedia, m) + } + } + return brokenMedia + } + + workerCount := runtime.NumCPU() * 4 + if len(media) < workerCount { + workerCount = len(media) + } + + jobs := make(chan Content) + results := make(chan Content) + var brokenMedia []Content + + var wg sync.WaitGroup + for i := 0; i < workerCount; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for m := range jobs { + if a.checkMediaFilesParallel(m) { + results <- m + } + } + }() + } + + go func() { + for _, m := range media { + jobs <- m + } + close(jobs) + }() + + go func() { + wg.Wait() + close(results) + }() + + for m := range results { + brokenMedia = append(brokenMedia, m) + } + + return brokenMedia +} + +func (a *Arr) checkMediaFilesParallel(m Content) bool { + if len(m.Files) <= 1 { + return a.checkMediaFiles(m) + } + + fileWorkers := runtime.NumCPU() * 2 + if len(m.Files) < fileWorkers { + fileWorkers = len(m.Files) + } + + fileJobs := make(chan contentFile) + brokenFiles := make(chan bool, len(m.Files)) + + var fileWg sync.WaitGroup + for i := 0; i < fileWorkers; i++ { + fileWg.Add(1) + go func() { + defer fileWg.Done() + for f := range fileJobs { + isBroken := false + if fileIsSymlinked(f.Path) { + if !fileIsCorrectSymlink(f.Path) { + isBroken = true + if err := a.DeleteFile(f.Id); err != nil { + repairLogger.Printf("Failed to delete file: %s %d: %v\n", f.Path, f.Id, err) + } + } + } else { + if !fileIsReadable(f.Path) { + isBroken = true + if err := a.DeleteFile(f.Id); err != nil { + repairLogger.Printf("Failed to delete file: %s %d: %v\n", f.Path, f.Id, err) + } + } + } + brokenFiles <- isBroken + } + }() + } + + go func() { + for _, f := range m.Files { + fileJobs <- f + } + close(fileJobs) + }() + + go func() { + fileWg.Wait() + close(brokenFiles) + }() + + isBroken := false + for broken := range brokenFiles { + if broken { + isBroken = true + } + } + + return isBroken +} + +func (a *Arr) checkMediaFiles(m Content) bool { + isBroken := false + for _, f := range m.Files { + if fileIsSymlinked(f.Path) { + if !fileIsCorrectSymlink(f.Path) { + isBroken = true + if err := a.DeleteFile(f.Id); err != nil { + repairLogger.Printf("Failed to delete file: %s %d: %v\n", f.Path, f.Id, err) + } + } + } else { + if !fileIsReadable(f.Path) { + isBroken = true + if err := a.DeleteFile(f.Id); err != nil { + repairLogger.Printf("Failed to delete file: %s %d: %v\n", f.Path, f.Id, err) + } + } + } + } + return isBroken +} + +func fileIsSymlinked(file string) bool { + info, err := os.Lstat(file) + if err != nil { + return false + } + return info.Mode()&os.ModeSymlink != 0 +} + +func fileIsCorrectSymlink(file string) bool { + target, err := os.Readlink(file) + if err != nil { + return false + } + + if !filepath.IsAbs(target) { + dir := filepath.Dir(file) + target = filepath.Join(dir, target) + } + + return fileIsReadable(target) +} + +func fileIsReadable(filePath string) bool { + // First check if file exists and is accessible + info, err := os.Stat(filePath) + if err != nil { + return false + } + + // Check if it's a regular file + if !info.Mode().IsRegular() { + return false + } + + // Try to read the first 1024 bytes + err = checkFileStart(filePath) + if err != nil { + return false + } + + return true +} + +func checkFileStart(filePath string) error { + f, err := os.Open(filePath) + if err != nil { + return err + } + defer f.Close() + + buffer := make([]byte, 1024) + _, err = io.ReadAtLeast(f, buffer, 1024) + if err != nil && err != io.EOF { + return err + } + + return nil +} diff --git a/pkg/arr/structs.go b/pkg/arr/structs.go new file mode 100644 index 0000000..cb9cee5 --- /dev/null +++ b/pkg/arr/structs.go @@ -0,0 +1,34 @@ +package arr + +type Movie struct { + Title string `json:"title"` + OriginalTitle string `json:"originalTitle"` + Path string `json:"path"` + MovieFile struct { + MovieId int `json:"movieId"` + RelativePath string `json:"relativePath"` + Path string `json:"path"` + Size int `json:"size"` + Id int `json:"id"` + } `json:"movieFile"` + Id int `json:"id"` +} + +type contentFile struct { + Name string `json:"name"` + Path string `json:"path"` + Id int `json:"id"` +} + +type Content struct { + Title string `json:"title"` + Id int `json:"id"` + Files []contentFile `json:"files"` +} + +type seriesFile struct { + SeriesId int `json:"seriesId"` + SeasonNumber int `json:"seasonNumber"` + Path string `json:"path"` + Id int `json:"id"` +} diff --git a/pkg/arr/utils.go b/pkg/arr/utils.go new file mode 100644 index 0000000..d0f7b5c --- /dev/null +++ b/pkg/arr/utils.go @@ -0,0 +1,5 @@ +package arr + +func Readfile(path string) error { + return nil +} diff --git a/pkg/debrid/alldebrid.go b/pkg/debrid/alldebrid.go index c199288..865dd78 100644 --- a/pkg/debrid/alldebrid.go +++ b/pkg/debrid/alldebrid.go @@ -127,6 +127,7 @@ func (r *AllDebrid) GetTorrent(id string) (*Torrent, error) { files = append(files, file) } parentFolder := data.Filename + if data.NbLinks == 1 { // All debrid doesn't return the parent folder for single file torrents parentFolder = "" diff --git a/pkg/debrid/torrent.go b/pkg/debrid/torrent.go index bc9c582..291ef4d 100644 --- a/pkg/debrid/torrent.go +++ b/pkg/debrid/torrent.go @@ -58,7 +58,7 @@ func (t *Torrent) GetSymlinkFolder(parent string) string { return filepath.Join(parent, t.Arr.Name, t.Folder) } -func (t *Torrent) GetMountFolder(rClonePath string) string { +func (t *Torrent) GetMountFolder(rClonePath string) (string, error) { possiblePaths := []string{ t.OriginalFilename, t.Filename, @@ -67,10 +67,10 @@ func (t *Torrent) GetMountFolder(rClonePath string) string { for _, path := range possiblePaths { if common.FileReady(filepath.Join(rClonePath, path)) { - return path + return path, nil } } - return "" + return "", nil } func (t *Torrent) Delete() { diff --git a/pkg/qbit/main.go b/pkg/qbit/main.go index 928c3a9..bce51be 100644 --- a/pkg/qbit/main.go +++ b/pkg/qbit/main.go @@ -4,12 +4,13 @@ import ( "context" "fmt" "goBlack/common" + "goBlack/pkg/arr" "goBlack/pkg/debrid" "goBlack/pkg/qbit/server" ) -func Start(ctx context.Context, config *common.Config, deb *debrid.DebridService) error { - srv := server.NewServer(config, deb) +func Start(ctx context.Context, config *common.Config, deb *debrid.DebridService, arrs *arr.Storage) error { + srv := server.NewServer(config, deb, arrs) if err := srv.Start(ctx); err != nil { return fmt.Errorf("failed to start qbit server: %w", err) } diff --git a/pkg/qbit/server/routes.go b/pkg/qbit/server/routes.go index 23d2d6a..8913217 100644 --- a/pkg/qbit/server/routes.go +++ b/pkg/qbit/server/routes.go @@ -46,6 +46,7 @@ func (s *Server) Routes(r chi.Router) http.Handler { r.Post("/add", s.handleAddContent) r.Get("/search", s.handleSearch) r.Get("/cached", s.handleCheckCached) + r.Post("/repair", s.handleRepair) }) return r } diff --git a/pkg/qbit/server/server.go b/pkg/qbit/server/server.go index 7185814..bdb8dcf 100644 --- a/pkg/qbit/server/server.go +++ b/pkg/qbit/server/server.go @@ -7,6 +7,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "goBlack/common" + "goBlack/pkg/arr" "goBlack/pkg/debrid" "goBlack/pkg/qbit/shared" "log" @@ -22,9 +23,9 @@ type Server struct { debug bool } -func NewServer(config *common.Config, deb *debrid.DebridService) *Server { +func NewServer(config *common.Config, deb *debrid.DebridService, arrs *arr.Storage) *Server { logger := common.NewLogger("QBit", os.Stdout) - q := shared.NewQBit(config, deb, logger) + q := shared.NewQBit(config, deb, logger, arrs) return &Server{ qbit: q, logger: logger, diff --git a/pkg/qbit/server/static/index.html b/pkg/qbit/server/static/index.html index 4fc3096..fe36aba 100644 --- a/pkg/qbit/server/static/index.html +++ b/pkg/qbit/server/static/index.html @@ -35,11 +35,12 @@