More refactoring work, some groundwork for Anilist integration

This commit is contained in:
Timothy Warren 2018-08-10 20:10:19 -04:00
parent dd46e292c4
commit 0dcf25e16c
18 changed files with 600 additions and 35 deletions

View File

@ -8,13 +8,12 @@
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2017 Timothy J. Warren
* @copyright 2015 - 2018 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://github.com/timw4mail/HummingBirdAnimeClient
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
use const Aviat\AnimeClient\{
DEFAULT_CONTROLLER_METHOD,
DEFAULT_CONTROLLER
@ -184,6 +183,16 @@ return [
// ---------------------------------------------------------------------
// Default / Shared routes
// ---------------------------------------------------------------------
'anilist-redirect' => [
'path' => '/anilist-redirect',
'action' => 'anilistRedirect',
'controller' => DEFAULT_CONTROLLER,
],
'anilist-oauth' => [
'path' => '/anilist-oauth',
'action' => 'anilistCallback',
'controller' => DEFAULT_CONTROLLER,
],
'image_proxy' => [
'path' => '/public/images/{type}/{file}',
'action' => 'images',

View File

@ -20,6 +20,7 @@ use Aura\Html\HelperLocatorFactory;
use Aura\Router\RouterContainer;
use Aura\Session\SessionFactory;
use Aviat\AnimeClient\API\{
Anilist,
Kitsu,
MAL,
Kitsu\KitsuRequestBuilder,
@ -45,11 +46,14 @@ return function (array $configArray = []) {
$appLogger = new Logger('animeclient');
$appLogger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/app.log', Logger::NOTICE));
$anilistRequestLogger = new Logger('anilist-request');
$anilistRequestLogger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/anilist_request.log', Logger::NOTICE));
$kitsuRequestLogger = new Logger('kitsu-request');
$kitsuRequestLogger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/kitsu_request.log', Logger::NOTICE));
$malRequestLogger = new Logger('mal-request');
$malRequestLogger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/mal_request.log', Logger::NOTICE));
$container->setLogger($appLogger);
$container->setLogger($anilistRequestLogger, 'anilist-request');
$container->setLogger($kitsuRequestLogger, 'kitsu-request');
$container->setLogger($malRequestLogger, 'mal-request');

73
src/API/Anilist.php Normal file
View File

@ -0,0 +1,73 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2018 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API;
use Aviat\AnimeClient\API\Enum\{
AnimeWatchingStatus\Kitsu as KAWS,
MangaReadingStatus\Kitsu as KMRS
};
use Aviat\AnimeClient\API\Enum\{
AnimeWatchingStatus\Anilist as AnimeWatchingStatus,
MangaReadingStatus\Anilist as MangaReadingStatus
};
/**
* Constants and mappings for the Anilist API
*/
final class Anilist {
const AUTH_URL = 'https://anilist.co/api/v2/oauth/authorize';
const BASE_URL = 'https://graphql.anilist.co';
const KITSU_ANILIST_WATCHING_STATUS_MAP = [
KAWS::WATCHING => AnimeWatchingStatus::WATCHING,
KAWS::COMPLETED => AnimeWatchingStatus::COMPLETED,
KAWS::ON_HOLD => AnimeWatchingStatus::ON_HOLD,
KAWS::DROPPED => AnimeWatchingStatus::DROPPED,
KAWS::PLAN_TO_WATCH => AnimeWatchingStatus::PLAN_TO_WATCH,
];
const ANILIST_KITSU_WATCHING_STATUS_MAP = [
'CURRENT' => KAWS::WATCHING,
'COMPLETED' => KAWS::COMPLETED,
'PAUSED' => KAWS::ON_HOLD,
'DROPPED' => KAWS::DROPPED,
'PLANNING' => KAWS::PLAN_TO_WATCH,
];
public static function getIdToWatchingStatusMap()
{
return [
'CURRENT' => AnimeWatchingStatus::WATCHING,
'COMPLETED' => AnimeWatchingStatus::COMPLETED,
'PAUSED' => AnimeWatchingStatus::ON_HOLD,
'DROPPED' => AnimeWatchingStatus::DROPPED,
'PLANNING' => AnimeWatchingStatus::PLAN_TO_WATCH,
'REPEATING' => AnimeWatchingStatus::WATCHING,
];
}
public static function getIdToReadingStatusMap()
{
return [
'CURRENT' => MangaReadingStatus::READING,
'COMPLETED' => MangaReadingStatus::COMPLETED,
'PAUSED' => MangaReadingStatus::ON_HOLD,
'DROPPED' => MangaReadingStatus::DROPPED,
'PLANNING' => MangaReadingStatus::PLAN_TO_READ
];
}
}

View File

@ -0,0 +1,49 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2018 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Anilist;
use const Aviat\AnimeClient\USER_AGENT;
use Aviat\AnimeClient\API\APIRequestBuilder;
final class AnilistRequestBuilder extends APIRequestBuilder {
/**
* The base url for api requests
* @var string $base_url
*/
protected $baseUrl = 'https://kitsu.io/api/edge/';
/**
* Valid HTTP request methods
* @var array
*/
protected $validMethods = ['POST'];
/**
* HTTP headers to send with every request
*
* @var array
*/
protected $defaultHeaders = [
'User-Agent' => USER_AGENT,
'Accept' => 'application/vnd.api+json',
'Content-Type' => 'application/vnd.api+json',
'CLIENT_ID' => 'dd031b32d2f56c990b1425efe6c42ad847e7fe3ab46bf1299f05ecd856bdb7dd',
'CLIENT_SECRET' => '54d7307928f63414defd96399fc31ba847961ceaecef3a5fd93144e960c0e151',
];
}

View File

@ -0,0 +1,179 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2018 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\MAL;
use function Amp\Promise\wait;
use Aviat\AnimeClient\API\{
Anilist,
HummingbirdClient
};
trait AnilistTrait {
/**
* The request builder for the MAL API
* @var AnilistRequestBuilder
*/
protected $requestBuilder;
/**
* The base url for api requests
* @var string $base_url
*/
protected $baseUrl = Anilist::BASE_URL;
/**
* HTTP headers to send with every request
*
* @var array
*/
protected $defaultHeaders = [
'Accept' => 'application/json',
'Accept-Encoding' => 'gzip',
'Content-type' => 'application/json',
'User-Agent' => "Tim's Anime Client/4.0"
];
/**
* Set the request builder object
*
* @param MALRequestBuilder $requestBuilder
* @return self
*/
public function setRequestBuilder($requestBuilder): self
{
$this->requestBuilder = $requestBuilder;
return $this;
}
/**
* Create a request object
*
* @param string $type
* @param string $url
* @param array $options
* @return \Amp\Artax\Response
*/
public function setUpRequest(string $type, string $url, array $options = [])
{
$config = $this->container->get('config');
$request = $this->requestBuilder
->newRequest($type, $url)
->setBasicAuth($config->get(['mal','username']), $config->get(['mal','password']));
if (array_key_exists('query', $options))
{
$request = $request->setQuery($options['query']);
}
if (array_key_exists('body', $options))
{
$request = $request->setBody($options['body']);
}
return $request->getFullRequest();
}
/**
* Make a request
*
* @param string $type
* @param string $url
* @param array $options
* @return \Amp\Artax\Response
*/
private function getResponse(string $type, string $url, array $options = [])
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('mal-request');
}
$request = $this->setUpRequest($type, $url, $options);
$response = wait((new HummingbirdClient)->request($request));
$logger->debug('MAL api response', [
'status' => $response->getStatus(),
'reason' => $response->getReason(),
'body' => $response->getBody(),
'headers' => $response->getHeaders(),
'requestHeaders' => $request->getHeaders(),
]);
return $response;
}
/**
* Make a request
*
* @param string $type
* @param string $url
* @param array $options
* @return array
*/
private function request(string $type, string $url, array $options = []): array
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('anilist-request');
}
$response = $this->getResponse($type, $url, $options);
if ((int) $response->getStatus() > 299 OR (int) $response->getStatus() < 200)
{
if ($logger)
{
$logger->warning('Non 200 response for api call', (array)$response->getBody());
}
}
return XML::toArray(wait($response->getBody()));
}
/**
* Remove some boilerplate for post requests
*
* @param mixed ...$args
* @return array
*/
protected function postRequest(...$args): array
{
$logger = NULL;
if ($this->getContainer())
{
$logger = $this->container->getLogger('anilist-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', (array)$response->getBody());
}
}
return XML::toArray($response->getBody());
}
}

