Skip to content

Commit

Permalink
add anilist implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
sonalys committed Feb 8, 2024
1 parent 76db7eb commit 0b43d27
Show file tree
Hide file tree
Showing 12 changed files with 259 additions and 36 deletions.
3 changes: 3 additions & 0 deletions cmd/service/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/sonalys/animeman/integrations/nyaa"
"github.com/sonalys/animeman/internal/anilist"
"github.com/sonalys/animeman/internal/configs"
"github.com/sonalys/animeman/internal/discovery"
"github.com/sonalys/animeman/internal/myanimelist"
Expand All @@ -27,6 +28,8 @@ func initializeAnimeList(c configs.AnimeListConfig) discovery.AnimeListSource {
switch c.Type {
case configs.AnimeListTypeMAL:
return myanimelist.New(c.Username)
case configs.AnimeListTypeAnilist:
return anilist.New(c.Username)
default:
log.Panic().Msgf("animeListType %s not implemented", c.Type)
}
Expand Down
124 changes: 124 additions & 0 deletions integrations/anilist/anime_list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package anilist

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"

"github.com/sonalys/animeman/internal/utils"
)

type AnimeListArg interface {
ApplyList(url.Values)
}

type (
ListStatus string
AiringStatus string
Title string

AnimeListEntry struct {
Status ListStatus `json:"status"`
Media struct {
Type string
AiringStatus AiringStatus `json:"status"`
Title struct {
Romaji string `json:"romaji"`
English string `json:"english"`
Native string `json:"native"`
} `json:"title"`
} `json:"media"`
}

GraphqlQuery struct {
Query string `json:"query"`
Variables map[string]any `json:"variables"`
}

AnimeListResp struct {
Data struct {
MediaListCollection struct {
Lists []struct {
Entries []AnimeListEntry `json:"entries"`
} `json:"lists"`
} `json:"MediaListCollection"`
} `json:"data"`
}
)

const (
ListStatusWatching ListStatus = "CURRENT"
ListStatusCompleted ListStatus = "COMPLETED"
ListStatusDropped ListStatus = "DROPPED"
ListStatusPlanning ListStatus = "PLANNING"
)

const (
AiringStatusAiring AiringStatus = "AIRING"
AiringStatusCompleted AiringStatus = "COMPLETED"
)

const getCurrentlyWatchingQuery = `query($userName:String,$type:MediaType){
MediaListCollection(userName:$userName,type:$type){
lists{
name
entries{
status
media{
title{romaji english native}
type
status(version:2)
}
}
}
}
}
`

func filter[T any](in []T, f func(T) bool) []T {
out := make([]T, 0, len(in))
for i := range in {
if f(in[i]) {
out = append(out, in[i])
}
}
return out
}

func (api *API) GetCurrentlyWatching(ctx context.Context) ([]AnimeListEntry, error) {
var path = API_URL + "/animelist/" + api.Username + "/load.json"
body := GraphqlQuery{
Query: getCurrentlyWatchingQuery,
Variables: map[string]any{
"userName": api.Username,
"type": "ANIME",
},
}
req := utils.Must(http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(utils.Must(json.Marshal(body)))))
req.Header.Add("Content-Type", "application/json")
req.Header.Add("Accept", "application/json")
resp, err := api.client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetching response: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("fetching anime list: %s", string(utils.Must(io.ReadAll(resp.Body))))
}
var entries AnimeListResp
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return nil, fmt.Errorf("reading response: %w", err)
}
var out []AnimeListEntry
for _, list := range entries.Data.MediaListCollection.Lists {
watchingEntries := filter(list.Entries, func(entry AnimeListEntry) bool {
return entry.Status == ListStatusWatching
})
out = append(out, watchingEntries...)
}
return out, nil
}
19 changes: 19 additions & 0 deletions integrations/anilist/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package anilist

import "net/http"

const API_URL = "https://graphql.anilist.co"

type (
API struct {
Username string
client *http.Client
}
)

func New(client *http.Client, username string) *API {
return &API{
client: client,
Username: username,
}
}
2 changes: 1 addition & 1 deletion integrations/myanimelist/anime_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ type AnimeListArg interface {
ApplyList(url.Values)
}

