All in GraphQL #34

Merged
timw4mail merged 87 commits from develop into master 2020-12-01 10:07:49 -05:00
11 changed files with 1151 additions and 976 deletions
Showing only changes of commit 3bb3d2a5cf - Show all commits

View File

@ -22,7 +22,7 @@ use Aura\Session\SessionFactory;
use Aviat\AnimeClient\API\{ use Aviat\AnimeClient\API\{
Anilist, Anilist,
Kitsu, Kitsu,
Kitsu\KitsuRequestBuilder Kitsu\KitsuJsonApiRequestBuilder
}; };
use Aviat\AnimeClient\Model; use Aviat\AnimeClient\Model;
use Aviat\Banker\Teller; use Aviat\Banker\Teller;
@ -114,16 +114,20 @@ return static function (array $configArray = []): Container {
// Models // Models
$container->set('kitsu-model', static function(ContainerInterface $container): Kitsu\Model { $container->set('kitsu-model', static function(ContainerInterface $container): Kitsu\Model {
$requestBuilder = new KitsuRequestBuilder($container); $jsonApiRequestBuilder = new KitsuJsonApiRequestBuilder($container);
$jsonApiRequestBuilder->setLogger($container->getLogger('kitsu-request'));
$requestBuilder = new Kitsu\KitsuRequestBuilder($container);
$requestBuilder->setLogger($container->getLogger('kitsu-request')); $requestBuilder->setLogger($container->getLogger('kitsu-request'));
$listItem = new Kitsu\ListItem(); $listItem = new Kitsu\ListItem();
$listItem->setContainer($container); $listItem->setContainer($container);
$listItem->setRequestBuilder($requestBuilder); $listItem->setJsonApiRequestBuilder($jsonApiRequestBuilder);
$model = new Kitsu\Model($listItem); $model = new Kitsu\Model($listItem);
$model->setContainer($container); $model->setContainer($container);
$model->setRequestBuilder($requestBuilder); $model->setJsonApiRequestBuilder($jsonApiRequestBuilder)
->setRequestBuilder($requestBuilder);
$cache = $container->get('cache'); $cache = $container->get('cache');
$model->setCache($cache); $model->setCache($cache);

View File

@ -1,120 +1,118 @@
query ($slug: String) { query ($slug: String) {
anime(slug: $slug) { findAnimeBySlug(slug: $slug) {
nodes { ageRating
ageRating ageRatingGuide
ageRatingGuide bannerImage {
bannerImage { original {
original { height
height name
name url
url width
width
}
views {
height
name
url
width
}
} }
characters { views {
nodes { height
character { name
names { url
canonical width
alternatives
}
slug
}
role
voices {
nodes {
id
licensor {
id
name
}
locale
person {
id
names {
alternatives
canonical
localized
}
}
}
}
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
} }
endDate
episodeCount
episodeLength
posterImage {
original {
height
name
url
width
}
views {
height
name
url
width
}
}
season
sfw
slug
staff {
nodes {
person {
id
birthday
image {
original {
height
name
url
width
}
views {
height
name
url
width
}
}
names {
alternatives
canonical
localized
}
}
role
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
status
synopsis
titles {
alternatives
canonical
localized
}
totalLength
} }
characters {
nodes {
character {
names {
canonical
alternatives
}
slug
}
role
voices {
nodes {
id
licensor {
id
name
}
locale
person {
id
names {
alternatives
canonical
localized
}
}
}
}
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
endDate
episodeCount
episodeLength
posterImage {
original {
height
name
url
width
}
views {
height
name
url
width
}
}
season
sfw
slug
staff {
nodes {
person {
id
birthday
image {
original {
height
name
url
width
}
views {
height
name
url
width
}
}
names {
alternatives
canonical
localized
}
}
role
}
pageInfo {
endCursor
hasNextPage
hasPreviousPage
startCursor
}
}
status
synopsis
titles {
alternatives
canonical
localized
}
totalLength
} }
} }

View File

@ -0,0 +1,316 @@
<?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
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Kitsu;
use Amp\Http\Client\Request;
use Aviat\AnimeClient\API\Enum\AnimeWatchingStatus\Kitsu as KitsuWatchingStatus;
use Aviat\AnimeClient\API\JsonAPI;
use Aviat\AnimeClient\API\Kitsu as K;
use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeHistoryTransformer;
use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeListTransformer;
use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeTransformer;
use Aviat\AnimeClient\API\Mapping\AnimeWatchingStatus;
use Aviat\AnimeClient\API\ParallelAPIRequest;
use Aviat\AnimeClient\Enum\ListType;
use Aviat\AnimeClient\Types\Anime;
use Aviat\Banker\Exception\InvalidArgumentException;
use Aviat\Ion\Json;
/**
* Anime-related list methods
*/
trait KitsuAnimeTrait {
/**
* Class to map anime list items
* to a common format used by
* templates
*
* @var AnimeListTransformer
*/
protected AnimeListTransformer $animeListTransformer;
/**
* @var AnimeTransformer
*/
protected AnimeTransformer $animeTransformer;
// -------------------------------------------------------------------------
// ! Anime-specific methods
// -------------------------------------------------------------------------
/**
* Get information about a particular anime
*
* @param string $slug
* @return Anime
*/
public function getAnime(string $slug): Anime
{
$baseData = $this->getRawMediaData('anime', $slug);
if (empty($baseData))
{
return Anime::from([]);
}
return $this->animeTransformer->transform($baseData);
}
/**
* Retrieve the data for the anime watch history page
*
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getAnimeHistory(): array
{
$key = K::ANIME_HISTORY_LIST_CACHE_KEY;
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$raw = $this->getRawHistoryList('anime');
$organized = JsonAPI::organizeData($raw);
$organized = array_filter($organized, fn ($item) => array_key_exists('relationships', $item));
$list = (new AnimeHistoryTransformer())->transform($organized);
$this->cache->set($key, $list);
}
return $list;
}
/**
* Get information about a particular anime
*
* @param string $animeId
* @return Anime
*/
public function getAnimeById(string $animeId): Anime
{
$baseData = $this->getRawMediaDataById('anime', $animeId);
return $this->animeTransformer->transform($baseData);
}
/**
* 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 getAnimeList(string $status): array
{
$key = "kitsu-anime-list-{$status}";
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$data = $this->getRawAnimeList($status) ?? [];
// Bail out on no data
if (empty($data))
{
return [];
}
$included = JsonAPI::organizeIncludes($data['included']);
$included = JsonAPI::inlineIncludedRelationships($included, 'anime');
foreach($data['data'] as $i => &$item)
{
$item['included'] = $included;
}
unset($item);
$transformed = $this->animeListTransformer->transformCollection($data['data']);
$keyed = [];
foreach($transformed as $item)
{
$keyed[$item['id']] = $item;
}
$list = $keyed;
$this->cache->set($key, $list);
}
return $list;
}
/**
* Get the number of anime list items
*
* @param string $status - Optional status to filter by
* @return int
* @throws InvalidArgumentException
*/
public function getAnimeListCount(string $status = '') : int
{
return $this->getListCount(ListType::ANIME, $status);
}
/**
* Get the full anime list
*
* @param array $options
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getFullRawAnimeList(array $options = [
'include' => 'anime.mappings'
]): array
{
$status = $options['filter']['status'] ?? '';
$count = $this->getAnimeListCount($status);
$size = static::LIST_PAGE_SIZE;
$pages = ceil($count / $size);
$requester = new ParallelAPIRequest();
// Set up requests
for ($i = 0; $i < $pages; $i++)
{
$offset = $i * $size;
$requester->addRequest($this->getPagedAnimeList($size, $offset, $options));
}
$responses = $requester->makeRequests();
$output = [];
foreach($responses as $response)
{
$data = Json::decode($response);
$output[] = $data;
}
return array_merge_recursive(...$output);
}
/**
* Get all the anime entries, that are organized for output to html
*
* @return array
* @throws ReflectionException
* @throws InvalidArgumentException
*/
public function getFullOrganizedAnimeList(): array
{
$output = [];
$statuses = KitsuWatchingStatus::getConstList();
foreach ($statuses as $key => $status)
{
$mappedStatus = AnimeWatchingStatus::KITSU_TO_TITLE[$status];
$output[$mappedStatus] = $this->getAnimeList($status) ?? [];
}
return $output;
}
/**
* Get the mal id for the anime represented by the kitsu id
* to enable updating MyAnimeList
*
* @param string $kitsuAnimeId The id of the anime on Kitsu
* @return string|null Returns the mal id if it exists, otherwise null
*/
public function getMalIdForAnime(string $kitsuAnimeId): ?string
{
$options = [
'query' => [
'include' => 'mappings'
]
];
$data = $this->jsonApiRequestBuilder->getRequest("anime/{$kitsuAnimeId}", $options);
if ( ! array_key_exists('included', $data))
{
return NULL;
}
$mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map)
{
if ($map['externalSite'] === 'myanimelist/anime')
{
return $map['externalId'];
}
}
return NULL;
}
/**
* Get the full anime list in paginated form
*
* @param int $limit
* @param int $offset
* @param array $options
* @return Request
* @throws InvalidArgumentException
*/
public function getPagedAnimeList(int $limit, int $offset = 0, array $options = [
'include' => 'anime.mappings'
]): Request
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => 'anime'
],
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
];
$options = array_merge($defaultOptions, $options);
return $this->jsonApiRequestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
/**
* Get the raw (unorganized) anime list for the configured user
*
* @param string $status - The watching status to filter the list with
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getRawAnimeList(string $status): array
{
$options = [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => 'anime',
'status' => $status,
],
'include' => 'media,media.categories,media.mappings,anime.streamingLinks',
'sort' => '-updated_at'
];
return $this->getFullRawAnimeList($options);
}
}

View File

@ -0,0 +1,271 @@
<?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
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Kitsu;
use const Aviat\AnimeClient\SESSION_SEGMENT;
use const Aviat\AnimeClient\USER_AGENT;
use function Amp\Promise\wait;
use function Aviat\AnimeClient\getResponse;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;
use Aviat\AnimeClient\API\APIRequestBuilder;
use Aviat\AnimeClient\API\FailedResponseException;
use Aviat\AnimeClient\API\Kitsu as K;
use Aviat\AnimeClient\Enum\EventType;
use Aviat\Ion\Di\ContainerAware;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Event;
use Aviat\Ion\Json;
use Aviat\Ion\JsonException;
final class KitsuJsonApiRequestBuilder extends APIRequestBuilder {
use ContainerAware;
/**
* The base url for api requests
* @var string $base_url
*/
protected string $baseUrl = 'https://kitsu.io/api/edge/';
/**
* HTTP headers to send with every request
*
* @var array
*/
protected array $defaultHeaders = [
'User-Agent' => USER_AGENT,
'Accept' => 'application/vnd.api+json',
'Content-Type' => 'application/vnd.api+json',
'CLIENT_ID' => 'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd',
'CLIENT_SECRET' => '54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151',
];
public function __construct(ContainerInterface $container)
{
$this->setContainer($container);
}
/**
* Create a request object
*
* @param string $type
* @param string $url
* @param array $options
* @return Request
*/
public function setUpRequest(string $type, string $url, array $options = []): Request
{
$request = $this->newRequest($type, $url);
$sessionSegment = $this->getContainer()
->get('session')
->getSegment(SESSION_SEGMENT);
$cache = $this->getContainer()->get('cache');
$token = null;
if ($cache->has(K::AUTH_TOKEN_CACHE_KEY))
{
$token = $cache->get(K::AUTH_TOKEN_CACHE_KEY);
}
else if ($url !== K::AUTH_URL && $sessionSegment->get('auth_token') !== NULL)
{
$token = $sessionSegment->get('auth_token');
if ( ! (empty($token) || $cache->has(K::AUTH_TOKEN_CACHE_KEY)))
{
$cache->set(K::AUTH_TOKEN_CACHE_KEY, $token);
}
}
if ($token !== NULL)
{
$request = $request->setAuth('bearer', $token);
}
if (array_key_exists('form_params', $options))
{
$request = $request->setFormFields($options['form_params']);
}
if (array_key_exists('query', $options))
{
$request = $request->setQuery($options['query']);
}
if (array_key_exists('body', $options))
{
$request = $request->setJsonBody($options['body']);
}
if (array_key_exists('headers', $options))
{
$request = $request->setHeaders($options['headers']);
}
return $request->getFullRequest();
}
/**
* Remove some boilerplate for get requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
public function getRequest(...$args): array
{
return $this->request('GET', ...$args);
}
/**
* Remove some boilerplate for patch requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
public function patchRequest(...$args): array
{
return $this->request('PATCH', ...$args);
}
/**
* Remove some boilerplate for post requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
public function postRequest(...$args): array
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('kitsu-request');
}
$response = $this->getResponse('POST', ...$args);
$validResponseCodes = [200, 201];
if ( ! in_array($response->getStatus(), $validResponseCodes, TRUE) && $logger)
{
$logger->warning('Non 2xx response for POST api call', $response->getBody());
}
return JSON::decode(wait($response->getBody()->buffer()), TRUE);
}
/**
* Remove some boilerplate for delete requests
*
* @param mixed ...$args
* @throws Throwable
* @return bool
*/
public function deleteRequest(...$args): bool
{
$response = $this->getResponse('DELETE', ...$args);
return ($response->getStatus() === 204);
}
/**
* Make a request
*
* @param string $type
* @param string $url
* @param array $options
* @return Response
* @throws Throwable
*/
public function getResponse(string $type, string $url, array $options = []): Response
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('kitsu-request');
}
$request = $this->setUpRequest($type, $url, $options);
$response = getResponse($request);
if ($logger)
{
$logger->debug('Kitsu API Response', [
'response_status' => $response->getStatus(),
'request_headers' => $response->getOriginalRequest()->getHeaders(),
'response_headers' => $response->getHeaders()
]);
}
return $response;
}
/**
* Make a request
*
* @param string $type
* @param string $url
* @param array $options
* @throws JsonException
* @throws FailedResponseException
* @throws Throwable
* @return array
*/
private function request(string $type, string $url, array $options = []): array
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('kitsu-request');
}
$response = $this->getResponse($type, $url, $options);
$statusCode = $response->getStatus();
// Check for requests that are unauthorized
if ($statusCode === 401 || $statusCode === 403)
{
Event::emit(EventType::UNAUTHORIZED);
}
// Any other type of failed request
if ($statusCode > 299 || $statusCode < 200)
{
if ($logger)
{
$logger->warning('Non 2xx response for api call', (array)$response);
}
throw new FailedResponseException('Failed to get the proper response from the API');
}
try
{
return Json::decode(wait($response->getBody()->buffer()));
}
catch (JsonException $e)
{
print_r($e);
die();
}
}
}

View File

@ -0,0 +1,291 @@
<?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
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Kitsu;
use Amp\Http\Client\Request;
use Aviat\AnimeClient\API\Enum\MangaReadingStatus\Kitsu as KitsuReadingStatus;
use Aviat\AnimeClient\API\JsonAPI;
use Aviat\AnimeClient\API\Kitsu as K;
use Aviat\AnimeClient\API\Kitsu\Transformer\MangaHistoryTransformer;
use Aviat\AnimeClient\API\Kitsu\Transformer\MangaListTransformer;
use Aviat\AnimeClient\API\Kitsu\Transformer\MangaTransformer;
use Aviat\AnimeClient\API\Mapping\MangaReadingStatus;
use Aviat\AnimeClient\API\ParallelAPIRequest;
use Aviat\AnimeClient\Enum\ListType;
use Aviat\AnimeClient\Types\MangaPage;
use Aviat\Banker\Exception\InvalidArgumentException;
use Aviat\Ion\Json;
/**
* Manga-related list methods
*/
trait KitsuMangaTrait {
/**
* @var MangaTransformer
*/
protected MangaTransformer $mangaTransformer;
/**
* @var MangaListTransformer
*/
protected MangaListTransformer $mangaListTransformer;
// -------------------------------------------------------------------------
// ! Manga-specific methods
// -------------------------------------------------------------------------
/**
* Get information about a particular manga
*
* @param string $slug
* @return MangaPage
*/
public function getManga(string $slug): MangaPage
{
$baseData = $this->getRawMediaData('manga', $slug);
if (empty($baseData))
{
return MangaPage::from([]);
}
return $this->mangaTransformer->transform($baseData);
}
/**
* Retrieve the data for the manga read history page
*
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getMangaHistory(): array
{
$key = K::MANGA_HISTORY_LIST_CACHE_KEY;
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$raw = $this->getRawHistoryList('manga');
$organized = JsonAPI::organizeData($raw);
$organized = array_filter($organized, fn ($item) => array_key_exists('relationships', $item));
$list = (new MangaHistoryTransformer())->transform($organized);
$this->cache->set($key, $list);
}
return $list;
}
/**
* Get information about a particular manga
*
* @param string $mangaId
* @return MangaPage
*/
public function getMangaById(string $mangaId): MangaPage
{
$baseData = $this->getRawMediaDataById('manga', $mangaId);
return $this->mangaTransformer->transform($baseData);
}
/**
* Get the manga list for the configured user
*
* @param string $status - The reading status by which to filter the list
* @param int $limit - The number of list items to fetch per page
* @param int $offset - The page offset
* @return array
* @throws InvalidArgumentException
*/
public function getMangaList(string $status, int $limit = 200, int $offset = 0): array
{
$options = [
'query' => [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => 'manga',
'status' => $status,
],
'include' => 'media,media.categories,media.mappings',
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
]
];
$key = "kitsu-manga-list-{$status}";
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$data = $this->jsonApiRequestBuilder->getRequest('library-entries', $options) ?? [];
// Bail out on no data
if (empty($data) || ( ! array_key_exists('included', $data)))
{
return [];
}
$included = JsonAPI::organizeIncludes($data['included']);
$included = JsonAPI::inlineIncludedRelationships($included, 'manga');
foreach($data['data'] as $i => &$item)
{
$item['included'] = $included;
}
unset($item);
$list = $this->mangaListTransformer->transformCollection($data['data']);
$this->cache->set($key, $list);
}
return $list;
}
/**
* Get the number of manga list items
*
* @param string $status - Optional status to filter by
* @return int
* @throws InvalidArgumentException
*/
public function getMangaListCount(string $status = '') : int
{
return $this->getListCount(ListType::MANGA, $status);
}
/**
* Get the full manga list
*
* @param array $options
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getFullRawMangaList(array $options = [
'include' => 'manga.mappings'
]): array
{
$status = $options['filter']['status'] ?? '';
$count = $this->getMangaListCount($status);
$size = static::LIST_PAGE_SIZE;
$pages = ceil($count / $size);
$requester = new ParallelAPIRequest();
// Set up requests
for ($i = 0; $i < $pages; $i++)
{
$offset = $i * $size;
$requester->addRequest($this->getPagedMangaList($size, $offset, $options));
}
$responses = $requester->makeRequests();
$output = [];
foreach($responses as $response)
{
$data = Json::decode($response);
$output[] = $data;
}
return array_merge_recursive(...$output);
}
/**
* Get all Manga lists
*
* @return array
* @throws ReflectionException
* @throws InvalidArgumentException
*/
public function getFullOrganizedMangaList(): array
{
$statuses = KitsuReadingStatus::getConstList();
$output = [];
foreach ($statuses as $status)
{
$mappedStatus = MangaReadingStatus::KITSU_TO_TITLE[$status];
$output[$mappedStatus] = $this->getMangaList($status);
}
return $output;
}
/**
* Get the full manga list in paginated form
*
* @param int $limit
* @param int $offset
* @param array $options
* @return Request
* @throws InvalidArgumentException
*/
public function getPagedMangaList(int $limit, int $offset = 0, array $options = [
'include' => 'manga.mappings'
]): Request
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => 'manga'
],
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
];
$options = array_merge($defaultOptions, $options);
return $this->jsonApiRequestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
/**
* Get the mal id for the manga represented by the kitsu id
* to enable updating MyAnimeList
*
* @param string $kitsuMangaId The id of the manga on Kitsu
* @return string|null Returns the mal id if it exists, otherwise null
*/
public function getMalIdForManga(string $kitsuMangaId): ?string
{
$options = [
'query' => [
'include' => 'mappings'
]
];
$data = $this->jsonApiRequestBuilder->getRequest("manga/{$kitsuMangaId}", $options);
$mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map)
{
if ($map['externalSite'] === 'myanimelist/manga')
{
return $map['externalId'];
}
}
return NULL;
}
}

