From 7fc58f1605c2b66b5d90aee6439876c05f3465bb Mon Sep 17 00:00:00 2001 From: "Timothy J. Warren" Date: Wed, 21 Oct 2020 17:06:50 -0400 Subject: [PATCH] Use GraphQL to update thumbnails, refactor GraphQL pagination, merge Anime and Manga traits back into the Kitsu model --- src/AnimeClient/API/JsonAPI.php | 354 --------------- src/AnimeClient/API/Kitsu/AnimeTrait.php | 267 ----------- src/AnimeClient/API/Kitsu/MangaTrait.php | 239 ---------- src/AnimeClient/API/Kitsu/Model.php | 423 ++++++++++++++++-- .../Kitsu/Queries/GetLibraryThumbs.graphql | 25 ++ src/AnimeClient/Command/UpdateThumbnails.php | 18 +- 6 files changed, 419 insertions(+), 907 deletions(-) delete mode 100644 src/AnimeClient/API/JsonAPI.php delete mode 100644 src/AnimeClient/API/Kitsu/AnimeTrait.php delete mode 100644 src/AnimeClient/API/Kitsu/MangaTrait.php create mode 100644 src/AnimeClient/API/Kitsu/Queries/GetLibraryThumbs.graphql diff --git a/src/AnimeClient/API/JsonAPI.php b/src/AnimeClient/API/JsonAPI.php deleted file mode 100644 index 2c711230..00000000 --- a/src/AnimeClient/API/JsonAPI.php +++ /dev/null @@ -1,354 +0,0 @@ - - * @copyright 2015 - 2020 Timothy J. Warren - * @license http://www.opensource.org/licenses/mit-license.html MIT License - * @version 5.1 - * @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient - */ - -namespace Aviat\AnimeClient\API; - -use function in_array; - -/** - * Class encapsulating Json API data structure for a request or response - */ -final class JsonAPI { - - /* - * Basic structure is generally like so: - * [ - * 'id' => '12016665', - * 'type' => 'libraryEntries', - * 'links' => [ - * 'self' => 'https://kitsu.io/api/edge/library-entries/13016665' - * ], - * 'attributes' => [ - * - * ] - * ] - */ - - /** - * Inline all included data - * - * @param array $data - The raw JsonAPI response data - * @return array - */ - public static function organizeData(array $data): array - { - // relationships that have singular data - $singular = [ - 'waifu' - ]; - - // Reorganize included data - $included = array_key_exists('included', $data) - ? static::organizeIncluded($data['included']) - : []; - - // Inline organized data - foreach($data['data'] as $i => &$item) - { - if ( ! is_array($item)) - { - continue; - } - - if (array_key_exists('relationships', $item)) - { - foreach($item['relationships'] as $relType => $props) - { - - if (array_keys($props) === ['links']) - { - unset($item['relationships'][$relType]); - - if (empty($item['relationships'])) - { - unset($item['relationships']); - } - - continue; - } - - if (array_key_exists('links', $props)) - { - unset($item['relationships'][$relType]['links']); - } - - if (array_key_exists('data', $props)) - { - if (empty($props['data'])) - { - unset($item['relationships'][$relType]['data']); - - if (empty($item['relationships'][$relType])) - { - unset($item['relationships'][$relType]); - } - - continue; - } - - // Single data item - if (array_key_exists('id', $props['data'])) - { - $idKey = $props['data']['id']; - $dataType = $props['data']['type']; - $relationship =& $item['relationships'][$relType]; - unset($relationship['data']); - - if (in_array($relType, $singular, TRUE)) - { - $relationship = $included[$dataType][$idKey]; - continue; - } - - if ($relType === $dataType) - { - $relationship[$idKey] = $included[$dataType][$idKey]; - continue; - } - - $relationship[$dataType][$idKey] = $included[$dataType][$idKey]; - } - // Multiple data items - else - { - foreach($props['data'] as $j => $datum) - { - $idKey = $props['data'][$j]['id']; - $dataType = $props['data'][$j]['type']; - $relationship =& $item['relationships'][$relType]; - - if ($relType === $dataType) - { - $relationship[$idKey] = $included[$dataType][$idKey]; - continue; - } - - $relationship[$dataType][$idKey][$j] = $included[$dataType][$idKey]; - } - - unset($item['relationships'][$relType]['data']); - } - } - } - } - } - - unset($item); - - $data['data']['included'] = $included; - - return $data['data']; - } - - /** - * Restructure included data to make it simpler to inline - * - * @param array $included - * @return array - */ - public static function organizeIncluded(array $included): array - { - $organized = []; - - // First pass, create [ type => items[] ] structure - foreach($included as &$item) - { - $type = $item['type']; - $id = $item['id']; - $organized[$type] = $organized[$type] ?? []; - $newItem = []; - - foreach(['attributes', 'relationships'] as $key) - { - if (array_key_exists($key, $item)) - { - // Remove 'links' type relationships - if ($key === 'relationships') - { - foreach($item['relationships'] as $relType => $props) - { - if (array_keys($props) === ['links']) - { - unset($item['relationships'][$relType]); - if (empty($item['relationships'])) - { - continue 2; - } - } - } - } - - $newItem[$key] = $item[$key]; - } - } - - $organized[$type][$id] = $newItem; - } - unset($item); - - // Second pass, go through and fill missing relationships in the first pass - foreach($organized as $type => $items) - { - foreach($items as $id => $item) - { - if (array_key_exists('relationships', $item) && is_array($item['relationships'])) - { - foreach($item['relationships'] as $relType => $props) - { - if (array_key_exists('data', $props) && is_array($props['data']) && array_key_exists('id', $props['data'])) - { - $idKey = $props['data']['id']; - $dataType = $props['data']['type']; - - $relationship =& $organized[$type][$id]['relationships'][$relType]; - unset($relationship['links'], $relationship['data']); - - if ($relType === $dataType) - { - $relationship[$idKey] = $included[$dataType][$idKey]; - continue; - } - - if ( ! array_key_exists($dataType, $organized)) - { - $organized[$dataType] = []; - } - - if (array_key_exists($idKey, $organized[$dataType])) - { - $relationship[$dataType][$idKey] = $organized[$dataType][$idKey]; - } - } - } - } - } - } - - return $organized; - } - - /** - * Take organized includes and inline them, where applicable - * - * @param array $included - * @param string $key The key of the include to inline the other included values into - * @return array - */ - public static function inlineIncludedRelationships(array $included, string $key): array - { - $inlined = [ - $key => [] - ]; - - foreach ($included[$key] as $itemId => $item) - { - // Duplicate the item for the output - $inlined[$key][$itemId] = $item; - - foreach($item['relationships'] as $type => $ids) - { - $inlined[$key][$itemId]['relationships'][$type] = []; - - if ( ! array_key_exists($type, $included)) continue; - - if (array_key_exists('data', $ids )) - { - $ids = array_column($ids['data'], 'id'); - } - - foreach($ids as $id) - { - $inlined[$key][$itemId]['relationships'][$type][$id] = $included[$type][$id]; - } - } - } - - return $inlined; - } - - /** - * Reorganizes 'included' data to be keyed by - * type => [ - * id => data/attributes, - * ] - * - * @param array $includes - * @return array - */ - public static function organizeIncludes(array $includes): array - { - $organized = []; - $types = array_unique(array_column($includes, 'type')); - sort($types); - - foreach ($types as $type) - { - $organized[$type] = []; - } - - foreach ($includes as $item) - { - $type = $item['type']; - $id = $item['id']; - - if (array_key_exists('attributes', $item)) - { - $organized[$type][$id] = $item['attributes']; - } - - if (array_key_exists('relationships', $item)) - { - $organized[$type][$id]['relationships'] = static::organizeRelationships($item['relationships']); - } - } - - return $organized; - } - - /** - * Reorganize relationship mappings to make them simpler to use - * - * Remove verbose structure, and just map: - * type => [ idArray ] - * - * @param array $relationships - * @return array - */ - public static function organizeRelationships(array $relationships): array - { - $organized = $relationships; - - foreach($relationships as $key => $data) - { - $organized[$key] = $organized[$key] ?? []; - - if ( ! array_key_exists('data', $data)) - { - continue; - } - - foreach ($data['data'] as $item) - { - if (is_array($item) && array_key_exists('id', $item)) - { - $organized[$key][] = $item['id']; - } - } - } - - return $organized; - } -} \ No newline at end of file diff --git a/src/AnimeClient/API/Kitsu/AnimeTrait.php b/src/AnimeClient/API/Kitsu/AnimeTrait.php deleted file mode 100644 index 88414035..00000000 --- a/src/AnimeClient/API/Kitsu/AnimeTrait.php +++ /dev/null @@ -1,267 +0,0 @@ - - * @copyright 2015 - 2020 Timothy J. Warren - * @license http://www.opensource.org/licenses/mit-license.html MIT License - * @version 5.1 - * @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient - */ - -namespace Aviat\AnimeClient\API\Kitsu; - -use Amp\Http\Client\Request; -use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeListTransformer; -use Aviat\AnimeClient\Kitsu as K; -use Aviat\AnimeClient\API\Enum\AnimeWatchingStatus\Kitsu as KitsuWatchingStatus; -use Aviat\AnimeClient\API\Kitsu\Transformer\AnimeHistoryTransformer; -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 AnimeTrait { - - /** - * @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->requestBuilder->runQuery('AnimeDetails', [ - 'slug' => $slug - ]); - - if (empty($baseData)) - { - return Anime::from([]); - } - - return $this->animeTransformer->transform($baseData); - } - - /** - * Get information about a particular anime - * - * @param string $animeId - * @return Anime - */ - public function getAnimeById(string $animeId): Anime - { - $baseData = $this->requestBuilder->runQuery('AnimeDetailsById', [ - 'id' => $animeId, - ]); - 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(); - - $list = (new AnimeHistoryTransformer())->transform($raw); - - $this->cache->set($key, $list); - - } - - return $list; - } - - /** - * Get the anime list for the configured user - * - * @param string $status - The watching status to filter the list with - * @return array - * @throws InvalidArgumentException - */ - public function getAnimeList(string $status): array - { - $key = "kitsu-anime-list-{$status}"; - - $list = $this->cache->get($key, NULL); - - if ($list === NULL) - { - $data = $this->getRawList(ListType::ANIME, $status) ?? []; - - // Bail out on no data - if (empty($data)) - { - return []; - } - - $transformer = new AnimeListTransformer(); - $transformed = $transformer->transformCollection($data); - $keyed = []; - - foreach($transformed as $item) - { - $keyed[$item['id']] = $item; - } - - $list = $keyed; - $this->cache->set($key, $list); - } - - return $list; - } - - /** - * Get the 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 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); - } -} \ No newline at end of file diff --git a/src/AnimeClient/API/Kitsu/MangaTrait.php b/src/AnimeClient/API/Kitsu/MangaTrait.php deleted file mode 100644 index 53c5d8e3..00000000 --- a/src/AnimeClient/API/Kitsu/MangaTrait.php +++ /dev/null @@ -1,239 +0,0 @@ - - * @copyright 2015 - 2020 Timothy J. Warren - * @license http://www.opensource.org/licenses/mit-license.html MIT License - * @version 5.1 - * @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient - */ - -namespace Aviat\AnimeClient\API\Kitsu; - -use Amp\Http\Client\Request; -use Aviat\AnimeClient\Kitsu as K; -use Aviat\AnimeClient\API\Enum\MangaReadingStatus\Kitsu as KitsuReadingStatus; -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 MangaTrait { - /** - * @var MangaTransformer - */ - protected MangaTransformer $mangaTransformer; - - // ------------------------------------------------------------------------- - // ! Manga-specific methods - // ------------------------------------------------------------------------- - - /** - * Get information about a particular manga - * - * @param string $slug - * @return MangaPage - */ - public function getManga(string $slug): MangaPage - { - $baseData = $this->requestBuilder->runQuery('MangaDetails', [ - 'slug' => $slug - ]); - - if (empty($baseData)) - { - return MangaPage::from([]); - } - - return $this->mangaTransformer->transform($baseData); - } - - /** - * Get information about a particular manga - * - * @param string $mangaId - * @return MangaPage - */ - public function getMangaById(string $mangaId): MangaPage - { - $baseData = $this->requestBuilder->runQuery('MangaDetailsById', [ - 'id' => $mangaId, - ]); - 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(); - $list = (new MangaHistoryTransformer())->transform($raw); - - $this->cache->set($key, $list); - } - - return $list; - } - - /** - * Get the manga list for the configured user - * - * @param string $status - The reading status by which to filter the list - * @return array - * @throws InvalidArgumentException - */ - public function getMangaList(string $status): array - { - $key = "kitsu-manga-list-{$status}"; - - $list = $this->cache->get($key, NULL); - - if ($list === NULL) - { - $data = $this->getRawList(ListType::MANGA, $status) ?? []; - - // Bail out on no data - if (empty($data)) - { - return []; - } - - $transformer = new MangaListTransformer(); - $transformed = $transformer->transformCollection($data); - $keyed = []; - - foreach($transformed as $item) - { - $keyed[$item['id']] = $item; - } - - $list = $keyed; - $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]); - } -} \ No newline at end of file diff --git a/src/AnimeClient/API/Kitsu/Model.php b/src/AnimeClient/API/Kitsu/Model.php index 0c9aa6d9..4a05e484 100644 --- a/src/AnimeClient/API/Kitsu/Model.php +++ b/src/AnimeClient/API/Kitsu/Model.php @@ -16,26 +16,35 @@ namespace Aviat\AnimeClient\API\Kitsu; -use function Amp\Promise\wait; -use function Aviat\AnimeClient\getApiClient; - use Amp; -use Amp\Http\Client\Request; -use Aviat\AnimeClient\Kitsu as K; use Aviat\AnimeClient\API\{ CacheTrait, - ParallelAPIRequest + Enum\AnimeWatchingStatus\Kitsu as KitsuWatchingStatus, + Enum\MangaReadingStatus\Kitsu as KitsuReadingStatus, + Mapping\AnimeWatchingStatus, + Mapping\MangaReadingStatus }; use Aviat\AnimeClient\API\Kitsu\Transformer\{ + AnimeHistoryTransformer, + AnimeListTransformer, AnimeTransformer, LibraryEntryTransformer, - MangaTransformer, + MangaHistoryTransformer, + MangaListTransformer, + MangaTransformer }; - +use Aviat\AnimeClient\Enum\ListType; +use Aviat\AnimeClient\Kitsu as K; +use Aviat\AnimeClient\Types\Anime; +use Aviat\AnimeClient\Types\MangaPage; use Aviat\Banker\Exception\InvalidArgumentException; -use Aviat\Ion\{Di\ContainerAware, Json}; - +use Aviat\Ion\{ + Di\ContainerAware, + Json +}; use Throwable; +use function Amp\Promise\wait; +use function Aviat\AnimeClient\getApiClient; /** * Kitsu API Model @@ -44,12 +53,20 @@ final class Model { use CacheTrait; use ContainerAware; use RequestBuilderTrait; - use AnimeTrait; - use MangaTrait; use MutationTrait; protected const LIST_PAGE_SIZE = 100; + /** + * @var AnimeTransformer + */ + protected AnimeTransformer $animeTransformer; + + /** + * @var MangaTransformer + */ + protected MangaTransformer $mangaTransformer; + /** * @var ListItem */ @@ -220,6 +237,278 @@ final class Model { ]); } + // ------------------------------------------------------------------------- + // ! Anime-specific methods + // ------------------------------------------------------------------------- + + /** + * Get information about a particular anime + * + * @param string $slug + * @return Anime + */ + public function getAnime(string $slug): Anime + { + $baseData = $this->requestBuilder->runQuery('AnimeDetails', [ + 'slug' => $slug + ]); + + if (empty($baseData)) + { + return Anime::from([]); + } + + return $this->animeTransformer->transform($baseData); + } + + /** + * Get information about a particular anime + * + * @param string $animeId + * @return Anime + */ + public function getAnimeById(string $animeId): Anime + { + $baseData = $this->requestBuilder->runQuery('AnimeDetailsById', [ + 'id' => $animeId, + ]); + 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->getHistoryList(); + + $list = (new AnimeHistoryTransformer())->transform($raw); + + $this->cache->set($key, $list); + + } + + return $list; + } + + /** + * Get the anime list for the configured user + * + * @param string $status - The watching status to filter the list with + * @return array + * @throws InvalidArgumentException + */ + public function getAnimeList(string $status): array + { + $key = "kitsu-anime-list-{$status}"; + + $list = $this->cache->get($key, NULL); + + if ($list === NULL) + { + $data = $this->getList(ListType::ANIME, $status) ?? []; + + // Bail out on no data + if (empty($data)) + { + return []; + } + + $transformer = new AnimeListTransformer(); + $transformed = $transformer->transformCollection($data); + $keyed = []; + + foreach($transformed as $item) + { + $keyed[$item['id']] = $item; + } + + $list = $keyed; + $this->cache->set($key, $list); + } + + return $list; + } + + /** + * Get the 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 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; + } + + // ------------------------------------------------------------------------- + // ! Manga-specific methods + // ------------------------------------------------------------------------- + + /** + * Get information about a particular manga + * + * @param string $slug + * @return MangaPage + */ + public function getManga(string $slug): MangaPage + { + $baseData = $this->requestBuilder->runQuery('MangaDetails', [ + 'slug' => $slug + ]); + + if (empty($baseData)) + { + return MangaPage::from([]); + } + + return $this->mangaTransformer->transform($baseData); + } + + /** + * Get information about a particular manga + * + * @param string $mangaId + * @return MangaPage + */ + public function getMangaById(string $mangaId): MangaPage + { + $baseData = $this->requestBuilder->runQuery('MangaDetailsById', [ + 'id' => $mangaId, + ]); + 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->getHistoryList(); + $list = (new MangaHistoryTransformer())->transform($raw); + + $this->cache->set($key, $list); + } + + return $list; + } + + /** + * Get the manga list for the configured user + * + * @param string $status - The reading status by which to filter the list + * @return array + * @throws InvalidArgumentException + */ + public function getMangaList(string $status): array + { + $key = "kitsu-manga-list-{$status}"; + + $list = $this->cache->get($key, NULL); + + if ($list === NULL) + { + $data = $this->getList(ListType::MANGA, $status) ?? []; + + // Bail out on no data + if (empty($data)) + { + return []; + } + + $transformer = new MangaListTransformer(); + $transformed = $transformer->transformCollection($data); + $keyed = []; + + foreach($transformed as $item) + { + $keyed[$item['id']] = $item; + } + + $list = $keyed; + $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 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; + } + + // ------------------------------------------------------------------------ + // Base methods + // ------------------------------------------------------------------------ + /** * Search for an anime or manga * @@ -300,6 +589,31 @@ final class Model { return (new LibraryEntryTransformer())->transform($baseData['data']['findLibraryEntryById']); } + public function getThumbList(string $type): array + { + $statuses = [ + 'CURRENT', + 'PLANNED', + 'ON_HOLD', + 'DROPPED', + 'COMPLETED', + ]; + + $pages = []; + + // Although I can fetch the whole list without segregating by status, + // this way is much faster... + foreach ($statuses as $status) + { + foreach ($this->getPages([$this, 'getThumbListPages'], strtoupper($type), $status) as $page) + { + $pages[] = $page; + } + } + + return array_merge(...$pages); + } + /** * Get the data to sync Kitsu anime/manga list with another API * @@ -324,7 +638,7 @@ final class Model { // this way is much faster... foreach ($statuses as $status) { - foreach ($this->getRawSyncListPages(strtoupper($type), $status) as $page) + foreach ($this->getPages([$this, 'getSyncPages'], strtoupper($type), $status) as $page) { $pages[] = $page; } @@ -338,7 +652,7 @@ final class Model { * * @return array */ - protected function getRawHistoryList(): array + protected function getHistoryList(): array { return $this->requestBuilder->runQuery('GetUserHistory', [ 'slug' => $this->getUsername(), @@ -352,11 +666,11 @@ final class Model { * @param string $status * @return array */ - public function getRawList(string $type, string $status = ''): array + protected function getList(string $type, string $status = ''): array { $pages = []; - foreach ($this->getRawListPages(strtoupper($type), strtoupper($status)) as $page) + foreach ($this->getPages([$this, 'getListPages'], strtoupper($type), strtoupper($status)) as $page) { $pages[] = $page; } @@ -364,17 +678,7 @@ final class Model { return array_merge(...$pages); } - protected function getRawListPages(string $type, string $status = ''): ?\Generator - { - $items = $this->getRawPages($type, $status); - - while (wait($items->advance())) - { - yield $items->getCurrent(); - } - } - - protected function getRawPages(string $type, string $status = ''): Amp\Iterator + private function getListPages(string $type, string $status = ''): Amp\Iterator { $cursor = ''; $username = $this->getUsername(); @@ -423,17 +727,7 @@ final class Model { }); } - protected function getRawSyncListPages(string $type, string $status): ?\Generator - { - $items = $this->getRawSyncPages($type, $status); - - while (wait($items->advance())) - { - yield $items->getCurrent(); - } - } - - protected function getRawSyncPages(string $type, $status): Amp\Iterator { + private function getSyncPages(string $type, string $status): Amp\Iterator { $cursor = ''; $username = $this->getUsername(); @@ -475,6 +769,59 @@ final class Model { }); } + private function getThumbListPages(string $type, string $status): Amp\Iterator + { + $cursor = ''; + $username = $this->getUsername(); + + return new Amp\Producer(function (callable $emit) use ($type, $status, $cursor, $username) { + while (TRUE) + { + $vars = [ + 'type' => $type, + 'slug' => $username, + 'status' => $status, + ]; + if ($cursor !== '') + { + $vars['after'] = $cursor; + } + + $request = $this->requestBuilder->queryRequest('GetLibraryThumbs', $vars); + $response = yield getApiClient()->request($request); + $json = yield $response->getBody()->buffer(); + + $rawData = Json::decode($json); + $data = $rawData['data']['findProfileBySlug']['library']['all'] ?? []; + $page = $data['pageInfo']; + if (empty($data)) + { + dump($rawData); + die(); + } + + $cursor = $page['endCursor']; + + yield $emit($data['nodes']); + + if ($page['hasNextPage'] === FALSE) + { + break; + } + } + }); + } + + private function getPages(callable $method, ...$args): ?\Generator + { + $items = $method(...$args); + + while (wait($items->advance())) + { + yield $items->getCurrent(); + } + } + private function getUserId(): string { static $userId = NULL; diff --git a/src/AnimeClient/API/Kitsu/Queries/GetLibraryThumbs.graphql b/src/AnimeClient/API/Kitsu/Queries/GetLibraryThumbs.graphql new file mode 100644 index 00000000..d8ab3182 --- /dev/null +++ b/src/AnimeClient/API/Kitsu/Queries/GetLibraryThumbs.graphql @@ -0,0 +1,25 @@ +query ( + $slug: String!, + $type: MediaTypeEnum!, + $status: [LibraryEntryStatusEnum!], + $after: String +) { + findProfileBySlug(slug: $slug) { + library { + all(first: 100, after: $after, mediaType: $type, status: $status) { + pageInfo { + endCursor + hasNextPage + hasPreviousPage + startCursor + } + totalCount + nodes { + media { + id + } + } + } + } + } +} \ No newline at end of file diff --git a/src/AnimeClient/Command/UpdateThumbnails.php b/src/AnimeClient/Command/UpdateThumbnails.php index c0e47509..e7d39be6 100644 --- a/src/AnimeClient/Command/UpdateThumbnails.php +++ b/src/AnimeClient/Command/UpdateThumbnails.php @@ -16,7 +16,6 @@ namespace Aviat\AnimeClient\Command; -use Aviat\AnimeClient\API\JsonAPI; use Aviat\AnimeClient\API\Kitsu\Model as KitsuModel; use Aviat\AnimeClient\Controller\Images; @@ -44,7 +43,7 @@ final class UpdateThumbnails extends ClearThumbnails { $this->controller = new Images($this->container); $this->kitsuModel = $this->container->get('kitsu-model'); - // Clear the existing thunbnails + // Clear the existing thumbnails parent::execute($args, $options); $ids = $this->getImageList(); @@ -69,13 +68,14 @@ final class UpdateThumbnails extends ClearThumbnails { */ public function getImageList(): array { - $mangaList = $this->kitsuModel->getFullRawMangaList(); - $includes = JsonAPI::organizeIncludes($mangaList['included']); - $mangaIds = array_keys($includes['manga']); - - $animeList = $this->kitsuModel->getFullRawAnimeList(); - $includes = JsonAPI::organizeIncludes($animeList['included']); - $animeIds = array_keys($includes['anime']); + $animeIds = array_map( + fn ($item) => $item['media']['id'], + $this->kitsuModel->getThumbList('ANIME') + ); + $mangaIds = array_map( + fn ($item) => $item['media']['id'], + $this->kitsuModel->getThumbList('MANGA') + ); return [ 'anime' => $animeIds,