Version 5.1 - All the GraphQL #32

Closed
timw4mail wants to merge 1160 commits from develop into master
53 changed files with 1543 additions and 916 deletions
Showing only changes of commit 9297ff4b94 - Show all commits

2
.gitignore vendored
View File

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

View File

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

View File

@ -4,7 +4,7 @@
# See https://git.timshomepage.net/aviat/banker for more information # 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 # Null cache driver means no caching
driver = "redis" driver = "redis"

View File

@ -30,7 +30,15 @@
<?php if ($item['rewatched'] > 0): ?> <?php if ($item['rewatched'] > 0): ?>
<div class="row"> <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> </div>
<?php endif ?> <?php endif ?>

View File

@ -45,7 +45,7 @@
</aside> </aside>
<article class="text"> <article class="text">
<h2 class="toph"><a rel="external" href="<?= $data['url'] ?>"><?= $data['title'] ?></a></h2> <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> <h3><?= $title ?></h3>
<?php endforeach ?> <?php endforeach ?>
<br /> <br />

View File

@ -86,7 +86,17 @@
<br /> <br />
<ul> <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 foreach(['private','rewatching'] as $attr): ?>
<?php if($item[$attr]): ?><li><?= ucfirst($attr); ?></li><?php endif ?> <?php if($item[$attr]): ?><li><?= ucfirst($attr); ?></li><?php endif ?>
<?php endforeach ?> <?php endforeach ?>

View File

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

View File

@ -11,10 +11,10 @@
</a> </a>
<?= ! empty($item['alternate_title']) ? ' <br /><small> ' . $item['alternate_title'] . '</small>' : '' ?> <?= ! empty($item['alternate_title']) ? ' <br /><small> ' . $item['alternate_title'] . '</small>' : '' ?>
</td> </td>
<td><?= $item['episode_count'] ?></td>
<td><?= ($item['episode_count'] > 1) ? $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['show_type'] ?></td>
<td><?= $item['age_rating'] ?></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> <td class="align-left"><?= implode(', ', $item['genres']) ?></td>
</tr> </tr>

View File

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

View File

@ -68,7 +68,15 @@
<?php if ($item['reread'] > 0): ?> <?php if ($item['reread'] > 0): ?>
<div class="row"> <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> </div>
<?php endif ?> <?php endif ?>

View File

