Pull anime lists from GraphQL, see #33
All checks were successful
timw4mail/HummingBirdAnimeClient/pipeline/pr-master This commit looks good

This commit is contained in:
Timothy Warren 2020-10-09 16:18:45 -04:00
parent 58669f023c
commit 30db156df7
9 changed files with 340 additions and 71 deletions

View File

@ -17,11 +17,12 @@
namespace Aviat\AnimeClient\API\Kitsu; namespace Aviat\AnimeClient\API\Kitsu;
use Amp\Http\Client\Request; use Amp\Http\Client\Request;
use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeListTransformer;
use Aviat\AnimeClient\Kitsu as K; use Aviat\AnimeClient\Kitsu as K;
use Aviat\AnimeClient\API\Enum\AnimeWatchingStatus\Kitsu as KitsuWatchingStatus; use Aviat\AnimeClient\API\Enum\AnimeWatchingStatus\Kitsu as KitsuWatchingStatus;
use Aviat\AnimeClient\API\JsonAPI; use Aviat\AnimeClient\API\JsonAPI;
use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeHistoryTransformer; use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeHistoryTransformer;
use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeListTransformer; use Aviat\AnimeClient\API\Kitsu\Transformer\OldAnimeListTransformer;
use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeTransformer; use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeTransformer;
use Aviat\AnimeClient\API\Mapping\AnimeWatchingStatus; use Aviat\AnimeClient\API\Mapping\AnimeWatchingStatus;
use Aviat\AnimeClient\API\ParallelAPIRequest; use Aviat\AnimeClient\API\ParallelAPIRequest;
@ -39,9 +40,9 @@ trait AnimeTrait {
* to a common format used by * to a common format used by
* templates * templates
* *
* @var AnimeListTransformer * @var OldAnimeListTransformer
*/ */
protected AnimeListTransformer $animeListTransformer; protected OldAnimeListTransformer $oldListTransformer;
/** /**
* @var AnimeTransformer * @var AnimeTransformer
@ -124,6 +125,45 @@ trait AnimeTrait {
$list = $this->cache->get($key, NULL); $list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$data = $this->getRawList(ListType::ANIME, $status) ?? [];
// Bail out on no data
if (empty($data))
{
return [];
}
$transformer = new AnimeListTransformer();
$transformed = $transformer->transformCollection($data);
$keyed = [];
foreach($transformed as $item)
{
$keyed[$item['id']] = $item;
}
$list = $keyed;
$this->cache->set($key, $list);
}
return $list;
}
/**
* Get the anime list for the configured user
*
* @param string $status - The watching status to filter the list with
* @return array
* @throws InvalidArgumentException
*/
public function oldGetAnimeList(string $status): array
{
$key = "kitsu-anime-list-{$status}";
$list = $this->cache->get($key, NULL);
if ($list === NULL) if ($list === NULL)
{ {
$data = $this->getRawAnimeList($status) ?? []; $data = $this->getRawAnimeList($status) ?? [];
@ -142,7 +182,7 @@ trait AnimeTrait {
$item['included'] = $included; $item['included'] = $included;
} }
unset($item); unset($item);
$transformed = $this->animeListTransformer->transformCollection($data['data']); $transformed = $this->oldListTransformer->transformCollection($data['data']);
$keyed = []; $keyed = [];
foreach($transformed as $item) foreach($transformed as $item)

View File

@ -17,7 +17,9 @@
namespace Aviat\AnimeClient\API\Kitsu; namespace Aviat\AnimeClient\API\Kitsu;
use function Amp\Promise\wait; use function Amp\Promise\wait;
use function Aviat\AnimeClient\getApiClient;
use Amp;
use Amp\Http\Client\Request; use Amp\Http\Client\Request;
use Aviat\AnimeClient\Kitsu as K; use Aviat\AnimeClient\Kitsu as K;
use Aviat\AnimeClient\API\{ use Aviat\AnimeClient\API\{
@ -27,7 +29,7 @@ use Aviat\AnimeClient\API\{
}; };
use Aviat\AnimeClient\API\Kitsu\Transformer\{ use Aviat\AnimeClient\API\Kitsu\Transformer\{
AnimeTransformer, AnimeTransformer,
AnimeListTransformer, OldAnimeListTransformer,
LibraryEntryTransformer, LibraryEntryTransformer,
MangaTransformer, MangaTransformer,
MangaListTransformer MangaListTransformer
@ -49,7 +51,7 @@ final class Model {
use MangaTrait; use MangaTrait;
use MutationTrait; use MutationTrait;
protected const LIST_PAGE_SIZE = 75; protected const LIST_PAGE_SIZE = 100;
/** /**
* @var ListItem * @var ListItem
@ -64,7 +66,7 @@ final class Model {
public function __construct(ListItem $listItem) public function __construct(ListItem $listItem)
{ {
$this->animeTransformer = new AnimeTransformer(); $this->animeTransformer = new AnimeTransformer();
$this->animeListTransformer = new AnimeListTransformer(); $this->oldListTransformer = new OldAnimeListTransformer();
$this->mangaTransformer = new MangaTransformer(); $this->mangaTransformer = new MangaTransformer();
$this->mangaListTransformer = new MangaListTransformer(); $this->mangaListTransformer = new MangaListTransformer();
@ -351,6 +353,81 @@ final class Model {
]); ]);
} }
/**
* Get the raw anime/manga list from GraphQL
*
* @param string $type
* @param string $status
* @return array
*/
public function getRawList(string $type, string $status = ''): array
{
$pages = [];
foreach ($this->getRawListPages(strtoupper($type), strtoupper($status)) as $page)
{
$pages[] = $page;
}
return array_merge(...$pages);
}
protected function getRawListPages(string $type, string $status = ''): ?\Generator
{
$items = $this->getRawPages($type, $status);
while (wait($items->advance()))
{
yield $items->getCurrent();
}
}
protected function getRawPages(string $type, string $status = ''): Amp\Iterator
{
$cursor = '';
$username = $this->getUsername();
return new Amp\Producer(function (callable $emit) use ($type, $status, $cursor, $username) {
while (TRUE)
{
$vars = [
'type' => $type,
'slug' => $username,
];
if ($status !== '')
{
$vars['status'] = $status;
}
if ($cursor !== '')
{
$vars['after'] = $cursor;
}
$request = $this->requestBuilder->queryRequest('GetLibrary', $vars);
$response = yield getApiClient()->request($request);
$json = yield $response->getBody()->buffer();
$rawData = Json::decode($json);
$data = $rawData['data']['findProfileBySlug']['library']['all'] ?? [];
$page = $data['pageInfo'];
if (empty($data))
{
// @TODO Error logging
break;
}
$cursor = $page['endCursor'];
yield $emit($data['nodes']);
if ($page['hasNextPage'] === FALSE)
{
break;
}
}
});
}
private function getUserId(): string private function getUserId(): string
{ {
static $userId = NULL; static $userId = NULL;

View File

@ -52,6 +52,8 @@ query (
sfw sfw
slug slug
status status
startDate
endDate
type type
titles { titles {
canonical canonical
@ -60,6 +62,7 @@ query (
} }
...on Anime { ...on Anime {
episodeCount episodeCount
episodeLength
streamingLinks(first: 10) { streamingLinks(first: 10) {
nodes { nodes {
dubs dubs

View File

@ -180,14 +180,7 @@ final class RequestBuilder extends APIRequestBuilder {
return ($response->getStatus() === 204); return ($response->getStatus() === 204);
} }
/** public function queryRequest(string $name, array $variables = []): Request
* Run a GraphQL API query
*
* @param string $name
* @param array $variables
* @return array
*/
public function runQuery(string $name, array $variables = []): array
{ {
$file = __DIR__ . "/Queries/{$name}.graphql"; $file = __DIR__ . "/Queries/{$name}.graphql";
if ( ! file_exists($file)) if ( ! file_exists($file))
@ -209,11 +202,33 @@ final class RequestBuilder extends APIRequestBuilder {
} }
} }
return $this->graphResponse([ return $this->setUpRequest('POST', K::GRAPHQL_ENDPOINT, [
'body' => $body 'body' => $body,
]); ]);
} }
/**
* Run a GraphQL API query
*
* @param string $name
* @param array $variables
* @return array
*/
public function runQuery(string $name, array $variables = []): array
{
$request = $this->queryRequest($name, $variables);
$response = getResponse($request);
$validResponseCodes = [200, 201];
if ( ! \in_array($response->getStatus(), $validResponseCodes, TRUE))
{
$logger = $this->container->getLogger('kitsu-graphql');
$logger->warning('Non 200 response for GraphQL call', (array)$response->getBody());
}
return Json::decode(wait($response->getBody()->buffer()));
}
/** /**
* @param string $name * @param string $name
* @param array $variables * @param array $variables
@ -256,6 +271,13 @@ final class RequestBuilder extends APIRequestBuilder {
{ {
$request = $this->mutateRequest($name, $variables); $request = $this->mutateRequest($name, $variables);
$response = getResponse($request); $response = getResponse($request);
$validResponseCodes = [200, 201];
if ( ! \in_array($response->getStatus(), $validResponseCodes, TRUE))
{
$logger = $this->container->getLogger('kitsu-graphql');
$logger->warning('Non 200 response for GraphQL call', (array)$response->getBody());
}
return Json::decode(wait($response->getBody()->buffer())); return Json::decode(wait($response->getBody()->buffer()));
} }
@ -286,27 +308,6 @@ final class RequestBuilder extends APIRequestBuilder {
return $response; return $response;
} }
/**
* Remove some boilerplate for GraphQL requests
*
* @param array $options
* @return array
* @throws \Throwable
*/
protected function graphResponse(array $options = []): array
{
$response = $this->getResponse('POST', K::GRAPHQL_ENDPOINT, $options);
$validResponseCodes = [200, 201];
if ( ! \in_array($response->getStatus(), $validResponseCodes, TRUE))
{
$logger = $this->container->getLogger('kitsu-graphql');
$logger->warning('Non 200 response for GraphQL call', (array)$response->getBody());
}
return Json::decode(wait($response->getBody()->buffer()));
}
/** /**
* Make a request * Make a request
* *

View File

@ -38,34 +38,27 @@ final class AnimeListTransformer extends AbstractTransformer {
*/ */
public function transform($item): AnimeListItem public function transform($item): AnimeListItem
{ {
$included = $item['included']; $animeId = $item['media']['id'];
$animeId = $item['relationships']['media']['data']['id']; $anime = $item['media'];
$anime = $included['anime'][$animeId];
$genres = []; $genres = [];
foreach($anime['relationships']['categories'] as $genre) $rating = (int) $item['rating'] !== 0
{ ? (int)$item['rating'] / 2
$genres[] = $genre['title'];
}
sort($genres);
$rating = (int) $item['attributes']['ratingTwenty'] !== 0
? $item['attributes']['ratingTwenty'] / 2
: '-'; : '-';
$total_episodes = array_key_exists('episodeCount', $anime) && (int) $anime['episodeCount'] !== 0 $total_episodes = (int) $anime['episodeCount'] !== 0
? (int) $anime['episodeCount'] ? (int) $anime['episodeCount']
: '-'; : '-';
$MALid = NULL; $MALid = NULL;
if (array_key_exists('mappings', $anime['relationships'])) $mappings = $anime['mappings']['nodes'] ?? [];
if ( ! empty($mappings))
{ {
foreach ($anime['relationships']['mappings'] as $mapping) foreach ($mappings as $mapping)
{ {
if ($mapping['externalSite'] === 'myanimelist/anime') if ($mapping['externalSite'] === 'MYANIMELIST_ANIME')
{ {
$MALid = $mapping['externalId']; $MALid = $mapping['externalId'];
break; break;
@ -73,19 +66,19 @@ final class AnimeListTransformer extends AbstractTransformer {
} }
} }
$streamingLinks = array_key_exists('streamingLinks', $anime['relationships']) $streamingLinks = array_key_exists('nodes', $anime['streamingLinks'])
? Kitsu::parseListItemStreamingLinks($included, $animeId) ? Kitsu::parseStreamingLinks($anime['streamingLinks']['nodes'])
: []; : [];
$titles = Kitsu::filterTitles($anime); $titles = Kitsu::getFilteredTitles($anime['titles']);
$title = array_shift($titles); $title = $anime['titles']['canonical'];
return AnimeListItem::from([ return AnimeListItem::from([
'id' => $item['id'], 'id' => $item['id'],
'mal_id' => $MALid, 'mal_id' => $MALid,
'episodes' => [ 'episodes' => [
'watched' => (int) $item['attributes']['progress'] !== 0 'watched' => (int) $item['progress'] !== 0
? (int) $item['attributes']['progress'] ? (int) $item['progress']
: '-', : '-',
'total' => $total_episodes, 'total' => $total_episodes,
'length' => $anime['episodeLength'], 'length' => $anime['episodeLength'],
@ -102,16 +95,16 @@ final class AnimeListTransformer extends AbstractTransformer {
'titles' => $titles, 'titles' => $titles,
'slug' => $anime['slug'], 'slug' => $anime['slug'],
'show_type' => (string)StringType::from($anime['subtype'])->upperCaseFirst(), 'show_type' => (string)StringType::from($anime['subtype'])->upperCaseFirst(),
'cover_image' => $anime['posterImage']['small'], 'cover_image' => $anime['posterImage']['views'][1]['url'],
'genres' => $genres, 'genres' => $genres,
'streaming_links' => $streamingLinks, 'streaming_links' => $streamingLinks,
], ],
'watching_status' => $item['attributes']['status'], 'watching_status' => $item['status'],
'notes' => $item['attributes']['notes'], 'notes' => $item['notes'],
'rewatching' => (bool) $item['attributes']['reconsuming'], 'rewatching' => (bool) $item['reconsuming'],
'rewatched' => (int) $item['attributes']['reconsumeCount'], 'rewatched' => (int) $item['reconsumeCount'],
'user_rating' => $rating, 'user_rating' => $rating,
'private' => $item['attributes']['private'] ?? FALSE, 'private' => $item['private'] ?? FALSE,
]); ]);
} }

View File

@ -0,0 +1,156 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 7.4
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2020 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5.1
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Kitsu\Transformer;
use Aviat\AnimeClient\Kitsu;
use Aviat\AnimeClient\Types\{
FormItem,
AnimeListItem
};
use Aviat\Ion\Transformer\AbstractTransformer;
use Aviat\Ion\Type\StringType;
/**
* Transformer for anime list
*/
final class OldAnimeListTransformer extends AbstractTransformer {
/**
* Convert raw api response to a more
* logical and workable structure
*
* @param array $item API library item
* @return AnimeListItem
*/
public function transform($item): AnimeListItem
{
$included = $item['included'];
$animeId = $item['relationships']['media']['data']['id'];
$anime = $included['anime'][$animeId];
$genres = [];
foreach($anime['relationships']['categories'] as $genre)
{
$genres[] = $genre['title'];
}
sort($genres);
$rating = (int) $item['attributes']['ratingTwenty'] !== 0
? $item['attributes']['ratingTwenty'] / 2
: '-';
$total_episodes = array_key_exists('episodeCount', $anime) && (int) $anime['episodeCount'] !== 0
? (int) $anime['episodeCount']
: '-';
$MALid = NULL;
if (array_key_exists('mappings', $anime['relationships']))
{
foreach ($anime['relationships']['mappings'] as $mapping)
{
if ($mapping['externalSite'] === 'myanimelist/anime')
{
$MALid = $mapping['externalId'];
break;
}
}
}
$streamingLinks = array_key_exists('streamingLinks', $anime['relationships'])
? Kitsu::parseListItemStreamingLinks($included, $animeId)
: [];
$titles = Kitsu::filterTitles($anime);
$title = array_shift($titles);
return AnimeListItem::from([
'id' => $item['id'],
'mal_id' => $MALid,
'episodes' => [
'watched' => (int) $item['attributes']['progress'] !== 0
? (int) $item['attributes']['progress']
: '-',
'total' => $total_episodes,
'length' => $anime['episodeLength'],
],
'airing' => [
'status' => Kitsu::getAiringStatus($anime['startDate'], $anime['endDate']),
'started' => $anime['startDate'],
'ended' => $anime['endDate']
],
'anime' => [
'id' => $animeId,
'age_rating' => $anime['ageRating'],
'title' => $title,
'titles' => $titles,
'slug' => $anime['slug'],
'show_type' => (string)StringType::from($anime['subtype'])->upperCaseFirst(),
'cover_image' => $anime['posterImage']['small'],
'genres' => $genres,
'streaming_links' => $streamingLinks,
],
'watching_status' => $item['attributes']['status'],
'notes' => $item['attributes']['notes'],
'rewatching' => (bool) $item['attributes']['reconsuming'],
'rewatched' => (int) $item['attributes']['reconsumeCount'],
'user_rating' => $rating,
'private' => $item['attributes']['private'] ?? FALSE,
]);
}
/**
* Convert transformed data to
* api response format
*
* @param array $item Transformed library item
* @return FormItem API library item
*/
public function untransform($item): FormItem
{
$privacy = (array_key_exists('private', $item) && $item['private']);
$rewatching = (array_key_exists('rewatching', $item) && $item['rewatching']);
$untransformed = FormItem::from([
'id' => $item['id'],
'anilist_item_id' => $item['anilist_item_id'] ?? NULL,
'mal_id' => $item['mal_id'] ?? NULL,
'data' => [
'status' => $item['watching_status'],
'reconsuming' => $rewatching,
'reconsumeCount' => $item['rewatched'],
'notes' => $item['notes'],
'private' => $privacy
]
]);
if (is_numeric($item['episodes_watched']) && $item['episodes_watched'] > 0)
{
$untransformed['data']['progress'] = (int) $item['episodes_watched'];
}
if (is_numeric($item['user_rating']) && $item['user_rating'] > 0)
{
$untransformed['data']['ratingTwenty'] = $item['user_rating'] * 2;
}
return $untransformed;
}
}
// End of AnimeListTransformer.php

View File

@ -240,7 +240,6 @@ function getResponse ($request): Response
$request = new Request($request); $request = new Request($request);
} }
return wait($client->request($request)); return wait($client->request($request));
} }

View File

@ -18,7 +18,7 @@ namespace Aviat\AnimeClient\Controller;
use Aura\Router\Exception\RouteNotFound; use Aura\Router\Exception\RouteNotFound;
use Aviat\AnimeClient\Controller as BaseController; use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeListTransformer; use Aviat\AnimeClient\API\Kitsu\Transformer\OldAnimeListTransformer;
use Aviat\AnimeClient\API\Enum\AnimeWatchingStatus\Kitsu as KitsuWatchingStatus; use Aviat\AnimeClient\API\Enum\AnimeWatchingStatus\Kitsu as KitsuWatchingStatus;
use Aviat\AnimeClient\API\Mapping\AnimeWatchingStatus; use Aviat\AnimeClient\API\Mapping\AnimeWatchingStatus;
use Aviat\AnimeClient\Model\Anime as AnimeModel; use Aviat\AnimeClient\Model\Anime as AnimeModel;
@ -228,7 +228,7 @@ final class Anime extends BaseController {
// Do some minor data manipulation for // Do some minor data manipulation for
// large form-based updates // large form-based updates
$transformer = new AnimeListTransformer(); $transformer = new OldAnimeListTransformer();
$postData = $transformer->untransform($data); $postData = $transformer->untransform($data);
$fullResult = $this->model->updateLibraryItem(FormItem::from($postData)); $fullResult = $this->model->updateLibraryItem(FormItem::from($postData));

View File

@ -16,7 +16,7 @@
namespace Aviat\AnimeClient\Tests\API\Kitsu\Transformer; namespace Aviat\AnimeClient\Tests\API\Kitsu\Transformer;
use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeListTransformer; use Aviat\AnimeClient\API\Kitsu\Transformer\OldAnimeListTransformer;
use Aviat\AnimeClient\Tests\AnimeClientTestCase; use Aviat\AnimeClient\Tests\AnimeClientTestCase;
use Aviat\Ion\Friend; use Aviat\Ion\Friend;
use Aviat\Ion\Json; use Aviat\Ion\Json;
@ -33,7 +33,7 @@ class AnimeListTransformerTest extends AnimeClientTestCase {
$this->beforeTransform = Json::decodeFile("{$this->dir}/animeListItemBeforeTransform.json"); $this->beforeTransform = Json::decodeFile("{$this->dir}/animeListItemBeforeTransform.json");
$this->transformer = new AnimeListTransformer(); $this->transformer = new OldAnimeListTransformer();
} }
public function testTransform() public function testTransform()