Merge remote-tracking branch 'origin/develop'
Some checks failed
timw4mail/HummingBirdAnimeClient/pipeline/head There was a failure building this commit

This commit is contained in:
Timothy Warren 2020-05-19 13:19:20 -04:00
commit 9297ff4b94
53 changed files with 1543 additions and 916 deletions

2
.gitignore vendored
View File

@ -147,3 +147,5 @@ public/images/characters/**
public/images/people/**
public/mal_mappings.json
.phpunit.result.cache
.is-dev

View File

@ -25,10 +25,11 @@ use Aviat\AnimeClient\API\{
Kitsu\KitsuRequestBuilder
};
use Aviat\AnimeClient\Model;
use Aviat\Banker\Pool;
use Aviat\Banker\Teller;
use Aviat\Ion\Config;
use Aviat\Ion\Di\Container;
use Aviat\Ion\Di\ContainerInterface;
use Psr\SimpleCache\CacheInterface;
use Laminas\Diactoros\{Response, ServerRequestFactory};
use Monolog\Handler\RotatingFileHandler;
use Monolog\Logger;
@ -64,10 +65,10 @@ return static function (array $configArray = []): Container {
$container->set('config', fn () => new Config($configArray));
// Create Cache Object
$container->set('cache', static function(ContainerInterface $container): Pool {
$container->set('cache', static function(ContainerInterface $container): CacheInterface {
$logger = $container->getLogger();
$config = $container->get('config')->get('cache');
return new Pool($config, $logger);
return new Teller($config, $logger);
});
// Create Aura Router Object
@ -113,7 +114,7 @@ return static function (array $configArray = []): Container {
// Models
$container->set('kitsu-model', static function(ContainerInterface $container): Kitsu\Model {
$requestBuilder = new KitsuRequestBuilder();
$requestBuilder = new KitsuRequestBuilder($container);
$requestBuilder->setLogger($container->getLogger('kitsu-request'));
$listItem = new Kitsu\ListItem();

View File

@ -4,7 +4,7 @@
# See https://git.timshomepage.net/aviat/banker for more information
# Available drivers are apcu, memcache, memcached, redis or null
# Available drivers are memcached, redis or null
# Null cache driver means no caching
driver = "redis"

View File

@ -30,7 +30,15 @@
<?php if ($item['rewatched'] > 0): ?>
<div class="row">
<div>Rewatched <?= $item['rewatched'] ?> time(s)</div>
<?php if ($item['rewatched'] == 1): ?>
<div>Rewatched once</div>
<?php elseif ($item['rewatched'] == 2): ?>
<div>Rewatched twice</div>
<?php elseif ($item['rewatched'] == 3): ?>
<div>Rewatched thrice</div>
<?php else: ?>
<div>Rewatched <?= $item['rewatched'] ?> times</div>
<?php endif ?>
</div>
<?php endif ?>

View File

@ -45,7 +45,7 @@
</aside>
<article class="text">
<h2 class="toph"><a rel="external" href="<?= $data['url'] ?>"><?= $data['title'] ?></a></h2>
<?php foreach ($data['titles'] as $title): ?>
<?php foreach ($data['titles_more'] as $title): ?>
<h3><?= $title ?></h3>
<?php endforeach ?>
<br />

View File

@ -86,7 +86,17 @@
<br />
<ul>
<?php if ($item['rewatched'] > 0): ?>li>Rewatched <?= $item['rewatched'] ?> time(s)</li><?php endif ?>
<?php if ($item['rewatched'] > 0): ?>
<?php if ($item['rewatched'] == 1): ?>
<li>Rewatched once</li>
<?php elseif ($item['rewatched'] == 2): ?>
<li>Rewatched twice</li>
<?php elseif ($item['rewatched'] == 3): ?>
<li>Rewatched thrice</li>
<?php else: ?>
<li>Rewatched <?= $item['rewatched'] ?> times</li>
<?php endif ?>
<?php endif ?>
<?php foreach(['private','rewatching'] as $attr): ?>
<?php if($item[$attr]): ?><li><?= ucfirst($attr); ?></li><?php endif ?>
<?php endforeach ?>

View File

@ -35,7 +35,7 @@
<td><?= $item['episode_length'] ?></td>
<td><?= $item['show_type'] ?></td>
<td><?= $item['age_rating'] ?></td>
<td class="align-left"><?= $item['notes'] ?></td>
<td class="align-left"><?= nl2br($item['notes'], TRUE) ?></td>
<td class="align-left"><?= implode(', ', $item['genres']) ?></td>
</tr>
<?php endforeach ?>

View File

@ -11,10 +11,10 @@
</a>
<?= ! empty($item['alternate_title']) ? ' <br /><small> ' . $item['alternate_title'] . '</small>' : '' ?>
</td>
<td><?= $item['episode_count'] ?></td>
<td><?= ($item['episode_count'] > 1) ? $item['episode_count'] : '-' ?></td>
<td><?= $item['episode_length'] ?></td>
<td><?= $item['show_type'] ?></td>
<td><?= $item['age_rating'] ?></td>
<?php if ($hasNotes): ?><td class="align-left"><?= $item['notes'] ?></td><?php endif ?>
<?php if ($hasNotes): ?><td class="align-left"><?= nl2br($item['notes'], TRUE) ?></td><?php endif ?>
<td class="align-left"><?= implode(', ', $item['genres']) ?></td>
</tr>

View File

@ -14,7 +14,8 @@ $hasManga = stripos($_SERVER['REQUEST_URI'], 'manga') !== FALSE;
<?php if(strpos($route_path, 'collection') === FALSE): ?>
<?= $helper->a(
$urlGenerator->defaultUrl($url_type),
$whose . ucfirst($url_type) . ' List'
$whose . ucfirst($url_type) . ' List',
['aria-current'=> 'page']
) ?>
<?php if($config->get("show_{$url_type}_collection")): ?>
[<?= $helper->a(
@ -35,7 +36,8 @@ $hasManga = stripos($_SERVER['REQUEST_URI'], 'manga') !== FALSE;
<?php else: ?>
<?= $helper->a(
$url->generate("{$url_type}.collection.view") . $extraSegment,
$whose . ucfirst($url_type) . ' Collection'
$whose . ucfirst($url_type) . ' Collection',
['aria-current'=> 'page']
) ?>
<?php if($config->get("show_{$other_type}_collection")): ?>
[<?= $helper->a(
@ -79,15 +81,22 @@ $hasManga = stripos($_SERVER['REQUEST_URI'], 'manga') !== FALSE;
</span>
<?php endif ?>
</div>
<nav>
<?php if ($container->get('util')->isViewPage() && ($hasAnime || $hasManga)): ?>
<nav>
<?= $helper->menu($menu_name) ?>
<?php if (stripos($_SERVER['REQUEST_URI'], 'history') === FALSE): ?>
<br />
<ul>
<li class="<?= Util::isNotSelected('list', $lastSegment) ?>"><a href="<?= $urlGenerator->url($route_path) ?>">Cover View</a></li>
<li class="<?= Util::isSelected('list', $lastSegment) ?>"><a href="<?= $urlGenerator->url("{$route_path}/list") ?>">List View</a></li>
<?php $currentView = Util::eq('list', $lastSegment) ? 'list' : 'cover' ?>
<li class="<?= Util::isNotSelected('list', $lastSegment) ?>">
<a aria-current="<?= Util::ariaCurrent($currentView === 'cover') ?>"
href="<?= $urlGenerator->url($route_path) ?>">Cover View</a>
</li>
<li class="<?= Util::isSelected('list', $lastSegment) ?>">
<a aria-current="<?= Util::ariaCurrent($currentView === 'list') ?>"
href="<?= $urlGenerator->url("{$route_path}/list") ?>">List View</a>
</li>
</ul>
<?php endif ?>
<?php endif ?>
</nav>
<?php endif ?>

View File

@ -68,7 +68,15 @@
<?php if ($item['reread'] > 0): ?>
<div class="row">
<div>Reread <?= $item['reread'] ?> time(s)</div>
<?php if ($item['reread'] == 1): ?>
<div>Reread once</div>
<?php elseif ($item['reread'] == 2): ?>
<div>Reread twice</div>
<?php elseif ($item['reread'] == 3): ?>
<div>Reread thrice</div>
<?php else: ?>
<div>Reread <?= $item['reread'] ?> times</div>
<?php endif ?>
</div>
<?php endif ?>

View File

@ -53,8 +53,14 @@
<td><?= $item['volumes']['total'] ?></td>
<td>
<ul>
<?php if ($item['reread'] > 0): ?>
<li>Reread <?= $item['reread'] ?> time(s)</li>
<?php if ($item['reread'] == 1): ?>
<li>Reread once</li>
<?php elseif ($item['reread'] == 2): ?>
<li>Reread twice</li>
<?php elseif ($item['reread'] == 3): ?>
<li>Reread thrice</li>
<?php elseif ($item['reread'] > 3): ?>
<li>Reread <?= $item['reread'] ?> times</li>
<?php endif ?>
<?php foreach(['rereading'] as $attr): ?>
<?php if($item[$attr]): ?>

View File

@ -0,0 +1,5 @@
<article>
<label for="<?= $fieldName ?>"><?= $field['title'] ?></label><br />
<small><?= $field['description'] ?></small><br />
<?= $helper->field($fieldName, $field); ?>
</article>

View File

@ -6,19 +6,19 @@
?>
<?php foreach ($fields as $name => $field): ?>
<?php $fieldname = ($section === 'config' || $nestedPrefix !== 'config') ? "{$nestedPrefix}[{$name}]" : "{$nestedPrefix}[{$section}][{$name}]"; ?>
<?php
$fieldName = ($section === 'config' || $nestedPrefix !== 'config')
? "{$nestedPrefix}[{$name}]"
: "{$nestedPrefix}[{$section}][{$name}]";
?>
<?php if ($field['type'] === 'subfield'): ?>
<section>
<h4><?= $field['title'] ?></h4>
<?php include_once '_form.php'; ?>
<?php include '_subfield.php'; ?>
</section>
<?php elseif ( ! empty($field['display'])): ?>
<article>
<label for="<?= $fieldname ?>"><?= $field['title'] ?></label><br />
<small><?= $field['description'] ?></small><br />
<?= $helper->field($fieldname, $field); ?>
</article>
<?php include '_field.php' ?>
<?php else: ?>
<?php $hiddenFields[] = $helper->field($fieldname, $field); ?>
<?php $hiddenFields[] = $helper->field($fieldName, $field); ?>
<?php endif ?>
<?php endforeach ?>

View File

@ -0,0 +1,20 @@
<?php
// Higher scoped variables:
// $field
// $fields
// $hiddenFields
// $nestedPrefix
?>
<?php foreach ($field['fields'] as $name => $field): ?>
<?php
$fieldName = ($section === 'config' || $nestedPrefix !== 'config')
? "{$nestedPrefix}[{$name}]"
: "{$nestedPrefix}[{$section}][{$name}]";
?>
<?php if ( ! empty($field['display'])): ?>
<?php include '_field.php' ?>
<?php else: ?>
<?php $hiddenFields[] = $helper->field($fieldName, $field); ?>
<?php endif ?>
<?php endforeach ?>

View File

@ -38,7 +38,7 @@
"aura/html": "^2.5.0",
"aura/router": "^3.1.0",
"aura/session": "^2.1.0",
"aviat/banker": "^2.0.0",
"aviat/banker": "^3.1.1",
"aviat/query": "^3.0.0",
"danielstjules/stringy": "^3.1.0",
"ext-dom": "*",

View File

@ -27,7 +27,7 @@ setlocale(LC_CTYPE, 'en_US');
// Load composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
if (array_key_exists('ENV', $_SERVER) && $_SERVER['ENV'] === 'development')
if (file_exists('.is-dev'))
{
$whoops = new Run;
$whoops->pushHandler(new PrettyPageHandler);

View File

@ -21,7 +21,6 @@ use const Aviat\AnimeClient\USER_AGENT;
use function Amp\Promise\wait;
use function Aviat\AnimeClient\getResponse;
use Amp;
use Amp\Http\Client\Request;
use Amp\Http\Client\Body\FormBody;
use Aviat\Ion\Json;
@ -80,6 +79,8 @@ abstract class APIRequestBuilder {
{
$request = (new Request($uri));
$request->setHeader('User-Agent', USER_AGENT);
$request->setTcpConnectTimeout(300000);
$request->setTransferTimeout(300000);
return $request;
}
@ -270,7 +271,7 @@ abstract class APIRequestBuilder {
*/
public function newRequest(string $type, string $uri): self
{
if ( ! \in_array($type, $this->validMethods, TRUE))
if ( ! in_array($type, $this->validMethods, TRUE))
{
throw new InvalidArgumentException('Invalid HTTP method');
}
@ -328,6 +329,8 @@ abstract class APIRequestBuilder {
$this->path = '';
$this->query = '';
$this->request = new Request($requestUrl, $type);
$this->request->setInactivityTimeout(300000);
$this->request->setTlsHandshakeTimeout(300000);
$this->request->setTcpConnectTimeout(300000);
$this->request->setTransferTimeout(300000);
}

View File

@ -29,7 +29,7 @@ class AnimeListTransformer extends AbstractTransformer {
public function transform($item): AnimeListItem
{
return new AnimeListItem([]);
return AnimeListItem::from([]);
}
/**

View File

@ -17,6 +17,7 @@
namespace Aviat\AnimeClient\API\Anilist\Transformer;
use Aviat\AnimeClient\API\Enum\MangaReadingStatus\Anilist as AnilistStatus;
use Aviat\AnimeClient\API\Enum\MangaReadingStatus\Kitsu as KitsuStatus;
use Aviat\AnimeClient\API\Mapping\MangaReadingStatus;
use Aviat\AnimeClient\Types\MangaListItem;
use Aviat\AnimeClient\Types\FormItem;
@ -40,6 +41,8 @@ class MangaListTransformer extends AbstractTransformer {
*/
public function untransform(array $item): FormItem
{
$reconsuming = $item['status'] === AnilistStatus::REPEATING;
return FormItem::from([
'id' => $item['id'],
'mal_id' => $item['media']['idMal'],
@ -49,8 +52,10 @@ class MangaListTransformer extends AbstractTransformer {
'progress' => $item['progress'],
'rating' => $item['score'],
'reconsumeCount' => $item['repeat'],
'reconsuming' => $item['status'] === AnilistStatus::REPEATING,
'status' => MangaReadingStatus::ANILIST_TO_KITSU[$item['status']],
'reconsuming' => $reconsuming,
'status' => $reconsuming
? KitsuStatus::READING
: MangaReadingStatus::ANILIST_TO_KITSU[$item['status']],
'updatedAt' => (new DateTime())
->setTimestamp($item['updatedAt'])
->format(DateTime::W3C),

View File

@ -16,7 +16,8 @@
namespace Aviat\AnimeClient\API;
use Aviat\Banker\Pool;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;
/**
* Helper methods for dealing with the Cache
@ -24,17 +25,17 @@ use Aviat\Banker\Pool;
trait CacheTrait {
/**
* @var Pool
* @var CacheInterface
*/
protected Pool $cache;
protected CacheInterface $cache;
/**
* Inject the cache object
*
* @param Pool $cache
* @param CacheInterface $cache
* @return $this
*/
public function setCache(Pool $cache): self
public function setCache(CacheInterface $cache): self
{
$this->cache = $cache;
return $this;
@ -43,13 +44,41 @@ trait CacheTrait {
/**
* Get the cache object if it exists
*
* @return Pool
* @return CacheInterface
*/
public function getCache(): Pool
public function getCache(): CacheInterface
{
return $this->cache;
}
/**
* Get the cached value if it exists, otherwise set the cache value
* and return it.
*
* @param string $key
* @param callable $primer
* @param array $primeArgs
* @return mixed|null
* @throws InvalidArgumentException
*/
public function getCached(string $key, callable $primer, ?array $primeArgs = [])
{
$value = $this->cache->get($key, NULL);
if ($value === NULL)
{
$value = $primer(...$primeArgs);
if ($value === NULL)
{
return NULL;
}
$this->cache->set($key, $value);
}
return $value;
}
/**
* Generate a hash as a cache key from the current method call
*
@ -61,7 +90,7 @@ trait CacheTrait {
public function getHashForMethodCall($object, string $method, array $args = []): string
{
$keyObj = [
'class' => \get_class($object),
'class' => get_class($object),
'method' => $method,
'args' => $args,
];

View File

@ -28,6 +28,8 @@ final class Kitsu {
public const AUTH_TOKEN_CACHE_KEY = 'kitsu-auth-token';
public const AUTH_TOKEN_EXP_CACHE_KEY = 'kitsu-auth-token-expires';
public const AUTH_TOKEN_REFRESH_CACHE_KEY = 'kitsu-auth-token-refresh';
public const ANIME_HISTORY_LIST_CACHE_KEY = 'kitsu-anime-history-list';
public const MANGA_HISTORY_LIST_CACHE_KEY = 'kitsu-manga-history-list';
/**
* Determine whether an anime is airing, finished airing, or has not yet aired
@ -58,73 +60,6 @@ final class Kitsu {
return AnimeAiringStatus::NOT_YET_AIRED;
}
/**
* Get the name and logo for the streaming service of the current link
*
* @param string $hostname
* @return array
*/
protected static function getServiceMetaData(string $hostname = NULL): array
{
$hostname = str_replace('www.', '', $hostname);
$serviceMap = [
'amazon.com' => [
'name' => 'Amazon Prime',
'link' => TRUE,
'image' => 'streaming-logos/amazon.svg',
],
'crunchyroll.com' => [
'name' => 'Crunchyroll',
'link' => TRUE,
'image' => 'streaming-logos/crunchyroll.svg',
],
'daisuki.net' => [
'name' => 'Daisuki',
'link' => TRUE,
'image' => 'streaming-logos/daisuki.svg'
],
'funimation.com' => [
'name' => 'Funimation',
'link' => TRUE,
'image' => 'streaming-logos/funimation.svg',
],
'hidive.com' => [
'name' => 'Hidive',
'link' => TRUE,
'image' => 'streaming-logos/hidive.svg',
],
'hulu.com' => [
'name' => 'Hulu',
'link' => TRUE,
'image' => 'streaming-logos/hulu.svg',
],
'tubitv.com' => [
'name' => 'TubiTV',
'link' => TRUE,
'image' => 'streaming-logos/tubitv.svg',
],
'viewster.com' => [
'name' => 'Viewster',
'link' => TRUE,
'image' => 'streaming-logos/viewster.svg'
],
];
if (array_key_exists($hostname, $serviceMap))
{
return $serviceMap[$hostname];
}
// Default to Netflix, because the API links are broken,
// and there's no other real identifier for Netflix
return [
'name' => 'Netflix',
'link' => FALSE,
'image' => 'streaming-logos/netflix.svg',
];
}
/**
* Reorganize streaming links
*
@ -195,6 +130,23 @@ final class Kitsu {
return [];
}
/**
* Get the list of titles
*
* @param array $data
* @return array
*/
public static function getTitles(array $data): array
{
$raw = array_unique([
$data['canonicalTitle'],
...array_values($data['titles']),
...array_values($data['abbreviatedTitles'] ?? []),
]);
return array_diff($raw,[$data['canonicalTitle']]);
}
/**
* Filter out duplicate and very similar names from
*
@ -206,7 +158,7 @@ final class Kitsu {
// The 'canonical' title is always returned
$valid = [$data['canonicalTitle']];
if (array_key_exists('titles', $data))
if (array_key_exists('titles', $data) && is_array($data['titles']))
{
foreach($data['titles'] as $alternateTitle)
{
@ -220,6 +172,74 @@ final class Kitsu {
return $valid;
}
/**
* Get the name and logo for the streaming service of the current link
*
* @param string $hostname
* @return array
*/
protected static function getServiceMetaData(string $hostname = NULL): array
{
$hostname = str_replace('www.', '', $hostname);
$serviceMap = [
'amazon.com' => [
'name' => 'Amazon Prime',
'link' => TRUE,
'image' => 'streaming-logos/amazon.svg',
],
'crunchyroll.com' => [
'name' => 'Crunchyroll',
'link' => TRUE,
'image' => 'streaming-logos/crunchyroll.svg',
],
'daisuki.net' => [
'name' => 'Daisuki',
'link' => TRUE,
'image' => 'streaming-logos/daisuki.svg'
],
'funimation.com' => [
'name' => 'Funimation',
'link' => TRUE,
'image' => 'streaming-logos/funimation.svg',
],
'hidive.com' => [
'name' => 'Hidive',
'link' => TRUE,
'image' => 'streaming-logos/hidive.svg',
],
'hulu.com' => [
'name' => 'Hulu',
'link' => TRUE,
'image' => 'streaming-logos/hulu.svg',
],
'tubitv.com' => [
'name' => 'TubiTV',
'link' => TRUE,
'image' => 'streaming-logos/tubitv.svg',
],
'viewster.com' => [
'name' => 'Viewster',
'link' => TRUE,
'image' => 'streaming-logos/viewster.svg'
],
];
if (array_key_exists($hostname, $serviceMap))
{
return $serviceMap[$hostname];
}
// Default to Netflix, because the API links are broken,
// and there's no other real identifier for Netflix
return [
'name' => 'Netflix',
'link' => FALSE,
'image' => 'streaming-logos/netflix.svg',
];
}
/**
* Determine if an alternate title is unique enough to list
*

View File

@ -18,7 +18,6 @@ namespace Aviat\AnimeClient\API\Kitsu;
use Aura\Session\Segment;
use Aviat\Banker\Exception\InvalidArgumentException;
use const Aviat\AnimeClient\SESSION_SEGMENT;
use Aviat\AnimeClient\API\{
@ -27,6 +26,9 @@ use Aviat\AnimeClient\API\{
};
use Aviat\Ion\Di\{ContainerAware, ContainerInterface};
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
use Aviat\Ion\Event;
use Psr\SimpleCache\InvalidArgumentException;
use Throwable;
@ -65,6 +67,8 @@ final class Auth {
$this->segment = $container->get('session')
->getSegment(SESSION_SEGMENT);
$this->model = $container->get('kitsu-model');
Event::on('::unauthorized::', [$this, 'reAuthenticate'], []);
}
/**
@ -73,7 +77,6 @@ final class Auth {
*
* @param string $password
* @return boolean
* @throws InvalidArgumentException
* @throws Throwable
*/
public function authenticate(string $password): bool
@ -83,85 +86,39 @@ final class Auth {
$auth = $this->model->authenticate($username, $password);
if (FALSE !== $auth)
{
// Set the token in the cache for command line operations
$cacheItem = $this->cache->getItem(K::AUTH_TOKEN_CACHE_KEY);
$cacheItem->set($auth['access_token']);
$cacheItem->save();
// Set the token expiration in the cache
$expireTime = $auth['created_at'] + $auth['expires_in'];
$cacheItem = $this->cache->getItem(K::AUTH_TOKEN_EXP_CACHE_KEY);
$cacheItem->set($expireTime);
$cacheItem->save();
// Set the refresh token in the cache
$cacheItem = $this->cache->getItem(K::AUTH_TOKEN_REFRESH_CACHE_KEY);
$cacheItem->set($auth['refresh_token']);
$cacheItem->save();
// Set the session values
$this->segment->set('auth_token', $auth['access_token']);
$this->segment->set('auth_token_expires', $expireTime);
$this->segment->set('refresh_token', $auth['refresh_token']);
return TRUE;
return $this->storeAuth($auth);
}
return FALSE;
}
/**
* Make the call to re-authenticate with the existing refresh token
*
* @param string $token
* @param string $refreshToken
* @return boolean
* @throws InvalidArgumentException
* @throws Throwable
* @throws Throwable|InvalidArgumentException
*/
public function reAuthenticate(string $token): bool
public function reAuthenticate(?string $refreshToken = NULL): bool
{
$auth = $this->model->reAuthenticate($token);
$refreshToken ??= $this->getRefreshToken();
if (FALSE !== $auth)
if (empty($refreshToken))
{
// Set the token in the cache for command line operations
$cacheItem = $this->cache->getItem(K::AUTH_TOKEN_CACHE_KEY);
$cacheItem->set($auth['access_token']);
$cacheItem->save();
// Set the token expiration in the cache
$expire_time = $auth['created_at'] + $auth['expires_in'];
$cacheItem = $this->cache->getItem(K::AUTH_TOKEN_EXP_CACHE_KEY);
$cacheItem->set($expire_time);
$cacheItem->save();
// Set the refresh token in the cache
$cacheItem = $this->cache->getItem(K::AUTH_TOKEN_REFRESH_CACHE_KEY);
$cacheItem->set($auth['refresh_token']);
$cacheItem->save();
// Set the session values
$this->segment->set('auth_token', $auth['access_token']);
$this->segment->set('auth_token_expires', $expire_time);
$this->segment->set('refresh_token', $auth['refresh_token']);
return TRUE;
}
return FALSE;
}
$auth = $this->model->reAuthenticate($refreshToken);
return $this->storeAuth($auth);
}
/**
* Check whether the current user is authenticated
*
* @return boolean
* @throws InvalidArgumentException
*/
public function isAuthenticated(): bool
{
return ($this->get_auth_token() !== FALSE);
return ($this->getAuthToken() !== NULL);
}
/**
@ -177,28 +134,70 @@ final class Auth {
/**
* Retrieve the authentication token from the session
*
* @return string|false
* @return string
* @throws InvalidArgumentException
*/
public function get_auth_token()
public function getAuthToken(): ?string
{
$now = time();
if (PHP_SAPI === 'cli')
{
return $this->segment->get('auth_token', NULL)
?? $this->cache->get(K::AUTH_TOKEN_CACHE_KEY, NULL);
}
$token = $this->segment->get('auth_token', FALSE);
$refreshToken = $this->segment->get('refresh_token', FALSE);
$isExpired = time() > $this->segment->get('auth_token_expires', $now + 5000);
return $this->segment->get('auth_token', NULL);
}
// Attempt to re-authenticate with refresh token
/* if ($isExpired && $refreshToken)
/**
* Retrieve the refresh token
*
* @return string|null
* @throws InvalidArgumentException
*/
private function getRefreshToken(): ?string
{
if ($this->reAuthenticate($refreshToken))
if (PHP_SAPI === 'cli')
{
return $this->segment->get('auth_token', FALSE);
return $this->segment->get('refresh_token')
?? $this->cache->get(K::AUTH_TOKEN_REFRESH_CACHE_KEY, NULL);
}
return $this->segment->get('refresh_token');
}
/**
* Save the new authentication information
*
* @param $auth
* @return bool
* @throws InvalidArgumentException
*/
private function storeAuth($auth): bool
{
if (FALSE !== $auth)
{
$expire_time = $auth['created_at'] + $auth['expires_in'];
// Set the token in the cache for command line operations
// Set the token expiration in the cache
// Set the refresh token in the cache
$saved = $this->cache->setMultiple([
K::AUTH_TOKEN_CACHE_KEY => $auth['access_token'],
K::AUTH_TOKEN_EXP_CACHE_KEY => $expire_time,
K::AUTH_TOKEN_REFRESH_CACHE_KEY => $auth['refresh_token'],
]);
// Set the session values
if ($saved)
{
$this->segment->set('auth_token', $auth['access_token']);
$this->segment->set('auth_token_expires', $expire_time);
$this->segment->set('refresh_token', $auth['refresh_token']);
return TRUE;
}
}
return FALSE;
} */
return $token;
}
}
// End of KitsuAuth.php

View File

@ -16,10 +16,26 @@
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 KitsuRequestBuilder extends APIRequestBuilder {
use ContainerAware;
/**
* The base url for api requests
@ -39,4 +55,217 @@ final class KitsuRequestBuilder extends APIRequestBuilder {
'CLIENT_ID' => 'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd',
'CLIENT_SECRET' => '54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151',
];
public function __construct(ContainerInterface $container)
{
$this->setContainer($container);
}
/**
* Create a request object
*
* @param string $type
* @param string $url
* @param array $options
* @return Request
*/
public function setUpRequest(string $type, string $url, array $options = []): Request
{
$request = $this->newRequest($type, $url);
$sessionSegment = $this->getContainer()
->get('session')
->getSegment(SESSION_SEGMENT);
$cache = $this->getContainer()->get('cache');
$token = null;
if ($cache->has(K::AUTH_TOKEN_CACHE_KEY))
{
$token = $cache->get(K::AUTH_TOKEN_CACHE_KEY);
}
else if ($url !== K::AUTH_URL && $sessionSegment->get('auth_token') !== NULL)
{
$token = $sessionSegment->get('auth_token');
if ( ! (empty($token) || $cache->has(K::AUTH_TOKEN_CACHE_KEY)))
{
$cache->set(K::AUTH_TOKEN_CACHE_KEY, $token);
}
}
if ($token !== NULL)
{
$request = $request->setAuth('bearer', $token);
}
if (array_key_exists('form_params', $options))
{
$request = $request->setFormFields($options['form_params']);
}
if (array_key_exists('query', $options))
{
$request = $request->setQuery($options['query']);
}
if (array_key_exists('body', $options))
{
$request = $request->setJsonBody($options['body']);
}
if (array_key_exists('headers', $options))
{
$request = $request->setHeaders($options['headers']);
}
return $request->getFullRequest();
}
/**
* Remove some boilerplate for get requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
public function getRequest(...$args): array
{
return $this->request('GET', ...$args);
}
/**
* Remove some boilerplate for patch requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
public function patchRequest(...$args): array
{
return $this->request('PATCH', ...$args);
}
/**
* Remove some boilerplate for post requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
public function postRequest(...$args): array
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('kitsu-request');
}
$response = $this->getResponse('POST', ...$args);
$validResponseCodes = [200, 201];
if ( ! in_array($response->getStatus(), $validResponseCodes, TRUE) && $logger)
{
$logger->warning('Non 2xx response for POST api call', $response->getBody());
}
return JSON::decode(wait($response->getBody()->buffer()), TRUE);
}
/**
* Remove some boilerplate for delete requests
*
* @param mixed ...$args
* @throws Throwable
* @return bool
*/
public function deleteRequest(...$args): bool
{
$response = $this->getResponse('DELETE', ...$args);
return ($response->getStatus() === 204);
}
/**
* Make a request
*
* @param string $type
* @param string $url
* @param array $options
* @return Response
* @throws Throwable
*/
public function getResponse(string $type, string $url, array $options = []): Response
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('kitsu-request');
}
$request = $this->setUpRequest($type, $url, $options);
$response = getResponse($request);
if ($logger)
{
$logger->debug('Kitsu API Response', [
'response_status' => $response->getStatus(),
'request_headers' => $response->getOriginalRequest()->getHeaders(),
'response_headers' => $response->getHeaders()
]);
}
return $response;
}
/**
* Make a request
*
* @param string $type
* @param string $url
* @param array $options
* @throws JsonException
* @throws FailedResponseException
* @throws Throwable
* @return array
*/
private function request(string $type, string $url, array $options = []): array
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('kitsu-request');
}
$response = $this->getResponse($type, $url, $options);
$statusCode = $response->getStatus();
// Check for requests that are unauthorized
if ($statusCode === 401 || $statusCode === 403)
{
Event::emit(EventType::UNAUTHORIZED);
}
// Any other type of failed request
if ($statusCode > 299 || $statusCode < 200)
{
if ($logger)
{
$logger->warning('Non 2xx response for api call', (array)$response);
}
throw new FailedResponseException('Failed to get the proper response from the API');
}
try
{
return Json::decode(wait($response->getBody()->buffer()));
}
catch (JsonException $e)
{
print_r($e);
die();
}
}
}

View File

@ -16,24 +16,7 @@
namespace Aviat\AnimeClient\API\Kitsu;
use const Aviat\AnimeClient\SESSION_SEGMENT;
use function Amp\Promise\wait;
use function Aviat\AnimeClient\getResponse;
use Amp\Http\Client\Request;
use Amp\Http\Client\Response;
use Aviat\AnimeClient\API\{
FailedResponseException,
Kitsu as K
};
use Aviat\Ion\Json;
use Aviat\Ion\JsonException;
use Throwable;
trait KitsuTrait {
/**
* The request builder for the Kitsu API
* @var KitsuRequestBuilder
@ -51,209 +34,4 @@ trait KitsuTrait {
$this->requestBuilder = $requestBuilder;
return $this;
}
/**
* 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->requestBuilder->newRequest($type, $url);
$sessionSegment = $this->getContainer()
->get('session')
->getSegment(SESSION_SEGMENT);
$cache = $this->getContainer()->get('cache');
$cacheItem = $cache->getItem('kitsu-auth-token');
$token = null;
if ($sessionSegment->get('auth_token') !== NULL && $url !== K::AUTH_URL)
{
$token = $sessionSegment->get('auth_token');
if ( ! $cacheItem->isHit())
{
$cacheItem->set($token);
$cacheItem->save();
}
}
else if ($sessionSegment->get('auth_token') === NULL && $cacheItem->isHit())
{
$token = $cacheItem->get();
}
if (NULL !== $token)
{
$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();
}
/**
* Make a request
*
* @param string $type
* @param string $url
* @param array $options
* @return Response
* @throws Throwable
*/
private 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);
if ((int) $response->getStatus() > 299 || (int) $response->getStatus() < 200)
{
if ($logger)
{
$logger->warning('Non 200 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();
}
}
/**
* Remove some boilerplate for get requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
protected function getRequest(...$args): array
{
return $this->request('GET', ...$args);
}
/**
* Remove some boilerplate for patch requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
protected function patchRequest(...$args): array
{
return $this->request('PATCH', ...$args);
}
/**
* Remove some boilerplate for post requests
*
* @param mixed ...$args
* @throws Throwable
* @return array
*/
protected 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((int) $response->getStatus(), $validResponseCodes, TRUE))
{
if ($logger)
{
$logger->warning('Non 201 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
*/
protected function deleteRequest(...$args): bool
{
$response = $this->getResponse('DELETE', ...$args);
return ((int) $response->getStatus() === 204);
}
}

View File

@ -18,7 +18,6 @@ namespace Aviat\AnimeClient\API\Kitsu;
use Aviat\Ion\Di\Exception\ContainerException;
use Aviat\Ion\Di\Exception\NotFoundException;
use const Aviat\AnimeClient\SESSION_SEGMENT;
use function Amp\Promise\wait;
use function Aviat\AnimeClient\getResponse;
@ -78,7 +77,7 @@ final class ListItem extends AbstractListItem {
$request = $this->requestBuilder->newRequest('POST', 'library-entries');
if ($authHeader !== FALSE)
if ($authHeader !== NULL)
{
$request = $request->setHeader('Authorization', $authHeader);
}
@ -97,7 +96,7 @@ final class ListItem extends AbstractListItem {
$authHeader = $this->getAuthHeader();
$request = $this->requestBuilder->newRequest('DELETE', "library-entries/{$id}");
if ($authHeader !== FALSE)
if ($authHeader !== NULL)
{
$request = $request->setHeader('Authorization', $authHeader);
}
@ -119,7 +118,7 @@ final class ListItem extends AbstractListItem {
'include' => 'media,media.categories,media.mappings'
]);
if ($authHeader !== FALSE)
if ($authHeader !== NULL)
{
$request = $request->setHeader('Authorization', $authHeader);
}
@ -159,7 +158,7 @@ final class ListItem extends AbstractListItem {
$request = $this->requestBuilder->newRequest('PATCH', "library-entries/{$id}")
->setJsonBody($requestData);
if ($authHeader !== FALSE)
if ($authHeader !== NULL)
{
$request = $request->setHeader('Authorization', $authHeader);
}
@ -172,24 +171,15 @@ final class ListItem extends AbstractListItem {
* @throws ContainerException
* @throws NotFoundException
*/
private function getAuthHeader()
private function getAuthHeader(): ?string
{
$cache = $this->getContainer()->get('cache');
$cacheItem = $cache->getItem('kitsu-auth-token');
$sessionSegment = $this->getContainer()
->get('session')
->getSegment(SESSION_SEGMENT);
$auth = $this->getContainer()->get('auth');
$token = $auth->getAuthToken();
if ($sessionSegment->get('auth_token') !== NULL) {
$token = $sessionSegment->get('auth_token');
if ( ! empty($token)) {
return "bearer {$token}";
}
if ($cacheItem->isHit()) {
$token = $cacheItem->get();
return "bearer {$token}";
}
return FALSE;
return NULL;
}
}

View File

@ -38,6 +38,7 @@ use Aviat\AnimeClient\API\Kitsu\Transformer\{
MangaTransformer,
MangaListTransformer
};
use Aviat\AnimeClient\Enum\ListType;
use Aviat\AnimeClient\Types\{
Anime,
FormItem,
@ -115,7 +116,7 @@ final class Model {
public function authenticate(string $username, string $password)
{
// K::AUTH_URL
$response = $this->getResponse('POST', K::AUTH_URL, [
$response = $this->requestBuilder->getResponse('POST', K::AUTH_URL, [
'headers' => [
'accept' => NULL,
'Content-type' => 'application/x-www-form-urlencoded',
@ -154,19 +155,26 @@ final class Model {
*/
public function reAuthenticate(string $token)
{
$response = $this->getResponse('POST', K::AUTH_URL, [
$response = $this->requestBuilder->getResponse('POST', K::AUTH_URL, [
'headers' => [
'accept' => NULL,
'Content-type' => 'application/x-www-form-urlencoded',
'Accept-encoding' => '*'
],
'form_params' => [
'grant_type' => 'refresh_token',
'refresh_token' => $token
]
]);
$data = Json::decode(wait($response->getBody()->buffer()));
if (array_key_exists('error', $data))
{
dump($data['error']);
dump($response);
die();
}
if (array_key_exists('access_token', $data))
{
return $data;
@ -175,44 +183,13 @@ final class Model {
return FALSE;
}
/**
* Retrieve the data for the anime watch history page
*
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getAnimeHistory(): array
{
$raw = $this->getRawHistoryList('anime');
$organized = JsonAPI::organizeData($raw);
$organized = array_filter($organized, fn ($item) => array_key_exists('relationships', $item));
return (new AnimeHistoryTransformer())->transform($organized);
}
/**
* Retrieve the data for the manga read history page
*
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getMangaHistory(): array
{
$raw = $this->getRawHistoryList('manga');
$organized = JsonAPI::organizeData($raw);
$organized = array_filter($organized, fn ($item) => array_key_exists('relationships', $item));
return (new MangaHistoryTransformer())->transform($organized);
}
/**
* Get the userid for a username from Kitsu
*
* @param string $username
* @return string
* @throws InvalidArgumentException
* @throws Throwable
*/
public function getUserIdByUsername(string $username = NULL): string
{
@ -221,11 +198,8 @@ final class Model {
$username = $this->getUsername();
}
$cacheItem = $this->cache->getItem(K::AUTH_USER_ID_KEY);
if ( ! $cacheItem->isHit())
{
$data = $this->getRequest('users', [
return $this->getCached(K::AUTH_USER_ID_KEY, function(string $username) {
$data = $this->requestBuilder->getRequest('users', [
'query' => [
'filter' => [
'name' => $username
@ -233,11 +207,8 @@ final class Model {
]
]);
$cacheItem->set($data['data'][0]['id']);
$cacheItem->save();
}
return $cacheItem->get();
return $data['data'][0]['id'] ?? NULL;
}, [$username]);
}
/**
@ -248,14 +219,14 @@ final class Model {
*/
public function getCharacter(string $slug): array
{
return $this->getRequest('characters', [
return $this->requestBuilder->getRequest('characters', [
'query' => [
'filter' => [
'slug' => $slug,
],
'fields' => [
'anime' => 'canonicalTitle,titles,slug,posterImage',
'manga' => 'canonicalTitle,titles,slug,posterImage'
'anime' => 'canonicalTitle,abbreviatedTitles,titles,slug,posterImage',
'manga' => 'canonicalTitle,abbreviatedTitles,titles,slug,posterImage'
],
'include' => 'castings.person,castings.media'
]
@ -271,11 +242,7 @@ final class Model {
*/
public function getPerson(string $id): array
{
$cacheItem = $this->cache->getItem("kitsu-person-{$id}");
if ( ! $cacheItem->isHit())
{
$data = $this->getRequest("people/{$id}", [
return $this->getCached("kitsu-person-{$id}", fn () => $this->requestBuilder->getRequest("people/{$id}", [
'query' => [
'filter' => [
'id' => $id,
@ -283,19 +250,14 @@ final class Model {
'fields' => [
'characters' => 'canonicalName,slug,image',
'characterVoices' => 'mediaCharacter',
'anime' => 'canonicalTitle,titles,slug,posterImage',
'manga' => 'canonicalTitle,titles,slug,posterImage',
'anime' => 'canonicalTitle,abbreviatedTitles,titles,slug,posterImage',
'manga' => 'canonicalTitle,abbreviatedTitles,titles,slug,posterImage',
'mediaCharacters' => 'role,media,character',
'mediaStaff' => 'role,media,person',
],
'include' => 'voices.mediaCharacter.media,voices.mediaCharacter.character,staff.media',
],
]);
$cacheItem->set($data);
$cacheItem->save();
}
return $cacheItem->get();
]));
}
/**
@ -306,7 +268,7 @@ final class Model {
*/
public function getUserData(string $username): array
{
return $this->getRequest('users', [
return $this->requestBuilder->getRequest('users', [
'query' => [
'filter' => [
'name' => $username,
@ -343,7 +305,7 @@ final class Model {
]
];
$raw = $this->getRequest($type, $options);
$raw = $this->requestBuilder->getRequest($type, $options);
$raw['included'] = JsonAPI::organizeIncluded($raw['included']);
foreach ($raw['data'] as &$item)
@ -388,7 +350,7 @@ final class Model {
]
];
$raw = $this->getRequest('mappings', $options);
$raw = $this->requestBuilder->getRequest('mappings', $options);
if ( ! array_key_exists('included', $raw))
{
@ -420,6 +382,34 @@ final class Model {
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
*
@ -441,9 +431,11 @@ final class Model {
*/
public function getAnimeList(string $status): array
{
$cacheItem = $this->cache->getItem("kitsu-anime-list-{$status}");
$key = "kitsu-anime-list-{$status}";
if ( ! $cacheItem->isHit())
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$data = $this->getRawAnimeList($status) ?? [];
@ -469,11 +461,11 @@ final class Model {
$keyed[$item['id']] = $item;
}
$cacheItem->set($keyed);
$cacheItem->save();
$list = $keyed;
$this->cache->set($key, $list);
}
return $cacheItem->get();
return $list;
}
/**
@ -485,27 +477,7 @@ final class Model {
*/
public function getAnimeListCount(string $status = '') : int
{
$options = [
'query' => [
'filter' => [
'user_id' => $this->getUserIdByUsername(),
'kind' => 'anime'
],
'page' => [
'limit' => 1
],
'sort' => '-updated_at'
]
];
if ( ! empty($status))
{
$options['query']['filter']['status'] = $status;
}
$response = $this->getRequest('library-entries', $options);
return $response['meta']['count'];
return $this->getListCount(ListType::ANIME, $status);
}
/**
@ -582,7 +554,7 @@ final class Model {
'include' => 'mappings'
]
];
$data = $this->getRequest("anime/{$kitsuAnimeId}", $options);
$data = $this->requestBuilder->getRequest("anime/{$kitsuAnimeId}", $options);
if ( ! array_key_exists('included', $data))
{
@ -617,7 +589,7 @@ final class Model {
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()),
'user_id' => $this->getUserId(),
'kind' => 'anime'
],
'page' => [
@ -628,7 +600,7 @@ final class Model {
];
$options = array_merge($defaultOptions, $options);
return $this->setUpRequest('GET', 'library-entries', ['query' => $options]);
return $this->requestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
/**
@ -643,7 +615,7 @@ final class Model {
{
$options = [
'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()),
'user_id' => $this->getUserId(),
'kind' => 'anime',
'status' => $status,
],
@ -676,6 +648,32 @@ final class Model {
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
*
@ -702,7 +700,7 @@ final class Model {
$options = [
'query' => [
'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()),
'user_id' => $this->getUserId(),
'kind' => 'manga',
'status' => $status,
],
@ -715,11 +713,13 @@ final class Model {
]
];
$cacheItem = $this->cache->getItem("kitsu-manga-list-{$status}");
$key = "kitsu-manga-list-{$status}";
if ( ! $cacheItem->isHit())
$list = $this->cache->get($key, NULL);
if ($list === NULL)
{
$data = $this->getRequest('library-entries', $options) ?? [];
$data = $this->requestBuilder->getRequest('library-entries', $options) ?? [];
// Bail out on no data
if (empty($data) || ( ! array_key_exists('included', $data)))
@ -736,13 +736,12 @@ final class Model {
}
unset($item);
$transformed = $this->mangaListTransformer->transformCollection($data['data']);
$list = $this->mangaListTransformer->transformCollection($data['data']);
$cacheItem->set($transformed);
$cacheItem->save();
$this->cache->set($key, $list);
}
return $cacheItem->get();
return $list;
}
/**
@ -754,27 +753,7 @@ final class Model {
*/
public function getMangaListCount(string $status = '') : int
{
$options = [
'query' => [
'filter' => [
'user_id' => $this->getUserIdByUsername(),
'kind' => 'manga'
],
'page' => [
'limit' => 1
],
'sort' => '-updated_at'
]
];
if ( ! empty($status))
{
$options['query']['filter']['status'] = $status;
}
$response = $this->getRequest('library-entries', $options);
return $response['meta']['count'];
return $this->getListCount(ListType::MANGA, $status);
}
/**
@ -850,7 +829,7 @@ final class Model {
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()),
'user_id' => $this->getUserId(),
'kind' => 'manga'
],
'page' => [
@ -861,7 +840,7 @@ final class Model {
];
$options = array_merge($defaultOptions, $options);
return $this->setUpRequest('GET', 'library-entries', ['query' => $options]);
return $this->requestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
/**
@ -878,7 +857,7 @@ final class Model {
'include' => 'mappings'
]
];
$data = $this->getRequest("manga/{$kitsuMangaId}", $options);
$data = $this->requestBuilder->getRequest("manga/{$kitsuMangaId}", $options);
$mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map)
@ -905,7 +884,7 @@ final class Model {
*/
public function createListItem(array $data): ?Request
{
$data['user_id'] = $this->getUserIdByUsername($this->getUsername());
$data['user_id'] = $this->getUserId();
if ($data['id'] === NULL)
{
return NULL;
@ -976,6 +955,20 @@ final class Model {
return $this->listItem->delete($id);
}
public function getSyncList(string $type): array
{
$options = [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => $type,
],
'include' => "{$type},{$type}.mappings",
'sort' => '-updated_at'
];
return $this->getRawSyncList($type, $options);
}
/**
* Get the aggregated pages of anime or manga history
*
@ -1022,11 +1015,11 @@ final class Model {
*/
protected function getRawHistoryPage(string $type, int $offset, int $limit = 20): Request
{
return $this->setUpRequest('GET', 'library-events', [
return $this->requestBuilder->setUpRequest('GET', 'library-events', [
'query' => [
'filter' => [
'kind' => 'progressed,updated',
'userId' => $this->getUserIdByUsername($this->getUsername()),
'userId' => $this->getUserId(),
],
'page' => [
'offset' => $offset,
@ -1043,6 +1036,18 @@ final class Model {
]);
}
private function getUserId(): string
{
static $userId = NULL;
if ($userId === NULL)
{
$userId = $this->getUserIdByUsername($this->getUsername());
}
return $userId;
}
/**
* Get the kitsu username from config
*
@ -1072,7 +1077,7 @@ final class Model {
]
];
$data = $this->getRequest("{$type}/{$id}", $options);
$data = $this->requestBuilder->getRequest("{$type}/{$id}", $options);
if (empty($data['data']))
{
@ -1112,7 +1117,7 @@ final class Model {
]
];
$data = $this->getRequest($type, $options);
$data = $this->requestBuilder->getRequest($type, $options);
if (empty($data['data']))
{
@ -1124,4 +1129,93 @@ final class Model {
$baseData['included'] = $data['included'];
return $baseData;
}
private function getListCount(string $type, string $status = ''): int
{
$options = [
'query' => [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => $type,
],
'page' => [
'limit' => 1
],
'sort' => '-updated_at'
]
];
if ( ! empty($status))
{
$options['query']['filter']['status'] = $status;
}
$response = $this->requestBuilder->getRequest('library-entries', $options);
return $response['meta']['count'];
}
/**
* Get the full anime list
*
* @param string $type
* @param array $options
* @return array
* @throws InvalidArgumentException
* @throws Throwable
*/
private function getRawSyncList(string $type, array $options): array
{
$count = $this->getListCount($type);
$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->getRawSyncListPage($type, $size, $offset, $options));
}
$responses = $requester->makeRequests();
$output = [];
foreach($responses as $response)
{
$data = Json::decode($response);
$output[] = $data;
}
return array_merge_recursive(...$output);
}
/**
* Get the full anime list in paginated form
*
* @param string $type
* @param int $limit
* @param int $offset
* @param array $options
* @return Request
* @throws InvalidArgumentException
*/
private function getRawSyncListPage(string $type, int $limit, int $offset = 0, array $options = []): Request
{
$defaultOptions = [
'filter' => [
'user_id' => $this->getUserId(),
'kind' => $type,
],
'page' => [
'offset' => $offset,
'limit' => $limit
],
'sort' => '-updated_at'
];
$options = array_merge($defaultOptions, $options);
return $this->requestBuilder->setUpRequest('GET', 'library-entries', ['query' => $options]);
}
}

View File

@ -42,6 +42,7 @@ final class AnimeTransformer extends AbstractTransformer {
$title = $item['canonicalTitle'];
$titles = Kitsu::filterTitles($item);
$titles_more = Kitsu::getTitles($item);
$characters = [];
$staff = [];
@ -123,6 +124,7 @@ final class AnimeTransformer extends AbstractTransformer {
'synopsis' => $item['synopsis'],
'title' => $title,
'titles' => $titles,
'titles_more' => $titles_more,
'trailer_id' => $item['youtubeVideoId'],
'url' => "https://kitsu.io/anime/{$item['slug']}",
]);

View File

@ -149,7 +149,7 @@ final class CharacterTransformer extends AbstractTransformer {
$person = $p['attributes'];
$person['id'] = $pid;
$person['image'] = $person['image']['original'];
$person['image'] = $person['image']['original'] ?? '';
uasort($role['relationships']['media']['anime'], static function ($a, $b) {
return $a['attributes']['canonicalTitle'] <=> $b['attributes']['canonicalTitle'];

View File

@ -98,16 +98,12 @@ final class MangaTransformer extends AbstractTransformer {
if ( ! empty($characters['main']))
{
uasort($characters['main'], static function ($a, $b) {
return $a['name'] <=> $b['name'];
});
uasort($characters['main'], fn ($a, $b) => $a['name'] <=> $b['name']);
}
if ( ! empty($characters['supporting']))
{
uasort($characters['supporting'], static function ($a, $b) {
return $a['name'] <=> $b['name'];
});
uasort($characters['supporting'], fn ($a, $b) => $a['name'] <=> $b['name']);
}
ksort($characters);

View File

@ -103,9 +103,7 @@ final class ParallelAPIRequest {
foreach ($this->requests as $key => $url)
{
$promises[$key] = call(static function () use ($client, $url) {
return yield $client->request($url);
});
$promises[$key] = call(fn () => yield $client->request($url));
}
return wait(all($promises));

View File

@ -16,6 +16,9 @@
namespace Aviat\AnimeClient;
use Aviat\AnimeClient\API\Kitsu;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;
use function Amp\Promise\wait;
use Amp\Http\Client\Request;
@ -26,6 +29,8 @@ use Amp\Http\Client\HttpClientBuilder;
use Aviat\Ion\ConfigInterface;
use Yosymfony\Toml\{Toml, TomlBuilder};
use Throwable;
// ----------------------------------------------------------------------------
//! TOML Functions
// ----------------------------------------------------------------------------
@ -232,7 +237,7 @@ function getApiClient (): HttpClient
*
* @param string|Request $request
* @return Response
* @throws \Throwable
* @throws Throwable
*/
function getResponse ($request): Response
{
@ -256,7 +261,7 @@ function getResponse ($request): Response
*/
function getLocalImg ($kitsuUrl, $webp = TRUE): string
{
if ( ! is_string($kitsuUrl))
if (empty($kitsuUrl) || ( ! is_string($kitsuUrl)))
{
return 'images/placeholder.webp';
}
@ -346,3 +351,30 @@ function col_not_empty(array $search, string $key): bool
$items = array_filter(array_column($search, $key), fn ($x) => ( ! empty($x)));
return count($items) > 0;
}
/**
* Clear the cache, but save user auth data
*
* @param CacheInterface $cache
* @return bool
* @throws InvalidArgumentException
*/
function clearCache(CacheInterface $cache): bool
{
// Save the user data, if it exists, for priming the cache
$userData = $cache->getMultiple([
Kitsu::AUTH_USER_ID_KEY,
Kitsu::AUTH_TOKEN_CACHE_KEY,
Kitsu::AUTH_TOKEN_EXP_CACHE_KEY,
Kitsu::AUTH_TOKEN_REFRESH_CACHE_KEY,
], NULL);
$userData = array_filter((array)$userData, fn ($value) => $value !== NULL);
$cleared = $cache->clear();
$saved = ( ! empty($userData))
? $cache->setMultiple($userData)
: TRUE;
return $cleared && $saved;
}

View File

@ -24,10 +24,10 @@ 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\Banker\Pool;
use Aviat\Banker\Teller;
use Aviat\Ion\Config;
use Aviat\Ion\Di\{Container, ContainerAware};
use ConsoleKit\{Command, ConsoleException};
use Aviat\Ion\Di\{Container, ContainerInterface, ContainerAware};
use ConsoleKit\{Colors, Command, ConsoleException};
use ConsoleKit\Widgets\Box;
use Laminas\Diactoros\{Response, ServerRequestFactory};
use Monolog\Handler\RotatingFileHandler;
@ -43,16 +43,28 @@ abstract class BaseCommand extends Command {
/**
* Echo text in a box
*
* @param string $message
* @param string|array $message
* @param string|int|null $fgColor
* @param string|int|null $bgColor
* @return void
*/
protected function echoBox($message): void
public function echoBox($message, $fgColor = NULL, $bgColor = NULL): void
{
if (is_array($message))
{
$message = implode("\n", $message);
}
try
{
echo "\n";
// color message
$message = Colors::colorize($message, $fgColor, $bgColor);
// create the box
$box = new Box($this->getConsole(), $message);
$box->write();
echo "\n";
}
catch (ConsoleException $e)
@ -61,12 +73,47 @@ abstract class BaseCommand extends Command {
}
}
public function echo(string $message): void
{
$this->_line($message);
}
public function echoSuccess(string $message): void
{
$this->_line($message, Colors::GREEN | Colors::BOLD, Colors::BLACK);
}
public function echoWarning(string $message): void
{
$this->_line($message, Colors::YELLOW | Colors::BOLD, Colors::BLACK);
}
public function echoWarningBox(string $message): void
{
$this->echoBox($message, Colors::YELLOW | Colors::BOLD, Colors::BLACK);
}
public function echoError(string $message): void
{
$this->_line($message, Colors::RED | Colors::BOLD, Colors::BLACK);
}
public function echoErrorBox(string $message): void
{
$this->echoBox($message, Colors::RED | Colors::BOLD, Colors::BLACK);
}
public function clearLine(): void
{
$this->getConsole()->write("\r\e[2K");
}
/**
* Setup the Di container
*
* @return Container
* @return Containerinterface
*/
protected function setupContainer(): Container
public function setupContainer(): ContainerInterface
{
$APP_DIR = realpath(__DIR__ . '/../../../app');
$APPCONF_DIR = realpath("{$APP_DIR}/appConf/");
@ -82,7 +129,17 @@ abstract class BaseCommand extends Command {
$configArray = array_replace_recursive($baseConfig, $config, $overrideConfig);
$di = static function ($configArray) use ($APP_DIR): Container {
return $this->_di($configArray, $APP_DIR);
}
private function _line(string $message, $fgColor = NULL, $bgColor = NULL): void
{
$message = Colors::colorize($message, $fgColor, $bgColor);
$this->getConsole()->writeln($message);
}
private function _di(array $configArray, string $APP_DIR): ContainerInterface
{
$container = new Container();
// -------------------------------------------------------------------------
@ -103,44 +160,34 @@ abstract class BaseCommand extends Command {
$container->setLogger($kitsu_request_logger, 'kitsu-request');
// Create Config Object
$container->set('config', static function() use ($configArray): Config {
return new Config($configArray);
});
$container->set('config', fn () => new Config($configArray));
// Create Cache Object
$container->set('cache', static function($container) {
$logger = $container->getLogger();
$config = $container->get('config')->get('cache');
return new Pool($config, $logger);
return new Teller($config, $logger);
});
// Create Aura Router Object
$container->set('aura-router', static function() {
return new RouterContainer;
});
$container->set('aura-router', fn () => new RouterContainer);
// Create Request/Response Objects
$container->set('request', static function() {
return ServerRequestFactory::fromGlobals(
$container->set('request', fn () => ServerRequestFactory::fromGlobals(
$_SERVER,
$_GET,
$_POST,
$_COOKIE,
$_FILES
);
});
$container->set('response', static function(): Response {
return new Response;
});
));
$container->set('response', fn () => new Response);
// Create session Object
$container->set('session', static function() {
return (new SessionFactory())->newInstance($_COOKIE);
});
$container->set('session', fn () => (new SessionFactory())->newInstance($_COOKIE));
// Models
$container->set('kitsu-model', static function($container): Kitsu\Model {
$requestBuilder = new KitsuRequestBuilder();
$requestBuilder = new KitsuRequestBuilder($container);
$requestBuilder->setLogger($container->getLogger('kitsu-request'));
$listItem = new Kitsu\ListItem();
@ -175,21 +222,12 @@ abstract class BaseCommand extends Command {
return $model;
});
$container->set('auth', static function($container): Kitsu\Auth {
return new Kitsu\Auth($container);
});
$container->set('auth', fn ($container) => new Kitsu\Auth($container));
$container->set('url-generator', static function($container): UrlGenerator {
return new UrlGenerator($container);
});
$container->set('url-generator', fn ($container) => new UrlGenerator($container));
$container->set('util', static function($container): Util {
return new Util($container);
});
$container->set('util', fn ($container) => new Util($container));
return $container;
};
return $di($configArray);
}
}

View File

@ -18,6 +18,7 @@ namespace Aviat\AnimeClient\Command;
use Aviat\Ion\Di\Exception\ContainerException;
use Aviat\Ion\Di\Exception\NotFoundException;
use function Aviat\AnimeClient\clearCache;
/**
* Clears the API Cache
@ -36,8 +37,17 @@ final class CacheClear extends BaseCommand {
{
$this->setContainer($this->setupContainer());
$this->container->get('cache')->clear();
$cache = $this->container->get('cache');
$cleared = clearCache($cache);
if ($cleared)
{
$this->echoBox('API Cache has been cleared.');
}
else
{
$this->echoErrorBox('Failed to clear cache.');
}
}
}

View File

@ -16,8 +16,10 @@
namespace Aviat\AnimeClient\Command;
use Aviat\AnimeClient\API\Kitsu;
use Aviat\Ion\Di\Exception\ContainerException;
use Aviat\Ion\Di\Exception\NotFoundException;
use function Aviat\AnimeClient\clearCache;
/**
* Clears the API Cache
@ -35,30 +37,25 @@ final class CachePrime extends BaseCommand {
public function execute(array $args, array $options = []): void
{
$this->setContainer($this->setupContainer());
$cache = $this->container->get('cache');
// Save the user id, if it exists, for priming the cache
$userIdItem = $cache->getItem('kitsu-auth-token');
$userId = $userIdItem->isHit() ? $userIdItem->get() : null;
$cache->clear();
$cleared = clearCache($cache);
if ( ! $cleared)
{
$this->echoErrorBox('Failed to clear cache.');
return;
}
$this->echoBox('Cache cleared, re-priming...');
if ($userId !== NULL)
{
$userIdItem = $cache->getItem('kitsu-auth-token');
$userIdItem->set($userId);
$userIdItem->save();
}
$kitsuModel = $this->container->get('kitsu-model');
// Prime anime list cache
// Prime anime list and history cache
$kitsuModel->getAnimeHistory();
$kitsuModel->getFullOrganizedAnimeList();
// Prime manga list cache
$kitsuModel->getMangaHistory();
$kitsuModel->getFullOrganizedMangaList();
$this->echoBox('API Cache has been primed.');

View File

@ -16,19 +16,18 @@
namespace Aviat\AnimeClient\Command;
use ConsoleKit\Widgets;
use Aviat\AnimeClient\API\{
Anilist\MissingIdException,
FailedResponseException,
JsonAPI,
ParallelAPIRequest
};
use Aviat\AnimeClient\API\Anilist\Transformer\{
AnimeListTransformer as AALT,
MangaListTransformer as AMLT
};
use Aviat\AnimeClient\API\Anilist\Model as AnilistModel;
use Aviat\AnimeClient\API\Kitsu\Model as KitsuModel;
use Aviat\AnimeClient\API\Anilist;
use Aviat\AnimeClient\API\Kitsu;
use Aviat\AnimeClient\API\Mapping\{AnimeWatchingStatus, MangaReadingStatus};
use Aviat\AnimeClient\Enum\{APISource, ListType, SyncAction};
use Aviat\AnimeClient\Types\FormItem;
use Aviat\Ion\Di\Exception\ContainerException;
use Aviat\Ion\Di\Exception\NotFoundException;
@ -44,18 +43,24 @@ final class SyncLists extends BaseCommand {
/**
* Model for making requests to Anilist API
* @var AnilistModel
* @var Anilist\Model
*/
protected AnilistModel $anilistModel;
private Anilist\Model $anilistModel;
/**
* Model for making requests to Kitsu API
* @var KitsuModel
* @var Kitsu\Model
*/
protected KitsuModel $kitsuModel;
private Kitsu\Model $kitsuModel;
/**
* Run the Kitsu <=> Anilist sync script
* Does the Kitsu API have valid authentication?
* @var bool
*/
private bool $isKitsuAuthenticated = FALSE;
/**
* Sync Kitsu <=> Anilist
*
* @param array $args
* @param array $options
@ -64,6 +69,31 @@ final class SyncLists extends BaseCommand {
* @throws Throwable
*/
public function execute(array $args, array $options = []): void
{
$this->init();
foreach ([ListType::ANIME, ListType::MANGA] as $type)
{
// Main Sync flow
$this->fetchCount($type);
$rawData = $this->fetch($type);
$normalized = $this->transform($type, $rawData);
$compared = $this->compare($type, $normalized);
$this->update($type, $compared);
}
}
// ------------------------------------------------------------------------
// Main sync flow methods
// ------------------------------------------------------------------------
/**
* Set up dependencies
*
* @throws ContainerException
* @throws NotFoundException
*/
protected function init(): void
{
$this->setContainer($this->setupContainer());
$this->setCache($this->container->get('cache'));
@ -71,28 +101,198 @@ final class SyncLists extends BaseCommand {
$config = $this->container->get('config');
$anilistEnabled = $config->get(['anilist', 'enabled']);
// We can't sync kitsu against itself!
if ( ! $anilistEnabled)
{
$this->echoBox('Anlist API is not enabled. Can not sync.');
return;
$this->echoErrorBox('Anlist API is not enabled. Can not sync.');
die();
}
// Authentication is required to update Kitsu
$this->isKitsuAuthenticated = $this->container->get('auth')->isAuthenticated();
if ( ! $this->isKitsuAuthenticated)
{
$this->echoWarningBox('Kitsu is not authenticated. Kitsu list can not be updated.');
}
$this->anilistModel = $this->container->get('anilist-model');
$this->kitsuModel = $this->container->get('kitsu-model');
$this->sync('anime');
$this->sync('manga');
$this->echoBox('Finished syncing lists');
}
/**
* Attempt to synchronize external APIs
* Get and display the count of items for each API
*
* @param string $type
*/
protected function fetchCount(string $type): void
{
$this->echo('Fetching List Counts');
$progress = new Widgets\ProgressBar($this->getConsole(), 2, 50, FALSE);
$displayLines = [];
$kitsuCount = $this->fetchKitsuCount($type);
$displayLines[] = "Number of Kitsu {$type} list items: {$kitsuCount}";
$progress->incr();
$anilistCount = $this->fetchAnilistCount($type);
$displayLines[] = "Number of Anilist {$type} list items: {$anilistCount}";
$progress->incr();
$this->clearLine();
$this->echoBox($displayLines);
}
/**
* Get the list data
*
* @param string $type
* @return array
*/
protected function fetch(string $type): array
{
$this->echo('Fetching List Data');
$progress = new Widgets\ProgressBar($this->getConsole(), 2, 50, FALSE);
$anilist = $this->fetchAnilist($type);
$progress->incr();
$kitsu = $this->fetchKitsu($type);
$progress->incr();
$this->clearLine();
return [
'anilist' => $anilist,
'kitsu' => $kitsu,
];
}
/**
* Normalize the list data for comparison
*
* @param string $type
* @param array $data
* @return array
*/
protected function transform(string $type, array $data): array
{
$this->echo('Normalizing List Data');
$progress = new Widgets\ProgressBar($this->getConsole(), 2, 50, FALSE);
$kitsu = $this->transformKitsu($type, $data['kitsu']);
$progress->incr();
$anilist = $this->transformAnilist($type, $data['anilist']);
$progress->incr();
$this->clearLine();
return [
'anilist' => $anilist,
'kitsu' => $kitsu,
];
}
/**
* Compare the lists data
*
* @param string $type
* @param array $data
* @return array|array[]
*/
protected function compare(string $type, array $data): array
{
$this->echo('Comparing List Items');
return $this->compareLists($type, $data['anilist'], $data['kitsu']);
}
/**
* Updated outdated list items
*
* @param string $type
* @param array $data
* @throws Throwable
*/
protected function sync(string $type): void
protected function update(string $type, array $data)
{
if ( ! empty($data['addToAnilist']))
{
$count = count($data['addToAnilist']);
$this->echoBox("Adding {$count} missing {$type} list items to Anilist");
$this->updateAnilistListItems($data['addToAnilist'], SyncAction::CREATE, $type);
}
if ( ! empty($data['updateAnilist']))
{
$count = count($data['updateAnilist']);
$this->echoBox("Updating {$count} outdated Anilist {$type} list items");
$this->updateAnilistListItems($data['updateAnilist'], SyncAction::UPDATE, $type);
}
if ($this->isKitsuAuthenticated)
{
if ( ! empty($data['addToKitsu']))
{
$count = count($data['addToKitsu']);
$this->echoBox("Adding {$count} missing {$type} list items to Kitsu");
$this->updateKitsuListItems($data['addToKitsu'], SyncAction::CREATE, $type);
}
if ( ! empty($data['updateKitsu']))
{
$count = count($data['updateKitsu']);
$this->echoBox("Updating {$count} outdated Kitsu {$type} list items");
$this->updateKitsuListItems($data['updateKitsu'], SyncAction::UPDATE, $type);
}
}
else
{
$this->echoErrorBox('Kitsu is not authenticated, so lists can not be updated');
}
}
// ------------------------------------------------------------------------
// Fetch helpers
// ------------------------------------------------------------------------
private function fetchAnilistCount(string $type)
{
$list = $this->fetchAnilist($type);
if ( ! isset($list['data']['MediaListCollection']['lists']))
{
return 0;
}
$count = 0;
foreach ($list['data']['MediaListCollection']['lists'] as $subList)
{
$count += array_reduce($subList, fn ($carry, $item) => $carry + count(array_values($item)), 0);
}
return $count;
}
private function fetchAnilist(string $type): array
{
static $list = [
ListType::ANIME => NULL,
ListType::MANGA => NULL,
];
// This uses a static so I don't have to fetch this list twice for a count
if ($list[$type] === NULL)
{
$list[$type] = $this->anilistModel->getSyncList(strtoupper($type));
}
return $list[$type];
}
private function fetchKitsuCount(string $type): int
{
$uType = ucfirst($type);
@ -106,157 +306,31 @@ final class SyncLists extends BaseCommand {
dump($e);
}
$this->echoBox("Number of Kitsu {$type} list items: {$kitsuCount}");
$data = $this->diffLists($type);
if ( ! empty($data['addToAnilist']))
{
$count = count($data['addToAnilist']);
$this->echoBox("Adding {$count} missing {$type} list items to Anilist");
$this->updateAnilistListItems($data['addToAnilist'], 'create', $type);
return $kitsuCount;
}
if ( ! empty($data['updateAnilist']))
private function fetchKitsu(string $type): array
{
$count = count($data['updateAnilist']);
$this->echoBox("Updating {$count} outdated Anilist {$type} list items");
$this->updateAnilistListItems($data['updateAnilist'], 'update', $type);
return $this->kitsuModel->getSyncList($type);
}
if ( ! empty($data['addToKitsu']))
// ------------------------------------------------------------------------
// Transform Helpers
// ------------------------------------------------------------------------
private function transformKitsu(string $type, array $data): array
{
$count = count($data['addToKitsu']);
$this->echoBox("Adding {$count} missing {$type} list items to Kitsu");
$this->updateKitsuListItems($data['addToKitsu'], 'create', $type);
}
if ( ! empty($data['updateKitsu']))
{
$count = count($data['updateKitsu']);
$this->echoBox("Updating {$count} outdated Kitsu {$type} list items");
$this->updateKitsuListItems($data['updateKitsu'], 'update', $type);
}
}
/**
* Filter Kitsu mappings for the specified type
*
* @param array $includes
* @param string $type
* @return array
*/
protected function filterMappings(array $includes, string $type = 'anime'): array
{
$output = [];
foreach($includes as $id => $mapping)
{
if ($mapping['externalSite'] === "myanimelist/{$type}")
{
$output[$id] = $mapping;
}
}
return $output;
}
/**
* Format an Anilist list for comparison
*
* @param string $type
* @return array
*/
protected function formatAnilistList(string $type): array
{
$type = ucfirst($type);
$method = "formatAnilist{$type}List";
return $this->$method();
}
/**
* Format an Anilist anime list for comparison
*
* @return array
* @throws ContainerException
* @throws NotFoundException
*/
protected function formatAnilistAnimeList(): array
{
$anilistList = $this->anilistModel->getSyncList('ANIME');
$anilistTransformer = new AALT();
$transformedAnilist = [];
foreach ($anilistList['data']['MediaListCollection']['lists'] as $list)
{
$newTransformed = $anilistTransformer->untransformCollection($list['entries']);
$transformedAnilist = array_merge($transformedAnilist, $newTransformed);
}
// Key the array by the mal_id for easier reference in the next comparision step
$output = [];
foreach ($transformedAnilist as $item)
{
$output[$item['mal_id']] = $item->toArray();
}
$count = count($output);
$this->echoBox("Number of Anilist anime list items: {$count}");
return $output;
}
/**
* Format an Anilist manga list for comparison
*
* @return array
* @throws ContainerException
* @throws NotFoundException
*/
protected function formatAnilistMangaList(): array
{
$anilistList = $this->anilistModel->getSyncList('MANGA');
$anilistTransformer = new AMLT();
$transformedAnilist = [];
foreach ($anilistList['data']['MediaListCollection']['lists'] as $list)
{
$newTransformed = $anilistTransformer->untransformCollection($list['entries']);
$transformedAnilist = array_merge($transformedAnilist, $newTransformed);
}
// Key the array by the mal_id for easier reference in the next comparision step
$output = [];
foreach ($transformedAnilist as $item)
{
$output[$item['mal_id']] = $item->toArray();
}
$count = count($output);
$this->echoBox("Number of Anilist manga list items: {$count}");
return $output;
}
/**
* Format a kitsu list for the sake of comparision
*
* @param string $type
* @return array
*/
protected function formatKitsuList(string $type = 'anime'): array
{
$method = 'getFullRaw' . ucfirst($type) . 'List';
$data = $this->kitsuModel->$method();
if (empty($data))
{
return [];
}
if ( ! array_key_exists('included', $data))
{
dump($data);
return [];
}
$includes = JsonAPI::organizeIncludes($data['included']);
$includes['mappings'] = $this->filterMappings($includes['mappings'], $type);
@ -271,7 +345,7 @@ final class SyncLists extends BaseCommand {
foreach ($potentialMappings as $mappingId)
{
if (\is_array($mappingId))
if (is_array($mappingId))
{
continue;
}
@ -298,21 +372,37 @@ final class SyncLists extends BaseCommand {
return $output;
}
/**
* Go through lists of the specified type, and determine what kind of action each item needs
*
* @param string $type
* @return array
*/
protected function diffLists(string $type = 'anime'): array
private function transformAnilist(string $type, array $data): array
{
// Get libraryEntries with media.mappings from Kitsu
// Organize mappings, and ignore entries without mappings
$kitsuList = $this->formatKitsuList($type);
$uType = ucfirst($type);
$className = "\\Aviat\\AnimeClient\\API\\Anilist\\Transformer\\{$uType}ListTransformer";
$transformer = new $className;
// Get Anilist list data
$anilistList = $this->formatAnilistList($type);
$firstTransformed = [];
foreach ($data['data']['MediaListCollection']['lists'] as $list)
{
$firstTransformed[] = $transformer->untransformCollection($list['entries']);
}
$transformed = array_merge_recursive(...$firstTransformed);
// Key the array by mal_id
$output = [];
foreach ($transformed as $item)
{
$output[$item['mal_id']] = $item->toArray();
}
return $output;
}
// ------------------------------------------------------------------------
// Compare Helpers
// ------------------------------------------------------------------------
private function compareLists(string $type, array $anilistList, array $kitsuList): array
{
$itemsToAddToAnilist = [];
$itemsToAddToKitsu = [];
$anilistUpdateItems = [];
@ -320,15 +410,21 @@ final class SyncLists extends BaseCommand {
$malIds = array_keys($anilistList);
$kitsuMalIds = array_map('intval', array_column($kitsuList, 'malId'));
$missingMalIds = array_diff($malIds, $kitsuMalIds);
$missingMalIds = array_filter(array_diff($kitsuMalIds, $malIds), fn ($id) => ! in_array($id, $kitsuMalIds));
// Add items on Anilist, but not Kitsu to Kitsu
foreach($missingMalIds as $mid)
{
$itemsToAddToKitsu[] = array_merge($anilistList[$mid]['data'], [
'id' => $this->kitsuModel->getKitsuIdFromMALId((string)$mid, $type),
'type' => $type
]);
if ( ! array_key_exists($mid, $anilistList))
{
continue;
}
$data = $anilistList[$mid]['data'];
$data['id'] = $this->kitsuModel->getKitsuIdFromMALId((string)$mid, $type);
$data['type'] = $type;
$itemsToAddToKitsu[] = $data;
}
foreach($kitsuList as $kitsuItem)
@ -359,7 +455,7 @@ final class SyncLists extends BaseCommand {
continue;
}
$statusMap = ($type === 'anime') ? AnimeWatchingStatus::class : MangaReadingStatus::class;
$statusMap = ($type === ListType::ANIME) ? AnimeWatchingStatus::class : MangaReadingStatus::class;
// Looks like this item only exists on Kitsu
$kItem = $kitsuItem['data'];
@ -392,7 +488,7 @@ final class SyncLists extends BaseCommand {
* @param array $anilistItem
* @return array|null
*/
protected function compareListItems(array $kitsuItem, array $anilistItem): ?array
private function compareListItems(array $kitsuItem, array $anilistItem): ?array
{
$compareKeys = [
'notes',
@ -585,6 +681,10 @@ final class SyncLists extends BaseCommand {
return $return;
}
// ------------------------------------------------------------------------
// Update Helpers
// ------------------------------------------------------------------------
/**
* Create/Update list items on Kitsu
*
@ -593,23 +693,23 @@ final class SyncLists extends BaseCommand {
* @param string $type
* @throws Throwable
*/
protected function updateKitsuListItems(array $itemsToUpdate, string $action = 'update', string $type = 'anime'): void
private function updateKitsuListItems(array $itemsToUpdate, string $action = SyncAction::UPDATE, string $type = ListType::ANIME): void
{
$requester = new ParallelAPIRequest();
foreach($itemsToUpdate as $item)
{
if ($action === 'update')
if ($action === SyncAction::UPDATE)
{
$requester->addRequest(
$this->kitsuModel->updateListItem(FormItem::from($item))
);
}
else if ($action === 'create')
else if ($action === SyncAction::CREATE)
{
$maybeRequest = $this->kitsuModel->createListItem($item);
if ($maybeRequest === NULL)
{
$this->echoBox("Skipped creating Kitsu {$type} due to missing id ¯\_(ツ)_/¯");
$this->echoWarning("Skipped creating Kitsu {$type} due to missing id ¯\_(ツ)_/¯");
continue;
}
$requester->addRequest($this->kitsuModel->createListItem($item));
@ -625,8 +725,8 @@ final class SyncLists extends BaseCommand {
$id = $itemsToUpdate[$key]['id'];
if ( ! array_key_exists('errors', $responseData))
{
$verb = ($action === 'update') ? 'updated' : 'created';
$this->echoBox("Successfully {$verb} Kitsu {$type} list item with id: {$id}");
$verb = ($action === SyncAction::UPDATE) ? 'updated' : 'created';
$this->echoSuccess("Successfully {$verb} Kitsu {$type} list item with id: {$id}");
continue;
}
@ -637,14 +737,14 @@ final class SyncLists extends BaseCommand {
if ($errorTitle === 'cannot exceed length of media')
{
$this->echoBox("Skipped Kitsu {$type} {$id} due to episode count mismatch with other API");
$this->echoWarning("Skipped Kitsu {$type} {$id} due to episode count mismatch with other API");
continue;
}
}
dump($responseData);
$verb = ($action === 'update') ? 'update' : 'create';
$this->echoBox("Failed to {$verb} Kitsu {$type} list item with id: {$id}");
$verb = ($action === SyncAction::UPDATE) ? SyncAction::UPDATE : SyncAction::CREATE;
$this->echoError("Failed to {$verb} Kitsu {$type} list item with id: {$id}");
}
}
@ -657,19 +757,19 @@ final class SyncLists extends BaseCommand {
* @param string $type
* @throws Throwable
*/
protected function updateAnilistListItems(array $itemsToUpdate, string $action = 'update', string $type = 'anime'): void
private function updateAnilistListItems(array $itemsToUpdate, string $action = SyncAction::UPDATE, string $type = ListType::ANIME): void
{
$requester = new ParallelAPIRequest();
foreach($itemsToUpdate as $item)
{
if ($action === 'update')
if ($action === SyncAction::UPDATE)
{
$requester->addRequest(
$this->anilistModel->updateListItem(FormItem::from($item), $type)
);
}
else if ($action === 'create')
else if ($action === SyncAction::CREATE)
{
try
{
@ -679,7 +779,7 @@ final class SyncLists extends BaseCommand {
{
// Case where there's a MAL mapping from Kitsu, but no equivalent Anlist item
$id = $item['mal_id'];
$this->echoBox("Skipping Anilist ${type} with mal_id: {$id} due to missing mapping");
$this->echoWarning("Skipping Anilist ${type} with MAL id: {$id} due to missing mapping");
}
}
}
@ -694,15 +794,41 @@ final class SyncLists extends BaseCommand {
if ( ! array_key_exists('errors', $responseData))
{
$verb = ($action === 'update') ? 'updated' : 'created';
$this->echoBox("Successfully {$verb} Anilist {$type} list item with id: {$id}");
$verb = ($action === SyncAction::UPDATE) ? 'updated' : 'created';
$this->echoSuccess("Successfully {$verb} Anilist {$type} list item with id: {$id}");
}
else
{
dump($responseData);
$verb = ($action === 'update') ? 'update' : 'create';
$this->echoBox("Failed to {$verb} Anilist {$type} list item with id: {$id}");
$verb = ($action === SyncAction::UPDATE) ? SyncAction::UPDATE : SyncAction::CREATE;
$this->echoError("Failed to {$verb} Anilist {$type} list item with id: {$id}");
}
}
}
// ------------------------------------------------------------------------
// Other Helpers
// ------------------------------------------------------------------------
/**
* Filter Kitsu mappings for the specified type
*
* @param array $includes
* @param string $type
* @return array
*/
private function filterMappings(array $includes, string $type = ListType::ANIME): array
{
$output = [];
foreach($includes as $id => $mapping)
{
if ($mapping['externalSite'] === "myanimelist/{$type}")
{
$output[$id] = $mapping;
}
}
return $output;
}
}

View File

@ -18,13 +18,14 @@ namespace Aviat\AnimeClient;
use function Aviat\Ion\_dir;
use Aviat\AnimeClient\Enum\EventType;
use Aura\Router\Generator;
use Aura\Session\Segment;
use Aviat\AnimeClient\API\Kitsu\Auth;
use Aviat\Ion\ConfigInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\SimpleCache\CacheInterface;
use Aviat\Ion\Di\{
ContainerAware,
@ -32,6 +33,7 @@ use Aviat\Ion\Di\{
Exception\ContainerException,
Exception\NotFoundException
};
use Aviat\Ion\Event;
use Aviat\Ion\Exception\DoubleRenderException;
use Aviat\Ion\View\{HtmlView, HttpView, JsonView};
use InvalidArgumentException;
@ -51,9 +53,9 @@ class Controller {
/**
* Cache manager
* @var CacheItemPoolInterface
* @var CacheInterface
*/
protected CacheItemPoolInterface $cache;
protected CacheInterface $cache;
/**
* The global configuration object
@ -131,6 +133,10 @@ class Controller {
'url_type' => 'anime',
'urlGenerator' => $urlGenerator,
];
// Set up 'global' events
Event::on(EventType::CLEAR_CACHE, fn () => clearCache($this->cache));
Event::on(EventType::RESET_CACHE_KEY, fn (string $key) => $this->cache->delete($key));
}
/**

View File

@ -17,6 +17,8 @@
namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\Enum\EventType;
use Aviat\Ion\Event;
use Aviat\Ion\View\HtmlView;
/**
@ -30,7 +32,10 @@ final class Misc extends BaseController {
*/
public function clearCache(): void
{
$this->cache->clear();
$this->checkAuth();
Event::emit(EventType::CLEAR_CACHE);
$this->outputHTML('blank', [
'title' => 'Cache cleared'
]);
@ -89,8 +94,6 @@ final class Misc extends BaseController {
*/
public function logout(): void
{
$this->checkAuth();
$auth = $this->container->get('auth');
$auth->logout();

View File

@ -16,12 +16,14 @@
namespace Aviat\AnimeClient;
use Aviat\AnimeClient\Enum\EventType;
use function Aviat\Ion\_dir;
use Aura\Router\{Map, Matcher, Route, Rule};
use Aviat\AnimeClient\API\FailedResponseException;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Event;
use Aviat\Ion\Friend;
use Aviat\Ion\Type\StringType;
use LogicException;
@ -161,10 +163,7 @@ final class Dispatcher extends RoutingBase {
throw new LogicException('Missing controller');
}
if (array_key_exists('controller', $route->attributes))
{
$controllerName = $route->attributes['controller'];
}
// Get the full namespace for a controller if a short name is given
if (strpos($controllerName, '\\') === FALSE)
@ -283,7 +282,7 @@ final class Dispatcher extends RoutingBase {
$logger->debug('Dispatcher - controller arguments', $params);
}
\call_user_func_array([$controller, $method], $params);
call_user_func_array([$controller, $method], $params);
}
catch (FailedResponseException $e)
{
@ -293,7 +292,14 @@ final class Dispatcher extends RoutingBase {
'API request timed out',
'Failed to retrieve data from API (╯°□°)╯︵ ┻━┻');
}
/* finally
{
// Log out on session/api token expiration
Event::on(EventType::UNAUTHORIZED, static function () {
$controllerName = DEFAULT_CONTROLLER;
(new $controllerName($this->container))->logout();
});
} */
}
/**

View File

@ -0,0 +1,27 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 7.4
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2020 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Enum;
use Aviat\Ion\Enum as BaseEnum;
/**
* Types of lists
*/
final class APISource extends BaseEnum {
public const KITSU = 'kitsu';
public const ANILIST = 'anilist';
}

View File

@ -0,0 +1,25 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 7.4
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2020 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Enum;
use Aviat\Ion\Enum as BaseEnum;
final class EventType extends BaseEnum {
public const CLEAR_CACHE = '::clear-cache::';
public const RESET_CACHE_KEY = '::reset-cache-key::';
public const UNAUTHORIZED = '::unauthorized::';
}

View File

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 7.4
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2020 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Enum;
use Aviat\Ion\Enum as BaseEnum;
/**
* Types of lists
*/
final class ListType extends BaseEnum {
public const ANIME = 'anime';
public const DRAMA = 'drama';
public const MANGA = 'manga';
}

View File

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 7.4
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2020 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\Enum;
use Aviat\Ion\Enum as BaseEnum;
/**
* Types of actions when syncing lists from different APIs
*/
final class SyncAction extends BaseEnum {
public const CREATE = 'create';
public const UPDATE = 'update';
public const DELETE = 'delete';
}

View File

@ -105,7 +105,10 @@ final class MenuGenerator extends UrlGenerator {
$has = StringType::from($this->path())->contains($path);
$selected = ($has && mb_strlen($this->path()) >= mb_strlen($path));
$link = $this->helper->a($this->url($path), $title);
$linkAttrs = ($selected)
? ['aria-current' => 'location']
: [];
$link = $this->helper->a($this->url($path), $title, $linkAttrs);
$attrs = $selected
? ['class' => 'selected']

View File

@ -97,6 +97,11 @@ class Anime extends AbstractType {
*/
public array $titles = [];
/**
* @var array
*/
public array $titles_more = [];
/**
* @var string
*/

View File

@ -23,10 +23,10 @@ final class AnimePage extends Anime {
/**
* @var array
*/
public $characters;
public array $characters = [];
/**
* @var array
*/
public $staff;
public array $staff = [];
}

View File

@ -28,7 +28,7 @@ class FormItemData extends AbstractType {
/**
* @var bool
*/
public bool $private = FALSE;
public ?bool $private = FALSE;
/**
* @var int

View File

@ -54,6 +54,29 @@ class Util {
$this->setContainer($container);
}
/**
* Absolutely equal?
*
* @param $left
* @param $right
* @return bool
*/
public static function eq($left, $right): bool
{
return $left === $right;
}
/**
* Set aria-current attribute based on a condition check
*
* @param bool $condition
* @return string
*/
public static function ariaCurrent(bool $condition): string
{
return $condition ? 'true' : 'false';
}
/**
* HTML selection helper function
*
@ -63,7 +86,7 @@ class Util {
*/
public static function isSelected(string $left, string $right): string
{
return ($left === $right) ? 'selected' : '';
return static::eq($left, $right) ? 'selected' : '';
}
/**

View File

@ -92,7 +92,6 @@ const SETTINGS_MAP = [
'title' => 'Cache Type',
'description' => 'The Cache backend',
'options' => [
'APCu' => 'apcu',
'Memcached' => 'memcached',
'Redis' => 'redis',
'No Cache' => 'null'

View File

@ -26,7 +26,7 @@ trait ContainerAware {
*
* @var ContainerInterface
*/
protected $container;
protected ContainerInterface $container;
/**
* Set the container for the current object

View File

@ -17,6 +17,7 @@
namespace Aviat\Ion;
use ReflectionClass;
use ReflectionException;
/**
* Class emulating an enumeration type
@ -27,7 +28,7 @@ abstract class Enum {
* Return the list of constant values for the Enum
*
* @return array
* @throws \ReflectionException
* @throws ReflectionException
*/
public static function getConstList(): array
{
@ -48,12 +49,12 @@ abstract class Enum {
*
* @param mixed $key
* @return boolean
* @throws \ReflectionException
* @throws ReflectionException
*/
public static function isValid($key): bool
{
$values = array_values(static::getConstList());
return \in_array($key, $values, TRUE);
return in_array($key, $values, TRUE);
}
}
// End of Enum.php

55
src/Ion/Event.php Normal file
View File

@ -0,0 +1,55 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu to manage anime and manga watch lists
*
* PHP version 7.4
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2020 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 5
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\Ion;
/**
* A basic event handler
*/
class Event {
private static array $eventMap = [];
/**
* Subscribe to an event
*
* @param string $eventName
* @param callable $handler
*/
public static function on(string $eventName, callable $handler): void
{
if ( ! array_key_exists($eventName, static::$eventMap))
{
static::$eventMap[$eventName] = [];
}
static::$eventMap[$eventName][] = $handler;
}
/**
* Fire off an event
*
* @param string $eventName
* @param array $args
*/
public static function emit(string $eventName, array $args = []): void
{
// Call each subscriber with the provided arguments
if (array_key_exists($eventName, static::$eventMap))
{
array_walk(static::$eventMap[$eventName], fn ($fn) => $fn(...$args));
}
}
}

View File

@ -29,5 +29,8 @@ titles:
- 'Attack on Titan'
- 'Shingeki no Kyojin'
- 進撃の巨人
titles_more:
2: 'Shingeki no Kyojin'
3: 進撃の巨人
trailer_id: n4Nj6Y_SNYI
url: 'https://kitsu.io/anime/attack-on-titan'

View File

@ -55,7 +55,7 @@ class MenuHelperTest extends AnimeClientTestCase {
$expected['no selection'] = $this->helper->ul()->__toString();
// selected
$link = $this->helper->a($this->urlGenerator->url('/foobar'), 'Index');
$link = $this->helper->a($this->urlGenerator->url('/foobar'), 'Index', ['aria-current' => 'location']);
$this->helper->ul()->rawItem($link, ['class' => 'selected']);
$expected['selected'] = $this->helper->ul()->__toString();