View File

@ -0,0 +1,81 @@
<?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
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Kitsu;
use Amp\Http\Client\Request;
use Aviat\AnimeClient\Types\FormItem;
use Aviat\Banker\Exception\InvalidArgumentException;
/**
* Kitsu API calls that mutate data, C/U/D parts of CRUD
*/
trait KitsuMutationTrait {
// -------------------------------------------------------------------------
// ! Generic API calls
// -------------------------------------------------------------------------
/**
* Create a list item
*
* @param array $data
* @return Request
* @throws InvalidArgumentException
*/
public function createListItem(array $data): ?Request
{
$data['user_id'] = $this->getUserId();
if ($data['id'] === NULL)
{
return NULL;
}
return $this->listItem->create($data);
}
/**
* Increase the progress count for a list item
*
* @param FormItem $data
* @return Request
*/
public function incrementListItem(FormItem $data): Request
{
return $this->listItem->increment($data['id'], $data['data']);
}
/**
* Modify a list item
*
* @param FormItem $data
* @return Request
*/
public function updateListItem(FormItem $data): Request
{
return $this->listItem->update($data['id'], $data['data']);
}
/**
* Remove a list item
*
* @param string $id - The id of the list item to remove
* @return Request
*/
public function deleteListItem(string $id): Request
{
return $this->listItem->delete($id);
}
}