@ -53,8 +53,14 @@
<td><?= $item['volumes']['total'] ?></td> <td><?= $item['volumes']['total'] ?></td>
<td> <td>
<ul> <ul>
<?php if ($item['reread'] > 0): ?> <?php if ($item['reread'] == 1): ?>
<li>Reread <?= $item['reread'] ?> time(s)</li> <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 endif ?>
<?php foreach(['rereading'] as $attr): ?> <?php foreach(['rereading'] as $attr): ?>
<?php if($item[$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 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'): ?> <?php if ($field['type'] === 'subfield'): ?>
<section> <section>
<h4><?= $field['title'] ?></h4> <h4><?= $field['title'] ?></h4>
<?php include_once '_form.php'; ?> <?php include '_subfield.php'; ?>
</section> </section>
<?php elseif ( ! empty($field['display'])): ?> <?php elseif ( ! empty($field['display'])): ?>
<article> <?php include '_field.php' ?>
<label for="<?= $fieldname ?>"><?= $field['title'] ?></label><br />
<small><?= $field['description'] ?></small><br />
<?= $helper->field($fieldname, $field); ?>
</article>
<?php else: ?> <?php else: ?>
<?php $hiddenFields[] = $helper->field($fieldname, $field); ?> <?php $hiddenFields[] = $helper->field($fieldName, $field); ?>
<?php endif ?> <?php endif ?>
<?php endforeach ?> <?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/html": "^2.5.0",
"aura/router": "^3.1.0", "aura/router": "^3.1.0",
"aura/session": "^2.1.0", "aura/session": "^2.1.0",
"aviat/banker": "^2.0.0", "aviat/banker": "^3.1.1",
"aviat/query": "^3.0.0", "aviat/query": "^3.0.0",
"danielstjules/stringy": "^3.1.0", "danielstjules/stringy": "^3.1.0",
"ext-dom": "*", "ext-dom": "*",

View File

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

View File

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

View File

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

View File

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

View File

@ -16,7 +16,8 @@
namespace Aviat\AnimeClient\API; namespace Aviat\AnimeClient\API;
use Aviat\Banker\Pool; use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;
/** /**
* Helper methods for dealing with the Cache * Helper methods for dealing with the Cache
@ -24,17 +25,17 @@ use Aviat\Banker\Pool;
trait CacheTrait { trait CacheTrait {
/** /**
* @var Pool * @var CacheInterface
*/ */
protected Pool $cache; protected CacheInterface $cache;
/** /**
* Inject the cache object * Inject the cache object
* *
* @param Pool $cache * @param CacheInterface $cache
* @return $this * @return $this
*/ */
public function setCache(Pool $cache): self public function setCache(CacheInterface $cache): self
{ {
$this->cache = $cache; $this->cache = $cache;
return $this; return $this;
@ -43,13 +44,41 @@ trait CacheTrait {
/** /**
* Get the cache object if it exists * Get the cache object if it exists
* *
* @return Pool * @return CacheInterface
*/ */
public function getCache(): Pool public function getCache(): CacheInterface
{ {
return $this->cache; 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 * 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 public function getHashForMethodCall($object, string $method, array $args = []): string
{ {
$keyObj = [ $keyObj = [
'class' => \get_class($object), 'class' => get_class($object),
'method' => $method, 'method' => $method,
'args' => $args, 'args' => $args,
]; ];

View File

@ -28,6 +28,8 @@ final class Kitsu {
public const AUTH_TOKEN_CACHE_KEY = 'kitsu-auth-token'; public const AUTH_TOKEN_CACHE_KEY = 'kitsu-auth-token';
public const AUTH_TOKEN_EXP_CACHE_KEY = 'kitsu-auth-token-expires'; public const AUTH_TOKEN_EXP_CACHE_KEY = 'kitsu-auth-token-expires';
public const AUTH_TOKEN_REFRESH_CACHE_KEY = 'kitsu-auth-token-refresh'; 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 * 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; 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 * Reorganize streaming links
* *
@ -195,6 +130,23 @@ final class Kitsu {
return []; 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 * Filter out duplicate and very similar names from
* *
@ -206,7 +158,7 @@ final class Kitsu {
// The 'canonical' title is always returned // The 'canonical' title is always returned
$valid = [$data['canonicalTitle']]; $valid = [$data['canonicalTitle']];
if (array_key_exists('titles', $data)) if (array_key_exists('titles', $data) && is_array($data['titles']))
{ {
foreach($data['titles'] as $alternateTitle) foreach($data['titles'] as $alternateTitle)
{ {
@ -220,6 +172,74 @@ final class Kitsu {
return $valid; 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 * 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 Aura\Session\Segment;
use Aviat\Banker\Exception\InvalidArgumentException;
use const Aviat\AnimeClient\SESSION_SEGMENT; use const Aviat\AnimeClient\SESSION_SEGMENT;
use Aviat\AnimeClient\API\{ use Aviat\AnimeClient\API\{
@ -27,6 +26,9 @@ use Aviat\AnimeClient\API\{
}; };
use Aviat\Ion\Di\{ContainerAware, ContainerInterface}; use Aviat\Ion\Di\{ContainerAware, ContainerInterface};
use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException}; use Aviat\Ion\Di\Exception\{ContainerException, NotFoundException};
use Aviat\Ion\Event;
use Psr\SimpleCache\InvalidArgumentException;
use Throwable; use Throwable;
@ -65,6 +67,8 @@ final class Auth {
$this->segment = $container->get('session') $this->segment = $container->get('session')
->getSegment(SESSION_SEGMENT); ->getSegment(SESSION_SEGMENT);
$this->model = $container->get('kitsu-model'); $this->model = $container->get('kitsu-model');
Event::on('::unauthorized::', [$this, 'reAuthenticate'], []);
} }
/** /**
@ -73,7 +77,6 @@ final class Auth {
* *
* @param string $password * @param string $password
* @return boolean * @return boolean
* @throws InvalidArgumentException
* @throws Throwable * @throws Throwable
*/ */
public function authenticate(string $password): bool public function authenticate(string $password): bool
@ -83,85 +86,39 @@ final class Auth {
$auth = $this->model->authenticate($username, $password); $auth = $this->model->authenticate($username, $password);
if (FALSE !== $auth) return $this->storeAuth($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 FALSE;
}
/** /**
* Make the call to re-authenticate with the existing refresh token * Make the call to re-authenticate with the existing refresh token
* *
* @param string $token * @param string $refreshToken
* @return boolean * @return boolean
* @throws InvalidArgumentException * @throws Throwable|InvalidArgumentException
* @throws Throwable
*/ */
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; return FALSE;
} }
$auth = $this->model->reAuthenticate($refreshToken);
return $this->storeAuth($auth);
}
/** /**
* Check whether the current user is authenticated * Check whether the current user is authenticated
* *
* @return boolean * @return boolean
* @throws InvalidArgumentException
*/ */
public function isAuthenticated(): bool 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 * 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); return $this->segment->get('auth_token', NULL);
$refreshToken = $this->segment->get('refresh_token', FALSE); }
$isExpired = time() > $this->segment->get('auth_token_expires', $now + 5000);
// 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 FALSE;
} */
return $token;
} }
} }
// End of KitsuAuth.php // End of KitsuAuth.php

View File

@ -16,10 +16,26 @@
namespace Aviat\AnimeClient\API\Kitsu; namespace Aviat\AnimeClient\API\Kitsu;
use const Aviat\AnimeClient\SESSION_SEGMENT;
use const Aviat\AnimeClient\USER_AGENT; 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\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 { final class KitsuRequestBuilder extends APIRequestBuilder {
use ContainerAware;
/** /**
* The base url for api requests * The base url for api requests
@ -39,4 +55,217 @@ final class KitsuRequestBuilder extends APIRequestBuilder {
'CLIENT_ID' => 'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd', 'CLIENT_ID' => 'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd',
'CLIENT_SECRET' => '54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151', '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; 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 { trait KitsuTrait {
/** /**
* The request builder for the Kitsu API * The request builder for the Kitsu API
* @var KitsuRequestBuilder * @var KitsuRequestBuilder
@ -51,209 +34,4 @@ trait KitsuTrait {
$this->requestBuilder = $requestBuilder; $this->requestBuilder = $requestBuilder;
return $this; 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\ContainerException;
use Aviat\Ion\Di\Exception\NotFoundException; use Aviat\Ion\Di\Exception\NotFoundException;
use const Aviat\AnimeClient\SESSION_SEGMENT;
use function Amp\Promise\wait; use function Amp\Promise\wait;
use function Aviat\AnimeClient\getResponse; use function Aviat\AnimeClient\getResponse;
@ -78,7 +77,7 @@ final class ListItem extends AbstractListItem {
$request = $this->requestBuilder->newRequest('POST', 'library-entries'); $request = $this->requestBuilder->newRequest('POST', 'library-entries');
if ($authHeader !== FALSE) if ($authHeader !== NULL)
{ {
$request = $request->setHeader('Authorization', $authHeader); $request = $request->setHeader('Authorization', $authHeader);
} }
@ -97,7 +96,7 @@ final class ListItem extends AbstractListItem {
$authHeader = $this->getAuthHeader(); $authHeader = $this->getAuthHeader();
$request = $this->requestBuilder->newRequest('DELETE', "library-entries/{$id}"); $request = $this->requestBuilder->newRequest('DELETE', "library-entries/{$id}");
if ($authHeader !== FALSE) if ($authHeader !== NULL)
{ {
$request = $request->setHeader('Authorization', $authHeader); $request = $request->setHeader('Authorization', $authHeader);
} }
@ -119,7 +118,7 @@ final class ListItem extends AbstractListItem {
'include' => 'media,media.categories,media.mappings' 'include' => 'media,media.categories,media.mappings'
]); ]);
if ($authHeader !== FALSE) if ($authHeader !== NULL)
{ {
$request = $request->setHeader('Authorization', $authHeader); $request = $request->setHeader('Authorization', $authHeader);
} }
@ -159,7 +158,7 @@ final class ListItem extends AbstractListItem {
$request = $this->requestBuilder->newRequest('PATCH', "library-entries/{$id}") $request = $this->requestBuilder->newRequest('PATCH', "library-entries/{$id}")
->setJsonBody($requestData); ->setJsonBody($requestData);
if ($authHeader !== FALSE) if ($authHeader !== NULL)
{ {
$request = $request->setHeader('Authorization', $authHeader); $request = $request->setHeader('Authorization', $authHeader);
} }
@ -172,24 +171,15 @@ final class ListItem extends AbstractListItem {
* @throws ContainerException * @throws ContainerException
* @throws NotFoundException * @throws NotFoundException
*/ */
private function getAuthHeader() private function getAuthHeader(): ?string
{ {
$cache = $this->getContainer()->get('cache'); $auth = $this->getContainer()->get('auth');
$cacheItem = $cache->getItem('kitsu-auth-token'); $token = $auth->getAuthToken();
$sessionSegment = $this->getContainer()
->get('session')
->getSegment(SESSION_SEGMENT);
if ($sessionSegment->get('auth_token') !== NULL) { if ( ! empty($token)) {
$token = $sessionSegment->get('auth_token');
return "bearer {$token}"; return "bearer {$token}";
} }
if ($cacheItem->isHit()) { return NULL;
$token = $cacheItem->get();
return "bearer {$token}";
}
return FALSE;
} }
} }

View File

@ -38,6 +38,7 @@ use Aviat\AnimeClient\API\Kitsu\Transformer\{
MangaTransformer, MangaTransformer,
MangaListTransformer MangaListTransformer
}; };
use Aviat\AnimeClient\Enum\ListType;
use Aviat\AnimeClient\Types\{ use Aviat\AnimeClient\Types\{
Anime, Anime,
FormItem, FormItem,
@ -115,7 +116,7 @@ final class Model {
public function authenticate(string $username, string $password) public function authenticate(string $username, string $password)
{ {
// K::AUTH_URL // K::AUTH_URL
$response = $this->getResponse('POST', K::AUTH_URL, [ $response = $this->requestBuilder->getResponse('POST', K::AUTH_URL, [
'headers' => [ 'headers' => [
'accept' => NULL, 'accept' => NULL,
'Content-type' => 'application/x-www-form-urlencoded', 'Content-type' => 'application/x-www-form-urlencoded',
@ -154,19 +155,26 @@ final class Model {
*/ */
public function reAuthenticate(string $token) public function reAuthenticate(string $token)
{ {
$response = $this->getResponse('POST', K::AUTH_URL, [ $response = $this->requestBuilder->getResponse('POST', K::AUTH_URL, [
'headers' => [ 'headers' => [
'accept' => NULL,
'Content-type' => 'application/x-www-form-urlencoded',
'Accept-encoding' => '*' 'Accept-encoding' => '*'
], ],
'form_params' => [ 'form_params' => [
'grant_type' => 'refresh_token', 'grant_type' => 'refresh_token',
'refresh_token' => $token 'refresh_token' => $token
] ]
]); ]);
$data = Json::decode(wait($response->getBody()->buffer())); $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)) if (array_key_exists('access_token', $data))
{ {
return $data; return $data;
@ -175,44 +183,13 @@ final class Model {
return FALSE; 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 * Get the userid for a username from Kitsu
* *
* @param string $username * @param string $username
* @return string * @return string
* @throws InvalidArgumentException * @throws InvalidArgumentException
* @throws Throwable
*/ */
public function getUserIdByUsername(string $username = NULL): string public function getUserIdByUsername(string $username = NULL): string
{ {
@ -221,11 +198,8 @@ final class Model {
$username = $this->getUsername(); $username = $this->getUsername();
} }
$cacheItem = $this->cache->getItem(K::AUTH_USER_ID_KEY); return $this->getCached(K::AUTH_USER_ID_KEY, function(string $username) {
$data = $this->requestBuilder->getRequest('users', [
if ( ! $cacheItem->isHit())
{
$data = $this->getRequest('users', [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'name' => $username 'name' => $username
@ -233,11 +207,8 @@ final class Model {
] ]
]); ]);
$cacheItem->set($data['data'][0]['id']); return $data['data'][0]['id'] ?? NULL;
$cacheItem->save(); }, [$username]);
}
return $cacheItem->get();
} }
/** /**
@ -248,14 +219,14 @@ final class Model {
*/ */
public function getCharacter(string $slug): array public function getCharacter(string $slug): array
{ {
return $this->getRequest('characters', [ return $this->requestBuilder->getRequest('characters', [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'slug' => $slug, 'slug' => $slug,
], ],
'fields' => [ 'fields' => [
'anime' => 'canonicalTitle,titles,slug,posterImage', 'anime' => 'canonicalTitle,abbreviatedTitles,titles,slug,posterImage',
'manga' => 'canonicalTitle,titles,slug,posterImage' 'manga' => 'canonicalTitle,abbreviatedTitles,titles,slug,posterImage'
], ],
'include' => 'castings.person,castings.media' 'include' => 'castings.person,castings.media'
] ]
@ -271,11 +242,7 @@ final class Model {
*/ */
public function getPerson(string $id): array public function getPerson(string $id): array
{ {
$cacheItem = $this->cache->getItem("kitsu-person-{$id}"); return $this->getCached("kitsu-person-{$id}", fn () => $this->requestBuilder->getRequest("people/{$id}", [
if ( ! $cacheItem->isHit())
{
$data = $this->getRequest("people/{$id}", [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'id' => $id, 'id' => $id,
@ -283,19 +250,14 @@ final class Model {
'fields' => [ 'fields' => [
'characters' => 'canonicalName,slug,image', 'characters' => 'canonicalName,slug,image',
'characterVoices' => 'mediaCharacter', 'characterVoices' => 'mediaCharacter',
'anime' => 'canonicalTitle,titles,slug,posterImage', 'anime' => 'canonicalTitle,abbreviatedTitles,titles,slug,posterImage',
'manga' => 'canonicalTitle,titles,slug,posterImage', 'manga' => 'canonicalTitle,abbreviatedTitles,titles,slug,posterImage',
'mediaCharacters' => 'role,media,character', 'mediaCharacters' => 'role,media,character',
'mediaStaff' => 'role,media,person', 'mediaStaff' => 'role,media,person',
], ],
'include' => 'voices.mediaCharacter.media,voices.mediaCharacter.character,staff.media', '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 public function getUserData(string $username): array
{ {
return $this->getRequest('users', [ return $this->requestBuilder->getRequest('users', [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'name' => $username, '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']); $raw['included'] = JsonAPI::organizeIncluded($raw['included']);
foreach ($raw['data'] as &$item) 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)) if ( ! array_key_exists('included', $raw))
{ {
@ -420,6 +382,34 @@ final class Model {
return $this->animeTransformer->transform($baseData); 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 * Get information about a particular anime
* *
@ -441,9 +431,11 @@ final class Model {
*/ */
public function getAnimeList(string $status): array 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) ?? []; $data = $this->getRawAnimeList($status) ?? [];
@ -469,11 +461,11 @@ final class Model {
$keyed[$item['id']] = $item; $keyed[$item['id']] = $item;
} }
$cacheItem->set($keyed); $list = $keyed;
$cacheItem->save(); $this->cache->set($key, $list);
} }
return $cacheItem->get(); return $list;
} }
/** /**
@ -485,27 +477,7 @@ final class Model {
*/ */
public function getAnimeListCount(string $status = '') : int public function getAnimeListCount(string $status = '') : int
{ {
$options = [ return $this->getListCount(ListType::ANIME, $status);
'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'];
} }
/** /**
@ -582,7 +554,7 @@ final class Model {
'include' => 'mappings' 'include' => 'mappings'
] ]
]; ];
$data = $this->getRequest("anime/{$kitsuAnimeId}", $options); $data = $this->requestBuilder->getRequest("anime/{$kitsuAnimeId}", $options);
if ( ! array_key_exists('included', $data)) if ( ! array_key_exists('included', $data))
{ {
@ -617,7 +589,7 @@ final class Model {
{ {
$defaultOptions = [ $defaultOptions = [
'filter' => [ 'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()), 'user_id' => $this->getUserId(),
'kind' => 'anime' 'kind' => 'anime'
], ],
'page' => [ 'page' => [
@ -628,7 +600,7 @@ final class Model {
]; ];
$options = array_merge($defaultOptions, $options); $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 = [ $options = [
'filter' => [ 'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()), 'user_id' => $this->getUserId(),
'kind' => 'anime', 'kind' => 'anime',
'status' => $status, 'status' => $status,
], ],
@ -676,6 +648,32 @@ final class Model {
return $this->mangaTransformer->transform($baseData); 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 * Get information about a particular manga
* *
@ -702,7 +700,7 @@ final class Model {
$options = [ $options = [
'query' => [ 'query' => [
'filter' => [ 'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()), 'user_id' => $this->getUserId(),
'kind' => 'manga', 'kind' => 'manga',
'status' => $status, '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 // Bail out on no data
if (empty($data) || ( ! array_key_exists('included', $data))) if (empty($data) || ( ! array_key_exists('included', $data)))
@ -736,13 +736,12 @@ final class Model {
} }
unset($item); unset($item);
$transformed = $this->mangaListTransformer->transformCollection($data['data']); $list = $this->mangaListTransformer->transformCollection($data['data']);
$cacheItem->set($transformed); $this->cache->set($key, $list);
$cacheItem->save();
} }
return $cacheItem->get(); return $list;
} }
/** /**
@ -754,27 +753,7 @@ final class Model {
*/ */
public function getMangaListCount(string $status = '') : int public function getMangaListCount(string $status = '') : int
{ {
$options = [ return $this->getListCount(ListType::MANGA, $status);
'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'];
} }
/** /**
@ -850,7 +829,7 @@ final class Model {
{ {
$defaultOptions = [ $defaultOptions = [
'filter' => [ 'filter' => [
'user_id' => $this->getUserIdByUsername($this->getUsername()), 'user_id' => $this->getUserId(),
'kind' => 'manga' 'kind' => 'manga'
], ],
'page' => [ 'page' => [
@ -861,7 +840,7 @@ final class Model {
]; ];
$options = array_merge($defaultOptions, $options); $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' 'include' => 'mappings'
] ]
]; ];
$data = $this->getRequest("manga/{$kitsuMangaId}", $options); $data = $this->requestBuilder->getRequest("manga/{$kitsuMangaId}", $options);
$mappings = array_column($data['included'], 'attributes'); $mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map) foreach($mappings as $map)
@ -905,7 +884,7 @@ final class Model {
*/ */
public function createListItem(array $data): ?Request public function createListItem(array $data): ?Request
{ {
$data['user_id'] = $this->getUserIdByUsername($this->getUsername()); $data['user_id'] = $this->getUserId();
if ($data['id'] === NULL) if ($data['id'] === NULL)
{ {
return NULL; return NULL;
@ -976,6 +955,20 @@ final class Model {
return $this->listItem->delete($id); 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 * 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 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' => [ 'query' => [
'filter' => [ 'filter' => [
'kind' => 'progressed,updated', 'kind' => 'progressed,updated',
'userId' => $this->getUserIdByUsername($this->getUsername()), 'userId' => $this->getUserId(),
], ],
'page' => [ 'page' => [
'offset' => $offset, '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 * 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'])) 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'])) if (empty($data['data']))
{ {
@ -1124,4 +1129,93 @@ final class Model {
$baseData['included'] = $data['included']; $baseData['included'] = $data['included'];
return $baseData; 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']; $title = $item['canonicalTitle'];
$titles = Kitsu::filterTitles($item); $titles = Kitsu::filterTitles($item);
$titles_more = Kitsu::getTitles($item);
$characters = []; $characters = [];
$staff = []; $staff = [];
@ -123,6 +124,7 @@ final class AnimeTransformer extends AbstractTransformer {
'synopsis' => $item['synopsis'], 'synopsis' => $item['synopsis'],
'title' => $title, 'title' => $title,
'titles' => $titles, 'titles' => $titles,
'titles_more' => $titles_more,
'trailer_id' => $item['youtubeVideoId'], 'trailer_id' => $item['youtubeVideoId'],
'url' => "https://kitsu.io/anime/{$item['slug']}", 'url' => "https://kitsu.io/anime/{$item['slug']}",
]); ]);

View File

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

View File

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

View File

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

View File

@ -16,6 +16,9 @@
namespace Aviat\AnimeClient; namespace Aviat\AnimeClient;
use Aviat\AnimeClient\API\Kitsu;
use Psr\SimpleCache\CacheInterface;
use Psr\SimpleCache\InvalidArgumentException;
use function Amp\Promise\wait; use function Amp\Promise\wait;
use Amp\Http\Client\Request; use Amp\Http\Client\Request;
@ -26,6 +29,8 @@ use Amp\Http\Client\HttpClientBuilder;
use Aviat\Ion\ConfigInterface; use Aviat\Ion\ConfigInterface;
use Yosymfony\Toml\{Toml, TomlBuilder}; use Yosymfony\Toml\{Toml, TomlBuilder};
use Throwable;
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
//! TOML Functions //! TOML Functions
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -232,7 +237,7 @@ function getApiClient (): HttpClient
* *
* @param string|Request $request * @param string|Request $request
* @return Response * @return Response
* @throws \Throwable * @throws Throwable
*/ */
function getResponse ($request): Response function getResponse ($request): Response
{ {
@ -256,7 +261,7 @@ function getResponse ($request): Response
*/ */
function getLocalImg ($kitsuUrl, $webp = TRUE): string function getLocalImg ($kitsuUrl, $webp = TRUE): string
{ {
if ( ! is_string($kitsuUrl)) if (empty($kitsuUrl) || ( ! is_string($kitsuUrl)))
{ {
return 'images/placeholder.webp'; 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))); $items = array_filter(array_column($search, $key), fn ($x) => ( ! empty($x)));
return count($items) > 0; 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\{Model, UrlGenerator, Util};
use Aviat\AnimeClient\API\{Anilist, CacheTrait, Kitsu}; use Aviat\AnimeClient\API\{Anilist, CacheTrait, Kitsu};
use Aviat\AnimeClient\API\Kitsu\KitsuRequestBuilder; use Aviat\AnimeClient\API\Kitsu\KitsuRequestBuilder;
use Aviat\Banker\Pool; use Aviat\Banker\Teller;
use Aviat\Ion\Config; use Aviat\Ion\Config;
use Aviat\Ion\Di\{Container, ContainerAware}; use Aviat\Ion\Di\{Container, ContainerInterface, ContainerAware};
use ConsoleKit\{Command, ConsoleException}; use ConsoleKit\{Colors, Command, ConsoleException};
use ConsoleKit\Widgets\Box; use ConsoleKit\Widgets\Box;
use Laminas\Diactoros\{Response, ServerRequestFactory}; use Laminas\Diactoros\{Response, ServerRequestFactory};
use Monolog\Handler\RotatingFileHandler; use Monolog\Handler\RotatingFileHandler;
@ -43,16 +43,28 @@ abstract class BaseCommand extends Command {
/** /**
* Echo text in a box * Echo text in a box
* *
* @param string $message * @param string|array $message
* @param string|int|null $fgColor
* @param string|int|null $bgColor
* @return void * @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 try
{ {
echo "\n"; // color message
$message = Colors::colorize($message, $fgColor, $bgColor);
// create the box
$box = new Box($this->getConsole(), $message); $box = new Box($this->getConsole(), $message);
$box->write(); $box->write();
echo "\n"; echo "\n";
} }
catch (ConsoleException $e) 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 * Setup the Di container
* *
* @return Container * @return Containerinterface
*/ */
protected function setupContainer(): Container public function setupContainer(): ContainerInterface
{ {
$APP_DIR = realpath(__DIR__ . '/../../../app'); $APP_DIR = realpath(__DIR__ . '/../../../app');
$APPCONF_DIR = realpath("{$APP_DIR}/appConf/"); $APPCONF_DIR = realpath("{$APP_DIR}/appConf/");
@ -82,7 +129,17 @@ abstract class BaseCommand extends Command {
$configArray = array_replace_recursive($baseConfig, $config, $overrideConfig); $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(); $container = new Container();
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@ -103,44 +160,34 @@ abstract class BaseCommand extends Command {
$container->setLogger($kitsu_request_logger, 'kitsu-request'); $container->setLogger($kitsu_request_logger, 'kitsu-request');
// Create Config Object // Create Config Object
$container->set('config', static function() use ($configArray): Config { $container->set('config', fn () => new Config($configArray));
return new Config($configArray);
});
// Create Cache Object // Create Cache Object
$container->set('cache', static function($container) { $container->set('cache', static function($container) {
$logger = $container->getLogger(); $logger = $container->getLogger();
$config = $container->get('config')->get('cache'); $config = $container->get('config')->get('cache');
return new Pool($config, $logger); return new Teller($config, $logger);
}); });
// Create Aura Router Object // Create Aura Router Object
$container->set('aura-router', static function() { $container->set('aura-router', fn () => new RouterContainer);
return new RouterContainer;
});
// Create Request/Response Objects // Create Request/Response Objects
$container->set('request', static function() { $container->set('request', fn () => ServerRequestFactory::fromGlobals(
return ServerRequestFactory::fromGlobals(
$_SERVER, $_SERVER,
$_GET, $_GET,
$_POST, $_POST,
$_COOKIE, $_COOKIE,
$_FILES $_FILES
); ));
}); $container->set('response', fn () => new Response);
$container->set('response', static function(): Response {
return new Response;
});
// Create session Object // Create session Object
$container->set('session', static function() { $container->set('session', fn () => (new SessionFactory())->newInstance($_COOKIE));
return (new SessionFactory())->newInstance($_COOKIE);
});
// Models // Models
$container->set('kitsu-model', static function($container): Kitsu\Model { $container->set('kitsu-model', static function($container): Kitsu\Model {
$requestBuilder = new KitsuRequestBuilder(); $requestBuilder = new KitsuRequestBuilder($container);
$requestBuilder->setLogger($container->getLogger('kitsu-request')); $requestBuilder->setLogger($container->getLogger('kitsu-request'));
$listItem = new Kitsu\ListItem(); $listItem = new Kitsu\ListItem();
@ -175,21 +222,12 @@ abstract class BaseCommand extends Command {
return $model; return $model;
}); });
$container->set('auth', static function($container): Kitsu\Auth { $container->set('auth', fn ($container) => new Kitsu\Auth($container));
return new Kitsu\Auth($container);
});
$container->set('url-generator', static function($container): UrlGenerator { $container->set('url-generator', fn ($container) => new UrlGenerator($container));
return new UrlGenerator($container);
});
$container->set('util', static function($container): Util { $container->set('util', fn ($container) => new Util($container));
return new Util($container);
});
return $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\ContainerException;
use Aviat\Ion\Di\Exception\NotFoundException; use Aviat\Ion\Di\Exception\NotFoundException;
use function Aviat\AnimeClient\clearCache;
/** /**
* Clears the API Cache * Clears the API Cache
@ -36,8 +37,17 @@ final class CacheClear extends BaseCommand {
{ {
$this->setContainer($this->setupContainer()); $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.'); $this->echoBox('API Cache has been cleared.');
} }
else
{
$this->echoErrorBox('Failed to clear cache.');
}
}
} }

View File

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

View File

@ -16,19 +16,18 @@
namespace Aviat\AnimeClient\Command; namespace Aviat\AnimeClient\Command;
use ConsoleKit\Widgets;
use Aviat\AnimeClient\API\{ use Aviat\AnimeClient\API\{
Anilist\MissingIdException, Anilist\MissingIdException,
FailedResponseException, FailedResponseException,
JsonAPI, JsonAPI,
ParallelAPIRequest ParallelAPIRequest
}; };
use Aviat\AnimeClient\API\Anilist\Transformer\{ use Aviat\AnimeClient\API\Anilist;
AnimeListTransformer as AALT, use Aviat\AnimeClient\API\Kitsu;
MangaListTransformer as AMLT
};
use Aviat\AnimeClient\API\Anilist\Model as AnilistModel;
use Aviat\AnimeClient\API\Kitsu\Model as KitsuModel;
use Aviat\AnimeClient\API\Mapping\{AnimeWatchingStatus, MangaReadingStatus}; use Aviat\AnimeClient\API\Mapping\{AnimeWatchingStatus, MangaReadingStatus};
use Aviat\AnimeClient\Enum\{APISource, ListType, SyncAction};
use Aviat\AnimeClient\Types\FormItem; use Aviat\AnimeClient\Types\FormItem;
use Aviat\Ion\Di\Exception\ContainerException; use Aviat\Ion\Di\Exception\ContainerException;
use Aviat\Ion\Di\Exception\NotFoundException; use Aviat\Ion\Di\Exception\NotFoundException;
@ -44,18 +43,24 @@ final class SyncLists extends BaseCommand {
/** /**
* Model for making requests to Anilist API * 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 * 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 $args
* @param array $options * @param array $options
@ -64,6 +69,31 @@ final class SyncLists extends BaseCommand {
* @throws Throwable * @throws Throwable
*/ */
public function execute(array $args, array $options = []): void 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->setContainer($this->setupContainer());
$this->setCache($this->container->get('cache')); $this->setCache($this->container->get('cache'));
@ -71,28 +101,198 @@ final class SyncLists extends BaseCommand {
$config = $this->container->get('config'); $config = $this->container->get('config');
$anilistEnabled = $config->get(['anilist', 'enabled']); $anilistEnabled = $config->get(['anilist', 'enabled']);
// We can't sync kitsu against itself!
if ( ! $anilistEnabled) if ( ! $anilistEnabled)
{ {
$this->echoBox('Anlist API is not enabled. Can not sync.'); $this->echoErrorBox('Anlist API is not enabled. Can not sync.');
return; 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->anilistModel = $this->container->get('anilist-model');
$this->kitsuModel = $this->container->get('kitsu-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 * @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 * @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); $uType = ucfirst($type);
@ -106,157 +306,31 @@ final class SyncLists extends BaseCommand {
dump($e); dump($e);
} }
return $kitsuCount;
$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);
} }
if ( ! empty($data['updateAnilist'])) private function fetchKitsu(string $type): array
{ {
$count = count($data['updateAnilist']); return $this->kitsuModel->getSyncList($type);
$this->echoBox("Updating {$count} outdated Anilist {$type} list items");
$this->updateAnilistListItems($data['updateAnilist'], 'update', $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)) if (empty($data))
{ {
return []; return [];
} }
if ( ! array_key_exists('included', $data))
{
dump($data);
return [];
}
$includes = JsonAPI::organizeIncludes($data['included']); $includes = JsonAPI::organizeIncludes($data['included']);
$includes['mappings'] = $this->filterMappings($includes['mappings'], $type); $includes['mappings'] = $this->filterMappings($includes['mappings'], $type);
@ -271,7 +345,7 @@ final class SyncLists extends BaseCommand {
foreach ($potentialMappings as $mappingId) foreach ($potentialMappings as $mappingId)
{ {
if (\is_array($mappingId)) if (is_array($mappingId))
{ {
continue; continue;
} }
@ -298,21 +372,37 @@ final class SyncLists extends BaseCommand {
return $output; return $output;
} }
/** private function transformAnilist(string $type, array $data): array
* 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
{ {
// Get libraryEntries with media.mappings from Kitsu $uType = ucfirst($type);
// Organize mappings, and ignore entries without mappings $className = "\\Aviat\\AnimeClient\\API\\Anilist\\Transformer\\{$uType}ListTransformer";
$kitsuList = $this->formatKitsuList($type); $transformer = new $className;
// Get Anilist list data $firstTransformed = [];
$anilistList = $this->formatAnilistList($type);
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 = []; $itemsToAddToAnilist = [];
$itemsToAddToKitsu = []; $itemsToAddToKitsu = [];
$anilistUpdateItems = []; $anilistUpdateItems = [];
@ -320,15 +410,21 @@ final class SyncLists extends BaseCommand {
$malIds = array_keys($anilistList); $malIds = array_keys($anilistList);
$kitsuMalIds = array_map('intval', array_column($kitsuList, 'malId')); $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 // Add items on Anilist, but not Kitsu to Kitsu
foreach($missingMalIds as $mid) foreach($missingMalIds as $mid)
{ {
$itemsToAddToKitsu[] = array_merge($anilistList[$mid]['data'], [ if ( ! array_key_exists($mid, $anilistList))
'id' => $this->kitsuModel->getKitsuIdFromMALId((string)$mid, $type), {
'type' => $type continue;
]); }
$data = $anilistList[$mid]['data'];
$data['id'] = $this->kitsuModel->getKitsuIdFromMALId((string)$mid, $type);
$data['type'] = $type;
$itemsToAddToKitsu[] = $data;
} }
foreach($kitsuList as $kitsuItem) foreach($kitsuList as $kitsuItem)
@ -359,7 +455,7 @@ final class SyncLists extends BaseCommand {
continue; continue;
} }
$statusMap = ($type === 'anime') ? AnimeWatchingStatus::class : MangaReadingStatus::class; $statusMap = ($type === ListType::ANIME) ? AnimeWatchingStatus::class : MangaReadingStatus::class;
// Looks like this item only exists on Kitsu // Looks like this item only exists on Kitsu
$kItem = $kitsuItem['data']; $kItem = $kitsuItem['data'];
@ -392,7 +488,7 @@ final class SyncLists extends BaseCommand {
* @param array $anilistItem * @param array $anilistItem
* @return array|null * @return array|null
*/ */
protected function compareListItems(array $kitsuItem, array $anilistItem): ?array private function compareListItems(array $kitsuItem, array $anilistItem): ?array
{ {
$compareKeys = [ $compareKeys = [
'notes', 'notes',
@ -585,6 +681,10 @@ final class SyncLists extends BaseCommand {
return $return; return $return;
} }
// ------------------------------------------------------------------------
// Update Helpers
// ------------------------------------------------------------------------
/** /**
* Create/Update list items on Kitsu * Create/Update list items on Kitsu
* *
@ -593,23 +693,23 @@ final class SyncLists extends BaseCommand {
* @param string $type * @param string $type
* @throws Throwable * @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(); $requester = new ParallelAPIRequest();
foreach($itemsToUpdate as $item) foreach($itemsToUpdate as $item)
{ {
if ($action === 'update') if ($action === SyncAction::UPDATE)
{ {
$requester->addRequest( $requester->addRequest(
$this->kitsuModel->updateListItem(FormItem::from($item)) $this->kitsuModel->updateListItem(FormItem::from($item))
); );
} }
else if ($action === 'create') else if ($action === SyncAction::CREATE)
{ {
$maybeRequest = $this->kitsuModel->createListItem($item); $maybeRequest = $this->kitsuModel->createListItem($item);
if ($maybeRequest === NULL) if ($maybeRequest === NULL)
{ {
$this->echoBox("Skipped creating Kitsu {$type} due to missing id ¯\_(ツ)_/¯"); $this->echoWarning("Skipped creating Kitsu {$type} due to missing id ¯\_(ツ)_/¯");
continue; continue;
} }
$requester->addRequest($this->kitsuModel->createListItem($item)); $requester->addRequest($this->kitsuModel->createListItem($item));
@ -625,8 +725,8 @@ final class SyncLists extends BaseCommand {
$id = $itemsToUpdate[$key]['id']; $id = $itemsToUpdate[$key]['id'];
if ( ! array_key_exists('errors', $responseData)) if ( ! array_key_exists('errors', $responseData))
{ {
$verb = ($action === 'update') ? 'updated' : 'created'; $verb = ($action === SyncAction::UPDATE) ? 'updated' : 'created';
$this->echoBox("Successfully {$verb} Kitsu {$type} list item with id: {$id}"); $this->echoSuccess("Successfully {$verb} Kitsu {$type} list item with id: {$id}");
continue; continue;
} }
@ -637,14 +737,14 @@ final class SyncLists extends BaseCommand {
if ($errorTitle === 'cannot exceed length of media') 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; continue;
} }
} }
dump($responseData); dump($responseData);
$verb = ($action === 'update') ? 'update' : 'create'; $verb = ($action === SyncAction::UPDATE) ? SyncAction::UPDATE : SyncAction::CREATE;
$this->echoBox("Failed to {$verb} Kitsu {$type} list item with id: {$id}"); $this->echoError("Failed to {$verb} Kitsu {$type} list item with id: {$id}");
} }
} }
@ -657,19 +757,19 @@ final class SyncLists extends BaseCommand {
* @param string $type * @param string $type
* @throws Throwable * @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(); $requester = new ParallelAPIRequest();
foreach($itemsToUpdate as $item) foreach($itemsToUpdate as $item)
{ {
if ($action === 'update') if ($action === SyncAction::UPDATE)
{ {
$requester->addRequest( $requester->addRequest(
$this->anilistModel->updateListItem(FormItem::from($item), $type) $this->anilistModel->updateListItem(FormItem::from($item), $type)
); );
} }
else if ($action === 'create') else if ($action === SyncAction::CREATE)
{ {
try try
{ {
@ -679,7 +779,7 @@ final class SyncLists extends BaseCommand {
{ {
// Case where there's a MAL mapping from Kitsu, but no equivalent Anlist item // Case where there's a MAL mapping from Kitsu, but no equivalent Anlist item
$id = $item['mal_id']; $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)) if ( ! array_key_exists('errors', $responseData))
{ {
$verb = ($action === 'update') ? 'updated' : 'created'; $verb = ($action === SyncAction::UPDATE) ? 'updated' : 'created';
$this->echoBox("Successfully {$verb} Anilist {$type} list item with id: {$id}"); $this->echoSuccess("Successfully {$verb} Anilist {$type} list item with id: {$id}");
} }
else else
{ {
dump($responseData); dump($responseData);
$verb = ($action === 'update') ? 'update' : 'create'; $verb = ($action === SyncAction::UPDATE) ? SyncAction::UPDATE : SyncAction::CREATE;
$this->echoBox("Failed to {$verb} Anilist {$type} list item with id: {$id}"); $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 function Aviat\Ion\_dir;
use Aviat\AnimeClient\Enum\EventType;
use Aura\Router\Generator; use Aura\Router\Generator;
use Aura\Session\Segment; use Aura\Session\Segment;
use Aviat\AnimeClient\API\Kitsu\Auth; use Aviat\AnimeClient\API\Kitsu\Auth;
use Aviat\Ion\ConfigInterface; use Aviat\Ion\ConfigInterface;
use Psr\Cache\CacheItemPoolInterface;
use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Message\ServerRequestInterface;
use Psr\SimpleCache\CacheInterface;
use Aviat\Ion\Di\{ use Aviat\Ion\Di\{
ContainerAware, ContainerAware,
@ -32,6 +33,7 @@ use Aviat\Ion\Di\{
Exception\ContainerException, Exception\ContainerException,
Exception\NotFoundException Exception\NotFoundException
}; };
use Aviat\Ion\Event;
use Aviat\Ion\Exception\DoubleRenderException; use Aviat\Ion\Exception\DoubleRenderException;
use Aviat\Ion\View\{HtmlView, HttpView, JsonView}; use Aviat\Ion\View\{HtmlView, HttpView, JsonView};
use InvalidArgumentException; use InvalidArgumentException;
@ -51,9 +53,9 @@ class Controller {
/** /**
* Cache manager * Cache manager
* @var CacheItemPoolInterface * @var CacheInterface
*/ */
protected CacheItemPoolInterface $cache; protected CacheInterface $cache;
/** /**
* The global configuration object * The global configuration object
@ -131,6 +133,10 @@ class Controller {
'url_type' => 'anime', 'url_type' => 'anime',
'urlGenerator' => $urlGenerator, '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; namespace Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\Controller as BaseController; use Aviat\AnimeClient\Controller as BaseController;
use Aviat\AnimeClient\Enum\EventType;
use Aviat\Ion\Event;
use Aviat\Ion\View\HtmlView; use Aviat\Ion\View\HtmlView;
/** /**
@ -30,7 +32,10 @@ final class Misc extends BaseController {
*/ */
public function clearCache(): void public function clearCache(): void
{ {
$this->cache->clear(); $this->checkAuth();
Event::emit(EventType::CLEAR_CACHE);
$this->outputHTML('blank', [ $this->outputHTML('blank', [
'title' => 'Cache cleared' 'title' => 'Cache cleared'
]); ]);
@ -89,8 +94,6 @@ final class Misc extends BaseController {
*/ */
public function logout(): void public function logout(): void
{ {
$this->checkAuth();
$auth = $this->container->get('auth'); $auth = $this->container->get('auth');
$auth->logout(); $auth->logout();

View File

@ -16,12 +16,14 @@
namespace Aviat\AnimeClient; namespace Aviat\AnimeClient;
use Aviat\AnimeClient\Enum\EventType;
use function Aviat\Ion\_dir; use function Aviat\Ion\_dir;
use Aura\Router\{Map, Matcher, Route, Rule}; use Aura\Router\{Map, Matcher, Route, Rule};
use Aviat\AnimeClient\API\FailedResponseException; use Aviat\AnimeClient\API\FailedResponseException;
use Aviat\Ion\Di\ContainerInterface; use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\Event;
use Aviat\Ion\Friend; use Aviat\Ion\Friend;
use Aviat\Ion\Type\StringType; use Aviat\Ion\Type\StringType;
use LogicException; use LogicException;
@ -161,10 +163,7 @@ final class Dispatcher extends RoutingBase {
throw new LogicException('Missing controller'); throw new LogicException('Missing controller');
} }
if (array_key_exists('controller', $route->attributes))
{
$controllerName = $route->attributes['controller']; $controllerName = $route->attributes['controller'];
}
// Get the full namespace for a controller if a short name is given // Get the full namespace for a controller if a short name is given
if (strpos($controllerName, '\\') === FALSE) if (strpos($controllerName, '\\') === FALSE)
@ -283,7 +282,7 @@ final class Dispatcher extends RoutingBase {
$logger->debug('Dispatcher - controller arguments', $params); $logger->debug('Dispatcher - controller arguments', $params);
} }
\call_user_func_array([$controller, $method], $params); call_user_func_array([$controller, $method], $params);
} }
catch (FailedResponseException $e) catch (FailedResponseException $e)
{ {
@ -293,7 +292,14 @@ final class Dispatcher extends RoutingBase {
'API request timed out', 'API request timed out',
'Failed to retrieve data from API (╯°□°)╯︵ ┻━┻'); '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); $has = StringType::from($this->path())->contains($path);
$selected = ($has && mb_strlen($this->path()) >= mb_strlen($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 $attrs = $selected
? ['class' => 'selected'] ? ['class' => 'selected']

View File

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

View File

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

View File

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

View File

@ -54,6 +54,29 @@ class Util {
$this->setContainer($container); $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 * HTML selection helper function
* *
@ -63,7 +86,7 @@ class Util {
*/ */
public static function isSelected(string $left, string $right): string 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', 'title' => 'Cache Type',
'description' => 'The Cache backend', 'description' => 'The Cache backend',
'options' => [ 'options' => [
'APCu' => 'apcu',
'Memcached' => 'memcached', 'Memcached' => 'memcached',
'Redis' => 'redis', 'Redis' => 'redis',
'No Cache' => 'null' 'No Cache' => 'null'

View File

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

View File

@ -17,6 +17,7 @@
namespace Aviat\Ion; namespace Aviat\Ion;
use ReflectionClass; use ReflectionClass;
use ReflectionException;
/** /**
* Class emulating an enumeration type * Class emulating an enumeration type
@ -27,7 +28,7 @@ abstract class Enum {
* Return the list of constant values for the Enum * Return the list of constant values for the Enum
* *
* @return array * @return array
* @throws \ReflectionException * @throws ReflectionException
*/ */
public static function getConstList(): array public static function getConstList(): array
{ {
@ -48,12 +49,12 @@ abstract class Enum {
* *
* @param mixed $key * @param mixed $key
* @return boolean * @return boolean
* @throws \ReflectionException * @throws ReflectionException
*/ */
public static function isValid($key): bool public static function isValid($key): bool
{ {
$values = array_values(static::getConstList()); $values = array_values(static::getConstList());
return \in_array($key, $values, TRUE); return in_array($key, $values, TRUE);
} }
} }
// End of Enum.php // 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' - 'Attack on Titan'
- 'Shingeki no Kyojin' - 'Shingeki no Kyojin'
- 進撃の巨人 - 進撃の巨人
titles_more:
2: 'Shingeki no Kyojin'
3: 進撃の巨人
trailer_id: n4Nj6Y_SNYI trailer_id: n4Nj6Y_SNYI
url: 'https://kitsu.io/anime/attack-on-titan' 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(); $expected['no selection'] = $this->helper->ul()->__toString();
// selected // 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']); $this->helper->ul()->rawItem($link, ['class' => 'selected']);
$expected['selected'] = $this->helper->ul()->__toString(); $expected['selected'] = $this->helper->ul()->__toString();