node build fixed
This commit is contained in:
186
seanime-2.9.10/internal/api/mal/anime.go
Normal file
186
seanime-2.9.10/internal/api/mal/anime.go
Normal 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
|
||||
}
|
||||
62
seanime-2.9.10/internal/api/mal/anime_test.go
Normal file
62
seanime-2.9.10/internal/api/mal/anime_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
185
seanime-2.9.10/internal/api/mal/manga.go
Normal file
185
seanime-2.9.10/internal/api/mal/manga.go
Normal 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
|
||||
}
|
||||
62
seanime-2.9.10/internal/api/mal/manga_test.go
Normal file
62
seanime-2.9.10/internal/api/mal/manga_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
232
seanime-2.9.10/internal/api/mal/search.go
Normal file
232
seanime-2.9.10/internal/api/mal/search.go
Normal 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
|
||||
}
|
||||
34
seanime-2.9.10/internal/api/mal/search_test.go
Normal file
34
seanime-2.9.10/internal/api/mal/search_test.go
Normal 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)
|
||||
|
||||
}
|
||||
40
seanime-2.9.10/internal/api/mal/types.go
Normal file
40
seanime-2.9.10/internal/api/mal/types.go
Normal 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
|
||||
)
|
||||
160
seanime-2.9.10/internal/api/mal/wrapper.go
Normal file
160
seanime-2.9.10/internal/api/mal/wrapper.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user