diff --git a/README.md b/README.md index 320b45e..2983b49 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,21 @@ -# DecyphArr +# Decypharr ![ui](docs/docs/images/main.png) -**DecyphArr** is an implementation of QbitTorrent with **Multiple Debrid service support**, written in Go. +**Decypharr** is an implementation of QbitTorrent with **Multiple Debrid service support**, written in Go. -## What is DecyphArr? +## What is Decypharr? -DecyphArr combines the power of QBittorrent with popular Debrid services to enhance your media management. It provides a familiar interface for Sonarr, Radarr, and other \*Arr applications while leveraging the capabilities of Debrid providers. +Decypharr combines the power of QBittorrent with popular Debrid services to enhance your media management. It provides a familiar interface for Sonarr, Radarr, and other \*Arr applications. ## Features -- 🔄 Mock Qbittorent API that supports the Arrs (Sonarr, Radarr, Lidarr etc) -- 🖥️ Full-fledged UI for managing torrents -- 🛡️ Proxy support for filtering out un-cached Debrid torrents -- 🔌 Multiple Debrid providers support -- 📁 WebDAV server support for each debrid provider -- 🔧 Repair Worker for missing files +- Mock Qbittorent API that supports the Arrs (Sonarr, Radarr, Lidarr etc) +- Full-fledged UI for managing torrents +- Proxy support for filtering out un-cached Debrid torrents +- Multiple Debrid providers support +- WebDAV server support for each debrid provider +- Repair Worker for missing files ## Supported Debrid Providers diff --git a/cmd/healthcheck/main.go b/cmd/healthcheck/main.go index f2f459c..fe17f87 100644 --- a/cmd/healthcheck/main.go +++ b/cmd/healthcheck/main.go @@ -25,9 +25,11 @@ func main() { var ( configPath string isBasicCheck bool + debug bool ) flag.StringVar(&configPath, "config", "/data", "path to the data folder") flag.BoolVar(&isBasicCheck, "basic", false, "perform basic health check without WebDAV") + flag.BoolVar(&debug, "debug", false, "enable debug mode for detailed output") flag.Parse() config.SetConfigPath(configPath) cfg := config.Get() @@ -67,16 +69,17 @@ func main() { status.WebUI = true } - // Check WebDAV if enabled - if !isBasicCheck && webdavPath != "" { - if checkWebDAV(ctx, baseUrl, port, webdavPath) { + if isBasicCheck { + status.WebDAVService = checkBaseWebdav(ctx, baseUrl, port) + } else { + // If not a basic check, check WebDAV with debrid path + if webdavPath != "" { + status.WebDAVService = checkDebridWebDAV(ctx, baseUrl, port, webdavPath) + } else { + // If no WebDAV path is set, consider it healthy status.WebDAVService = true } - } else { - // If WebDAV is not enabled, consider it healthy - status.WebDAVService = true } - // Determine overall status // Consider the application healthy if core services are running status.OverallStatus = status.QbitAPI && status.WebUI @@ -85,7 +88,7 @@ func main() { } // Optional: output health status as JSON for logging - if os.Getenv("DEBUG") == "true" { + if debug { statusJSON, _ := json.MarshalIndent(status, "", " ") fmt.Println(string(statusJSON)) } @@ -136,7 +139,24 @@ func checkWebUI(ctx context.Context, baseUrl, port string) bool { return resp.StatusCode == http.StatusOK } -func checkWebDAV(ctx context.Context, baseUrl, port, path string) bool { +func checkBaseWebdav(ctx context.Context, baseUrl, port string) bool { + url := fmt.Sprintf("http://localhost:%s%swebdav/", port, baseUrl) + req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, nil) + if err != nil { + return false + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + return resp.StatusCode == http.StatusMultiStatus || + resp.StatusCode == http.StatusOK +} + +func checkDebridWebDAV(ctx context.Context, baseUrl, port, path string) bool { url := fmt.Sprintf("http://localhost:%s%swebdav/%s", port, baseUrl, path) req, err := http.NewRequestWithContext(ctx, "PROPFIND", url, nil) if err != nil { @@ -150,7 +170,6 @@ func checkWebDAV(ctx context.Context, baseUrl, port, path string) bool { defer resp.Body.Close() return resp.StatusCode == http.StatusMultiStatus || - resp.StatusCode == http.StatusOK || - resp.StatusCode == http.StatusServiceUnavailable // It's still indexing + resp.StatusCode == http.StatusOK } diff --git a/pkg/debrid/providers/alldebrid/alldebrid.go b/pkg/debrid/providers/alldebrid/alldebrid.go index 7dec3d2..e870d68 100644 --- a/pkg/debrid/providers/alldebrid/alldebrid.go +++ b/pkg/debrid/providers/alldebrid/alldebrid.go @@ -190,7 +190,7 @@ func (ad *AllDebrid) GetTorrent(torrentId string) (*types.Torrent, error) { var res TorrentInfoResponse err = json.Unmarshal(resp, &res) if err != nil { - ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err) + ad.logger.Error().Err(err).Msgf("Error unmarshalling torrent info") return nil, err } data := res.Data.Magnets @@ -232,7 +232,7 @@ func (ad *AllDebrid) UpdateTorrent(t *types.Torrent) error { var res TorrentInfoResponse err = json.Unmarshal(resp, &res) if err != nil { - ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err) + ad.logger.Error().Err(err).Msgf("Error unmarshalling torrent info") return err } data := res.Data.Magnets @@ -393,7 +393,7 @@ func (ad *AllDebrid) GetTorrents() ([]*types.Torrent, error) { var res TorrentsListResponse err = json.Unmarshal(resp, &res) if err != nil { - ad.logger.Info().Msgf("Error unmarshalling torrent info: %s", err) + ad.logger.Error().Err(err).Msgf("Error unmarshalling torrent info") return torrents, err } for _, magnet := range res.Data.Magnets { diff --git a/pkg/debrid/providers/debrid_link/debrid_link.go b/pkg/debrid/providers/debrid_link/debrid_link.go index 6dd3edc..e15a35c 100644 --- a/pkg/debrid/providers/debrid_link/debrid_link.go +++ b/pkg/debrid/providers/debrid_link/debrid_link.go @@ -110,13 +110,13 @@ func (dl *DebridLink) IsAvailable(hashes []string) map[string]bool { req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := dl.client.MakeRequest(req) if err != nil { - dl.logger.Info().Msgf("Error checking availability: %v", err) + dl.logger.Error().Err(err).Msgf("Error checking availability") return result } var data AvailableResponse err = json.Unmarshal(resp, &data) if err != nil { - dl.logger.Info().Msgf("Error marshalling availability: %v", err) + dl.logger.Error().Err(err).Msgf("Error marshalling availability") return result } if data.Value == nil { @@ -406,7 +406,7 @@ func (dl *DebridLink) getTorrents(page, perPage int) ([]*types.Torrent, error) { var res torrentInfo err = json.Unmarshal(resp, &res) if err != nil { - dl.logger.Info().Msgf("Error unmarshalling torrent info: %s", err) + dl.logger.Error().Err(err).Msgf("Error unmarshalling torrent info") return torrents, err } diff --git a/pkg/debrid/providers/realdebrid/realdebrid.go b/pkg/debrid/providers/realdebrid/realdebrid.go index 5dfa438..0943e72 100644 --- a/pkg/debrid/providers/realdebrid/realdebrid.go +++ b/pkg/debrid/providers/realdebrid/realdebrid.go @@ -81,7 +81,7 @@ func New(dc config.Debrid) (*RealDebrid, error) { request.WithHeaders(headers), request.WithRateLimiter(rl), request.WithLogger(_log), - request.WithMaxRetries(5), + request.WithMaxRetries(10), request.WithRetryableStatus(429, 502), request.WithProxy(dc.Proxy), ), @@ -302,13 +302,13 @@ func (r *RealDebrid) IsAvailable(hashes []string) map[string]bool { req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := r.client.MakeRequest(req) if err != nil { - r.logger.Info().Msgf("Error checking availability: %v", err) + r.logger.Error().Err(err).Msgf("Error checking availability") return result } var data AvailabilityResponse err = json.Unmarshal(resp, &data) if err != nil { - r.logger.Info().Msgf("Error marshalling availability: %v", err) + r.logger.Error().Err(err).Msgf("Error marshalling availability") return result } for _, h := range hashes[i:end] { diff --git a/pkg/debrid/providers/torbox/torbox.go b/pkg/debrid/providers/torbox/torbox.go index 30ed9c6..7f22280 100644 --- a/pkg/debrid/providers/torbox/torbox.go +++ b/pkg/debrid/providers/torbox/torbox.go @@ -117,13 +117,13 @@ func (tb *Torbox) IsAvailable(hashes []string) map[string]bool { req, _ := http.NewRequest(http.MethodGet, url, nil) resp, err := tb.client.MakeRequest(req) if err != nil { - tb.logger.Info().Msgf("Error checking availability: %v", err) + tb.logger.Error().Err(err).Msgf("Error checking availability") return result } var res AvailableResponse err = json.Unmarshal(resp, &res) if err != nil { - tb.logger.Info().Msgf("Error marshalling availability: %v", err) + tb.logger.Error().Err(err).Msgf("Error marshalling availability") return result } if res.Data == nil { diff --git a/pkg/debrid/store/cache.go b/pkg/debrid/store/cache.go index 9d08d7d..9ac6412 100644 --- a/pkg/debrid/store/cache.go +++ b/pkg/debrid/store/cache.go @@ -231,6 +231,8 @@ func (c *Cache) Start(ctx context.Context) error { if err := c.Sync(ctx); err != nil { return fmt.Errorf("failed to sync cache: %w", err) } + // Fire the ready channel + close(c.ready) // initial download links go c.refreshDownloadLinks(ctx) @@ -242,8 +244,6 @@ func (c *Cache) Start(ctx context.Context) error { c.repairChan = make(chan RepairRequest, 100) go c.repairWorker(ctx) - // Fire the ready channel - close(c.ready) cfg := config.Get() name := c.client.GetName() addr := cfg.BindAddress + ":" + cfg.Port + cfg.URLBase + "webdav/" + name + "/" diff --git a/pkg/debrid/store/refresh.go b/pkg/debrid/store/refresh.go index 882eb4e..f9e5a5d 100644 --- a/pkg/debrid/store/refresh.go +++ b/pkg/debrid/store/refresh.go @@ -261,7 +261,4 @@ func (c *Cache) refreshDownloadLinks(ctx context.Context) { c.downloadLinks.Delete(k) } } - - c.logger.Trace().Msgf("Refreshed %d download links", len(downloadLinks)) - } diff --git a/pkg/qbit/http.go b/pkg/qbit/http.go index 1f81e7f..3115189 100644 --- a/pkg/qbit/http.go +++ b/pkg/qbit/http.go @@ -17,7 +17,7 @@ func (q *QBit) handleLogin(w http.ResponseWriter, r *http.Request) { return } if err := _arr.Validate(); err != nil { - q.logger.Info().Msgf("Error validating arr: %v", err) + q.logger.Error().Err(err).Msgf("Error validating arr") } _, _ = w.Write([]byte("Ok.")) } @@ -73,13 +73,13 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) { contentType := r.Header.Get("Content-Type") if strings.Contains(contentType, "multipart/form-data") { if err := r.ParseMultipartForm(32 << 20); err != nil { - q.logger.Info().Msgf("Error parsing multipart form: %v", err) + q.logger.Error().Err(err).Msgf("Error parsing multipart form") http.Error(w, err.Error(), http.StatusBadRequest) return } } else if strings.Contains(contentType, "application/x-www-form-urlencoded") { if err := r.ParseForm(); err != nil { - q.logger.Info().Msgf("Error parsing form: %v", err) + q.logger.Error().Err(err).Msgf("Error parsing form") http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -105,7 +105,7 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) { } for _, url := range urlList { if err := q.addMagnet(ctx, url, _arr, debridName, isSymlink); err != nil { - q.logger.Info().Msgf("Error adding magnet: %v", err) + q.logger.Error().Err(err).Msgf("Error adding magnet") http.Error(w, err.Error(), http.StatusBadRequest) return } @@ -118,7 +118,7 @@ func (q *QBit) handleTorrentsAdd(w http.ResponseWriter, r *http.Request) { if files := r.MultipartForm.File["torrents"]; len(files) > 0 { for _, fileHeader := range files { if err := q.addTorrent(ctx, fileHeader, _arr, debridName, isSymlink); err != nil { - q.logger.Info().Msgf("Error adding torrent: %v", err) + q.logger.Error().Err(err).Msgf("Error adding torrent") http.Error(w, err.Error(), http.StatusBadRequest) return } diff --git a/pkg/repair/repair.go b/pkg/repair/repair.go index 7a50a54..f21a69a 100644 --- a/pkg/repair/repair.go +++ b/pkg/repair/repair.go @@ -440,7 +440,7 @@ func (r *Repair) repairArr(job *Job, _arr string, tmdbId string) ([]arr.ContentF } // Check first media to confirm mounts are accessible if !r.isMediaAccessible(media) { - r.logger.Info().Msgf("Skipping repair. Parent directory not accessible for. Check your mounts") + r.logger.Info().Msgf("Skipping repair. Parent directory not accessible. Check your mounts") return brokenItems, nil } @@ -520,19 +520,18 @@ func (r *Repair) isMediaAccessible(media []arr.Content) bool { firstFile := files[0] symlinkPath := getSymlinkTarget(firstFile.Path) + if symlinkPath == "" { + r.logger.Debug().Msgf("No symlink target found for %s", firstFile.Path) + return false + } r.logger.Debug().Msgf("Checking symlink parent directory for %s", symlinkPath) - parentSymlink := "" - if symlinkPath != "" { - parentSymlink = filepath.Dir(filepath.Dir(symlinkPath)) // /mnt/zurg/torrents/movie/movie.mkv -> /mnt/zurg/torrents + parentSymlink := filepath.Dir(filepath.Dir(symlinkPath)) // /mnt/zurg/torrents/movie/movie.mkv -> /mnt/zurg/torrents + if _, err := os.Stat(parentSymlink); os.IsNotExist(err) { + r.logger.Debug().Msgf("Cannot access parent directory %s for %s", parentSymlink, firstFile.Path) + return false } - if parentSymlink != "" { - if _, err := os.Stat(parentSymlink); os.IsNotExist(err) { - return false - } - return true - } - return false + return true } func (r *Repair) getBrokenFiles(job *Job, media arr.Content) []arr.ContentFile { diff --git a/pkg/server/server.go b/pkg/server/server.go index 8dd793c..66ac869 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -70,7 +70,7 @@ func (s *Server) Start(ctx context.Context) error { go func() { if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - s.logger.Info().Msgf("Error starting server: %v", err) + s.logger.Error().Err(err).Msgf("Error starting server") } }() diff --git a/pkg/store/store.go b/pkg/store/store.go index 00f25bb..d404152 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -41,7 +41,7 @@ func GetStore() *Store { arr: arrs, debrid: deb, torrents: newTorrentStorage(cfg.TorrentsFile()), - logger: logger.New("store"), + logger: logger.Default(), // Use default logger [decypharr] refreshInterval: time.Duration(cmp.Or(qbitCfg.RefreshInterval, 10)) * time.Minute, skipPreCache: qbitCfg.SkipPreCache, downloadSemaphore: make(chan struct{}, cmp.Or(qbitCfg.MaxDownloads, 5)), diff --git a/pkg/store/torrent.go b/pkg/store/torrent.go index a5c0388..419c28b 100644 --- a/pkg/store/torrent.go +++ b/pkg/store/torrent.go @@ -101,7 +101,7 @@ func (s *Store) processFiles(torrent *Torrent, debridTorrent *types.Torrent, imp go func() { _ = client.DeleteTorrent(debridTorrent.Id) }() - s.logger.Info().Msgf("Error: %v", err) + s.logger.Error().Err(err).Msgf("Error occured while processing torrent %s", debridTorrent.Name) importReq.markAsFailed(err, torrent, debridTorrent) return }