View File

@ -0,0 +1,109 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2018 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Anilist;
use Amp\Artax\{FormBody, Request};
use Aviat\AnimeClient\API\{
XML
};
use Aviat\AnimeClient\Types\AbstractType;
use Aviat\Ion\Di\ContainerAware;
/**
* CRUD operations for MAL list items
*/
final class ListItem {
use ContainerAware;
use AnilistTrait;
/**
* Create a list item
*
* @param array $data
* @param string $type
* @return Request
*/
public function create(array $data, string $type = 'anime'): Request
{
$id = $data['id'];
$createData = [
'id' => $id,
'data' => XML::toXML([
'entry' => $data['data']
])
];
$config = $this->container->get('config');
return $this->requestBuilder->newRequest('POST', "{$type}list/add/{$id}.xml")
->setFormFields($createData)
->setBasicAuth($config->get(['mal','username']), $config->get(['mal', 'password']))
->getFullRequest();
}
/**
* Delete a list item
*
* @param string $id
* @param string $type
* @return Request
*/
public function delete(string $id, string $type = 'anime'): Request
{
$config = $this->container->get('config');
return $this->requestBuilder->newRequest('DELETE', "{$type}list/delete/{$id}.xml")
->setFormFields([
'id' => $id
])
->setBasicAuth($config->get(['mal','username']), $config->get(['mal', 'password']))
->getFullRequest();
// return $response->getBody() === 'Deleted'
}
public function get(string $id): array
{
return [];
}
/**
* Update a list item
*
* @param string $id
* @param AbstractType $data
* @param string $type
* @return Request
*/
public function update(string $id, AbstractType $data, string $type = 'anime'): Request
{
$config = $this->container->get('config');
$xml = XML::toXML(['entry' => $data]);
$body = new FormBody();
$body->addField('id', $id);
$body->addField('data', $xml);
return $this->requestBuilder->newRequest('POST', "{$type}list/update/{$id}.xml")
->setFormFields([
'id' => $id,
'data' => $xml
])
->setBasicAuth($config->get(['mal','username']), $config->get(['mal', 'password']))
->getFullRequest();
}
}