func (api *API) GetAnimeList(ctx context.Context, args ...AnimeListArg) ([]AnimeListEntry, error) {
func (api *API) GetCurrentlyWatching(ctx context.Context, args ...AnimeListArg) ([]AnimeListEntry, error) {
var path = API_URL + "/animelist/" + api.Username + "/load.json"
req := utils.Must(http.NewRequestWithContext(ctx, http.MethodGet, path, nil))
v := url.Values{
Expand Down
2 changes: 1 addition & 1 deletion integrations/myanimelist/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ type (

func New(client *http.Client, username string) *API {
return &API{
Username: username,
client: client,
Username: username,
}
}
1 change: 0 additions & 1 deletion integrations/myanimelist/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
type (
ListStatus int
AiringStatus int
Title string

AnimeListEntry struct {
Status ListStatus `json:"status"`
Expand Down
81 changes: 81 additions & 0 deletions internal/anilist/wrapper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package anilist

import (
"context"
"net/http"
"time"

"github.com/rs/zerolog/log"
"github.com/sonalys/animeman/integrations/anilist"
"github.com/sonalys/animeman/internal/roundtripper"
"github.com/sonalys/animeman/pkg/v1/animelist"
"golang.org/x/time/rate"
)

type (
Wrapper struct {
client *anilist.API
}
)

const userAgent = "github.com/sonalys/animeman"

func New(username string) *Wrapper {
client := &http.Client{
Transport: roundtripper.NewUserAgentTransport(
roundtripper.NewRateLimitedTransport(
http.DefaultTransport, rate.NewLimiter(rate.Every(time.Second), 1),
), userAgent),
Timeout: 10 * time.Second,
}
return &Wrapper{
client: anilist.New(client, username),
}
}

func convertStatus(in anilist.ListStatus) animelist.ListStatus {
switch in {
case anilist.ListStatusWatching:
return animelist.ListStatusWatching
case anilist.ListStatusCompleted:
return animelist.ListStatusCompleted
case anilist.ListStatusDropped:
return animelist.ListStatusDropped
case anilist.ListStatusPlanning:
return animelist.ListStatusPlanToWatch
default:
log.Fatal().Msgf("unexpected status from anilist: %s", in)
}
return animelist.ListStatusAll
}

func convertAiringStatus(in anilist.AiringStatus) animelist.AiringStatus {
switch in {
case anilist.AiringStatusAiring:
return animelist.AiringStatusAiring
case anilist.AiringStatusCompleted:
return animelist.AiringStatusAired
}
return animelist.AiringStatus(-1)
}

func convertMALEntry(in []anilist.AnimeListEntry) []animelist.Entry {
out := make([]animelist.Entry, 0, len(in))
for i := range in {
titles := in[i].Media.Title
out = append(out, animelist.Entry{
ListStatus: convertStatus(in[i].Status),
Titles: []string{titles.English, titles.Romaji, titles.Native},
AiringStatus: convertAiringStatus(in[i].Media.AiringStatus),
})
}
return out
}

func (w *Wrapper) GetCurrentlyWatching(ctx context.Context) ([]animelist.Entry, error) {
resp, err := w.client.GetCurrentlyWatching(ctx)
if err != nil {
return nil, err
}
return convertMALEntry(resp), nil
}
2 changes: 1 addition & 1 deletion internal/discovery/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ type (
}

AnimeListSource interface {
GetAnimeList(ctx context.Context, args ...animelist.AnimeListArg) ([]animelist.Entry, error)
GetCurrentlyWatching(ctx context.Context) ([]animelist.Entry, error)
}

TorrentClient interface {
Expand Down
16 changes: 12 additions & 4 deletions internal/discovery/discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type ParsedNyaa struct {
func (c *Controller) RunDiscovery(ctx context.Context) error {
t1 := time.Now()
log.Debug().Msgf("discovery started")
entries, err := c.dep.AnimeListClient.GetAnimeList(ctx, animelist.ListStatusWatching)
entries, err := c.dep.AnimeListClient.GetCurrentlyWatching(ctx)
if err != nil {
log.Fatal().Msgf("getting MAL list: %s", err)
}
Expand Down Expand Up @@ -109,22 +109,30 @@ func filterNyaaFeed(entries []nyaa.Entry, latestTag string, animeStatus animelis
return episodeFilter(parseNyaaEntries(entries), latestTag, !useBatch)
}

func ForEach[T any](in []T, f func(T) T) []T {
out := make([]T, 0, len(in))
for i := range in {
out = append(out, f(in[i]))
}
return out
}

// DigestAnimeListEntry receives an anime list entry and fetches the anime feed, looking for new content.
func (c *Controller) DigestAnimeListEntry(ctx context.Context, entry animelist.Entry) (count int, err error) {
// Build search query for Nyaa.
// For title we filter for english and original titles.
titleQuery := nyaa.OrQuery{parser.TitleStrip(entry.TitleEng), parser.TitleStrip(entry.Title)}
titleQuery := nyaa.OrQuery(ForEach(entry.Titles, func(title string) string { return parser.TitleStrip(title) }))
sourceQuery := nyaa.OrQuery(c.dep.Config.Sources)
qualityQuery := nyaa.OrQuery(c.dep.Config.Qualitites)

nyaaEntries, err := c.dep.NYAA.List(ctx, titleQuery, sourceQuery, qualityQuery)
log.Debug().Str("entry", entry.GetTitle()).Msgf("found %d torrents", len(nyaaEntries))
log.Debug().Str("entry", entry.Titles[0]).Msgf("found %d torrents", len(nyaaEntries))
if err != nil {
return count, fmt.Errorf("getting nyaa list: %w", err)
}
// There should always be torrents for entries, if there aren't we can just exit the routine.
if len(nyaaEntries) == 0 {
log.Error().Msgf("no torrents found for entry '%s'", entry.GetTitle())
log.Error().Msgf("no torrents found for entry '%s'", entry.Titles[0])
return count, nil
}
latestTag, err := c.TagGetLatest(ctx, entry)
Expand Down
27 changes: 13 additions & 14 deletions internal/discovery/torrent.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,20 +83,19 @@ func tagGetLatest(torrents ...torrentclient.Torrent) string {

// TagGetLatest will receive an anime list entry and return all torrents listed from the anime.
func (c *Controller) TagGetLatest(ctx context.Context, entry animelist.Entry) (string, error) {
// check if torrent already exists, if so we skip it.
title := parser.TitleParse(entry.Title)
titleEng := parser.TitleParse(entry.TitleEng)
// we should consider both title and titleEng, because your anime list has different titles available,
// some torrent sources will use one, some will use the other, so to avoid duplication we check for both.
torrents1, err := c.dep.TorrentClient.List(ctx, torrentclient.Tag(title.TagBuildSeries()))
if err != nil {
return "", fmt.Errorf("listing torrents: %w", err)
}
torrents2, err := c.dep.TorrentClient.List(ctx, torrentclient.Tag(titleEng.TagBuildSeries()))
if err != nil {
return "", fmt.Errorf("listing torrents: %w", err)
var torrents []torrentclient.Torrent
for i := range entry.Titles {
// check if torrent already exists, if so we skip it.
title := parser.TitleParse(entry.Titles[i])
// we should consider both title and titleEng, because your anime list has different titles available,
// some torrent sources will use one, some will use the other, so to avoid duplication we check for both.
torrents1, err := c.dep.TorrentClient.List(ctx, torrentclient.Tag(title.TagBuildSeries()))
if err != nil {
return "", fmt.Errorf("listing torrents: %w", err)
}
torrents = append(torrents, torrents1...)
}
return tagGetLatest(append(torrents1, torrents2...)...), nil
return tagGetLatest(torrents...), nil
}

// torrentGetPath returns a torrent path, creating a show folder if configured.
Expand All @@ -111,7 +110,7 @@ func (c *Controller) torrentGetPath(title string) (path torrentclient.SavePath)
// It will configure all necessary metadata and send it to your torrent client.
func (c *Controller) DigestNyaaTorrent(ctx context.Context, entry animelist.Entry, nyaaEntry ParsedNyaa) error {
tags := nyaaEntry.meta.TagsBuildTorrent()
savePath := c.torrentGetPath(entry.GetTitle())
savePath := c.torrentGetPath(entry.Titles[0])
torrentURL := torrentclient.TorrentURL{nyaaEntry.entry.Link}
category := torrentclient.Category(c.dep.Config.Category)
if err := c.dep.TorrentClient.AddTorrent(ctx, tags, savePath, torrentURL, category); err != nil {
Expand Down
8 changes: 3 additions & 5 deletions internal/myanimelist/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (

"github.com/sonalys/animeman/integrations/myanimelist"
"github.com/sonalys/animeman/internal/roundtripper"
"github.com/sonalys/animeman/internal/utils"
"github.com/sonalys/animeman/pkg/v1/animelist"
"golang.org/x/time/rate"
)
Expand Down Expand Up @@ -38,16 +37,15 @@ func convertMALEntry(in []myanimelist.AnimeListEntry) []animelist.Entry {
for i := range in {
out = append(out, animelist.Entry{
ListStatus: animelist.ListStatus(in[i].Status),
Title: in[i].Title,
TitleEng: in[i].TitleEng,
Titles: []string{in[i].Title, in[i].TitleEng},
AiringStatus: animelist.AiringStatus(in[i].AiringStatus),
})
}
return out
}

func (w *Wrapper) GetAnimeList(ctx context.Context, args ...animelist.AnimeListArg) ([]animelist.Entry, error) {
resp, err := w.client.GetAnimeList(ctx, utils.ConvertInterfaceList[animelist.AnimeListArg, myanimelist.AnimeListArg](args)...)
func (w *Wrapper) GetCurrentlyWatching(ctx context.Context) ([]animelist.Entry, error) {
resp, err := w.client.GetCurrentlyWatching(ctx)
if err != nil {
return nil, err
}
Expand Down
Loading

0 comments on commit 0b43d27

Please sign in to comment.