diff --git a/internal/dav/response.go b/internal/dav/response.go index a87ec58..a50d86f 100644 --- a/internal/dav/response.go +++ b/internal/dav/response.go @@ -52,6 +52,7 @@ func createSingleTorrentResponse(basePath string, torrents []torrent.Torrent) (* for _, torrent := range torrents { for _, file := range torrent.SelectedFiles { if file.Link == "" { + // TODO: Fix this file log.Println("File has no link, skipping", file.Path) continue } diff --git a/internal/torrent/manager.go b/internal/torrent/manager.go index 221c224..69cfdc2 100644 --- a/internal/torrent/manager.go +++ b/internal/torrent/manager.go @@ -22,6 +22,102 @@ type TorrentManager struct { workerPool chan bool } +// NewTorrentManager creates a new torrent manager +// it will fetch all torrents and their info in the background +// and store them in-memory +func NewTorrentManager(config config.ConfigInterface, cache *expirable.LRU[string, string]) *TorrentManager { + handler := &TorrentManager{ + config: config, + cache: cache, + workerPool: make(chan bool, config.GetNumOfWorkers()), + } + + // Initialize torrents for the first time + handler.torrents = handler.getAll() + + for _, torrent := range handler.torrents { + go func(id string) { + handler.workerPool <- true + handler.getInfo(id) + <-handler.workerPool + time.Sleep(1 * time.Second) // sleep for 1 second to avoid rate limiting + }(torrent.ID) + } + + // Start the periodic refresh + go handler.refreshTorrents() + + return handler +} + +// GetByDirectory returns all torrents that have a file in the specified directory +func (t *TorrentManager) GetByDirectory(directory string) []Torrent { + var torrents []Torrent + for i := range t.torrents { + for _, dir := range t.torrents[i].Directories { + if dir == directory { + torrents = append(torrents, t.torrents[i]) + } + } + } + return torrents +} + +// RefreshInfo refreshes the info for a torrent +func (t *TorrentManager) RefreshInfo(torrentID string) { + filePath := fmt.Sprintf("data/%s.bin", torrentID) + // Check the last modified time of the .bin file + fileInfo, err := os.Stat(filePath) + if err == nil { + modTime := fileInfo.ModTime() + // If the file was modified less than an hour ago, don't refresh + if time.Since(modTime) < time.Duration(t.config.GetCacheTimeHours())*time.Hour { + return + } + err = os.Remove(filePath) + if err != nil && !os.IsNotExist(err) { // File doesn't exist or other error + log.Printf("Cannot remove file: %v\n", err) + } + } else if !os.IsNotExist(err) { // Error other than file not existing + log.Printf("Error checking file info: %v\n", err) + return + } + info := t.getInfo(torrentID) + log.Println("Refreshed info for", info.Name) +} + +// MarkFileAsDeleted marks a file as deleted +func (t *TorrentManager) MarkFileAsDeleted(torrent *Torrent, file *File) { + log.Println("Marking file as deleted", file.Path) + file.Link = "" + t.writeToFile(torrent.ID, torrent) +} + +// GetInfo returns the info for a torrent +func (t *TorrentManager) GetInfo(torrentID string) *Torrent { + for i := range t.torrents { + if t.torrents[i].ID == torrentID { + return &t.torrents[i] + } + } + return t.getInfo(torrentID) +} + +// getChecksum returns the checksum based on the total count and the first torrent's ID +func (t *TorrentManager) getChecksum() string { + torrents, totalCount, err := realdebrid.GetTorrents(t.config.GetToken(), 1) + if err != nil { + log.Printf("Cannot get torrents: %v\n", err) + return t.checksum + } + if len(torrents) == 0 { + log.Println("Huh, no torrents returned") + return t.checksum + } + return fmt.Sprintf("%d-%s", totalCount, torrents[0].ID) +} + +// refreshTorrents periodically refreshes the torrents func (t *TorrentManager) refreshTorrents() { log.Println("Starting periodic refresh") for { @@ -73,47 +169,7 @@ func (t *TorrentManager) refreshTorrents() { } } -// NewTorrentManager creates a new torrent manager -// it will fetch all torrents and their info in the background -// and store them in-memory -func NewTorrentManager(config config.ConfigInterface, cache *expirable.LRU[string, string]) *TorrentManager { - handler := &TorrentManager{ - config: config, - cache: cache, - workerPool: make(chan bool, config.GetNumOfWorkers()), - } - - // Initialize torrents for the first time - handler.torrents = handler.getAll() - - for _, torrent := range handler.torrents { - go func(id string) { - handler.workerPool <- true - handler.getInfo(id) - <-handler.workerPool - time.Sleep(1 * time.Second) // sleep for 1 second to avoid rate limiting - }(torrent.ID) - } - - // Start the periodic refresh - go handler.refreshTorrents() - - return handler -} - -func (t *TorrentManager) getChecksum() string { - torrents, totalCount, err := realdebrid.GetTorrents(t.config.GetToken(), 1) - if err != nil { - log.Printf("Cannot get torrents: %v\n", err) - return t.checksum - } - if len(torrents) == 0 { - log.Println("Huh, no torrents returned") - return t.checksum - } - return fmt.Sprintf("%d-%s", totalCount, torrents[0].ID) -} - +// getAll returns all torrents func (t *TorrentManager) getAll() []Torrent { log.Println("Getting all torrents") @@ -159,40 +215,7 @@ func (t *TorrentManager) getAll() []Torrent { return torrentsV2 } -func (t *TorrentManager) GetByDirectory(directory string) []Torrent { - var torrents []Torrent - for i := range t.torrents { - for _, dir := range t.torrents[i].Directories { - if dir == directory { - torrents = append(torrents, t.torrents[i]) - } - } - } - return torrents -} - -func (t *TorrentManager) RefreshInfo(torrentID string) { - filePath := fmt.Sprintf("data/%s.bin", torrentID) - // Check the last modified time of the .bin file - fileInfo, err := os.Stat(filePath) - if err == nil { - modTime := fileInfo.ModTime() - // If the file was modified less than an hour ago, don't refresh - if time.Since(modTime) < time.Duration(t.config.GetCacheTimeHours())*time.Hour { - return - } - err = os.Remove(filePath) - if err != nil && !os.IsNotExist(err) { // File doesn't exist or other error - log.Printf("Cannot remove file: %v\n", err) - } - } else if !os.IsNotExist(err) { // Error other than file not existing - log.Printf("Error checking file info: %v\n", err) - return - } - info := t.getInfo(torrentID) - log.Println("Refreshed info for", info.Name) -} - +// getInfo returns the info for a torrent func (t *TorrentManager) getInfo(torrentID string) *Torrent { torrentFromFile := t.readFromFile(torrentID) if torrentFromFile != nil { @@ -290,21 +313,7 @@ func (t *TorrentManager) getInfo(torrentID string) *Torrent { return torrent } -func (t *TorrentManager) MarkFileAsDeleted(torrent *Torrent, file *File) { - log.Println("Marking file as deleted", file.Path) - file.Link = "" - t.writeToFile(torrent.ID, torrent) -} - -func (t *TorrentManager) GetInfo(torrentID string) *Torrent { - for i := range t.torrents { - if t.torrents[i].ID == torrentID { - return &t.torrents[i] - } - } - return t.getInfo(torrentID) -} - +// getByID returns a torrent by its ID func (t *TorrentManager) getByID(torrentID string) *Torrent { for i := range t.torrents { if t.torrents[i].ID == torrentID { @@ -314,6 +323,7 @@ func (t *TorrentManager) getByID(torrentID string) *Torrent { return nil } +// writeToFile writes a torrent to a file func (t *TorrentManager) writeToFile(torrentID string, torrent *Torrent) { filePath := fmt.Sprintf("data/%s.bin", torrentID) file, err := os.Create(filePath) @@ -327,6 +337,7 @@ func (t *TorrentManager) writeToFile(torrentID string, torrent *Torrent) { dataEncoder.Encode(torrent) } +// readFromFile reads a torrent from a file func (t *TorrentManager) readFromFile(torrentID string) *Torrent { filePath := fmt.Sprintf("data/%s.bin", torrentID) fileInfo, err := os.Stat(filePath) diff --git a/pkg/realdebrid/api.go b/pkg/realdebrid/api.go index 4150346..06050d6 100644 --- a/pkg/realdebrid/api.go +++ b/pkg/realdebrid/api.go @@ -3,6 +3,7 @@ package realdebrid import ( "bytes" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -87,39 +88,6 @@ func UnrestrictLink(accessToken, link string) (*UnrestrictResponse, error) { return &response, nil } -func canFetchFirstByte(url string) bool { - // Create a new HTTP request - req, err := http.NewRequest("GET", url, nil) - if err != nil { - return false - } - - // Set the Range header to request only the first byte - req.Header.Set("Range", "bytes=0-0") - - // Execute the request - resp, err := http.DefaultClient.Do(req) - if err != nil { - return false - } - defer resp.Body.Close() - - // If server supports partial content - if resp.StatusCode == http.StatusPartialContent { - buffer := make([]byte, 1) - _, err := resp.Body.Read(buffer) - return err == nil - } - if resp.StatusCode != http.StatusOK { - return false - } - // If server doesn't support partial content, try reading the first byte and immediately close - buffer := make([]byte, 1) - _, err = resp.Body.Read(buffer) - resp.Body.Close() // Close immediately after reading - return err == nil -} - // GetTorrents returns all torrents, paginated // if customLimit is 0, the default limit of 2500 is used func GetTorrents(accessToken string, customLimit int) ([]Torrent, int, error) { @@ -216,3 +184,168 @@ func GetTorrentInfo(accessToken, id string) (*Torrent, error) { return &response, nil } + +// SelectTorrentFiles selects files of a torrent to start it. +func SelectTorrentFiles(accessToken string, id string, files string) error { + // Prepare request data + data := url.Values{} + data.Set("files", files) + + // Construct request URL + reqURL := fmt.Sprintf("https://api.real-debrid.com/rest/1.0/torrents/selectFiles/%s", id) + req, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(data.Encode())) + if err != nil { + return err + } + + // Set request headers + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Handle response status codes + switch resp.StatusCode { + case http.StatusOK, http.StatusNoContent: + return nil // Success + case http.StatusAccepted: + return errors.New("action already done") + case http.StatusBadRequest: + return errors.New("bad request") + case http.StatusUnauthorized: + return errors.New("bad token (expired or invalid)") + case http.StatusForbidden: + return errors.New("permission denied (account locked or not premium)") + case http.StatusNotFound: + return errors.New("wrong parameter (invalid file id(s)) or unknown resource (invalid id)") + default: + return fmt.Errorf("unexpected HTTP error: %s", resp.Status) + } +} + +// DeleteTorrent deletes a torrent from the torrents list. +func DeleteTorrent(accessToken string, id string) error { + // Construct request URL + reqURL := fmt.Sprintf("https://api.real-debrid.com/rest/1.0/torrents/delete/%s", id) + req, err := http.NewRequest("DELETE", reqURL, nil) + if err != nil { + return err + } + + // Set request headers + req.Header.Set("Authorization", "Bearer "+accessToken) + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // Handle response status codes + switch resp.StatusCode { + case http.StatusNoContent: + return nil // Success + case http.StatusUnauthorized: + return errors.New("bad token (expired or invalid)") + case http.StatusForbidden: + return errors.New("permission denied (account locked)") + case http.StatusNotFound: + return errors.New("unknown resource") + default: + return fmt.Errorf("unexpected HTTP error: %s", resp.Status) + } +} + +// AddMagnet adds a magnet link to download. +func AddMagnet(accessToken, magnet, host string) (*MagnetResponse, error) { + // Prepare request data + data := url.Values{} + data.Set("magnet", magnet) + data.Set("host", host) + + // Construct request URL + reqURL := "https://api.real-debrid.com/rest/1.0/torrents/addMagnet" + req, err := http.NewRequest("POST", reqURL, bytes.NewBufferString(data.Encode())) + if err != nil { + return nil, err + } + + // Set request headers + req.Header.Set("Authorization", "Bearer "+accessToken) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Handle response status codes + switch resp.StatusCode { + case http.StatusCreated: + var response MagnetResponse + err := json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + return &response, nil + case http.StatusBadRequest: + return nil, errors.New("bad request") + case http.StatusUnauthorized: + return nil, errors.New("bad token (expired or invalid)") + case http.StatusForbidden: + return nil, errors.New("permission denied (account locked or not premium)") + case http.StatusServiceUnavailable: + return nil, errors.New("service unavailable") + default: + return nil, fmt.Errorf("unexpected HTTP error: %s", resp.Status) + } +} + +// GetActiveTorrentCount gets the number of currently active torrents and the current maximum limit. +func GetActiveTorrentCount(accessToken string) (*ActiveTorrentCountResponse, error) { + // Construct request URL + reqURL := "https://api.real-debrid.com/rest/1.0/torrents/activeCount" + req, err := http.NewRequest("GET", reqURL, nil) + if err != nil { + return nil, err + } + + // Set request headers + req.Header.Set("Authorization", "Bearer "+accessToken) + + // Send the request + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + // Handle response status codes + switch resp.StatusCode { + case http.StatusOK: + var response ActiveTorrentCountResponse + err := json.NewDecoder(resp.Body).Decode(&response) + if err != nil { + return nil, err + } + return &response, nil + case http.StatusUnauthorized: + return nil, errors.New("bad token (expired or invalid)") + case http.StatusForbidden: + return nil, errors.New("permission denied (account locked)") + default: + return nil, fmt.Errorf("unexpected HTTP error: %s", resp.Status) + } +} diff --git a/pkg/realdebrid/types.go b/pkg/realdebrid/types.go index d5bf443..105b88d 100644 --- a/pkg/realdebrid/types.go +++ b/pkg/realdebrid/types.go @@ -29,3 +29,13 @@ type File struct { Bytes int64 `json:"bytes"` Selected int `json:"selected"` } + +type MagnetResponse struct { + ID string `json:"id"` + URI string `json:"uri"` +} + +type ActiveTorrentCountResponse struct { + DownloadingCount int `json:"nb"` + MaxNumberOfTorrents int `json:"limit"` +} diff --git a/pkg/realdebrid/util.go b/pkg/realdebrid/util.go index aaf4a54..4022b41 100644 --- a/pkg/realdebrid/util.go +++ b/pkg/realdebrid/util.go @@ -2,6 +2,7 @@ package realdebrid import ( "math" + "net/http" "strings" "time" ) @@ -18,3 +19,36 @@ func RetryUntilOk[T any](fn func() (T, error)) T { time.Sleep(delay) } } + +func canFetchFirstByte(url string) bool { + // Create a new HTTP request + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return false + } + + // Set the Range header to request only the first byte + req.Header.Set("Range", "bytes=0-0") + + // Execute the request + resp, err := http.DefaultClient.Do(req) + if err != nil { + return false + } + defer resp.Body.Close() + + // If server supports partial content + if resp.StatusCode == http.StatusPartialContent { + buffer := make([]byte, 1) + _, err := resp.Body.Read(buffer) + return err == nil + } + if resp.StatusCode != http.StatusOK { + return false + } + // If server doesn't support partial content, try reading the first byte and immediately close + buffer := make([]byte, 1) + _, err = resp.Body.Read(buffer) + resp.Body.Close() // Close immediately after reading + return err == nil +}