View File

@ -16,23 +16,12 @@
namespace Aviat\AnimeClient\API\Kitsu; namespace Aviat\AnimeClient\API\Kitsu;
use const Aviat\AnimeClient\SESSION_SEGMENT;
use const Aviat\AnimeClient\USER_AGENT;
use function Amp\Promise\wait;
use function Aviat\AnimeClient\getResponse;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;
use Aviat\AnimeClient\API\APIRequestBuilder;
use Aviat\AnimeClient\API\FailedResponseException;
use Aviat\AnimeClient\API\Kitsu as K;
use Aviat\AnimeClient\Enum\EventType;
use Aviat\Ion\Di\ContainerAware; use Aviat\Ion\Di\ContainerAware;
use Aviat\Ion\Di\ContainerInterface; use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Event;
use Aviat\Ion\Json; use const Aviat\AnimeClient\USER_AGENT;
use Aviat\Ion\JsonException;
use Aviat\AnimeClient\API\APIRequestBuilder;
final class KitsuRequestBuilder extends APIRequestBuilder { final class KitsuRequestBuilder extends APIRequestBuilder {
use ContainerAware; use ContainerAware;
@ -41,7 +30,13 @@ final class KitsuRequestBuilder extends APIRequestBuilder {
* The base url for api requests * The base url for api requests
* @var string $base_url * @var string $base_url
*/ */
protected string $baseUrl = 'https://kitsu.io/api/edge/'; protected string $baseUrl = 'https://kitsu.io/api/graphql';
/**
* Valid HTTP request methods
* @var array
*/
protected array $validMethods = ['POST'];
/** /**
* HTTP headers to send with every request * HTTP headers to send with every request
@ -49,223 +44,13 @@ final class KitsuRequestBuilder extends APIRequestBuilder {
* @var array * @var array
*/ */
protected array $defaultHeaders = [ protected array $defaultHeaders = [
'User-Agent' => USER_AGENT, 'User-Agent' => USER_AGENT,
'Accept' => 'application/vnd.api+json', 'Accept' => 'application/json',
'Content-Type' => 'application/vnd.api+json', 'Content-Type' => 'application/json',
'CLIENT_ID' => 'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd',
'CLIENT_SECRET' => '54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151',
]; ];
public function __construct(ContainerInterface $container) public function __construct(ContainerInterface $container)
{ {
$this->setContainer($container); $this->setContainer($container);
} }
/**
* Create a request object
*
* @param string $type
* @param string $url
* @param array $options
* @return Request
*/
public function setUpRequest(string $type, string $url, array $options = []): Request
{
$request = $this->newRequest($type, $url);
$sessionSegment = $this->getContainer()
->get('session')
->getSegment(SESSION_SEGMENT);
$cache = $this->getContainer()->get('cache');
$token = null;
if ($cache->has(K::AUTH_TOKEN_CACHE_KEY))
{
$token = $cache->get(K::AUTH_TOKEN_CACHE_KEY);
}
else if ($url !== K::AUTH_URL && $sessionSegment->get('auth_token') !== NULL)
{
$token = $sessionSegment->get('auth_token');
if ( ! (empty($token) || $cache->has(K::AUTH_TOKEN_CACHE_KEY)))
{
$cache->set(K::AUTH_TOKEN_CACHE_KEY, $token);
}
}
if ($token !== NULL)
{
$request = $request->setAuth('bearer', $token);
}
if (array_key_exists('form_params', $options))
{
$request = $request->setFormFields($options['form_params']);
}
if (array_key_exists('query', $options))
{
$request = $request->setQuery($options['query']);
}
if (array_key_exists('body', $options))
{
$request = $request->setJsonBody($options['body']);
}
if (array_key_exists('headers', $options))
{
$request = $request->setHeaders($options['headers']);
}
return $request->getFullRequest();
}
/**
* Remove some boilerplate for get requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
public function getRequest(...$args): array
{
return $this->request('GET', ...$args);
}
/**
* Remove some boilerplate for patch requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
public function patchRequest(...$args): array
{
return $this->request('PATCH', ...$args);
}
/**
* Remove some boilerplate for post requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
public function postRequest(...$args): array
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('kitsu-request');
}
$response = $this->getResponse('POST', ...$args);
$validResponseCodes = [200, 201];
if ( ! in_array($response->getStatus(), $validResponseCodes, TRUE) && $logger)
{
$logger->warning('Non 2xx response for POST api call', $response->getBody());
}
return JSON::decode(wait($response->getBody()->buffer()), TRUE);
}
/**
* Remove some boilerplate for delete requests
*
* @param mixed ...$args
* @throws Throwable
* @return bool
*/
public function deleteRequest(...$args): bool
{
$response = $this->getResponse('DELETE', ...$args);
return ($response->getStatus() === 204);
}
/**
* Make a request
*
* @param string $type
* @param string $url
* @param array $options
* @return Response
* @throws Throwable
*/
public function getResponse(string $type, string $url, array $options = []): Response
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('kitsu-request');
}
$request = $this->setUpRequest($type, $url, $options);
$response = getResponse($request);
if ($logger)
{
$logger->debug('Kitsu API Response', [
'response_status' => $response->getStatus(),
'request_headers' => $response->getOriginalRequest()->getHeaders(),
'response_headers' => $response->getHeaders()
]);
}
return $response;
}
/**
* Make a request
*
* @param string $type
* @param string $url
* @param array $options
* @throws JsonException
* @throws FailedResponseException
* @throws Throwable
* @return array
*/
private function request(string $type, string $url, array $options = []): array
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('kitsu-request');
}
$response = $this->getResponse($type, $url, $options);
$statusCode = $response->getStatus();
// Check for requests that are unauthorized
if ($statusCode === 401 || $statusCode === 403)
{
Event::emit(EventType::UNAUTHORIZED);
}
// Any other type of failed request
if ($statusCode > 299 || $statusCode < 200)
{
if ($logger)
{
$logger->warning('Non 2xx response for api call', (array)$response);
}
throw new FailedResponseException('Failed to get the proper response from the API');
}
try
{
return Json::decode(wait($response->getBody()->buffer()));
}
catch (JsonException $e)
{
print_r($e);
die();
}
}
} }

