node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,186 @@
package mal
import (
"fmt"
"net/url"
)
const (
BaseAnimeFields string = "id,title,main_picture,alternative_titles,start_date,end_date,start_season,nsfw,synopsis,num_episodes,mean,rank,popularity,media_type,status"
)
type (
BasicAnime struct {
ID int `json:"id"`
Title string `json:"title"`
MainPicture struct {
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"main_picture"`
AlternativeTitles struct {
Synonyms []string `json:"synonyms"`
En string `json:"en"`
Ja string `json:"ja"`
} `json:"alternative_titles"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
StartSeason struct {
Year int `json:"year"`
Season string `json:"season"`
} `json:"start_season"`
Synopsis string `json:"synopsis"`
NSFW string `json:"nsfw"`
NumEpisodes int `json:"num_episodes"`
Mean float32 `json:"mean"`
Rank int `json:"rank"`
Popularity int `json:"popularity"`
MediaType MediaType `json:"media_type"`
Status MediaStatus `json:"status"`
}
AnimeListEntry struct {
Node struct {
ID int `json:"id"`
Title string `json:"title"`
MainPicture struct {
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"main_picture"`
} `json:"node"`
ListStatus struct {
Status MediaListStatus `json:"status"`
IsRewatching bool `json:"is_rewatching"`
NumEpisodesWatched int `json:"num_episodes_watched"`
Score int `json:"score"`
UpdatedAt string `json:"updated_at"`
} `json:"list_status"`
}
)
func (w *Wrapper) GetAnimeDetails(mId int) (*BasicAnime, error) {
w.logger.Debug().Int("mId", mId).Msg("mal: Getting anime details")
reqUrl := fmt.Sprintf("%s/anime/%d?fields=%s", ApiBaseURL, mId, BaseAnimeFields)
if w.AccessToken == "" {
return nil, fmt.Errorf("access token is empty")
}
var anime BasicAnime
err := w.doQuery("GET", reqUrl, nil, "application/json", &anime)
if err != nil {
w.logger.Error().Err(err).Int("mId", mId).Msg("mal: Failed to get anime details")
return nil, err
}
w.logger.Info().Int("mId", mId).Msg("mal: Fetched anime details")
return &anime, nil
}
func (w *Wrapper) GetAnimeCollection() ([]*AnimeListEntry, error) {
w.logger.Debug().Msg("mal: Getting anime collection")
reqUrl := fmt.Sprintf("%s/users/@me/animelist?fields=list_status&limit=1000", ApiBaseURL)
type response struct {
Data []*AnimeListEntry `json:"data"`
}
var data response
err := w.doQuery("GET", reqUrl, nil, "application/json", &data)
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to get anime collection")
return nil, err
}
w.logger.Info().Msg("mal: Fetched anime collection")
return data.Data, nil
}
type AnimeListProgressParams struct {
NumEpisodesWatched *int
}
func (w *Wrapper) UpdateAnimeProgress(opts *AnimeListProgressParams, mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Updating anime progress")
// Get anime details
anime, err := w.GetAnimeDetails(mId)
if err != nil {
return err
}
status := MediaListStatusWatching
if anime.Status == MediaStatusFinishedAiring && anime.NumEpisodes > 0 && anime.NumEpisodes <= *opts.NumEpisodesWatched {
status = MediaListStatusCompleted
}
if anime.NumEpisodes > 0 && *opts.NumEpisodesWatched > anime.NumEpisodes {
*opts.NumEpisodesWatched = anime.NumEpisodes
}
// Update MAL list entry
err = w.UpdateAnimeListStatus(&AnimeListStatusParams{
Status: &status,
NumEpisodesWatched: opts.NumEpisodesWatched,
}, mId)
if err == nil {
w.logger.Info().Int("mId", mId).Msg("mal: Updated anime progress")
}
return err
}
type AnimeListStatusParams struct {
Status *MediaListStatus
IsRewatching *bool
NumEpisodesWatched *int
Score *int
}
func (w *Wrapper) UpdateAnimeListStatus(opts *AnimeListStatusParams, mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Updating anime list status")
reqUrl := fmt.Sprintf("%s/anime/%d/my_list_status", ApiBaseURL, mId)
// Build URL
urlData := url.Values{}
if opts.Status != nil {
urlData.Set("status", string(*opts.Status))
}
if opts.IsRewatching != nil {
urlData.Set("is_rewatching", fmt.Sprintf("%t", *opts.IsRewatching))
}
if opts.NumEpisodesWatched != nil {
urlData.Set("num_watched_episodes", fmt.Sprintf("%d", *opts.NumEpisodesWatched))
}
if opts.Score != nil {
urlData.Set("score", fmt.Sprintf("%d", *opts.Score))
}
encodedData := urlData.Encode()
err := w.doMutation("PATCH", reqUrl, encodedData)
if err != nil {
w.logger.Error().Err(err).Int("mId", mId).Msg("mal: Failed to update anime list status")
return err
}
return nil
}
func (w *Wrapper) DeleteAnimeListItem(mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Deleting anime list item")
reqUrl := fmt.Sprintf("%s/anime/%d/my_list_status", ApiBaseURL, mId)
err := w.doMutation("DELETE", reqUrl, "")
if err != nil {
w.logger.Error().Err(err).Int("mId", mId).Msg("mal: Failed to delete anime list item")
return err
}
w.logger.Info().Int("mId", mId).Msg("mal: Deleted anime list item")
return nil
}

View File

@@ -0,0 +1,62 @@
package mal
import (
"github.com/davecgh/go-spew/spew"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
)
func TestGetAnimeDetails(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
res, err := malWrapper.GetAnimeDetails(51179)
spew.Dump(res)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
t.Log(res.Title)
}
func TestGetAnimeCollection(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
res, err := malWrapper.GetAnimeCollection()
if err != nil {
t.Fatalf("error while fetching anime collection, %v", err)
}
for _, entry := range res {
t.Log(entry.Node.Title)
if entry.Node.ID == 51179 {
spew.Dump(entry)
}
}
}
func TestUpdateAnimeListStatus(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList(), test_utils.MyAnimeListMutation())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
mId := 51179
progress := 2
status := MediaListStatusWatching
err := malWrapper.UpdateAnimeListStatus(&AnimeListStatusParams{
Status: &status,
NumEpisodesWatched: &progress,
}, mId)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
}

View File

@@ -0,0 +1,185 @@
package mal
import (
"fmt"
"net/url"
)
const (
BaseMangaFields string = "id,title,main_picture,alternative_titles,start_date,end_date,nsfw,synopsis,num_volumes,num_chapters,mean,rank,popularity,media_type,status"
)
type (
BasicManga struct {
ID int `json:"id"`
Title string `json:"title"`
MainPicture struct {
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"main_picture"`
AlternativeTitles struct {
Synonyms []string `json:"synonyms"`
En string `json:"en"`
Ja string `json:"ja"`
} `json:"alternative_titles"`
StartDate string `json:"start_date"`
EndDate string `json:"end_date"`
Synopsis string `json:"synopsis"`
NSFW string `json:"nsfw"`
NumVolumes int `json:"num_volumes"`
NumChapters int `json:"num_chapters"`
Mean float32 `json:"mean"`
Rank int `json:"rank"`
Popularity int `json:"popularity"`
MediaType MediaType `json:"media_type"`
Status MediaStatus `json:"status"`
}
MangaListEntry struct {
Node struct {
ID int `json:"id"`
Title string `json:"title"`
MainPicture struct {
Medium string `json:"medium"`
Large string `json:"large"`
} `json:"main_picture"`
} `json:"node"`
ListStatus struct {
Status MediaListStatus `json:"status"`
IsRereading bool `json:"is_rereading"`
NumVolumesRead int `json:"num_volumes_read"`
NumChaptersRead int `json:"num_chapters_read"`
Score int `json:"score"`
UpdatedAt string `json:"updated_at"`
} `json:"list_status"`
}
)
func (w *Wrapper) GetMangaDetails(mId int) (*BasicManga, error) {
w.logger.Debug().Int("mId", mId).Msg("mal: Getting manga details")
reqUrl := fmt.Sprintf("%s/manga/%d?fields=%s", ApiBaseURL, mId, BaseMangaFields)
if w.AccessToken == "" {
return nil, fmt.Errorf("access token is empty")
}
var manga BasicManga
err := w.doQuery("GET", reqUrl, nil, "application/json", &manga)
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to get manga details")
return nil, err
}
w.logger.Info().Int("mId", mId).Msg("mal: Fetched manga details")
return &manga, nil
}
func (w *Wrapper) GetMangaCollection() ([]*MangaListEntry, error) {
w.logger.Debug().Msg("mal: Getting manga collection")
reqUrl := fmt.Sprintf("%s/users/@me/mangalist?fields=list_status&limit=1000", ApiBaseURL)
type response struct {
Data []*MangaListEntry `json:"data"`
}
var data response
err := w.doQuery("GET", reqUrl, nil, "application/json", &data)
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to get manga collection")
return nil, err
}
w.logger.Info().Msg("mal: Fetched manga collection")
return data.Data, nil
}
type MangaListProgressParams struct {
NumChaptersRead *int
}
func (w *Wrapper) UpdateMangaProgress(opts *MangaListProgressParams, mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Updating manga progress")
// Get manga details
manga, err := w.GetMangaDetails(mId)
if err != nil {
return err
}
status := MediaListStatusReading
if manga.Status == MediaStatusFinished && manga.NumChapters > 0 && manga.NumChapters <= *opts.NumChaptersRead {
status = MediaListStatusCompleted
}
if manga.NumChapters > 0 && *opts.NumChaptersRead > manga.NumChapters {
*opts.NumChaptersRead = manga.NumChapters
}
// Update MAL list entry
err = w.UpdateMangaListStatus(&MangaListStatusParams{
Status: &status,
NumChaptersRead: opts.NumChaptersRead,
}, mId)
if err == nil {
w.logger.Info().Int("mId", mId).Msg("mal: Updated manga progress")
}
return err
}
type MangaListStatusParams struct {
Status *MediaListStatus
IsRereading *bool
NumChaptersRead *int
Score *int
}
func (w *Wrapper) UpdateMangaListStatus(opts *MangaListStatusParams, mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Updating manga list status")
reqUrl := fmt.Sprintf("%s/manga/%d/my_list_status", ApiBaseURL, mId)
// Build URL
urlData := url.Values{}
if opts.Status != nil {
urlData.Set("status", string(*opts.Status))
}
if opts.IsRereading != nil {
urlData.Set("is_rereading", fmt.Sprintf("%t", *opts.IsRereading))
}
if opts.NumChaptersRead != nil {
urlData.Set("num_chapters_read", fmt.Sprintf("%d", *opts.NumChaptersRead))
}
if opts.Score != nil {
urlData.Set("score", fmt.Sprintf("%d", *opts.Score))
}
encodedData := urlData.Encode()
err := w.doMutation("PATCH", reqUrl, encodedData)
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to update manga list status")
return err
}
return nil
}
func (w *Wrapper) DeleteMangaListItem(mId int) error {
w.logger.Debug().Int("mId", mId).Msg("mal: Deleting manga list item")
reqUrl := fmt.Sprintf("%s/manga/%d/my_list_status", ApiBaseURL, mId)
err := w.doMutation("DELETE", reqUrl, "")
if err != nil {
w.logger.Error().Err(err).Msg("mal: Failed to delete manga list item")
return err
}
w.logger.Info().Int("mId", mId).Msg("mal: Deleted manga list item")
return nil
}

