aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorBobby <[email protected]>2026-02-05 16:47:26 +0530
committerBobby <[email protected]>2026-02-05 16:47:26 +0530
commit3980ea772e3895c127ac147ec07d69a2ab9b71a7 (patch)
tree11ac3a3f12b8e148beda55686db7d42dcad0c6c2
parentc1b751e33f3e2859f203dc1e1e6e530eb0d09199 (diff)
downloadmetachan-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.go2
-rw-r--r--types/malsync.go27
-rw-r--r--utils/api/malsync/malsync.go157
-rw-r--r--utils/api/malsync/types.go35
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
}