diff --git a/app/bootstrap.php b/app/bootstrap.php index 34068f1b..206ed872 100644 --- a/app/bootstrap.php +++ b/app/bootstrap.php @@ -22,7 +22,7 @@ use Aura\Session\SessionFactory; use Aviat\AnimeClient\API\{ Anilist, Kitsu, - Kitsu\KitsuRequestBuilder + Kitsu\KitsuJsonApiRequestBuilder }; use Aviat\AnimeClient\Model; use Aviat\Banker\Teller; @@ -114,16 +114,20 @@ return static function (array $configArray = []): Container { // Models $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')); $listItem = new Kitsu\ListItem(); $listItem->setContainer($container); - $listItem->setRequestBuilder($requestBuilder); + $listItem->setJsonApiRequestBuilder($jsonApiRequestBuilder); $model = new Kitsu\Model($listItem); $model->setContainer($container); - $model->setRequestBuilder($requestBuilder); + $model->setJsonApiRequestBuilder($jsonApiRequestBuilder) + ->setRequestBuilder($requestBuilder); $cache = $container->get('cache'); $model->setCache($cache); diff --git a/src/AnimeClient/API/Kitsu/GraphQL/Queries/AnimeDetails.graphql b/src/AnimeClient/API/Kitsu/GraphQL/Queries/AnimeDetails.graphql index 055ed172..889800b5 100644 --- a/src/AnimeClient/API/Kitsu/GraphQL/Queries/AnimeDetails.graphql +++ b/src/AnimeClient/API/Kitsu/GraphQL/Queries/AnimeDetails.graphql @@ -1,120 +1,118 @@ query ($slug: String) { - anime(slug: $slug) { - nodes { - ageRating - ageRatingGuide - bannerImage { - original { - height - name - url - width - } - views { - height - name - url - width - } + findAnimeBySlug(slug: $slug) { + ageRating + ageRatingGuide + bannerImage { + original { + height + name + url + width } - 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 - } + views { + height + name + url + width } - 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 } } diff --git a/src/AnimeClient/API/Kitsu/KitsuAnimeTrait.php b/src/AnimeClient/API/Kitsu/KitsuAnimeTrait.php new file mode 100644 index 00000000..43192448 --- /dev/null +++ b/src/AnimeClient/API/Kitsu/KitsuAnimeTrait.php @@ -0,0 +1,316 @@ + + * @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); + } +} \ No newline at end of file diff --git a/src/AnimeClient/API/Kitsu/KitsuJsonApiRequestBuilder.php b/src/AnimeClient/API/Kitsu/KitsuJsonApiRequestBuilder.php new file mode 100644 index 00000000..93c7601d --- /dev/null +++ b/src/AnimeClient/API/Kitsu/KitsuJsonApiRequestBuilder.php @@ -0,0 +1,271 @@ + + * @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(); + } + } + + +} \ No newline at end of file diff --git a/src/AnimeClient/API/Kitsu/KitsuMangaTrait.php b/src/AnimeClient/API/Kitsu/KitsuMangaTrait.php new file mode 100644 index 00000000..9c844bd0 --- /dev/null +++ b/src/AnimeClient/API/Kitsu/KitsuMangaTrait.php @@ -0,0 +1,291 @@ + + * @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; + } +} \ No newline at end of file diff --git a/src/AnimeClient/API/Kitsu/KitsuMutationTrait.php b/src/AnimeClient/API/Kitsu/KitsuMutationTrait.php new file mode 100644 index 00000000..d74b4d74 --- /dev/null +++ b/src/AnimeClient/API/Kitsu/KitsuMutationTrait.php @@ -0,0 +1,81 @@ + + * @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); + } +} \ No newline at end of file diff --git a/src/AnimeClient/API/Kitsu/KitsuRequestBuilder.php b/src/AnimeClient/API/Kitsu/KitsuRequestBuilder.php index 4e6b7c21..379af7ba 100644 --- a/src/AnimeClient/API/Kitsu/KitsuRequestBuilder.php +++ b/src/AnimeClient/API/Kitsu/KitsuRequestBuilder.php @@ -16,23 +16,12 @@ 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; + +use const Aviat\AnimeClient\USER_AGENT; + +use Aviat\AnimeClient\API\APIRequestBuilder; final class KitsuRequestBuilder extends APIRequestBuilder { use ContainerAware; @@ -41,7 +30,13 @@ final class KitsuRequestBuilder extends APIRequestBuilder { * The base url for api requests * @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 @@ -49,223 +44,13 @@ final class KitsuRequestBuilder extends APIRequestBuilder { * @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', + 'User-Agent' => USER_AGENT, + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', ]; 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(); - } - } - - } \ No newline at end of file diff --git a/src/AnimeClient/API/Kitsu/KitsuTrait.php b/src/AnimeClient/API/Kitsu/KitsuTrait.php index c4d477af..694c3148 100644 --- a/src/AnimeClient/API/Kitsu/KitsuTrait.php +++ b/src/AnimeClient/API/Kitsu/KitsuTrait.php @@ -18,20 +18,38 @@ namespace Aviat\AnimeClient\API\Kitsu; trait KitsuTrait { /** - * The request builder for the Kitsu API + * The request builder for the Kitsu GraphQL API * @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 - * @return self + * @return $this */ - public function setRequestBuilder($requestBuilder): self + public function setRequestBuilder(KitsuRequestBuilder $requestBuilder): self { $this->requestBuilder = $requestBuilder; return $this; } + + /** + * Set the request builder object + * + * @param KitsuJsonApiRequestBuilder $requestBuilder + * @return self + */ + public function setJsonApiRequestBuilder($requestBuilder): self + { + $this->jsonApiRequestBuilder = $requestBuilder; + return $this; + } } \ No newline at end of file diff --git a/src/AnimeClient/API/Kitsu/ListItem.php b/src/AnimeClient/API/Kitsu/ListItem.php index 76fc7fef..2a0b38a3 100644 --- a/src/AnimeClient/API/Kitsu/ListItem.php +++ b/src/AnimeClient/API/Kitsu/ListItem.php @@ -75,7 +75,7 @@ final class ListItem extends AbstractListItem { $authHeader = $this->getAuthHeader(); - $request = $this->requestBuilder->newRequest('POST', 'library-entries'); + $request = $this->jsonApiRequestBuilder->newRequest('POST', 'library-entries'); if ($authHeader !== NULL) { @@ -94,7 +94,7 @@ final class ListItem extends AbstractListItem { public function delete(string $id): Request { $authHeader = $this->getAuthHeader(); - $request = $this->requestBuilder->newRequest('DELETE', "library-entries/{$id}"); + $request = $this->jsonApiRequestBuilder->newRequest('DELETE', "library-entries/{$id}"); if ($authHeader !== NULL) { @@ -113,7 +113,7 @@ final class ListItem extends AbstractListItem { { $authHeader = $this->getAuthHeader(); - $request = $this->requestBuilder->newRequest('GET', "library-entries/{$id}") + $request = $this->jsonApiRequestBuilder->newRequest('GET', "library-entries/{$id}") ->setQuery([ 'include' => 'media,media.categories,media.mappings' ]); @@ -155,7 +155,7 @@ final class ListItem extends AbstractListItem { $data->progress = 0; } - $request = $this->requestBuilder->newRequest('PATCH', "library-entries/{$id}") + $request = $this->jsonApiRequestBuilder->newRequest('PATCH', "library-entries/{$id}") ->setJsonBody($requestData); if ($authHeader !== NULL) diff --git a/src/AnimeClient/API/Kitsu/Model.php b/src/AnimeClient/API/Kitsu/Model.php index 671a04d6..01c60fe6 100644 --- a/src/AnimeClient/API/Kitsu/Model.php +++ b/src/AnimeClient/API/Kitsu/Model.php @@ -25,30 +25,16 @@ use Aviat\AnimeClient\API\{ Kitsu as K, 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\{ - AnimeHistoryTransformer, AnimeTransformer, AnimeListTransformer, - MangaHistoryTransformer, MangaTransformer, MangaListTransformer }; -use Aviat\AnimeClient\Enum\ListType; -use Aviat\AnimeClient\Types\{ - Anime, - FormItem, - MangaPage -}; use Aviat\Banker\Exception\InvalidArgumentException; use Aviat\Ion\{Di\ContainerAware, Json}; -use ReflectionException; use Throwable; /** @@ -58,37 +44,16 @@ final class Model { use CacheTrait; use ContainerAware; use KitsuTrait; + use KitsuAnimeTrait; + use KitsuMangaTrait; + use KitsuMutationTrait; - private 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; + protected const LIST_PAGE_SIZE = 100; /** * @var ListItem */ - private ListItem $listItem; - - /** - * @var MangaTransformer - */ - private MangaTransformer $mangaTransformer; - - /** - * @var MangaListTransformer - */ - private MangaListTransformer $mangaListTransformer; + protected ListItem $listItem; /** * Constructor @@ -116,7 +81,7 @@ final class Model { public function authenticate(string $username, string $password) { // K::AUTH_URL - $response = $this->requestBuilder->getResponse('POST', K::AUTH_URL, [ + $response = $this->jsonApiRequestBuilder->getResponse('POST', K::AUTH_URL, [ 'headers' => [ 'accept' => NULL, 'Content-type' => 'application/x-www-form-urlencoded', @@ -155,7 +120,7 @@ final class Model { */ public function reAuthenticate(string $token) { - $response = $this->requestBuilder->getResponse('POST', K::AUTH_URL, [ + $response = $this->jsonApiRequestBuilder->getResponse('POST', K::AUTH_URL, [ 'headers' => [ 'accept' => NULL, '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) { - $data = $this->requestBuilder->getRequest('users', [ + $data = $this->jsonApiRequestBuilder->getRequest('users', [ 'query' => [ 'filter' => [ 'name' => $username @@ -219,7 +184,7 @@ final class Model { */ public function getCharacter(string $slug): array { - return $this->requestBuilder->getRequest('characters', [ + return $this->jsonApiRequestBuilder->getRequest('characters', [ 'query' => [ 'filter' => [ 'slug' => $slug, @@ -242,7 +207,7 @@ final class Model { */ 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' => [ 'filter' => [ 'id' => $id, @@ -268,7 +233,7 @@ final class Model { */ public function getUserData(string $username): array { - return $this->requestBuilder->getRequest('users', [ + return $this->jsonApiRequestBuilder->getRequest('users', [ 'query' => [ 'filter' => [ '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']); 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)) { @@ -360,539 +325,6 @@ final class Model { 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 * @@ -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 - * @return Request + * @param string $type + * @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 { $options = [ @@ -1015,7 +422,7 @@ final class Model { */ 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' => [ 'filter' => [ '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'])) { @@ -1117,7 +524,7 @@ final class Model { ] ]; - $data = $this->requestBuilder->getRequest($type, $options); + $data = $this->jsonApiRequestBuilder->getRequest($type, $options); if (empty($data['data'])) { @@ -1150,7 +557,7 @@ final class Model { $options['query']['filter']['status'] = $status; } - $response = $this->requestBuilder->getRequest('library-entries', $options); + $response = $this->jsonApiRequestBuilder->getRequest('library-entries', $options); return $response['meta']['count']; } @@ -1216,6 +623,6 @@ final class Model { ]; $options = array_merge($defaultOptions, $options); - return $this->requestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]); + return $this->jsonApiRequestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]); } } \ No newline at end of file diff --git a/src/AnimeClient/Command/BaseCommand.php b/src/AnimeClient/Command/BaseCommand.php index 7f03656d..e05b8f25 100644 --- a/src/AnimeClient/Command/BaseCommand.php +++ b/src/AnimeClient/Command/BaseCommand.php @@ -23,7 +23,7 @@ use Aura\Router\RouterContainer; use Aura\Session\SessionFactory; use Aviat\AnimeClient\{Model, UrlGenerator, Util}; 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\Ion\Config; use Aviat\Ion\Di\{Container, ContainerInterface, ContainerAware}; @@ -187,16 +187,20 @@ abstract class BaseCommand extends Command { // Models $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')); $listItem = new Kitsu\ListItem(); $listItem->setContainer($container); - $listItem->setRequestBuilder($requestBuilder); + $listItem->setJsonApiRequestBuilder($jsonApiRequestBuilder); $model = new Kitsu\Model($listItem); $model->setContainer($container); - $model->setRequestBuilder($requestBuilder); + $model->setJsonApiRequestBuilder($jsonApiRequestBuilder) + ->setRequestBuilder($requestBuilder); $cache = $container->get('cache'); $model->setCache($cache);