diff options
| author | Bobby <[email protected]> | 2026-02-05 16:47:26 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-02-05 16:47:26 +0530 |
| commit | 3980ea772e3895c127ac147ec07d69a2ab9b71a7 (patch) | |
| tree | 11ac3a3f12b8e148beda55686db7d42dcad0c6c2 | |
| parent | c1b751e33f3e2859f203dc1e1e6e530eb0d09199 (diff) | |
| download | metachan-3980ea772e3895c127ac147ec07d69a2ab9b71a7.tar.xz metachan-3980ea772e3895c127ac147ec07d69a2ab9b71a7.zip | |
Refactor MALSync API client: enhance request handling with retry logic and error management, and introduce Malsync types for streaming site and anime response
| -rw-r--r-- | entities/episode.go | 2 | ||||
| -rw-r--r-- | types/malsync.go | 27 | ||||
| -rw-r--r-- | utils/api/malsync/malsync.go | 157 | ||||
| -rw-r--r-- | utils/api/malsync/types.go | 35 |
4 files changed, 139 insertions, 82 deletions
diff --git a/entities/episode.go b/entities/episode.go index 799c4e7..8425f51 100644 --- a/entities/episode.go +++ b/entities/episode.go @@ -28,7 +28,7 @@ type Episode struct { type EpisodeSkipTime struct { gorm.Model EpisodeID string `gorm:"index;size:32" json:"episode_id"` - SkipType string `gorm:"index" json:"skip_type"` // op, ed, recap, etc. + SkipType string `gorm:"index" json:"skip_type"` StartTime float64 `json:"start_time"` EndTime float64 `json:"end_time"` } diff --git a/types/malsync.go b/types/malsync.go new file mode 100644 index 0000000..2957ce8 --- /dev/null +++ b/types/malsync.go @@ -0,0 +1,27 @@ +package types + +type MalsyncStreamingSite struct { + ID int `json:"id,omitempty"` + Identifier any `json:"identifier"` + Image string `json:"image,omitempty"` + MalID int `json:"malId,omitempty"` + AniID int `json:"aniId,omitempty"` + Page string `json:"page"` + Title string `json:"title"` + Type string `json:"type"` + URL string `json:"url"` + External bool `json:"external,omitempty"` +} + +type MalsyncSitesCollection map[string]map[string]MalsyncStreamingSite + +type MalsyncAnimeResponse struct { + ID int `json:"id"` + Type string `json:"type"` + Title string `json:"title"` + URL string `json:"url"` + Total int `json:"total"` + Image string `json:"image"` + AnidbID int `json:"anidbId,omitempty"` + Sites MalsyncSitesCollection `json:"Sites"` +} diff --git a/utils/api/malsync/malsync.go b/utils/api/malsync/malsync.go index 379c6ad..5fcc01e 100644 --- a/utils/api/malsync/malsync.go +++ b/utils/api/malsync/malsync.go @@ -3,96 +3,145 @@ package malsync import ( "context" "encoding/json" + "errors" "fmt" "io" + "math" + "metachan/types" + "metachan/utils/logger" "net/http" + "strconv" "time" ) const ( malsyncAPIBaseURL = "https://api.malsync.moe/mal" + contextTimeout = 10 * time.Second ) -// MALSyncClient provides methods for interacting with the MALSync API -type MALSyncClient struct { - client *http.Client - maxRetries int -} - -// NewMALSyncClient creates a new client for the MALSync API -func NewMALSyncClient() *MALSyncClient { - return &MALSyncClient{ - client: &http.Client{ - Timeout: 8 * time.Second, // Shorter timeout since this is a less critical API +var ( + clientInstance = &client{ + httpClient: &http.Client{ + Timeout: 10 * time.Second, }, - maxRetries: 2, + maxRetries: 3, + backoff: 1 * time.Second, } +) + +func (c *client) getBackOffDuration(attempt int) time.Duration { + return time.Duration(float64(c.backoff) * math.Pow(2, float64(attempt-1))) } -// GetAnimeByMALID fetches anime metadata from MALSync by MAL ID -func (c *MALSyncClient) GetAnimeByMALID(malID int) (*MALSyncAnimeResponse, error) { - apiURL := fmt.Sprintf("%s/anime/%d", malsyncAPIBaseURL, malID) +func (c *client) getRetryAfterDuration(resp *http.Response) time.Duration { + if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" { + if seconds, err := strconv.Atoi(retryAfter); err == nil { + return time.Duration(seconds) * time.Second + } + } + return c.backoff +} - // Create context with timeout - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() +func (c *client) handleRetry(retries *int, url string, reason string, retryAfter time.Duration) bool { + *retries++ + if *retries >= c.maxRetries { + return false + } - // Create request - req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil) - if err != nil { - return nil, fmt.Errorf("failed to create request: %w", err) + backoffDuration := c.getBackOffDuration(*retries) + if retryAfter > backoffDuration { + backoffDuration = retryAfter } - req.Header.Set("Accept", "application/json") + logger.Warnf("MalsyncClient", "%s for %s (attempt %d/%d)", reason, url, *retries, c.maxRetries) + time.Sleep(backoffDuration) + return true +} - // Execute request with retries - var resp *http.Response - var lastErr error - success := false +func (c *client) makeRequest(ctx context.Context, url string) ([]byte, error) { + var response *http.Response + var retries int - for i := 0; i <= c.maxRetries && !success; i++ { - resp, err = c.client.Do(req) + for retries < c.maxRetries { + request, err := http.NewRequestWithContext(ctx, "GET", url, nil) if err != nil { - lastErr = err - time.Sleep(time.Duration((i+1)*300) * time.Millisecond) // Short backoff on error - continue + logger.Errorf("MalsyncClient", "Failed to create request: %v", err) + return nil, errors.New("failed to create request to Malsync API") } - defer resp.Body.Close() + request.Header.Set("Accept", "application/json") - if resp.StatusCode == http.StatusNotFound { - return nil, nil // Not found is not an error, just return nil - } - - if resp.StatusCode != http.StatusOK { - bodyBytes, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - lastErr = fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) - time.Sleep(time.Duration((i+1)*300) * time.Millisecond) + response, err = c.httpClient.Do(request) + if err != nil { + if !c.handleRetry(&retries, url, fmt.Sprintf("Request failed: %v", err), 0) { + logger.Errorf("MalsyncClient", "All retries exhausted for request to %s: %v", url, err) + return nil, errors.New("failed to make request to Malsync API after max retries") + } continue } - success = true + defer response.Body.Close() + + switch response.StatusCode { + case http.StatusNotFound: + // Not found is not an error, return nil + return nil, nil + case http.StatusTooManyRequests: + retryAfter := c.getRetryAfterDuration(response) + if !c.handleRetry(&retries, url, "Rate limited", retryAfter) { + logger.Errorf("MalsyncClient", "All retries exhausted for request to %s", url) + return nil, errors.New("failed to make request to Malsync API after max retries") + } + case http.StatusOK: + bytes, err := io.ReadAll(response.Body) + + if err != nil { + logger.Errorf("MalsyncClient", "Failed to read response body from %s: %v", url, err) + return nil, errors.New("failed to read response from Malsync API") + } + + return bytes, nil + default: + retries++ + backoffDuration := c.getBackOffDuration(retries) + + logger.Warnf("MalsyncClient", "Request to %s returned status %d (attempt %d/%d)", url, response.StatusCode, retries, c.maxRetries) + + time.Sleep(backoffDuration) + } } - if !success { - return nil, fmt.Errorf("failed after %d retries: %w", c.maxRetries, lastErr) - } + logger.Errorf("MalsyncClient", "All retries exhausted for request to %s", url) + return nil, errors.New("failed to make request to Malsync API after max retries") +} - // Parse response - bodyBytes, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024)) // 1MB limit +func GetAnimeByMALID(malID int) (*types.MalsyncAnimeResponse, error) { + url := fmt.Sprintf("%s/anime/%d", malsyncAPIBaseURL, malID) + ctx, cancel := context.WithTimeout(context.Background(), contextTimeout) + + defer cancel() + + bytes, err := clientInstance.makeRequest(ctx, url) if err != nil { - return nil, fmt.Errorf("failed to read response: %w", err) + logger.Errorf("MalsyncClient", "GetAnimeByMALID failed for MAL ID %d: %v", malID, err) + return nil, errors.New("failed to fetch anime data from Malsync API") + } + + // Handle 404 case where makeRequest returns nil, nil + if bytes == nil { + return nil, nil } - var malSyncResponse MALSyncAnimeResponse - if err := json.Unmarshal(bodyBytes, &malSyncResponse); err != nil { - return nil, fmt.Errorf("failed to decode response: %w", err) + var response types.MalsyncAnimeResponse + if err := json.Unmarshal(bytes, &response); err != nil { + logger.Errorf("MalsyncClient", "Failed to unmarshal response for MAL ID %d: %v", malID, err) + return nil, errors.New("failed to parse anime data from Malsync API") } - // Simple validation - if malSyncResponse.ID == 0 { + if response.ID == 0 { + logger.Errorf("MalsyncClient", "Received empty response for MAL ID %d", malID) return nil, fmt.Errorf("received empty response for MAL ID %d", malID) } - return &malSyncResponse, nil + return &response, nil } diff --git a/utils/api/malsync/types.go b/utils/api/malsync/types.go index 9944166..6d4303c 100644 --- a/utils/api/malsync/types.go +++ b/utils/api/malsync/types.go @@ -1,31 +1,12 @@ package malsync -// MALSyncStreamingSite represents a single streaming site entry in the MALSync API -type MALSyncStreamingSite struct { - ID int `json:"id,omitempty"` - Identifier any `json:"identifier"` - Image string `json:"image,omitempty"` - MalID int `json:"malId,omitempty"` - AniID int `json:"aniId,omitempty"` - Page string `json:"page"` - Title string `json:"title"` - Type string `json:"type"` - URL string `json:"url"` - External bool `json:"external,omitempty"` -} - -// MALSyncSitesCollection represents the nested structure of streaming sites -// Format: map[platformName]map[identifier]siteObject -type MALSyncSitesCollection map[string]map[string]MALSyncStreamingSite +import ( + "net/http" + "time" +) -// MALSyncAnimeResponse is the top-level response from the MALSync API -type MALSyncAnimeResponse struct { - ID int `json:"id"` - Type string `json:"type"` - Title string `json:"title"` - URL string `json:"url"` - Total int `json:"total"` - Image string `json:"image"` - AnidbID int `json:"anidbId,omitempty"` - Sites MALSyncSitesCollection `json:"Sites"` +type client struct { + httpClient *http.Client + maxRetries int + backoff time.Duration } |
