Vastly simplify logic for getting a user's anime library. Most basic API functionality seems to be working

This commit is contained in:
Timothy Warren 2023-10-26 16:00:13 -04:00
parent 1ad4427584
commit 0e684736bd
10 changed files with 178 additions and 303 deletions

View File

@ -18,9 +18,9 @@ use const Aviat\AnimeClient\{
ALPHA_SLUG_PATTERN, ALPHA_SLUG_PATTERN,
DEFAULT_CONTROLLER, DEFAULT_CONTROLLER,
DEFAULT_CONTROLLER_METHOD, DEFAULT_CONTROLLER_METHOD,
KITSU_SLUG_PATTERN,
NUM_PATTERN, NUM_PATTERN,
SLUG_PATTERN, SLUG_PATTERN,
SLUG_SPACE_PATTERN,
}; };
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -28,7 +28,7 @@ use const Aviat\AnimeClient\{
// //
// Maps paths to controllers and methods // Maps paths to controllers and methods
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
$routes = [ $base_routes = [
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// AJAX Routes // AJAX Routes
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -60,7 +60,7 @@ $routes = [
'path' => '/anime/details/{id}', 'path' => '/anime/details/{id}',
'action' => 'details', 'action' => 'details',
'tokens' => [ 'tokens' => [
'id' => SLUG_PATTERN, 'id' => KITSU_SLUG_PATTERN,
], ],
], ],
'anime.delete' => [ 'anime.delete' => [
@ -97,7 +97,7 @@ $routes = [
'path' => '/manga/details/{id}', 'path' => '/manga/details/{id}',
'action' => 'details', 'action' => 'details',
'tokens' => [ 'tokens' => [
'id' => SLUG_PATTERN, 'id' => KITSU_SLUG_PATTERN,
], ],
], ],
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
@ -191,13 +191,13 @@ $routes = [
'character' => [ 'character' => [
'path' => '/character/{slug}', 'path' => '/character/{slug}',
'tokens' => [ 'tokens' => [
'slug' => SLUG_PATTERN, 'slug' => KITSU_SLUG_PATTERN,
], ],
], ],
'person' => [ 'person' => [
'path' => '/people/{slug}', 'path' => '/people/{slug}',
'tokens' => [ 'tokens' => [
'slug' => SLUG_PATTERN, 'slug' => KITSU_SLUG_PATTERN,
], ],
], ],
'default_user_info' => [ 'default_user_info' => [
@ -291,8 +291,8 @@ $routes = [
'path' => '/{controller}/edit/{id}/{status}', 'path' => '/{controller}/edit/{id}/{status}',
'action' => 'edit', 'action' => 'edit',
'tokens' => [ 'tokens' => [
'id' => SLUG_PATTERN, 'id' => KITSU_SLUG_PATTERN,
'status' => SLUG_SPACE_PATTERN, 'status' => SLUG_PATTERN,
], ],
], ],
'list' => [ 'list' => [
@ -315,15 +315,10 @@ $defaultMap = [
'verb' => 'get', 'verb' => 'get',
]; ];
foreach ($routes as &$route) $routes = [];
foreach ($base_routes as $name => $route)
{ {
foreach ($defaultMap as $key => $val) $routes[$name] = array_merge($defaultMap, $route);
{
if ( ! array_key_exists($key, $route))
{
$route[$key] = $val;
}
}
} }
return $routes; return $routes;

View File

@ -32,7 +32,7 @@
"require": { "require": {
"amphp/http-client": "^v5.0.0", "amphp/http-client": "^v5.0.0",
"aura/html": "^2.5.0", "aura/html": "^2.5.0",
"aura/router": "3.2.0", "aura/router": "^3.3.0",
"aura/session": "^2.1.0", "aura/session": "^2.1.0",
"aviat/banker": "^4.1.2", "aviat/banker": "^4.1.2",
"aviat/query": "^4.1.0", "aviat/query": "^4.1.0",

View File

@ -24,7 +24,6 @@ use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
use Aviat\Ion\Json; use Aviat\Ion\Json;
use InvalidArgumentException; use InvalidArgumentException;
use Throwable; use Throwable;
use function Amp\Promise\wait;
/** /**
* Anilist API Model * Anilist API Model
@ -67,7 +66,7 @@ final class Model
$response = $this->requestBuilder->getResponseFromRequest($request); $response = $this->requestBuilder->getResponseFromRequest($request);
return Json::decode(wait($response->getBody()->buffer())); return Json::decode($response->getBody()->buffer());
} }
/** /**

View File

@ -22,8 +22,6 @@ use Aviat\Ion\{Json, JsonException};
use LogicException; use LogicException;
use Throwable; use Throwable;
use function Amp\Promise\wait;
use function Aviat\AnimeClient\getResponse; use function Aviat\AnimeClient\getResponse;
use function in_array; use function in_array;
use const Aviat\AnimeClient\USER_AGENT; use const Aviat\AnimeClient\USER_AGENT;
@ -170,7 +168,7 @@ final class RequestBuilder extends APIRequestBuilder
$request = $this->mutateRequest($name, $variables); $request = $this->mutateRequest($name, $variables);
$response = $this->getResponseFromRequest($request); $response = $this->getResponseFromRequest($request);
return Json::decode(wait($response->getBody()->buffer())); return Json::decode($response->getBody()->buffer());
} }
/** /**
@ -246,7 +244,7 @@ final class RequestBuilder extends APIRequestBuilder
$logger?->warning('Non 200 response for POST api call', (array) $response->getBody()); $logger?->warning('Non 200 response for POST api call', (array) $response->getBody());
} }
$rawBody = wait($response->getBody()->buffer()); $rawBody = $response->getBody()->buffer();
try try
{ {

View File

@ -0,0 +1,30 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 8.1
*
* @copyright 2015 - 2023 Timothy J. Warren <tim@timshome.page>
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5.2
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Kitsu\Enum;
use Aviat\Ion\Enum as BaseEnum;
/**
* Status of when anime is being/was/will be aired
*/
final class MediaStatus extends BaseEnum
{
public const CURRENT = 'CURRENT';
public const PLANNED = 'PLANNED';
public const ON_HOLD = 'ON_HOLD';
public const DROPPED = 'DROPPED';
public const COMPLETED = 'COMPLETED';
}
// End of MediaStatus

View File

@ -14,8 +14,6 @@
namespace Aviat\AnimeClient\API\Kitsu; namespace Aviat\AnimeClient\API\Kitsu;
use Amp;
use Amp\Future;
use Aviat\AnimeClient\API\Kitsu\Transformer\{ use Aviat\AnimeClient\API\Kitsu\Transformer\{
AnimeHistoryTransformer, AnimeHistoryTransformer,
AnimeListTransformer, AnimeListTransformer,
@ -29,6 +27,7 @@ use Aviat\AnimeClient\API\{
CacheTrait, CacheTrait,
Enum\AnimeWatchingStatus\Kitsu as KitsuWatchingStatus, Enum\AnimeWatchingStatus\Kitsu as KitsuWatchingStatus,
Enum\MangaReadingStatus\Kitsu as KitsuReadingStatus, Enum\MangaReadingStatus\Kitsu as KitsuReadingStatus,
Kitsu\Enum\MediaStatus,
Mapping\AnimeWatchingStatus, Mapping\AnimeWatchingStatus,
Mapping\MangaReadingStatus Mapping\MangaReadingStatus
}; };
@ -553,27 +552,7 @@ final class Model
*/ */
public function getThumbList(string $type): array public function getThumbList(string $type): array
{ {
$statuses = [ return $this->getZippedListPerStatus('GetLibraryThumbs', $type);
'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);
} }
/** /**
@ -583,27 +562,7 @@ final class Model
*/ */
public function getSyncList(string $type): array public function getSyncList(string $type): array
{ {
$statuses = [ return $this->getZippedListPerStatus('GetSyncLibrary', $type);
'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->getSyncPages(...), strtoupper($type), $status) as $page)
{
$pages[] = $page;
}
}
return array_merge(...$pages);
} }
/** /**
@ -619,15 +578,39 @@ final class Model
} }
/** /**
* Get the raw anime/manga list from GraphQL * Get all the raw data for the current list, chunking by status
* *
* @return mixed[] * @param string $queryName - The GraphQL query
* @param string $type - Media type (anime, manga)
* @return array
*/ */
protected function getList(string $type, string $status = ''): array protected function getZippedListPerStatus(string $queryName, string $type): array
{
$statusPages = [];
// Although I can fetch the whole list without segregating by status,
// this way is much faster...
foreach (MediaStatus::getConstList() as $status)
{
$statusPages[] = $this->getZippedList($queryName, $type, $status);
}
return array_merge(...$statusPages);
}
/**
* Get all the raw data for the current list
*
* @param string $queryName - The GraphQL query
* @param string $type - Media type (anime, manga)
* @param string $status - Media 'consumption' status
* @return array
*/
protected function getZippedList(string $queryName, string $type, string $status): array
{ {
$pages = []; $pages = [];
foreach ($this->getPages($this->getListPages(...), strtoupper($type), strtoupper($status)) as $page) foreach ($this->getListPages($queryName, $type, $status) as $page)
{ {
$pages[] = $page; $pages[] = $page;
} }
@ -635,158 +618,92 @@ final class Model
return array_merge(...$pages); return array_merge(...$pages);
} }
private function getListPages(string $type, string $status = ''): Amp\Iterator /**
* Get the raw anime/manga list from GraphQL
*
* @return mixed[]
*/
protected function getList(string $type, string $status = ''): array
{
return $this->getZippedList('GetLibrary', $type, $status);
}
/**
* A generator returning the relevant snippet for each 'page' of
* a media list request
*
* @param string $queryName - The GraphQL query
* @param string $type - Media type (anime, manga)
* @param string $status - Media 'consumption' status
* @return iterable
*/
private function getListPages(string $queryName, string $type, string $status): iterable
{ {
$cursor = ''; $cursor = '';
$username = $this->getUsername(); $username = $this->getUsername();
return new Amp\Producer(function (callable $emit) use ($type, $status, $cursor, $username): Generator { while (TRUE)
while (TRUE)
{
$vars = [
'type' => $type,
'slug' => $username,
];
if ($status !== '')
{
$vars['status'] = $status;
}
if ($cursor !== '')
{
$vars['after'] = $cursor;
}
$request = $this->requestBuilder->queryRequest('GetLibrary', $vars);
$response = yield getApiClient()->request($request);
$json = yield $response->getBody()->buffer();
$rawData = Json::decode($json);
$data = $rawData['data']['findProfileBySlug']['library']['all'] ?? [];
$page = $data['pageInfo'] ?? [];
if (empty($data))
{
// Clear session, in case the error is an invalid token.
$segment = $this->container->get('session')
->getSegment(SESSION_SEGMENT);
$segment->clear();
// @TODO Proper Error logging
dump($rawData);
exit();
}
$cursor = $page['endCursor'];
yield $emit($data['nodes']);
if ($page['hasNextPage'] !== TRUE)
{
break;
}
}
});
}
private function getSyncPages(string $type, string $status): Amp\Iterator
{
$cursor = '';
$username = $this->getUsername();
return new Amp\Producer(function (callable $emit) use ($type, $status, $cursor, $username): Generator {
while (TRUE)
{
$vars = [
'type' => $type,
'slug' => $username,
'status' => $status,
];
if ($cursor !== '')
{
$vars['after'] = $cursor;
}
$request = $this->requestBuilder->queryRequest('GetSyncLibrary', $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);
exit();
}
$cursor = $page['endCursor'];
yield $emit($data['nodes']);
if ($page['hasNextPage'] === FALSE)
{
break;
}
}
});
}
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): Generator {
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);
exit();
}
$cursor = $page['endCursor'];
yield $emit($data['nodes']);
if ($page['hasNextPage'] === FALSE)
{
break;
}
}
});
}
private function getPages(callable $method, mixed ...$args): Generator
{
$items = $method(...$args);
while (wait($items->advance()))
{ {
yield $items->getCurrent(); $vars = [
'type' => strtoupper($type),
'slug' => $username,
];
if ($status !== '')
{
$vars['status'] = strtoupper($status);
}
if ($cursor !== '')
{
$vars['after'] = $cursor;
}
$request = $this->requestBuilder->queryRequest($queryName, $vars);
$response = getApiClient()->request($request);
$json = $response->getBody()->buffer();
$rawData = Json::decode($json);
$data = $rawData['data']['findProfileBySlug']['library']['all'] ?? [];
$page = $data['pageInfo'] ?? [];
if (empty($data))
{
// Clear session, in case the error is an invalid token.
$segment = $this->container->get('session')
->getSegment(SESSION_SEGMENT);
$segment->clear();
// @TODO Proper Error logging
dump($rawData);
exit();
}
$cursor = $page['endCursor'];
yield $data['nodes'];
if ($page['hasNextPage'] === FALSE || $page === [])
{
break;
}
} }
} }
private function getListCount(string $type, string $status = ''): int
{
$args = [
'type' => strtoupper($type),
'slug' => $this->getUsername(),
];
if ($status !== '')
{
$args['status'] = strtoupper($status);
}
$res = $this->requestBuilder->runQuery('GetLibraryCount', $args);
return $res['data']['findProfileBySlug']['library']['all']['totalCount'];
}
protected function getUserId(): string protected function getUserId(): string
{ {
static $userId = NULL; static $userId = NULL;
@ -808,20 +725,4 @@ final class Model
->get('config') ->get('config')
->get(['kitsu_username']); ->get(['kitsu_username']);
} }
private function getListCount(string $type, string $status = ''): int
{
$args = [
'type' => strtoupper($type),
'slug' => $this->getUsername(),
];
if ($status !== '')
{
$args['status'] = strtoupper($status);
}
$res = $this->requestBuilder->runQuery('GetLibraryCount', $args);
return $res['data']['findProfileBySlug']['library']['all']['totalCount'];
}
} }

View File

@ -21,7 +21,6 @@ use Aviat\Ion\Di\{ContainerAware, ContainerInterface};
use Aviat\Ion\{Event, Json, JsonException}; use Aviat\Ion\{Event, Json, JsonException};
use LogicException; use LogicException;
use function Amp\Promise\wait;
use function Aviat\AnimeClient\getResponse; use function Aviat\AnimeClient\getResponse;
use function in_array; use function in_array;
use const Aviat\AnimeClient\{SESSION_SEGMENT, USER_AGENT}; use const Aviat\AnimeClient\{SESSION_SEGMENT, USER_AGENT};
@ -125,13 +124,10 @@ final class RequestBuilder extends APIRequestBuilder
if ( ! in_array($response->getStatus(), $validResponseCodes, TRUE)) if ( ! in_array($response->getStatus(), $validResponseCodes, TRUE))
{ {
$logger = $this->container->getLogger('kitsu-graphql'); $logger = $this->container->getLogger('kitsu-graphql');
if ($logger !== NULL) $logger?->warning('Non 200 response for GraphQL call', (array)$response->getBody());
{
$logger->warning('Non 200 response for GraphQL call', (array) $response->getBody());
}
} }
return Json::decode(wait($response->getBody()->buffer())); return Json::decode($response->getBody()->buffer());
} }
/** /**
@ -148,13 +144,10 @@ final class RequestBuilder extends APIRequestBuilder
if ( ! in_array($response->getStatus(), $validResponseCodes, TRUE)) if ( ! in_array($response->getStatus(), $validResponseCodes, TRUE))
{ {
$logger = $this->container->getLogger('kitsu-graphql'); $logger = $this->container->getLogger('kitsu-graphql');
if ($logger !== NULL) $logger?->warning('Non 200 response for GraphQL call', (array)$response->getBody());
{
$logger->warning('Non 200 response for GraphQL call', (array) $response->getBody());
}
} }
return Json::decode(wait($response->getBody()->buffer())); return Json::decode($response->getBody()->buffer());
} }
/** /**

View File

@ -14,13 +14,11 @@
namespace Aviat\AnimeClient\API; namespace Aviat\AnimeClient\API;
use Amp\Http\Client\{HttpException, Request}; use Amp\Future;
use Generator; use Amp\Http\Client\{Request, Response};
use Throwable; use Throwable;
use function Amp\call;
// use function Amp\Future\{async, await}; use function Amp\async;
use function Amp\Promise\{all, wait};
use function Aviat\AnimeClient\getApiClient; use function Aviat\AnimeClient\getApiClient;
/** /**
@ -69,7 +67,14 @@ final class ParallelAPIRequest
*/ */
public function makeRequests(): array public function makeRequests(): array
{ {
return $this->makeRequestOld(); $futures = [];
foreach ($this->requests as $key => $url)
{
$futures[$key] = async(static fn () => self::bodyHandler($url));
}
return Future\await($futures);
} }
/** /**
@ -78,54 +83,6 @@ final class ParallelAPIRequest
* @throws Throwable * @throws Throwable
*/ */
public function getResponses(): array public function getResponses(): array
{
return $this->getResponsesOld();
}
private function makeRequestOld(): array
{
$client = getApiClient();
$promises = [];
foreach ($this->requests as $key => $url)
{
$promises[$key] = call(static function () use ($client, $url): Generator {
$response = yield $client->request($url);
return yield $response->getBody()->buffer();
});
}
return wait(all($promises));
}
private function makeRequestsNew(): array
{
$futures = [];
foreach ($this->requests as $key => $url)
{
$futures[$key] = async(static fn () => self::bodyHandler($url));
}
return await($futures);
}
private function getResponsesOld(): array
{
$client = getApiClient();
$promises = [];
foreach ($this->requests as $key => $url)
{
$promises[$key] = call(static fn () => yield $client->request($url));
}
return wait(all($promises));
}
private function getResponsesNew(): array
{ {
$futures = []; $futures = [];
@ -134,7 +91,7 @@ final class ParallelAPIRequest
$futures[$key] = async(static fn () => self::responseHandler($url)); $futures[$key] = async(static fn () => self::responseHandler($url));
} }
return await($futures); return Future\await($futures);
} }
private static function bodyHandler(string|Request $uri): string private static function bodyHandler(string|Request $uri): string
@ -150,7 +107,7 @@ final class ParallelAPIRequest
return $response->getBody()->buffer(); return $response->getBody()->buffer();
} }
private static function responseHandler(string|Request $uri) private static function responseHandler(string|Request $uri): Response
{ {
$client = getApiClient(); $client = getApiClient();

View File

@ -17,9 +17,7 @@ namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Controller as BaseController; use Aviat\AnimeClient\Controller as BaseController;
use Aviat\Ion\Attribute\{Controller, Route}; use Aviat\Ion\Attribute\{Controller, Route};
use Throwable; use Throwable;
use function Amp\Promise\wait;
use function Aviat\AnimeClient\{createPlaceholderImage, getResponse}; use function Aviat\AnimeClient\{createPlaceholderImage, getResponse};
use function imagepalletetotruecolor;
use function in_array; use function in_array;
@ -130,7 +128,7 @@ final class Images extends BaseController
return; return;
} }
$data = wait($response->getBody()->buffer()); $data = $response->getBody()->buffer();
$size = getimagesizefromstring($data); $size = getimagesizefromstring($data);
if ($size === FALSE) if ($size === FALSE)

View File

@ -27,8 +27,12 @@ const USER_AGENT = "Tim's Anime Client/5.2";
// Regex patterns // Regex patterns
const ALPHA_SLUG_PATTERN = '[a-zA-Z_]+'; const ALPHA_SLUG_PATTERN = '[a-zA-Z_]+';
const NUM_PATTERN = '[0-9]+'; const NUM_PATTERN = '[0-9]+';
const SLUG_PATTERN = '[a-zA-Z0-9\-]+'; /**
const SLUG_SPACE_PATTERN = '[a-zA-Z_\- ]+'; * Eugh...url slugs can have weird characters
* So...if it's not a forward slash, sure it's valid 😅
*/
const KITSU_SLUG_PATTERN = '[^\/]+';
const SLUG_PATTERN = '[a-zA-Z0-9\- ]+';
// Why doesn't this already exist? // Why doesn't this already exist?
const MILLI_FROM_NANO = 1000 * 1000; const MILLI_FROM_NANO = 1000 * 1000;