23
src/API/Anilist/Model.php Normal file
View File

@ -0,0 +1,23 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2018 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Anilist;
/**
* Anilist API Model
*/
final class Model {
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2018 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Enum\AnimeWatchingStatus;
use Aviat\Ion\Enum;
/**
* Possible values for watching status for the current anime
*/
final class Anilist extends Enum {
const WATCHING = 'CURRENT';
const COMPLETED = 'COMPLETED';
const ON_HOLD = 'PAUSED';
const DROPPED = 'DROPPED';
const PLAN_TO_WATCH = 'PLANNING';
const REPEATING = 'REPEATING';
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
/**
* Hummingbird Anime List Client
*
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
*
* PHP version 7
*
* @package HummingbirdAnimeClient
* @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2018 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0
* @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient
*/
namespace Aviat\AnimeClient\API\Enum\MangaReadingStatus;
use Aviat\Ion\Enum;
/**
* Possible values for watching status for the current anime
*/
final class Anilist extends Enum {
const WATCHING = 'CURRENT';
const COMPLETED = 'COMPLETED';
const ON_HOLD = 'PAUSED';
const DROPPED = 'DROPPED';
const PLAN_TO_WATCH = 'PLANNING';
const REPEATING = 'REPEATING';
}

View File

@ -143,7 +143,10 @@ final class Kitsu {
*/
public static function parseStreamingLinks(array $included): array
{
if ( ! array_key_exists('streamingLinks', $included))
if (
( ! array_key_exists('streamingLinks', $included)) ||
count($included['streamingLinks']) === 0
)
{
return [];
}
@ -152,7 +155,16 @@ final class Kitsu {
foreach ($included['streamingLinks'] as $streamingLink)
{
$host = parse_url($streamingLink['url'], \PHP_URL_HOST);
$url = $streamingLink['url'];
// 'Fix' links that start with the hostname,
// rather than a protocol
if (strpos($url, '//') === FALSE)
{
$url = '//' . $url;
}
$host = parse_url($url, \PHP_URL_HOST);
$links[] = [
'meta' => static::getServiceMetaData($host),
@ -182,17 +194,7 @@ final class Kitsu {
if (count($anime['relationships']['streamingLinks']) > 0)
{
foreach ($anime['relationships']['streamingLinks'] as $streamingLink)
{
$host = parse_url($streamingLink['url'], \PHP_URL_HOST);
$links[] = [
'meta' => static::getServiceMetaData($host),
'link' => $streamingLink['url'],
'subs' => $streamingLink['subs'],
'dubs' => $streamingLink['dubs']
];
}
return static::parseStreamingLinks($anime['relationships']);
}
return $links;
@ -243,10 +245,9 @@ final class Kitsu {
foreach($existingTitles as $existing)
{
$isSubset = mb_substr_count($existing, $title) > 0;
$diff = levenshtein($existing, $title);
$onlydifferentCase = (mb_strtolower($existing) === mb_strtolower($title));
$diff = levenshtein(mb_strtolower($existing), mb_strtolower($title));
if ($diff <= 3 OR $isSubset OR $onlydifferentCase OR mb_strlen($title) > 55 OR mb_strlen($existing) > 60)
if ($diff <= 4 || $isSubset || mb_strlen($title) > 40 || mb_strlen($existing) > 40)
{
return FALSE;
}

View File

@ -39,8 +39,8 @@ final class AnimeTransformer extends AbstractTransformer {
$item['genres'] = array_column($genres, 'title') ?? [];
sort($item['genres']);
$titles = Kitsu::filterTitles($item);
$title = array_shift($titles);
$title = $item['canonicalTitle'];
$titles = array_diff($item['titles'], [$title]);
return new Anime([
'age_rating' => $item['ageRating'],

View File

@ -16,6 +16,8 @@
namespace Aviat\AnimeClient\API\MAL;
use const Aviat\AnimeClient\USER_AGENT;
use Aviat\AnimeClient\API\{
APIRequestBuilder,
MAL as M
@ -38,7 +40,7 @@ final class MALRequestBuilder extends APIRequestBuilder {
'Accept' => 'text/xml',
'Accept-Encoding' => 'gzip',
'Content-type' => 'application/x-www-form-urlencoded',
'User-Agent' => "Tim's Anime Client/4.0"
'User-Agent' => USER_AGENT,
];
/**

View File

@ -16,7 +16,7 @@
namespace Aviat\AnimeClient\API\Mapping;
use Aviat\AnimeClient\API\Enum\AnimeWatchingStatus\{Kitsu, MAL, Route, Title};
use Aviat\AnimeClient\API\Enum\AnimeWatchingStatus\{Anilist, Kitsu, MAL, Route, Title};
use Aviat\Ion\Enum;
/**
@ -24,6 +24,22 @@ use Aviat\Ion\Enum;
* and url route segments
*/
final class AnimeWatchingStatus extends Enum {
const ANILIST_TO_KITSU = [
Anilist::WATCHING => Kitsu::WATCHING,
Anilist::PLAN_TO_WATCH => Kitsu::PLAN_TO_WATCH,
Anilist::COMPLETED => Kitsu::COMPLETED,
Anilist::ON_HOLD => Kitsu::ON_HOLD,
Anilist::DROPPED => Kitsu::DROPPED
];
const KITSU_TO_ANILIST = [
Kitsu::WATCHING => Anilist::WATCHING,
Kitsu::PLAN_TO_WATCH => Anilist::PLAN_TO_WATCH,
Kitsu::COMPLETED => Anilist::COMPLETED,
Kitsu::ON_HOLD => Anilist::ON_HOLD,
Kitsu::DROPPED => Anilist::DROPPED
];
const KITSU_TO_MAL = [
Kitsu::WATCHING => MAL::WATCHING,
Kitsu::PLAN_TO_WATCH => MAL::PLAN_TO_WATCH,

View File

@ -16,7 +16,7 @@
namespace Aviat\AnimeClient\API\Mapping;
use Aviat\AnimeClient\API\Enum\MangaReadingStatus\{Kitsu, MAL, Title, Route};
use Aviat\AnimeClient\API\Enum\MangaReadingStatus\{Anilist, Kitsu, MAL, Title, Route};
use Aviat\Ion\Enum;
/**
@ -24,6 +24,23 @@ use Aviat\Ion\Enum;
* and url route segments
*/
final class MangaReadingStatus extends Enum {
const ANILIST_TO_KITSU = [
Anilist::READING => Kitsu::READING,
Anilist::PLAN_TO_READ => Kitsu::PLAN_TO_READ,
Anilist::COMPLETED => Kitsu::COMPLETED,
Anilist::ON_HOLD => Kitsu::ON_HOLD,
Anilist::DROPPED => Kitsu::DROPPED
];
const KITSU_TO_ANILIST = [
Kitsu::READING => Anilist::READING,
Kitsu::PLAN_TO_READ => Anilist::PLAN_TO_READ,
Kitsu::COMPLETED => Anilist::COMPLETED,
Kitsu::ON_HOLD => Anilist::ON_HOLD,
Kitsu::DROPPED => Anilist::DROPPED
];
const KITSU_TO_MAL = [
Kitsu::READING => MAL::READING,
Kitsu::PLAN_TO_READ => MAL::PLAN_TO_READ,

View File

@ -72,6 +72,24 @@ final class Index extends BaseController {
], $view);
}
/**
* Redirect to Anilist to start Oauth flow
*/
public function anilistRedirect()
{
}
/**
* Oauth callback for Anilist API
*/
public function anilistCallback()
{
$this->outputHTML('blank', [
'title' => 'Oauth!'
]);
}
/**
* Attempt login authentication
*

View File

@ -20,6 +20,7 @@ use Aviat\AnimeClient\Controller;
use Aviat\AnimeClient\API\Kitsu\Transformer\MangaListTransformer;
use Aviat\AnimeClient\API\Mapping\MangaReadingStatus;
use Aviat\AnimeClient\Model\Manga as MangaModel;
use Aviat\AnimeClient\Types\MangaFormItem;
use Aviat\Ion\Di\ContainerInterface;
use Aviat\Ion\{Json, StringWrapper};
@ -200,7 +201,7 @@ final class Manga extends Controller {
// large form-based updates
$transformer = new MangaListTransformer();
$post_data = $transformer->untransform($data);
$full_result = $this->model->updateLibraryItem($post_data);
$full_result = $this->model->updateLibraryItem(new MangaFormItem($post_data));
if ($full_result['statusCode'] === 200)
{
@ -234,7 +235,7 @@ final class Manga extends Controller {
$data = $this->request->getParsedBody();
}
$response = $this->model->updateLibraryItem($data);
$response = $this->model->updateLibraryItem(new MangaFormItem($data));
$this->cache->clear();
$this->outputJSON($response['body'], $response['statusCode']);

View File

@ -71,12 +71,18 @@ class Manga extends API {
{
if ($status === 'All')
{
return $this->kitsuModel->getFullOrganizedMangaList();
$data = $this->kitsuModel->getFullOrganizedMangaList();
foreach($data as &$section)
{
$this->sortByName($section, 'manga');
}
return $data;
}
$APIstatus = MangaReadingStatus::TITLE_TO_KITSU[$status];
$data =
$this->mapByStatus($this->kitsuModel->getMangaList($APIstatus));
$data = $this->mapByStatus($this->kitsuModel->getMangaList($APIstatus));
$this->sortByName($data[$status], 'manga');
return $data[$status];
}
@ -228,11 +234,6 @@ class Manga extends API {
unset($entry);
foreach ($output as &$val)
{
$this->sortByName($val, 'manga');
}
return $output;
}
}

View File

@ -24,3 +24,4 @@ const ERROR_MESSAGE_METHOD = 'errorPage';
const NOT_FOUND_METHOD = 'notFound';
const SESSION_SEGMENT = 'Aviat\AnimeClient\Auth';
const SRC_DIR = __DIR__;
const USER_AGENT = "Tim's Anime Client/4.0";