View File

@ -18,20 +18,38 @@ namespace Aviat\AnimeClient\API\Kitsu;
trait KitsuTrait { trait KitsuTrait {
/** /**
* The request builder for the Kitsu API * The request builder for the Kitsu GraphQL API
* @var KitsuRequestBuilder * @var KitsuRequestBuilder
*/ */
protected KitsuRequestBuilder $requestBuilder; protected ?KitsuRequestBuilder $requestBuilder = null;
/** /**
* Set the request builder object * The request builder for the Kitsu API
* @var KitsuJsonApiRequestBuilder
*/
protected KitsuJsonApiRequestBuilder $jsonApiRequestBuilder;
/**
* Set the GraphQL request builder object
* *
* @param KitsuRequestBuilder $requestBuilder * @param KitsuRequestBuilder $requestBuilder
* @return self * @return $this
*/ */
public function setRequestBuilder($requestBuilder): self public function setRequestBuilder(KitsuRequestBuilder $requestBuilder): self
{ {
$this->requestBuilder = $requestBuilder; $this->requestBuilder = $requestBuilder;
return $this; return $this;
} }
/**
* Set the request builder object
*
* @param KitsuJsonApiRequestBuilder $requestBuilder
* @return self
*/
public function setJsonApiRequestBuilder($requestBuilder): self
{
$this->jsonApiRequestBuilder = $requestBuilder;
return $this;
}
} }