View File

@@ -0,0 +1,62 @@
package mal
import (
"github.com/davecgh/go-spew/spew"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
)
func TestGetMangaDetails(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
res, err := malWrapper.GetMangaDetails(13)
spew.Dump(res)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
t.Log(res.Title)
}
func TestGetMangaCollection(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
res, err := malWrapper.GetMangaCollection()
if err != nil {
t.Fatalf("error while fetching anime collection, %v", err)
}
for _, entry := range res {
t.Log(entry.Node.Title)
if entry.Node.ID == 13 {
spew.Dump(entry)
}
}
}
func TestUpdateMangaListStatus(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList(), test_utils.MyAnimeListMutation())
malWrapper := NewWrapper(test_utils.ConfigData.Provider.MalJwt, util.NewLogger())
mId := 13
progress := 1000
status := MediaListStatusReading
err := malWrapper.UpdateMangaListStatus(&MangaListStatusParams{
Status: &status,
NumChaptersRead: &progress,
}, mId)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
}

View File

@@ -0,0 +1,232 @@
package mal
import (
"errors"
"fmt"
"github.com/goccy/go-json"
"github.com/samber/lo"
"io"
"math"
"net/http"
"net/url"
"regexp"
"seanime/internal/util/comparison"
"seanime/internal/util/result"
"sort"
"strings"
)
type (
SearchResultPayload struct {
MediaType string `json:"media_type"`
StartYear int `json:"start_year"`
Aired string `json:"aired,omitempty"`
Score string `json:"score"`
Status string `json:"status"`
}
SearchResultAnime struct {
ID int `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
URL string `json:"url"`
ImageURL string `json:"image_url"`
ThumbnailURL string `json:"thumbnail_url"`
Payload *SearchResultPayload `json:"payload"`
ESScore float64 `json:"es_score"`
}
SearchResult struct {
Categories []*struct {
Type string `json:"type"`
Items []*SearchResultAnime `json:"items"`
} `json:"categories"`
}
SearchCache struct {
*result.Cache[int, *SearchResultAnime]
}
)
//----------------------------------------------------------------------------------------------------------------------
// SearchWithMAL uses MAL's search API to find suggestions that match the title provided.
func SearchWithMAL(title string, slice int) ([]*SearchResultAnime, error) {
url := "https://myanimelist.net/search/prefix.json?type=anime&v=1&keyword=" + url.QueryEscape(title)
res, err := http.Get(url)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, fmt.Errorf("request failed with status code: %d", res.StatusCode)
}
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, err
}
var bodyMap SearchResult
err = json.Unmarshal(body, &bodyMap)
if err != nil {
return nil, fmt.Errorf("unmarshaling error: %v", err)
}
if bodyMap.Categories == nil {
return nil, fmt.Errorf("missing 'categories' in response")
}
items := make([]*SearchResultAnime, 0)
for _, cat := range bodyMap.Categories {
if cat.Type == "anime" {
items = append(items, cat.Items...)
}
}
if len(items) > slice {
return items[:slice], nil
}
return items, nil
}
// AdvancedSearchWithMAL is like SearchWithMAL, but it uses additional algorithms to find the best match.
func AdvancedSearchWithMAL(title string) (*SearchResultAnime, error) {
if len(title) == 0 {
return nil, fmt.Errorf("title is empty")
}
// trim the title
title = strings.ToLower(strings.TrimSpace(title))
// MAL typically doesn't use "cour"
re := regexp.MustCompile(`\bcour\b`)
title = re.ReplaceAllString(title, "part")
// fetch suggestions from MAL
suggestions, err := SearchWithMAL(title, 8)
if err != nil {
return nil, err
}
// sort the suggestions by score
sort.Slice(suggestions, func(i, j int) bool {
return suggestions[i].ESScore > suggestions[j].ESScore
})
// keep anime that have aired
suggestions = lo.Filter(suggestions, func(n *SearchResultAnime, index int) bool {
return n.ESScore >= 0.1 && n.Payload.Status != "Not yet aired"
})
// reduce score if anime is older than 2006
suggestions = lo.Map(suggestions, func(n *SearchResultAnime, index int) *SearchResultAnime {
if n.Payload.StartYear < 2006 {
n.ESScore -= 0.1
}
return n
})
tparts := strings.Fields(title)
tsub := tparts[0]
if len(tparts) > 1 {
tsub += " " + tparts[1]
}
tsub = strings.TrimSpace(tsub)
//
t1, foundT1 := lo.Find(suggestions, func(n *SearchResultAnime) bool {
nTitle := strings.ToLower(n.Name)
_tsub := tparts[0]
if len(tparts) > 1 {
_tsub += " " + tparts[1]
}
_tsub = strings.TrimSpace(_tsub)
re := regexp.MustCompile(`\b(film|movie|season|part|(s\d{2}e?))\b`)
return strings.HasPrefix(nTitle, tsub) && n.Payload.MediaType == "TV" && !re.MatchString(nTitle)
})
// very generous
t2, foundT2 := lo.Find(suggestions, func(n *SearchResultAnime) bool {
nTitle := strings.ToLower(n.Name)
_tsub := tparts[0]
re := regexp.MustCompile(`\b(film|movie|season|part|(s\d{2}e?))\b`)
return strings.HasPrefix(nTitle, _tsub) && n.Payload.MediaType == "TV" && !re.MatchString(nTitle)
})
levResult, found := comparison.FindBestMatchWithLevenshtein(&title, lo.Map(suggestions, func(n *SearchResultAnime, index int) *string { return &n.Name }))
if !found {
return nil, errors.New("couldn't find a suggestion from levenshtein")
}
levSuggestion, found := lo.Find(suggestions, func(n *SearchResultAnime) bool {
return strings.ToLower(n.Name) == strings.ToLower(*levResult.Value)
})
if !found {
return nil, errors.New("couldn't locate lenshtein result")
}
if foundT1 {
d, found := comparison.FindBestMatchWithLevenshtein(&tsub, []*string{&title, new(string)})
if found && len(*d.Value) > 0 {
if d.Distance <= 1 {
return t1, nil
}
}
}
// Strong correlation using MAL
if suggestions[0].ESScore >= 4.5 {
return suggestions[0], nil
}
// Very Likely match using distance
if levResult.Distance <= 4 {
return levSuggestion, nil
}
if suggestions[0].ESScore < 5 {
// Likely match using [startsWith]
if foundT1 {
dev := math.Abs(t1.ESScore-suggestions[0].ESScore) < 2.0
if len(tsub) > 6 && dev {
return t1, nil
}
}
// Likely match using [startsWith]
if foundT2 {
dev := math.Abs(t2.ESScore-suggestions[0].ESScore) < 2.0
if len(tparts[0]) > 6 && dev {
return t2, nil
}
}
// Likely match using distance
if levSuggestion.ESScore >= 1 && !(suggestions[0].ESScore > 3) {
return suggestions[0], nil
}
// Less than likely match using MAL
return suggestions[0], nil
}
// Distance above threshold, falling back to first MAL suggestion above
if levResult.Distance >= 5 && suggestions[0].ESScore >= 1 {
return suggestions[0], nil
}
return nil, nil
}

View File

@@ -0,0 +1,34 @@
package mal
import (
"seanime/internal/test_utils"
"testing"
)
func TestSearchWithMAL(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
res, err := SearchWithMAL("bungo stray dogs", 4)
if err != nil {
t.Fatalf("error while fetching media, %v", err)
}
for _, m := range res {
t.Log(m.Name)
}
}
func TestAdvancedSearchWithMal(t *testing.T) {
test_utils.InitTestProvider(t, test_utils.MyAnimeList())
res, err := AdvancedSearchWithMAL("sousou no frieren")
if err != nil {
t.Fatal("expected result, got error: ", err)
}
t.Log(res.Name)
}

View File

@@ -0,0 +1,40 @@
package mal
import "time"
type (
RequestOptions struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
}
MediaType string
MediaStatus string
MediaListStatus string
)
const (
MediaTypeTV MediaType = "tv" // Anime
MediaTypeOVA MediaType = "ova" // Anime
MediaTypeMovie MediaType = "movie" // Anime
MediaTypeSpecial MediaType = "special" // Anime
MediaTypeONA MediaType = "ona" // Anime
MediaTypeMusic MediaType = "music"
MediaTypeManga MediaType = "manga" // Manga
MediaTypeNovel MediaType = "novel" // Manga
MediaTypeOneShot MediaType = "oneshot" // Manga
MediaStatusFinishedAiring MediaStatus = "finished_airing" // Anime
MediaStatusCurrentlyAiring MediaStatus = "currently_airing" // Anime
MediaStatusNotYetAired MediaStatus = "not_yet_aired" // Anime
MediaStatusFinished MediaStatus = "finished" // Manga
MediaStatusCurrentlyPublishing MediaStatus = "currently_publishing" // Manga
MediaStatusNotYetPublished MediaStatus = "not_yet_published" // Manga
MediaListStatusReading MediaListStatus = "reading" // Manga
MediaListStatusWatching MediaListStatus = "watching" // Anime
MediaListStatusCompleted MediaListStatus = "completed"
MediaListStatusOnHold MediaListStatus = "on_hold"
MediaListStatusDropped MediaListStatus = "dropped"
MediaListStatusPlanToWatch MediaListStatus = "plan_to_watch" // Anime
MediaListStatusPlanToRead MediaListStatus = "plan_to_read" // Manga
)

View File

@@ -0,0 +1,160 @@
package mal
import (
"fmt"
"github.com/goccy/go-json"
"github.com/rs/zerolog"
"io"
"net/http"
"net/url"
"seanime/internal/database/db"
"seanime/internal/database/models"
"strings"
"time"
)
const (
ApiBaseURL string = "https://api.myanimelist.net/v2"
)
type (
Wrapper struct {
AccessToken string
client *http.Client
logger *zerolog.Logger
}
)
func NewWrapper(accessToken string, logger *zerolog.Logger) *Wrapper {
return &Wrapper{
AccessToken: accessToken,
client: &http.Client{},
logger: logger,
}
}
func (w *Wrapper) doQuery(method, uri string, body io.Reader, contentType string, data interface{}) error {
req, err := http.NewRequest(method, uri, body)
if err != nil {
return err
}
req.Header.Add("Content-Type", contentType)
req.Header.Add("Authorization", "Bearer "+w.AccessToken)
// Make the HTTP request
resp, err := w.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !((resp.StatusCode >= 200) && (resp.StatusCode <= 299)) {
return fmt.Errorf("invalid response status %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(data); err != nil {
return err
}
return nil
}
func (w *Wrapper) doMutation(method, uri, encodedParams string) error {
var reader io.Reader
reader = nil
if encodedParams != "" {
reader = strings.NewReader(encodedParams)
}
req, err := http.NewRequest(method, uri, reader)
if err != nil {
return err
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Authorization", "Bearer "+w.AccessToken)
// Make the HTTP request
resp, err := w.client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if !((resp.StatusCode >= 200) && (resp.StatusCode <= 299)) {
return fmt.Errorf("invalid response status %s", resp.Status)
}
return nil
}
func VerifyMALAuth(malInfo *models.Mal, db *db.Database, logger *zerolog.Logger) (*models.Mal, error) {
// Token has not expired
if malInfo.TokenExpiresAt.After(time.Now()) {
logger.Debug().Msg("mal: Token is still valid")
return malInfo, nil
}
// Token is expired, refresh it
client := &http.Client{}
// Build URL
urlData := url.Values{}
urlData.Set("grant_type", "refresh_token")
urlData.Set("refresh_token", malInfo.RefreshToken)
encodedData := urlData.Encode()
req, err := http.NewRequest("POST", "https://myanimelist.net/v1/oauth2/token", strings.NewReader(encodedData))
if err != nil {
logger.Error().Err(err).Msg("mal: Failed to create request")
return malInfo, err
}
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
req.Header.Add("Authorization", "Basic "+malInfo.AccessToken)
// Response
res, err := client.Do(req)
if err != nil {
logger.Error().Err(err).Msg("mal: Failed to refresh token")
return malInfo, err
}
defer res.Body.Close()
type malAuthResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int32 `json:"expires_in"`
TokenType string `json:"token_type"`
}
ret := malAuthResponse{}
if err := json.NewDecoder(res.Body).Decode(&ret); err != nil {
return malInfo, err
}
if ret.AccessToken == "" {
logger.Error().Msgf("mal: Failed to refresh token %s", res.Status)
return malInfo, fmt.Errorf("mal: Failed to refresh token %s", res.Status)
}
// Save
updatedMalInfo := models.Mal{
BaseModel: models.BaseModel{
ID: 1,
UpdatedAt: time.Now(),
},
Username: "",
AccessToken: ret.AccessToken,
RefreshToken: ret.RefreshToken,
TokenExpiresAt: time.Now().Add(time.Duration(ret.ExpiresIn) * time.Second),
}
_, err = db.UpsertMalInfo(&updatedMalInfo)
if err != nil {
logger.Error().Err(err).Msg("mal: Failed to save updated MAL info")
return malInfo, err
}
logger.Info().Msg("mal: Refreshed token")
return &updatedMalInfo, nil
}