View File

@ -75,7 +75,7 @@ final class ListItem extends AbstractListItem {
$authHeader = $this->getAuthHeader(); $authHeader = $this->getAuthHeader();
$request = $this->requestBuilder->newRequest('POST', 'library-entries'); $request = $this->jsonApiRequestBuilder->newRequest('POST', 'library-entries');
if ($authHeader !== NULL) if ($authHeader !== NULL)
{ {
@ -94,7 +94,7 @@ final class ListItem extends AbstractListItem {
public function delete(string $id): Request public function delete(string $id): Request
{ {
$authHeader = $this->getAuthHeader(); $authHeader = $this->getAuthHeader();
$request = $this->requestBuilder->newRequest('DELETE', "library-entries/{$id}"); $request = $this->jsonApiRequestBuilder->newRequest('DELETE', "library-entries/{$id}");
if ($authHeader !== NULL) if ($authHeader !== NULL)
{ {
@ -113,7 +113,7 @@ final class ListItem extends AbstractListItem {
{ {
$authHeader = $this->getAuthHeader(); $authHeader = $this->getAuthHeader();
$request = $this->requestBuilder->newRequest('GET', "library-entries/{$id}") $request = $this->jsonApiRequestBuilder->newRequest('GET', "library-entries/{$id}")
->setQuery([ ->setQuery([
'include' => 'media,media.categories,media.mappings' 'include' => 'media,media.categories,media.mappings'
]); ]);
@ -155,7 +155,7 @@ final class ListItem extends AbstractListItem {
$data->progress = 0; $data->progress = 0;
} }
$request = $this->requestBuilder->newRequest('PATCH', "library-entries/{$id}") $request = $this->jsonApiRequestBuilder->newRequest('PATCH', "library-entries/{$id}")
->setJsonBody($requestData); ->setJsonBody($requestData);
if ($authHeader !== NULL) if ($authHeader !== NULL)

View File

@ -25,30 +25,16 @@ use Aviat\AnimeClient\API\{
Kitsu as K, Kitsu as K,
ParallelAPIRequest ParallelAPIRequest
}; };
use Aviat\AnimeClient\API\Enum\{
AnimeWatchingStatus\Kitsu as KitsuWatchingStatus,
MangaReadingStatus\Kitsu as KitsuReadingStatus
};
use Aviat\AnimeClient\API\Mapping\{AnimeWatchingStatus, MangaReadingStatus};
use Aviat\AnimeClient\API\Kitsu\Transformer\{ use Aviat\AnimeClient\API\Kitsu\Transformer\{
AnimeHistoryTransformer,
AnimeTransformer, AnimeTransformer,
AnimeListTransformer, AnimeListTransformer,
MangaHistoryTransformer,
MangaTransformer, MangaTransformer,
MangaListTransformer MangaListTransformer
}; };
use Aviat\AnimeClient\Enum\ListType;
use Aviat\AnimeClient\Types\{
Anime,
FormItem,
MangaPage
};
use Aviat\Banker\Exception\InvalidArgumentException; use Aviat\Banker\Exception\InvalidArgumentException;
use Aviat\Ion\{Di\ContainerAware, Json}; use Aviat\Ion\{Di\ContainerAware, Json};
use ReflectionException;
use Throwable; use Throwable;
/** /**
@ -58,37 +44,16 @@ final class Model {
use CacheTrait; use CacheTrait;
use ContainerAware; use ContainerAware;
use KitsuTrait; use KitsuTrait;
use KitsuAnimeTrait;
use KitsuMangaTrait;
use KitsuMutationTrait;
private const LIST_PAGE_SIZE = 100; protected const LIST_PAGE_SIZE = 100;
/**
* Class to map anime list items
* to a common format used by
* templates
*
* @var AnimeListTransformer
*/
private AnimeListTransformer $animeListTransformer;
/**
* @var AnimeTransformer
*/
private AnimeTransformer $animeTransformer;
/** /**
* @var ListItem * @var ListItem
*/ */
private ListItem $listItem; protected ListItem $listItem;
/**
* @var MangaTransformer
*/
private MangaTransformer $mangaTransformer;
/**
* @var MangaListTransformer
*/
private MangaListTransformer $mangaListTransformer;
/** /**
* Constructor * Constructor
@ -116,7 +81,7 @@ final class Model {
public function authenticate(string $username, string $password) public function authenticate(string $username, string $password)
{ {
// K::AUTH_URL // K::AUTH_URL
$response = $this->requestBuilder->getResponse('POST', K::AUTH_URL, [ $response = $this->jsonApiRequestBuilder->getResponse('POST', K::AUTH_URL, [
'headers' => [ 'headers' => [
'accept' => NULL, 'accept' => NULL,
'Content-type' => 'application/x-www-form-urlencoded', 'Content-type' => 'application/x-www-form-urlencoded',
@ -155,7 +120,7 @@ final class Model {
*/ */
public function reAuthenticate(string $token) public function reAuthenticate(string $token)
{ {
$response = $this->requestBuilder->getResponse('POST', K::AUTH_URL, [ $response = $this->jsonApiRequestBuilder->getResponse('POST', K::AUTH_URL, [
'headers' => [ 'headers' => [
'accept' => NULL, 'accept' => NULL,
'Content-type' => 'application/x-www-form-urlencoded', 'Content-type' => 'application/x-www-form-urlencoded',
@ -199,7 +164,7 @@ final class Model {
} }
return $this->getCached(K::AUTH_USER_ID_KEY, function(string $username) { return $this->getCached(K::AUTH_USER_ID_KEY, function(string $username) {
$data = $this->requestBuilder->getRequest('users', [ $data = $this->jsonApiRequestBuilder->getRequest('users', [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'name' => $username 'name' => $username
@ -219,7 +184,7 @@ final class Model {
*/ */
public function getCharacter(string $slug): array public function getCharacter(string $slug): array
{ {
return $this->requestBuilder->getRequest('characters', [ return $this->jsonApiRequestBuilder->getRequest('characters', [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'slug' => $slug, 'slug' => $slug,
@ -242,7 +207,7 @@ final class Model {
*/ */
public function getPerson(string $id): array public function getPerson(string $id): array
{ {
return $this->getCached("kitsu-person-{$id}", fn () => $this->requestBuilder->getRequest("people/{$id}", [ return $this->getCached("kitsu-person-{$id}", fn () => $this->jsonApiRequestBuilder->getRequest("people/{$id}", [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'id' => $id, 'id' => $id,
@ -268,7 +233,7 @@ final class Model {
*/ */
public function getUserData(string $username): array public function getUserData(string $username): array
{ {
return $this->requestBuilder->getRequest('users', [ return $this->jsonApiRequestBuilder->getRequest('users', [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'name' => $username, 'name' => $username,
@ -305,7 +270,7 @@ final class Model {
] ]
]; ];
$raw = $this->requestBuilder->getRequest($type, $options); $raw = $this->jsonApiRequestBuilder->getRequest($type, $options);
$raw['included'] = JsonAPI::organizeIncluded($raw['included']); $raw['included'] = JsonAPI::organizeIncluded($raw['included']);
foreach ($raw['data'] as &$item) foreach ($raw['data'] as &$item)
@ -350,7 +315,7 @@ final class Model {
] ]
]; ];
$raw = $this->requestBuilder->getRequest('mappings', $options); $raw = $this->jsonApiRequestBuilder->getRequest('mappings', $options);
if ( ! array_key_exists('included', $raw)) if ( ! array_key_exists('included', $raw))
{ {
@ -360,539 +325,6 @@ final class Model {
return $raw['included'][0]['id']; return $raw['included'][0]['id'];
} }
// -------------------------------------------------------------------------
// ! Anime-specific methods
// -------------------------------------------------------------------------
/**
* Get information about a particular anime
*
* @param string $slug
* @return Anime
*/
public function getAnime(string $slug): Anime
{
$baseData = $this->getRawMediaData('anime', $slug);
if (empty($baseData))
{
return Anime::from([]);
}
return $this->animeTransformer->transform($baseData);
}
/**
* Retrieve the data for the anime watch history page
*
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getAnimeHistory(): array
{
$key = K::ANIME_HISTORY_LIST_CACHE_KEY;
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$raw = $this->getRawHistoryList('anime');
$organized = JsonAPI::organizeData($raw);
$organized = array_filter($organized, fn ($item) => array_key_exists('relationships', $item));
$list = (new AnimeHistoryTransformer())->transform($organized);
$this->cache->set($key, $list);
}
return $list;
}
/**
* Get information about a particular anime
*
* @param string $animeId
* @return Anime
*/
public function getAnimeById(string $animeId): Anime
{
$baseData = $this->getRawMediaDataById('anime', $animeId);
return $this->animeTransformer->transform($baseData);
}
/**
* 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 getAnimeList(string $status): array
{
$key = "kitsu-anime-list-{$status}";
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$data = $this->getRawAnimeList($status) ?? [];
// Bail out on no data
if (empty($data))
{
return [];
}
$included = JsonAPI::organizeIncludes($data['included']);
$included = JsonAPI::inlineIncludedRelationships($included, 'anime');
foreach($data['data'] as $i => &$item)
{
$item['included'] = $included;
}
unset($item);
$transformed = $this->animeListTransformer->transformCollection($data['data']);
$keyed = [];
foreach($transformed as $item)
{
$keyed[$item['id']] = $item;
}
$list = $keyed;
$this->cache->set($key, $list);
}
return $list;
}
/**
* Get the number of anime list items
*
* @param string $status - Optional status to filter by
* @return int
* @throws InvalidArgumentException
*/
public function getAnimeListCount(string $status = '') : int
{
return $this->getListCount(ListType::ANIME, $status);
}
/**
* Get the full anime list
*
* @param array $options
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getFullRawAnimeList(array $options = [
'include' => 'anime.mappings'
]): array
{
$status = $options['filter']['status'] ?? '';
$count = $this->getAnimeListCount($status);
$size = static::LIST_PAGE_SIZE;
$pages = ceil($count / $size);
$requester = new ParallelAPIRequest();
// Set up requests
for ($i = 0; $i < $pages; $i++)
{
$offset = $i * $size;
$requester->addRequest($this->getPagedAnimeList($size, $offset, $options));
}
$responses = $requester->makeRequests();
$output = [];
foreach($responses as $response)
{
$data = Json::decode($response);
$output[] = $data;
}
return array_merge_recursive(...$output);
}
/**
* Get all the anime entries, that are organized for output to html
*
* @return array
* @throws ReflectionException
* @throws InvalidArgumentException
*/
public function getFullOrganizedAnimeList(): array
{
$output = [];
$statuses = KitsuWatchingStatus::getConstList();
foreach ($statuses as $key => $status)
{
$mappedStatus = AnimeWatchingStatus::KITSU_TO_TITLE[$status];
$output[$mappedStatus] = $this->getAnimeList($status) ?? [];
}
return $output;
}
/**
* Get the mal id for the anime represented by the kitsu id
* to enable updating MyAnimeList
*
* @param string $kitsuAnimeId The id of the anime on Kitsu
* @return string|null Returns the mal id if it exists, otherwise null
*/
public function getMalIdForAnime(string $kitsuAnimeId): ?string
{
$options = [
'query' => [
'include' => 'mappings'
]
];
$data = $this->requestBuilder->getRequest("anime/{$kitsuAnimeId}", $options);
if ( ! array_key_exists('included', $data))
{
return NULL;
}
$mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map)
{
if ($map['externalSite'] === 'myanimelist/anime')
{
return $map['externalId'];
}
}
return NULL;
}
/**
* Get the full anime list in paginated form
*
* @param int $limit
* @param int $offset
* @param array $options
* @return Request
* @throws InvalidArgumentException
*/
public function getPagedAnimeList(int $limit, int $offset = 0, array $options = [
'include' => 'anime.mappings'
]): Request
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => 'anime'
],
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
];
$options = array_merge($defaultOptions, $options);
return $this->requestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
/**
* Get the raw (unorganized) anime list for the configured user
*
* @param string $status - The watching status to filter the list with
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getRawAnimeList(string $status): array
{
$options = [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => 'anime',
'status' => $status,
],
'include' => 'media,media.categories,media.mappings,anime.streamingLinks',
'sort' => '-updated_at'
];
return $this->getFullRawAnimeList($options);
}
// -------------------------------------------------------------------------
// ! Manga-specific methods
// -------------------------------------------------------------------------
/**
* Get information about a particular manga
*
* @param string $slug
* @return MangaPage
*/
public function getManga(string $slug): MangaPage
{
$baseData = $this->getRawMediaData('manga', $slug);
if (empty($baseData))
{
return MangaPage::from([]);
}
return $this->mangaTransformer->transform($baseData);
}
/**
* Retrieve the data for the manga read history page
*
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getMangaHistory(): array
{
$key = K::MANGA_HISTORY_LIST_CACHE_KEY;
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$raw = $this->getRawHistoryList('manga');
$organized = JsonAPI::organizeData($raw);
$organized = array_filter($organized, fn ($item) => array_key_exists('relationships', $item));
$list = (new MangaHistoryTransformer())->transform($organized);
$this->cache->set($key, $list);
}
return $list;
}
/**
* Get information about a particular manga
*
* @param string $mangaId
* @return MangaPage
*/
public function getMangaById(string $mangaId): MangaPage
{
$baseData = $this->getRawMediaDataById('manga', $mangaId);
return $this->mangaTransformer->transform($baseData);
}
/**
* Get the manga list for the configured user
*
* @param string $status - The reading status by which to filter the list
* @param int $limit - The number of list items to fetch per page
* @param int $offset - The page offset
* @return array
* @throws InvalidArgumentException
*/
public function getMangaList(string $status, int $limit = 200, int $offset = 0): array
{
$options = [
'query' => [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => 'manga',
'status' => $status,
],
'include' => 'media,media.categories,media.mappings',
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
]
];
$key = "kitsu-manga-list-{$status}";
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$data = $this->requestBuilder->getRequest('library-entries', $options) ?? [];
// Bail out on no data
if (empty($data) || ( ! array_key_exists('included', $data)))
{
return [];
}
$included = JsonAPI::organizeIncludes($data['included']);
$included = JsonAPI::inlineIncludedRelationships($included, 'manga');
foreach($data['data'] as $i => &$item)
{
$item['included'] = $included;
}
unset($item);
$list = $this->mangaListTransformer->transformCollection($data['data']);
$this->cache->set($key, $list);
}
return $list;
}
/**
* Get the number of manga list items
*
* @param string $status - Optional status to filter by
* @return int
* @throws InvalidArgumentException
*/
public function getMangaListCount(string $status = '') : int
{
return $this->getListCount(ListType::MANGA, $status);
}
/**
* Get the full manga list
*
* @param array $options
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getFullRawMangaList(array $options = [
'include' => 'manga.mappings'
]): array
{
$status = $options['filter']['status'] ?? '';
$count = $this->getMangaListCount($status);
$size = static::LIST_PAGE_SIZE;
$pages = ceil($count / $size);
$requester = new ParallelAPIRequest();
// Set up requests
for ($i = 0; $i < $pages; $i++)
{
$offset = $i * $size;
$requester->addRequest($this->getPagedMangaList($size, $offset, $options));
}
$responses = $requester->makeRequests();
$output = [];
foreach($responses as $response)
{
$data = Json::decode($response);
$output[] = $data;
}
return array_merge_recursive(...$output);
}
/**
* Get all Manga lists
*
* @return array
* @throws ReflectionException
* @throws InvalidArgumentException
*/
public function getFullOrganizedMangaList(): array
{
$statuses = KitsuReadingStatus::getConstList();
$output = [];
foreach ($statuses as $status)
{
$mappedStatus = MangaReadingStatus::KITSU_TO_TITLE[$status];
$output[$mappedStatus] = $this->getMangaList($status);
}
return $output;
}
/**
* Get the full manga list in paginated form
*
* @param int $limit
* @param int $offset
* @param array $options
* @return Request
* @throws InvalidArgumentException
*/
public function getPagedMangaList(int $limit, int $offset = 0, array $options = [
'include' => 'manga.mappings'
]): Request
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => 'manga'
],
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
];
$options = array_merge($defaultOptions, $options);
return $this->requestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
/**
* Get the mal id for the manga represented by the kitsu id
* to enable updating MyAnimeList
*
* @param string $kitsuMangaId The id of the manga on Kitsu
* @return string|null Returns the mal id if it exists, otherwise null
*/
public function getMalIdForManga(string $kitsuMangaId): ?string
{
$options = [
'query' => [
'include' => 'mappings'
]
];
$data = $this->requestBuilder->getRequest("manga/{$kitsuMangaId}", $options);
$mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map)
{
if ($map['externalSite'] === 'myanimelist/manga')
{
return $map['externalId'];
}
}
return NULL;
}
// -------------------------------------------------------------------------
// ! Generic API calls
// -------------------------------------------------------------------------
/**
* Create a list item
*
* @param array $data
* @return Request
* @throws InvalidArgumentException
*/
public function createListItem(array $data): ?Request
{
$data['user_id'] = $this->getUserId();
if ($data['id'] === NULL)
{
return NULL;
}
return $this->listItem->create($data);
}
/** /**
* Get the data for a specific list item, generally for editing * Get the data for a specific list item, generally for editing
* *
@ -923,38 +355,13 @@ final class Model {
} }
/** /**
* Increase the progress count for a list item * Get the data to sync Kitsu anime/manga list with another API
* *
* @param FormItem $data * @param string $type
* @return Request * @return array
* @throws InvalidArgumentException
* @throws Throwable
*/ */
public function incrementListItem(FormItem $data): Request
{
return $this->listItem->increment($data['id'], $data['data']);
}
/**
* Modify a list item
*
* @param FormItem $data
* @return Request
*/
public function updateListItem(FormItem $data): Request
{
return $this->listItem->update($data['id'], $data['data']);
}
/**
* Remove a list item
*
* @param string $id - The id of the list item to remove
* @return Request
*/
public function deleteListItem(string $id): Request
{
return $this->listItem->delete($id);
}
public function getSyncList(string $type): array public function getSyncList(string $type): array
{ {
$options = [ $options = [
@ -1015,7 +422,7 @@ final class Model {
*/ */
protected function getRawHistoryPage(string $type, int $offset, int $limit = 20): Request protected function getRawHistoryPage(string $type, int $offset, int $limit = 20): Request
{ {
return $this->requestBuilder->setUpRequest('GET', 'library-events', [ return $this->jsonApiRequestBuilder->setUpRequest('GET', 'library-events', [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'kind' => 'progressed,updated', 'kind' => 'progressed,updated',
@ -1077,7 +484,7 @@ final class Model {
] ]
]; ];
$data = $this->requestBuilder->getRequest("{$type}/{$id}", $options); $data = $this->jsonApiRequestBuilder->getRequest("{$type}/{$id}", $options);
if (empty($data['data'])) if (empty($data['data']))
{ {
@ -1117,7 +524,7 @@ final class Model {
] ]
]; ];
$data = $this->requestBuilder->getRequest($type, $options); $data = $this->jsonApiRequestBuilder->getRequest($type, $options);
if (empty($data['data'])) if (empty($data['data']))
{ {
@ -1150,7 +557,7 @@ final class Model {
$options['query']['filter']['status'] = $status; $options['query']['filter']['status'] = $status;
} }
$response = $this->requestBuilder->getRequest('library-entries', $options); $response = $this->jsonApiRequestBuilder->getRequest('library-entries', $options);
return $response['meta']['count']; return $response['meta']['count'];
} }
@ -1216,6 +623,6 @@ final class Model {
]; ];
$options = array_merge($defaultOptions, $options); $options = array_merge($defaultOptions, $options);
return $this->requestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]); return $this->jsonApiRequestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]);
} }
} }

View File

@ -23,7 +23,7 @@ use Aura\Router\RouterContainer;
use Aura\Session\SessionFactory; use Aura\Session\SessionFactory;
use Aviat\AnimeClient\{Model, UrlGenerator, Util}; use Aviat\AnimeClient\{Model, UrlGenerator, Util};
use Aviat\AnimeClient\API\{Anilist, CacheTrait, Kitsu}; use Aviat\AnimeClient\API\{Anilist, CacheTrait, Kitsu};
use Aviat\AnimeClient\API\Kitsu\KitsuRequestBuilder; use Aviat\AnimeClient\API\Kitsu\KitsuJsonApiRequestBuilder;
use Aviat\Banker\Teller; use Aviat\Banker\Teller;
use Aviat\Ion\Config; use Aviat\Ion\Config;
use Aviat\Ion\Di\{Container, ContainerInterface, ContainerAware}; use Aviat\Ion\Di\{Container, ContainerInterface, ContainerAware};
@ -187,16 +187,20 @@ abstract class BaseCommand extends Command {
// Models // Models
$container->set('kitsu-model', static function($container): Kitsu\Model { $container->set('kitsu-model', static function($container): Kitsu\Model {
$requestBuilder = new KitsuRequestBuilder($container); $jsonApiRequestBuilder = new KitsuJsonApiRequestBuilder($container);
$jsonApiRequestBuilder->setLogger($container->getLogger('kitsu-request'));
$requestBuilder = new Kitsu\KitsuRequestBuilder($container);
$requestBuilder->setLogger($container->getLogger('kitsu-request')); $requestBuilder->setLogger($container->getLogger('kitsu-request'));
$listItem = new Kitsu\ListItem(); $listItem = new Kitsu\ListItem();
$listItem->setContainer($container); $listItem->setContainer($container);
$listItem->setRequestBuilder($requestBuilder); $listItem->setJsonApiRequestBuilder($jsonApiRequestBuilder);
$model = new Kitsu\Model($listItem); $model = new Kitsu\Model($listItem);
$model->setContainer($container); $model->setContainer($container);
$model->setRequestBuilder($requestBuilder); $model->setJsonApiRequestBuilder($jsonApiRequestBuilder)
->setRequestBuilder($requestBuilder);
$cache = $container->get('cache'); $cache = $container->get('cache');
$model->setCache($cache); $model->setCache($cache);