Merge branch 'develop' into 'master'

Develop

See merge request !12
This commit is contained in:
Timothy Warren 2017-02-06 11:51:58 -05:00
commit 2f71a97327
53 changed files with 2780 additions and 3550 deletions

View File

@ -15,7 +15,7 @@ test:7:
- php composer.phar install --no-dev - php composer.phar install --no-dev
image: php:7 image: php:7
script: script:
- phpunit -c build --coverage-text - phpunit -c build --coverage-text --colors=never
test:7.1: test:7.1:
before_script: before_script:
@ -24,9 +24,10 @@ test:7.1:
- php composer.phar install --no-dev - php composer.phar install --no-dev
image: php:7.1 image: php:7.1
script: script:
- phpunit -c build --coverage-text - phpunit -c build --coverage-text --colors=never
test:hhvm: test:hhvm:
allow_failure: true
before_script: before_script:
- /usr/local/bin/composer self-update - /usr/local/bin/composer self-update
- curl -Lo /usr/local/bin/phpunit https://phar.phpunit.de/phpunit.phar - curl -Lo /usr/local/bin/phpunit https://phar.phpunit.de/phpunit.phar
@ -34,4 +35,4 @@ test:hhvm:
- composer install --no-dev - composer install --no-dev
image: 51systems/docker-gitlab-ci-runner-hhvm image: 51systems/docker-gitlab-ci-runner-hhvm
script: script:
- hhvm -d hhvm.php7.all=true /usr/local/bin/phpunit -c build --coverage-text - hhvm -d hhvm.php7.all=true /usr/local/bin/phpunit -c build --coverage-text --colors=never

View File

@ -3,6 +3,7 @@
## Version 4 ## Version 4
* Updated to use Kitsu API after discontinuation of Hummingbird * Updated to use Kitsu API after discontinuation of Hummingbird
* Added streaming links to list entries from the Kitsu API * Added streaming links to list entries from the Kitsu API
* Added simple integration with MyAnimeList, so an update can cross-post to both Kitsu and MyAnimeList
## Version 3 ## Version 3
* Converted user configuration to toml files * Converted user configuration to toml files

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2017 Timothy J Warren
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@ -3,6 +3,8 @@
A self-hosted client that allows custom formatting of data from the hummingbird api A self-hosted client that allows custom formatting of data from the hummingbird api
[![Build Status](https://travis-ci.org/timw4mail/HummingBirdAnimeClient.svg?branch=master)](https://travis-ci.org/timw4mail/HummingBirdAnimeClient) [![Build Status](https://travis-ci.org/timw4mail/HummingBirdAnimeClient.svg?branch=master)](https://travis-ci.org/timw4mail/HummingBirdAnimeClient)
[![build status](https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient/badges/develop/build.svg)](https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient/commits/develop)
[![coverage report](https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient/badges/develop/coverage.svg)](https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient/commits/develop)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/timw4mail/HummingBirdAnimeClient/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/timw4mail/HummingBirdAnimeClient/?branch=master) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/timw4mail/HummingBirdAnimeClient/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/timw4mail/HummingBirdAnimeClient/?branch=master)
[[Hosted Example](https://list.timshomepage.net)] [[Hosted Example](https://list.timshomepage.net)]
@ -50,6 +52,10 @@ or
* public/js/cache * public/js/cache
5. Make sure the `console` script is executable 5. Make sure the `console` script is executable
### Using MAL API
1. Update `app/config/mal.toml` with your username and password
2. Enable MAL api in `app/config/config.toml`
### Server Setup ### Server Setup
#### Caching #### Caching
@ -88,8 +94,4 @@ include the contents of the `.htaccess` file in your Apache configuration.
1. Login 1. Login
2. Use the form to select your media 2. Use the form to select your media
3. Save & Repeat as needed 3. Save & Repeat as needed
* For bulk importing anime:
1. Find the anime you are looking for on the hummingbird search api page: `https://hummingbird.me/api/v1/search/anime?query=`
2. Create an `import.json` file in the root of the app, with an array of objects from the search page that you want to import
3. Go to the anime collection tab, and the import will be run

View File

@ -48,10 +48,13 @@ return function(array $config_array = []) {
$app_logger = new Logger('animeclient'); $app_logger = new Logger('animeclient');
$app_logger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/app.log', Logger::NOTICE)); $app_logger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/app.log', Logger::NOTICE));
$request_logger = new Logger('request'); $kitsu_request_logger = new Logger('kitsu_request');
$request_logger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/request.log', Logger::NOTICE)); $kitsu_request_logger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/kitsu_request.log', Logger::NOTICE));
$mal_request_logger = new Logger('mal_request');
$mal_request_logger->pushHandler(new RotatingFileHandler(__DIR__ . '/logs/mal_request.log', Logger::NOTICE));
$container->setLogger($app_logger, 'default'); $container->setLogger($app_logger, 'default');
$container->setLogger($request_logger, 'request'); $container->setLogger($kitsu_request_logger, 'kitsu_request');
$container->setLogger($mal_request_logger, 'mal_request');
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Injected Objects // Injected Objects

View File

@ -1,5 +1,4 @@
<?php if ($auth->is_authenticated()): ?> <?php if ($auth->is_authenticated()): ?>
<?php /* <pre><?= json_encode($item, \JSON_PRETTY_PRINT); ?></pre> */ ?>
<main> <main>
<h2>Edit Anime List Item</h2> <h2>Edit Anime List Item</h2>
<form action="<?= $action ?>" method="post"> <form action="<?= $action ?>" method="post">
@ -86,15 +85,20 @@
</tbody> </tbody>
</table> </table>
</form> </form>
<br />
<br />
<fieldset> <fieldset>
<legend>Danger Zone</legend> <legend>Danger Zone</legend>
<form class="js-delete" action="<?= $url->generate('anime.delete') ?>" method="post"> <form class="js-delete" action="<?= $url->generate('anime.delete') ?>" method="post">
<table class="form invisible"> <table class="form invisible">
<tbody> <tbody>
<tr> <tr>
<td>&nbsp;</td> <td>
<strong>Permanently</strong> remove this list item and <strong>all</strong> its data?
</td>
<td> <td>
<input type="hidden" value="<?= $item['id'] ?>" name="id" /> <input type="hidden" value="<?= $item['id'] ?>" name="id" />
<input type="hidden" value="<?= $item['mal_id'] ?>" name="mal_id" />
<button type="submit" class="danger">Delete Entry</button> <button type="submit" class="danger">Delete Entry</button>
</td> </td>
</tr> </tr>

View File

@ -53,7 +53,7 @@
<tr> <tr>
<td><label for="rereading_flag">Rereading?</label></td> <td><label for="rereading_flag">Rereading?</label></td>
<td> <td>
<input type="checkbox" name="reareading" id="rereading_flag" <input type="checkbox" name="rereading" id="rereading_flag"
<?php if($item['rereading'] === TRUE): ?>checked="checked"<?php endif ?> <?php if($item['rereading'] === TRUE): ?>checked="checked"<?php endif ?>
/> />
</td> </td>

View File

@ -14,24 +14,25 @@
} }
}, },
"require": { "require": {
"aura/html": "2.*", "aura/html": "^2.0",
"aura/router": "3.*", "aura/router": "^3.0",
"aura/session": "2.*", "aura/session": "^2.0",
"aviat/banker": "^1.0.0", "aviat/banker": "^1.0.0",
"aviat/ion": "1.0.*", "aviat/ion": "1.0.*",
"filp/whoops": "2.0.*", "filp/whoops": "^2.1.5",
"guzzlehttp/guzzle": "6.*", "guzzlehttp/guzzle": "^6.0",
"monolog/monolog": "1.*", "monolog/monolog": "^1.0",
"psr/http-message": "~1.0", "psr/http-message": "~1.0",
"psr/log": "~1.0", "psr/log": "~1.0",
"yosymfony/toml": "0.3.*", "yosymfony/toml": "^0.3",
"zendframework/zend-diactoros": "1.3.*", "zendframework/zend-diactoros": "^1.3",
"maximebf/consolekit": "^1.0" "maximebf/consolekit": "^1.0",
"amphp/artax": "^2.0"
}, },
"require-dev": { "require-dev": {
"pdepend/pdepend": "^2.2", "pdepend/pdepend": "^2.2",
"sebastian/phpcpd": "^2.0", "sebastian/phpcpd": "^3.0",
"theseer/phpdox": "0.8.1.1", "theseer/phpdox": "^0.9.0",
"phploc/phploc": "^3.0", "phploc/phploc": "^3.0",
"phpmd/phpmd": "^2.4", "phpmd/phpmd": "^2.4",
"phpunit/phpunit": "^5.7", "phpunit/phpunit": "^5.7",
@ -46,4 +47,4 @@
"build:css": "cd public && npm run build && cd ..", "build:css": "cd public && npm run build && cd ..",
"watch:css": "cd public && npm run watch" "watch:css": "cd public && npm run watch"
} }
} }

View File

@ -1316,4 +1316,9 @@ a:hover, a:active {
.streaming-logo { .streaming-logo {
width: 50px; width: 50px;
height: 50px; height: 50px;
}
.cover_streaming_link .streaming-logo {
width: 20px;
height: 20px;
} }

View File

@ -568,4 +568,9 @@ a:hover, a:active {
.streaming-logo { .streaming-logo {
width: 50px; width: 50px;
height: 50px; height: 50px;
}
.cover_streaming_link .streaming-logo {
width: 20px;
height: 20px;
} }

View File

@ -41,74 +41,15 @@ class JsonAPI {
* @var array * @var array
*/ */
protected $data = []; protected $data = [];
/**
* Data array parsed out from a request
*
* @var array
*/
protected $parsedData = [];
/**
* Related objects included with the request
*
* @var array
*/
public $included = [];
/**
* Pagination links
*
* @var array
*/
protected $links = [];
/**
* JsonAPI constructor
*
* @param array $initital
*/
public function __construct(array $initial = [])
{
$this->data = $initial;
}
public function parseFromString(string $json) public static function inlineRawIncludes(array &$data, string $key): array
{ {
$this->parse(Json::decode($json)); foreach($data['data'] as $i => &$item)
} {
$item[$key] = $data['included'][$i];
/** }
* Parse a JsonAPI response into its components
* return $data['data'];
* @param array $data
*/
public function parse(array $data)
{
$this->included = static::organizeIncludes($data['included']);
}
/**
* Return data array after input is parsed
* to inline includes inside of relationship objects
*
* @return array
*/
public function getParsedData(): array
{
}
/**
* Take inlined included data and inline it into the main object's relationships
*
* @param array $mainObject
* @param array $included
* @return array
*/
public static function inlineIncludedIntoMainObject(array $mainObject, array $included): array
{
$output = clone $mainObject;
} }
/** /**

View File

@ -73,7 +73,7 @@ class Kitsu {
public static function getAiringStatus(string $startDate = null, string $endDate = null): string public static function getAiringStatus(string $startDate = null, string $endDate = null): string
{ {
$startAirDate = new DateTimeImmutable($startDate ?? 'tomorrow'); $startAirDate = new DateTimeImmutable($startDate ?? 'tomorrow');
$endAirDate = new DateTimeImmutable($endDate ?? 'tomorrow'); $endAirDate = new DateTimeImmutable($endDate ?? 'next year');
$now = new DateTimeImmutable(); $now = new DateTimeImmutable();
$isDoneAiring = $now > $endAirDate; $isDoneAiring = $now > $endAirDate;
@ -195,6 +195,8 @@ class Kitsu {
return $links; return $links;
} }
return [];
} }
/** /**

View File

@ -27,5 +27,4 @@ class AnimeWatchingStatus extends BaseEnum {
const COMPLETED = 'completed'; const COMPLETED = 'completed';
const ON_HOLD = 'on_hold'; const ON_HOLD = 'on_hold';
const DROPPED = 'dropped'; const DROPPED = 'dropped';
} }
// End of AnimeWatchingStatus.php

View File

@ -93,7 +93,7 @@ trait KitsuTrait {
'headers' => $this->defaultHeaders 'headers' => $this->defaultHeaders
]; ];
$logger = $this->container->getLogger('request'); $logger = $this->container->getLogger('kitsu_request');
$sessionSegment = $this->getContainer() $sessionSegment = $this->getContainer()
->get('session') ->get('session')
->getSegment(AnimeClient::SESSION_SEGMENT); ->getSegment(AnimeClient::SESSION_SEGMENT);
@ -106,10 +106,19 @@ trait KitsuTrait {
$options = array_merge($defaultOptions, $options); $options = array_merge($defaultOptions, $options);
$logger->debug(Json::encode([$type, $url])); $response = $this->client->request($type, $url, $options);
$logger->debug(Json::encode($options));
return $this->client->request($type, $url, $options); $logger->debug('Kitsu API request', [
'requestParams' => [
'type' => $type,
'url' => $url,
],
'responseValues' => [
'status' => $response->getStatusCode()
]
]);
return $response;
} }
/** /**
@ -125,7 +134,7 @@ trait KitsuTrait {
$logger = null; $logger = null;
if ($this->getContainer()) if ($this->getContainer())
{ {
$logger = $this->container->getLogger('request'); $logger = $this->container->getLogger('kitsu_request');
} }
$response = $this->getResponse($type, $url, $options); $response = $this->getResponse($type, $url, $options);
@ -134,11 +143,8 @@ trait KitsuTrait {
{ {
if ($logger) if ($logger)
{ {
$logger->warning('Non 200 response for api call'); $logger->warning('Non 200 response for api call', $response->getBody());
$logger->warning($response->getBody());
} }
// throw new RuntimeException($response->getBody());
} }
return JSON::decode($response->getBody(), TRUE); return JSON::decode($response->getBody(), TRUE);
@ -177,7 +183,7 @@ trait KitsuTrait {
$logger = null; $logger = null;
if ($this->getContainer()) if ($this->getContainer())
{ {
$logger = $this->container->getLogger('request'); $logger = $this->container->getLogger('kitsu_request');
} }
$response = $this->getResponse('POST', ...$args); $response = $this->getResponse('POST', ...$args);
@ -187,11 +193,8 @@ trait KitsuTrait {
{ {
if ($logger) if ($logger)
{ {
$logger->warning('Non 201 response for POST api call'); $logger->warning('Non 201 response for POST api call', $response->getBody());
$logger->warning($response->getBody());
} }
// throw new RuntimeException($response->getBody());
} }
return JSON::decode($response->getBody(), TRUE); return JSON::decode($response->getBody(), TRUE);

View File

@ -20,9 +20,9 @@ use Aviat\AnimeClient\API\CacheTrait;
use Aviat\AnimeClient\API\JsonAPI; use Aviat\AnimeClient\API\JsonAPI;
use Aviat\AnimeClient\API\Kitsu as K; use Aviat\AnimeClient\API\Kitsu as K;
use Aviat\AnimeClient\API\Kitsu\Transformer\{ use Aviat\AnimeClient\API\Kitsu\Transformer\{
AnimeTransformer, AnimeTransformer,
AnimeListTransformer, AnimeListTransformer,
MangaTransformer, MangaTransformer,
MangaListTransformer MangaListTransformer
}; };
use Aviat\Ion\Di\ContainerAware; use Aviat\Ion\Di\ContainerAware;
@ -65,7 +65,7 @@ class Model {
* @var MangaListTransformer * @var MangaListTransformer
*/ */
protected $mangaListTransformer; protected $mangaListTransformer;
/** /**
* Constructor. * Constructor.
@ -94,9 +94,9 @@ class Model {
{ {
$username = $this->getUsername(); $username = $this->getUsername();
} }
$cacheItem = $this->cache->getItem(K::AUTH_USER_ID_KEY); $cacheItem = $this->cache->getItem(K::AUTH_USER_ID_KEY);
if ( ! $cacheItem->isHit()) if ( ! $cacheItem->isHit())
{ {
$data = $this->getRequest('users', [ $data = $this->getRequest('users', [
@ -110,7 +110,7 @@ class Model {
$cacheItem->set($data['data'][0]['id']); $cacheItem->set($data['data'][0]['id']);
$cacheItem->save(); $cacheItem->save();
} }
return $cacheItem->get(); return $cacheItem->get();
} }
@ -154,7 +154,7 @@ class Model {
$baseData = $this->getRawMediaData('anime', $slug); $baseData = $this->getRawMediaData('anime', $slug);
return $this->animeTransformer->transform($baseData); return $this->animeTransformer->transform($baseData);
} }
/** /**
* Get information about a particular anime * Get information about a particular anime
* *
@ -167,6 +167,34 @@ class Model {
return $this->animeTransformer->transform($baseData); return $this->animeTransformer->transform($baseData);
} }
/**
* Get the mal id for the anime represented by the kitsu id
* to enable updating MyAnimeList
*
* @param string $kitsuAnimeId The id of the anime on Kitsu
* @return string|null Returns the mal id if it exists, otherwise null
*/
public function getMalIdForAnime(string $kitsuAnimeId)
{
$options = [
'query' => [
'include' => 'mappings'
]
];
$data = $this->getRequest("anime/{$kitsuAnimeId}", $options);
$mappings = array_column($data['included'], 'attributes');
foreach($mappings as $map)
{
if ($map['externalSite'] === 'myanimelist/anime')
{
return $map['externalId'];
}
}
return null;
}
/** /**
* Get information about a particular manga * Get information about a particular manga
* *
@ -178,7 +206,17 @@ class Model {
$baseData = $this->getRawMediaData('manga', $mangaId); $baseData = $this->getRawMediaData('manga', $mangaId);
return $this->mangaTransformer->transform($baseData); return $this->mangaTransformer->transform($baseData);
} }
/**
* Get and transform the entirety of the user's anime list
*
* @return array
*/
public function getFullAnimeList(): array
{
}
/** /**
* Get the raw (unorganized) anime list for the configured user * Get the raw (unorganized) anime list for the configured user
* *
@ -204,7 +242,7 @@ class Model {
'sort' => '-updated_at' 'sort' => '-updated_at'
] ]
]; ];
return $this->getRequest('library-entries', $options); return $this->getRequest('library-entries', $options);
} }
@ -219,7 +257,7 @@ class Model {
public function getAnimeList(string $status, int $limit = 600, int $offset = 0): array public function getAnimeList(string $status, int $limit = 600, int $offset = 0): array
{ {
$cacheItem = $this->cache->getItem($this->getHashForMethodCall($this, __METHOD__, [$status])); $cacheItem = $this->cache->getItem($this->getHashForMethodCall($this, __METHOD__, [$status]));
if ( ! $cacheItem->isHit()) if ( ! $cacheItem->isHit())
{ {
$data = $this->getRawAnimeList($status, $limit, $offset); $data = $this->getRawAnimeList($status, $limit, $offset);
@ -231,7 +269,7 @@ class Model {
$item['included'] = $included; $item['included'] = $included;
} }
$transformed = $this->animeListTransformer->transformCollection($data['data']); $transformed = $this->animeListTransformer->transformCollection($data['data']);
$cacheItem->set($transformed); $cacheItem->set($transformed);
$cacheItem->save(); $cacheItem->save();
} }
@ -264,24 +302,20 @@ class Model {
'sort' => '-updated_at' 'sort' => '-updated_at'
] ]
]; ];
$cacheItem = $this->cache->getItem($this->getHashForMethodCall($this, __METHOD__, $options)); $cacheItem = $this->cache->getItem($this->getHashForMethodCall($this, __METHOD__, $options));
if ( ! $cacheItem->isHit()) if ( ! $cacheItem->isHit())
{ {
$data = $this->getRequest('library-entries', $options); $data = $this->getRequest('library-entries', $options);
$data = JsonAPI::inlineRawIncludes($data, 'manga');
foreach($data['data'] as $i => &$item) $transformed = $this->mangaListTransformer->transformCollection($data);
{
$item['manga'] = $data['included'][$i];
}
$transformed = $this->mangaListTransformer->transformCollection($data['data']);
$cacheItem->set($transformed); $cacheItem->set($transformed);
$cacheItem->save(); $cacheItem->save();
} }
return $cacheItem->get(); return $cacheItem->get();
} }
@ -401,7 +435,7 @@ class Model {
->get('config') ->get('config')
->get(['kitsu_username']); ->get(['kitsu_username']);
} }
private function getRawMediaDataById(string $type, string $id): array private function getRawMediaDataById(string $type, string $id): array
{ {
$options = [ $options = [

View File

@ -113,6 +113,7 @@ class AnimeListTransformer extends AbstractTransformer {
$untransformed = [ $untransformed = [
'id' => $item['id'], 'id' => $item['id'],
'mal_id' => $item['mal_id'] ?? null,
'data' => [ 'data' => [
'status' => $item['watching_status'], 'status' => $item['watching_status'],
'rating' => $item['user_rating'] / 2, 'rating' => $item['user_rating'] / 2,

View File

@ -97,16 +97,10 @@ class MangaListTransformer extends AbstractTransformer {
'reconsuming' => $rereading, 'reconsuming' => $rereading,
'reconsumeCount' => (int)$item['reread_count'], 'reconsumeCount' => (int)$item['reread_count'],
'notes' => $item['notes'], 'notes' => $item['notes'],
'rating' => $item['new_rating'] / 2
], ],
]; ];
if ($item['new_rating'] !== $item['old_rating'] && $item['new_rating'] !== "")
{
$map['data']['rating'] = ($item['new_rating'] > 0)
? $item['new_rating'] / 2
: $item['old_rating'] / 2;
}
return $map; return $map;
} }
} }

View File

@ -32,6 +32,18 @@ class MangaTransformer extends AbstractTransformer {
*/ */
public function transform($item) public function transform($item)
{ {
$genres = [];
foreach($item['included'] as $included)
{
if ($included['type'] === 'genres')
{
$genres[] = $included['attributes']['name'];
}
}
sort($genres);
return [ return [
'title' => $item['canonicalTitle'], 'title' => $item['canonicalTitle'],
'en_title' => $item['titles']['en'], 'en_title' => $item['titles']['en'],
@ -42,7 +54,7 @@ class MangaTransformer extends AbstractTransformer {
'volume_count' => $this->count($item['volumeCount']), 'volume_count' => $this->count($item['volumeCount']),
'synopsis' => $item['synopsis'], 'synopsis' => $item['synopsis'],
'url' => "https://kitsu.io/manga/{$item['slug']}", 'url' => "https://kitsu.io/manga/{$item['slug']}",
'genres' => $item['genres'], 'genres' => $genres,
]; ];
} }

View File

@ -16,6 +16,10 @@
namespace Aviat\AnimeClient\API; namespace Aviat\AnimeClient\API;
use Aviat\AnimeClient\API\Kitsu\Enum\{
AnimeWatchingStatus as KAWS,
MangaReadingStatus as KMRS
};
use Aviat\AnimeClient\API\MAL\Enum\{AnimeWatchingStatus, MangaReadingStatus}; use Aviat\AnimeClient\API\MAL\Enum\{AnimeWatchingStatus, MangaReadingStatus};
/** /**
@ -25,6 +29,14 @@ class MAL {
const AUTH_URL = 'https://myanimelist.net/api/account/verify_credentials.xml'; const AUTH_URL = 'https://myanimelist.net/api/account/verify_credentials.xml';
const BASE_URL = 'https://myanimelist.net/api/'; const BASE_URL = 'https://myanimelist.net/api/';
const KITSU_MAL_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
];
public static function getIdToWatchingStatusMap() public static function getIdToWatchingStatusMap()
{ {
return [ return [
@ -32,7 +44,12 @@ class MAL {
2 => AnimeWatchingStatus::COMPLETED, 2 => AnimeWatchingStatus::COMPLETED,
3 => AnimeWatchingStatus::ON_HOLD, 3 => AnimeWatchingStatus::ON_HOLD,
4 => AnimeWatchingStatus::DROPPED, 4 => AnimeWatchingStatus::DROPPED,
5 => AnimeWatchingStatus::PLAN_TO_WATCH 6 => AnimeWatchingStatus::PLAN_TO_WATCH,
'watching' => AnimeWatchingStatus::WATCHING,
'completed' => AnimeWatchingStatus::COMPLETED,
'onhold' => AnimeWatchingStatus::ON_HOLD,
'dropped' => AnimeWatchingStatus::DROPPED,
'plantowatch' => AnimeWatchingStatus::PLAN_TO_WATCH
]; ];
} }
@ -43,7 +60,12 @@ class MAL {
2 => MangaReadingStatus::COMPLETED, 2 => MangaReadingStatus::COMPLETED,
3 => MangaReadingStatus::ON_HOLD, 3 => MangaReadingStatus::ON_HOLD,
4 => MangaReadingStatus::DROPPED, 4 => MangaReadingStatus::DROPPED,
5 => MangaReadingStatus::PLAN_TO_READ 6 => MangaReadingStatus::PLAN_TO_READ,
'reading' => MangaReadingStatus::READING,
'completed' => MangaReadingStatus::COMPLETED,
'onhold' => MangaReadingStatus::ON_HOLD,
'dropped' => MangaReadingStatus::DROPPED,
'plantoread' => MangaReadingStatus::PLAN_TO_WATCH
]; ];
} }
} }

View File

@ -22,9 +22,9 @@ use Aviat\Ion\Enum as BaseEnum;
* Possible values for watching status for the current anime * Possible values for watching status for the current anime
*/ */
class AnimeWatchingStatus extends BaseEnum { class AnimeWatchingStatus extends BaseEnum {
const WATCHING = 'watching'; const WATCHING = 1;
const COMPLETED = 'completed'; const COMPLETED = 2;
const ON_HOLD = 'onhold'; const ON_HOLD = 3;
const DROPPED = 'dropped'; const DROPPED = 4;
const PLAN_TO_WATCH = 'plantowatch'; const PLAN_TO_WATCH = 6;
} }

View File

@ -16,29 +16,44 @@
namespace Aviat\AnimeClient\API\MAL; namespace Aviat\AnimeClient\API\MAL;
use Aviat\AnimeClient\API\AbstractListItem; use Amp\Artax\FormBody;
use Aviat\AnimeClient\API\{
AbstractListItem,
XML
};
use Aviat\Ion\Di\ContainerAware; use Aviat\Ion\Di\ContainerAware;
/** /**
* CRUD operations for MAL list items * CRUD operations for MAL list items
*/ */
class ListItem extends AbstractListItem { class ListItem {
use ContainerAware; use ContainerAware;
use MALTrait; use MALTrait;
public function __construct()
{
$this->init();
}
public function create(array $data): bool public function create(array $data): bool
{ {
return FALSE; $id = $data['id'];
$createData = [
'id' => $id,
'data' => XML::toXML([
'entry' => $data['data']
])
];
$response = $this->getResponse('POST', "animelist/add/{$id}.xml", [
'body' => $this->fixBody((new FormBody)->addFields($createData))
]);
return $response->getBody() === 'Created';
} }
public function delete(string $id): bool public function delete(string $id): bool
{ {
return FALSE; $response = $this->getResponse('DELETE', "animelist/delete/{$id}.xml", [
'body' => $this->fixBody((new FormBody)->addField('id', $id))
]);
return $response->getBody() === 'Deleted';
} }
public function get(string $id): array public function get(string $id): array
@ -46,8 +61,15 @@ class ListItem extends AbstractListItem {
return []; return [];
} }
public function update(string $id, array $data): Response public function update(string $id, array $data)
{ {
$xml = XML::toXML(['entry' => $data]);
$body = (new FormBody)
->addField('id', $id)
->addField('data', $xml);
return $this->getResponse('POST', "animelist/update/{$id}.xml", [
'body' => $this->fixBody($body)
]);
} }
} }

View File

@ -16,18 +16,15 @@
namespace Aviat\AnimeClient\API\MAL; namespace Aviat\AnimeClient\API\MAL;
use Amp\Artax\{Client, FormBody, Request};
use Aviat\AnimeClient\API\{ use Aviat\AnimeClient\API\{
GuzzleTrait,
MAL as M, MAL as M,
XML XML
}; };
use GuzzleHttp\Client; use Aviat\Ion\Json;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Psr7\Response;
use InvalidArgumentException; use InvalidArgumentException;
trait MALTrait { trait MALTrait {
use GuzzleTrait;
/** /**
* The base url for api requests * The base url for api requests
@ -41,30 +38,25 @@ trait MALTrait {
* @var array * @var array
*/ */
protected $defaultHeaders = [ protected $defaultHeaders = [
'Accept' => 'text/xml',
'Accept-Encoding' => 'gzip',
'Content-type' => 'application/x-www-form-urlencoded',
'User-Agent' => "Tim's Anime Client/4.0" 'User-Agent' => "Tim's Anime Client/4.0"
]; ];
/** /**
* Set up the class properties * Unencode the dual-encoded ampersands in the body
* *
* @return void * This is a dirty hack until I can fully track down where
* the dual-encoding happens
*
* @param FormBody $formBody The form builder object to fix
* @return string
*/ */
protected function init() private function fixBody(FormBody $formBody): string
{ {
$defaults = [ $rawBody = \Amp\wait($formBody->getBody());
'cookies' => $this->cookieJar, return html_entity_decode($rawBody, \ENT_HTML5, 'UTF-8');
'headers' => $this->defaultHeaders,
'timeout' => 25,
'connect_timeout' => 25
];
$this->cookieJar = new CookieJar();
$this->client = new Client([
'base_uri' => $this->baseUrl,
'cookies' => TRUE,
'http_errors' => TRUE,
'defaults' => $defaults
]);
} }
/** /**
@ -77,31 +69,60 @@ trait MALTrait {
*/ */
private function getResponse(string $type, string $url, array $options = []) private function getResponse(string $type, string $url, array $options = [])
{ {
$this->defaultHeaders['User-Agent'] = $_SERVER['HTTP_USER_AGENT'] ?? $this->defaultHeaders;
$type = strtoupper($type); $type = strtoupper($type);
$validTypes = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']; $validTypes = ['GET', 'POST', 'DELETE'];
if ( ! in_array($type, $validTypes)) if ( ! in_array($type, $validTypes))
{ {
throw new InvalidArgumentException('Invalid http request type'); throw new InvalidArgumentException('Invalid http request type');
} }
$config = $this->container->get('config'); $config = $this->container->get('config');
$logger = $this->container->getLogger('request'); $logger = $this->container->getLogger('mal_request');
$defaultOptions = [ $headers = array_merge($this->defaultHeaders, $options['headers'] ?? [], [
'auth' => [ 'Authorization' => 'Basic ' .
$config->get(['mal','username']), base64_encode($config->get(['mal','username']) . ':' .$config->get(['mal','password']))
$config->get(['mal','password']) ]);
],
'headers' => $this->defaultHeaders
];
$options = array_merge($defaultOptions, $options); $query = $options['query'] ?? [];
$logger->debug(Json::encode([$type, $url])); $url = (strpos($url, '//') !== FALSE)
$logger->debug(Json::encode($options)); ? $url
: $this->baseUrl . $url;
return $this->client->request($type, $url, $options); if ( ! empty($query))
{
$url .= '?' . http_build_query($query);
}
$request = (new Request)
->setMethod($type)
->setUri($url)
->setProtocol('1.1')
->setAllHeaders($headers);
if (array_key_exists('body', $options))
{
$request->setBody($options['body']);
}
$response = \Amp\wait((new Client)->request($request));
$logger->debug('MAL api request', [
'url' => $url,
'status' => $response->getStatus(),
'reason' => $response->getReason(),
'headers' => $response->getAllHeaders(),
'requestHeaders' => $request->getAllHeaders(),
'requestBody' => $request->hasBody() ? $request->getBody() : 'No request body',
'requestBodyBeforeEncode' => $request->hasBody() ? urldecode($request->getBody()) : '',
'body' => $response->getBody()
]);
return $response;
} }
/** /**
@ -117,20 +138,17 @@ trait MALTrait {
$logger = null; $logger = null;
if ($this->getContainer()) if ($this->getContainer())
{ {
$logger = $this->container->getLogger('request'); $logger = $this->container->getLogger('mal_request');
} }
$response = $this->getResponse($type, $url, $options); $response = $this->getResponse($type, $url, $options);
if ((int) $response->getStatusCode() > 299 || (int) $response->getStatusCode() < 200) if ((int) $response->getStatus() > 299 || (int) $response->getStatus() < 200)
{ {
if ($logger) if ($logger)
{ {
$logger->warning('Non 200 response for api call'); $logger->warning('Non 200 response for api call', $response->getBody());
$logger->warning($response->getBody());
} }
// throw new RuntimeException($response->getBody());
} }
return XML::toArray((string) $response->getBody()); return XML::toArray((string) $response->getBody());
@ -158,33 +176,20 @@ trait MALTrait {
$logger = null; $logger = null;
if ($this->getContainer()) if ($this->getContainer())
{ {
$logger = $this->container->getLogger('request'); $logger = $this->container->getLogger('mal_request');
} }
$response = $this->getResponse('POST', ...$args); $response = $this->getResponse('POST', ...$args);
$validResponseCodes = [200, 201]; $validResponseCodes = [200, 201];
if ( ! in_array((int) $response->getStatusCode(), $validResponseCodes)) if ( ! in_array((int) $response->getStatus(), $validResponseCodes))
{ {
if ($logger) if ($logger)
{ {
$logger->warning('Non 201 response for POST api call'); $logger->warning('Non 201 response for POST api call', $response->getBody());
$logger->warning($response->getBody());
} }
} }
return XML::toArray((string) $response->getBody()); return XML::toArray($response->getBody());
}
/**
* Remove some boilerplate for delete requests
*
* @param array $args
* @return bool
*/
protected function deleteRequest(...$args): bool
{
$response = $this->getResponse('DELETE', ...$args);
return ((int) $response->getStatusCode() === 204);
} }
} }

View File

@ -1,70 +1,90 @@
<?php declare(strict_types=1); <?php declare(strict_types=1);
/** /**
* Anime List Client * Anime List Client
* *
* An API client for Kitsu and MyAnimeList to manage anime and manga watch lists * An API client for Kitsu and MyAnimeList to manage anime and manga watch lists
* *
* PHP version 7 * PHP version 7
* *
* @package AnimeListClient * @package AnimeListClient
* @author Timothy J. Warren <tim@timshomepage.net> * @author Timothy J. Warren <tim@timshomepage.net>
* @copyright 2015 - 2017 Timothy J. Warren * @copyright 2015 - 2017 Timothy J. Warren
* @license http://www.opensource.org/licenses/mit-license.html MIT License * @license http://www.opensource.org/licenses/mit-license.html MIT License
* @version 4.0 * @version 4.0
* @link https://github.com/timw4mail/HummingBirdAnimeClient * @link https://github.com/timw4mail/HummingBirdAnimeClient
*/ */
namespace Aviat\AnimeClient\API\MAL; namespace Aviat\AnimeClient\API\MAL;
use Aviat\AnimeClient\API\MAL as M; use Aviat\AnimeClient\API\MAL as M;
use Aviat\AnimeClient\API\MAL\{ use Aviat\AnimeClient\API\MAL\ListItem;
AnimeListTransformer, use Aviat\AnimeClient\API\MAL\Transformer\AnimeListTransformer;
ListItem use Aviat\AnimeClient\API\XML;
}; use Aviat\Ion\Di\ContainerAware;
use Aviat\AnimeClient\API\XML;
use Aviat\Ion\Di\ContainerAware; /**
* MyAnimeList API Model
/** */
* MyAnimeList API Model class Model {
*/ use ContainerAware;
class Model { use MALTrait;
use ContainerAware;
use MALTrait; /**
* @var AnimeListTransformer
/** */
* @var AnimeListTransformer protected $animeListTransformer;
*/
protected $animeListTransformer; /**
* KitsuModel constructor.
/** */
* KitsuModel constructor. public function __construct(ListItem $listItem)
*/ {
public function __construct(ListItem $listItem) $this->animeListTransformer = new AnimeListTransformer();
{ $this->listItem = $listItem;
// Set up Guzzle trait }
$this->init();
$this->animeListTransformer = new AnimeListTransformer(); public function createListItem(array $data): bool
$this->listItem = $listItem; {
} $createData = [
'id' => $data['id'],
public function createListItem(array $data): bool 'data' => [
{ 'status' => M::KITSU_MAL_WATCHING_STATUS_MAP[$data['status']]
return FALSE; ]
} ];
public function getListItem(string $listId): array return $this->listItem->create($createData);
{ }
return [];
} public function getFullList(): array
{
public function updateListItem(array $data) $config = $this->container->get('config');
{ $userName = $config->get(['mal', 'username']);
$updateData = $this->animeListTransformer->transform($data['data']); $list = $this->getRequest('https://myanimelist.net/malappinfo.php', [
return $this->listItem->update($data['mal_id'], $updateData); 'headers' => [
} 'Accept' => 'text/xml'
],
public function deleteListItem(string $id): bool 'query' => [
{ 'u' => $userName,
'status' => 'all'
} ]
]);
return $list;//['anime'];
}
public function getListItem(string $listId): array
{
return [];
}
public function updateListItem(array $data)
{
$updateData = $this->animeListTransformer->untransform($data);
return $this->listItem->update($updateData['id'], $updateData['data']);
}
public function deleteListItem(string $id): bool
{
return $this->listItem->delete($id);
}
} }

View File

@ -14,27 +14,32 @@
* @link https://github.com/timw4mail/HummingBirdAnimeClient * @link https://github.com/timw4mail/HummingBirdAnimeClient
*/ */
namespace Aviat\AnimeClient\API\MAL; namespace Aviat\AnimeClient\API\MAL\Transformer;
use Aviat\AnimeClient\API\Kitsu\Enum\AnimeWatchingStatus;
use Aviat\Ion\Transformer\AbstractTransformer; use Aviat\Ion\Transformer\AbstractTransformer;
/** /**
* Transformer for updating MAL List * Transformer for updating MAL List
*/ */
class AnimeListTransformer extends AbstractTransformer { class AnimeListTransformer extends AbstractTransformer {
const statusMap = [
AnimeWatchingStatus::WATCHING => '1',
AnimeWatchingStatus::COMPLETED => '2',
AnimeWatchingStatus::ON_HOLD => '3',
AnimeWatchingStatus::DROPPED => '4',
AnimeWatchingStatus::PLAN_TO_WATCH => '6'
];
public function transform($item) public function transform($item)
{ {
$rewatching = 'false'; $rewatching = (array_key_exists('rewatching', $item) && $item['rewatching']);
if (array_key_exists('rewatching', $item) && $item['rewatching'])
{
$rewatching = 'true';
}
return [ return [
'id' => $item['id'], 'id' => $item['mal_id'],
'data' => [ 'data' => [
'status' => $item['watching_status'], 'status' => self::statusMap[$item['watching_status']],
'rating' => $item['user_rating'], 'rating' => $item['user_rating'],
'rewatch_value' => (int) $rewatching, 'rewatch_value' => (int) $rewatching,
'times_rewatched' => $item['rewatched'], 'times_rewatched' => $item['rewatched'],
@ -43,4 +48,31 @@ class AnimeListTransformer extends AbstractTransformer {
] ]
]; ];
} }
/**
* Transform Kitsu episode data to MAL episode data
*
* @param array $item
* @return array
*/
public function untransform(array $item): array
{
$rewatching = (array_key_exists('reconsuming', $item['data']) && $item['data']['reconsuming']);
$map = [
'id' => $item['mal_id'],
'data' => [
'episode' => $item['data']['progress'],
'status' => self::statusMap[$item['data']['status']],
'score' => (array_key_exists('rating', $item['data']))
? $item['data']['rating'] * 2
: "",
// 'enable_rewatching' => $rewatching,
// 'times_rewatched' => $item['data']['reconsumeCount'],
// 'comments' => $item['data']['notes'],
]
];
return $map;
}
} }

View File

@ -107,12 +107,7 @@ class XML {
{ {
$data = []; $data = [];
// Get rid of unimportant text nodes by removing $xml = static::stripXMLWhitespace($xml);
// whitespace characters from between xml tags,
// except for the xml declaration tag, Which looks
// something like:
/* <?xml version="1.0" encoding="UTF-8"?> */
$xml = preg_replace('/([^\?])>\s+</', '$1><', $xml);
$dom = new DOMDocument(); $dom = new DOMDocument();
$dom->loadXML($xml); $dom->loadXML($xml);
@ -166,6 +161,16 @@ class XML {
return static::toXML($this->getData()); return static::toXML($this->getData());
} }
private static function stripXMLWhitespace(string $xml): string
{
// Get rid of unimportant text nodes by removing
// whitespace characters from between xml tags,
// except for the xml declaration tag, Which looks
// something like:
/* <?xml version="1.0" encoding="UTF-8"?> */
return preg_replace('/([^\?])>\s+</', '$1><', $xml);
}
/** /**
* Recursively create array structure based on xml structure * Recursively create array structure based on xml structure
* *
@ -180,7 +185,7 @@ class XML {
{ {
$el = $nodeList->item($i); $el = $nodeList->item($i);
$current =& $root[$el->nodeName]; $current =& $root[$el->nodeName];
// It's a top level element! // It's a top level element!
if (is_a($el->childNodes->item(0), 'DomText') || ( ! $el->hasChildNodes())) if (is_a($el->childNodes->item(0), 'DomText') || ( ! $el->hasChildNodes()))
{ {
@ -239,9 +244,9 @@ class XML {
break; break;
} }
} }
$node = $dom->createElement($key); $node = $dom->createElement($key);
if (is_array($props)) if (is_array($props))
{ {
static::arrayPropertiesToXmlNodes($dom, $node, $props); static::arrayPropertiesToXmlNodes($dom, $node, $props);

View File

@ -259,7 +259,7 @@ class Anime extends BaseController {
$data = $this->request->getParsedBody(); $data = $this->request->getParsedBody();
} }
$response = $this->model->updateLibraryItem($data); $response = $this->model->updateLibraryItem($data, $data);
$this->cache->clear(); $this->cache->clear();
$this->outputJSON($response['body'], $response['statusCode']); $this->outputJSON($response['body'], $response['statusCode']);
@ -273,7 +273,7 @@ class Anime extends BaseController {
public function delete() public function delete()
{ {
$body = $this->request->getParsedBody(); $body = $this->request->getParsedBody();
$response = $this->model->deleteLibraryItem($body['id']); $response = $this->model->deleteLibraryItem($body['id'], $body['mal_id']);
if ((bool)$response === TRUE) if ((bool)$response === TRUE)
{ {

View File

@ -43,6 +43,12 @@ class Anime extends API {
AnimeWatchingStatus::COMPLETED => self::COMPLETED, AnimeWatchingStatus::COMPLETED => self::COMPLETED,
]; ];
protected $kitsuModel;
protected $malModel;
protected $useMALAPI;
/** /**
* Anime constructor. * Anime constructor.
* @param ContainerInterface $container * @param ContainerInterface $container
@ -50,7 +56,11 @@ class Anime extends API {
public function __construct(ContainerInterface $container) { public function __construct(ContainerInterface $container) {
parent::__construct($container); parent::__construct($container);
$config = $container->get('config');
$this->kitsuModel = $container->get('kitsu-model'); $this->kitsuModel = $container->get('kitsu-model');
$this->malModel = $container->get('mal-model');
$this->useMALAPI = $config->get(['use_mal_api']) === TRUE;
} }
/** /**
@ -80,7 +90,7 @@ class Anime extends API {
{ {
return $this->kitsuModel->getAnime($slug); return $this->kitsuModel->getAnime($slug);
} }
public function getAnimeById($anime_id) public function getAnimeById($anime_id)
{ {
return $this->kitsuModel->getAnimeById($anime_id); return $this->kitsuModel->getAnimeById($anime_id);
@ -110,8 +120,26 @@ class Anime extends API {
return $this->kitsuModel->getListItem($itemId); return $this->kitsuModel->getListItem($itemId);
} }
/**
* Add an anime to your list
*
* @param array $data
* @return bool
*/
public function createLibraryItem(array $data): bool public function createLibraryItem(array $data): bool
{ {
if ($this->useMALAPI)
{
$malData = $data;
$malId = $this->kitsuModel->getMalIdForAnime($malData['id']);
if ( ! is_null($malId))
{
$malData['id'] = $malId;
$this->malModel->createListItem($malData);
}
}
return $this->kitsuModel->createListItem($data); return $this->kitsuModel->createListItem($data);
} }
@ -123,11 +151,28 @@ class Anime extends API {
*/ */
public function updateLibraryItem(array $data): array public function updateLibraryItem(array $data): array
{ {
if ($this->useMALAPI)
{
$this->malModel->updateListItem($data);
}
return $this->kitsuModel->updateListItem($data); return $this->kitsuModel->updateListItem($data);
} }
public function deleteLibraryItem($id): bool /**
* Delete a list entry
*
* @param string $id
* @param string|null $malId
* @return bool
*/
public function deleteLibraryItem(string $id, string $malId = null): bool
{ {
if ($this->useMALAPI && ! is_null($malId))
{
$this->malModel->deleteListItem($malId);
}
return $this->kitsuModel->deleteListItem($id); return $this->kitsuModel->deleteListItem($id);
} }
} }

View File

@ -0,0 +1,28 @@
<?php declare(strict_types=1);
namespace Aviat\AnimeClient\Tests\API;
use Aviat\AnimeClient\API\CacheTrait;
class CacheTraitTest extends \AnimeClient_TestCase {
public function setUp()
{
parent::setUp();
$this->testClass = new class {
use CacheTrait;
};
}
public function testSetGet()
{
$cachePool = $this->container->get('cache');
$this->testClass->setCache($cachePool);
$this->assertEquals($cachePool, $this->testClass->getCache());
}
public function testGetHashForMethodCall()
{
$hash = $this->testClass->getHashForMethodCall($this, __METHOD__, []);
$this->assertEquals('684ba0a5c29ffec452c5f6a07d2eee6932575490', $hash);
}
}

View File

@ -44,6 +44,7 @@ class AnimeListTransformerTest extends AnimeClient_TestCase {
], ],
'expected' => [ 'expected' => [
'id' => 14047981, 'id' => 14047981,
'mal_id' => null,
'data' => [ 'data' => [
'status' => 'current', 'status' => 'current',
'rating' => 4, 'rating' => 4,
@ -57,6 +58,7 @@ class AnimeListTransformerTest extends AnimeClient_TestCase {
], [ ], [
'input' => [ 'input' => [
'id' => 14047981, 'id' => 14047981,
'mal_id' => '12345',
'watching_status' => 'current', 'watching_status' => 'current',
'user_rating' => 8, 'user_rating' => 8,
'episodes_watched' => 38, 'episodes_watched' => 38,
@ -68,6 +70,7 @@ class AnimeListTransformerTest extends AnimeClient_TestCase {
], ],
'expected' => [ 'expected' => [
'id' => 14047981, 'id' => 14047981,
'mal_id' => '12345',
'data' => [ 'data' => [
'status' => 'current', 'status' => 'current',
'rating' => 4, 'rating' => 4,

View File

@ -0,0 +1,71 @@
<?php declare(strict_types=1);
namespace Aviat\AnimeClient\Tests\API\Kitsu\Transformer;
use AnimeClient_TestCase;
use Aviat\AnimeClient\API\JsonAPI;
use Aviat\AnimeClient\API\Kitsu\Transformer\MangaListTransformer;
use Aviat\Ion\Json;
class MangaListTransformerTest extends AnimeClient_TestCase {
public function setUp()
{
parent::setUp();
$this->dir = AnimeClient_TestCase::TEST_DATA_DIR . '/Kitsu';
$rawBefore = Json::decodeFile("{$this->dir}/mangaListBeforeTransform.json");
$this->beforeTransform = JsonAPI::inlineRawIncludes($rawBefore, 'manga');
$this->afterTransform = Json::decodeFile("{$this->dir}/mangaListAfterTransform.json");
$this->transformer = new MangaListTransformer();
}
public function testTransform()
{
$expected = $this->afterTransform;
$actual = $this->transformer->transformCollection($this->beforeTransform);
// Json::encodeFile("{$this->dir}/mangaListAfterTransform.json", $actual);
$this->assertEquals($expected, $actual);
}
public function testUntransform()
{
$input = [
'id' => "15084773",
'chapters_read' => 67,
'manga' => [
'titles' => ["Bokura wa Minna Kawaisou"],
'alternate_title' => NULL,
'slug' => "bokura-wa-minna-kawaisou",
'url' => "https://kitsu.io/manga/bokura-wa-minna-kawaisou",
'type' => 'manga',
'image' => 'https://media.kitsu.io/manga/poster_images/20286/small.jpg?1434293999',
'genres' => [],
],
'status' => 'current',
'notes' => '',
'rereading' => false,
'reread_count' => 0,
'new_rating' => 9,
];
$actual = $this->transformer->untransform($input);
$expected = [
'id' => '15084773',
'data' => [
'status' => 'current',
'progress' => 67,
'reconsuming' => false,
'reconsumeCount' => 0,
'notes' => '',
'rating' => 4.5
]
];
$this->assertEquals($expected, $actual);
}
}

View File

@ -0,0 +1,34 @@
<?php declare(strict_types=1);
namespace Aviat\AnimeClient\Tests\API\Kitsu\Transformer;
use AnimeClient_TestCase;
use Aviat\AnimeClient\API\JsonAPI;
use Aviat\AnimeClient\API\Kitsu\Transformer\MangaTransformer;
use Aviat\Ion\Json;
class MangaTransformerTest extends AnimeClient_TestCase {
public function setUp()
{
parent::setUp();
$this->dir = AnimeClient_TestCase::TEST_DATA_DIR . '/Kitsu';
$data = Json::decodeFile("{$this->dir}/mangaBeforeTransform.json");
$baseData = $data['data'][0]['attributes'];
$baseData['included'] = $data['included'];
$this->beforeTransform = $baseData;
$this->afterTransform = Json::decodeFile("{$this->dir}/mangaAfterTransform.json");
$this->transformer = new MangaTransformer();
}
public function testTransform()
{
$actual = $this->transformer->transform($this->beforeTransform);
$expected = $this->afterTransform;
//Json::encodeFile("{$this->dir}/mangaAfterTransform.json", $actual);
$this->assertEquals($expected, $actual);
}
}

59
tests/API/KitsuTest.php Normal file
View File

@ -0,0 +1,59 @@
<?php declare(strict_types=1);
namespace Aviat\AnimeClient\Tests\API;
use Aviat\AnimeClient\API\Kitsu;
use Aviat\AnimeClient\API\Kitsu\Enum\{
AnimeAiringStatus,
AnimeWatchingStatus,
MangaReadingStatus
};
use DateTimeImmutable;
use PHPUnit\Framework\TestCase;
class KitsuTest extends TestCase {
public function testGetStatusToSelectMap()
{
$this->assertEquals([
AnimeWatchingStatus::WATCHING => 'Currently Watching',
AnimeWatchingStatus::PLAN_TO_WATCH => 'Plan to Watch',
AnimeWatchingStatus::COMPLETED => 'Completed',
AnimeWatchingStatus::ON_HOLD => 'On Hold',
AnimeWatchingStatus::DROPPED => 'Dropped'
], Kitsu::getStatusToSelectMap());
}
public function testGetStatusToMangaSelectMap()
{
$this->assertEquals([
MangaReadingStatus::READING => 'Currently Reading',
MangaReadingStatus::PLAN_TO_READ => 'Plan to Read',
MangaReadingStatus::COMPLETED => 'Completed',
MangaReadingStatus::ON_HOLD => 'On Hold',
MangaReadingStatus::DROPPED => 'Dropped'
], Kitsu::getStatusToMangaSelectMap());
}
public function testGetAiringStatus()
{
$actual = Kitsu::getAiringStatus('next week', 'next year');
$this->assertEquals(AnimeAiringStatus::NOT_YET_AIRED, $actual);
}
public function testParseStreamingLinksEmpty()
{
$this->assertEquals([], Kitsu::parseStreamingLinks([]));
}
public function testTitleIsUniqueEmpty()
{
$actual = Kitsu::filterTitles([
'canonicalTitle' => 'Foo',
'titles' => [
null,
''
]
]);
$this->assertEquals(['Foo'], $actual);
}
}

View File

@ -87,10 +87,6 @@ class AnimeClient_TestCase extends TestCase {
'routes' => [ 'routes' => [
] ]
],
'redis' => [
'host' => (array_key_exists('REDIS_HOST', $_ENV)) ? $_ENV['REDIS_HOST'] : 'localhost',
'database' => 13
] ]
]; ];
@ -157,9 +153,9 @@ class AnimeClient_TestCase extends TestCase {
* *
* @return mixed - the decoded data * @return mixed - the decoded data
*/ */
public function getMockFileData() public function getMockFileData(...$args)
{ {
$rawData = call_user_func_array([$this, 'getMockFile'], func_get_args()); $rawData = $this->getMockFile(...$args);
return Json::decode($rawData); return Json::decode($rawData);
} }

View File

@ -194,7 +194,7 @@ class DispatcherTest extends AnimeClient_TestCase {
$this->assertEquals('//localhost/manga/all', $this->urlGenerator->default_url('manga'), "Incorrect default url"); $this->assertEquals('//localhost/manga/all', $this->urlGenerator->default_url('manga'), "Incorrect default url");
$this->assertEquals('//localhost/anime/watching', $this->urlGenerator->default_url('anime'), "Incorrect default url"); $this->assertEquals('//localhost/anime/watching', $this->urlGenerator->default_url('anime'), "Incorrect default url");
$this->setExpectedException('\InvalidArgumentException'); $this->expectException(\InvalidArgumentException::class);
$this->urlGenerator->default_url('foo'); $this->urlGenerator->default_url('foo');
} }

View File

@ -0,0 +1,12 @@
{
"title": "Bokura wa Minna Kawaisou",
"en_title": null,
"jp_title": "Bokura wa Minna Kawaisou",
"cover_image": "https:\/\/media.kitsu.io\/manga\/poster_images\/20286\/small.jpg?1434293999",
"manga_type": "manga",
"chapter_count": "-",
"volume_count": "-",
"synopsis": "Usa, a high-school student aspiring to begin a bachelor lifestyle, moves into a new apartment only to discover that he not only shares a room with a perverted roommate that has an obsession for underaged girls, but also that another girl, Ritsu, a love-at-first-sight, is living in the same building as well!\n(Source: Kirei Cake)",
"url": "https:\/\/kitsu.io\/manga\/bokura-wa-minna-kawaisou",
"genres": ["Comedy","Romance","School","Slice of Life","Thriller"]
}

View File

@ -0,0 +1,197 @@
{
"data": [{
"id": "20286",
"type": "manga",
"links": {
"self": "https://kitsu.io/api/edge/manga/20286"
},
"attributes": {
"slug": "bokura-wa-minna-kawaisou",
"synopsis": "Usa, a high-school student aspiring to begin a bachelor lifestyle, moves into a new apartment only to discover that he not only shares a room with a perverted roommate that has an obsession for underaged girls, but also that another girl, Ritsu, a love-at-first-sight, is living in the same building as well!\n(Source: Kirei Cake)",
"coverImageTopOffset": 40,
"titles": {
"en": null,
"en_jp": "Bokura wa Minna Kawaisou"
},
"canonicalTitle": "Bokura wa Minna Kawaisou",
"abbreviatedTitles": null,
"averageRating": 4.12281805954249,
"ratingFrequencies": {
"0.5": "0",
"1.0": "1",
"1.5": "0",
"2.0": "1",
"2.5": "2",
"3.0": "6",
"3.5": "21",
"4.0": "38",
"4.5": "35",
"5.0": "43",
"nil": "16"
},
"favoritesCount": 0,
"startDate": "2010-01-01",
"endDate": null,
"popularityRank": 262,
"ratingRank": 127,
"ageRating": "PG",
"ageRatingGuide": null,
"posterImage": {
"tiny": "https://media.kitsu.io/manga/poster_images/20286/tiny.jpg?1434293999",
"small": "https://media.kitsu.io/manga/poster_images/20286/small.jpg?1434293999",
"medium": "https://media.kitsu.io/manga/poster_images/20286/medium.jpg?1434293999",
"large": "https://media.kitsu.io/manga/poster_images/20286/large.jpg?1434293999",
"original": "https://media.kitsu.io/manga/poster_images/20286/original.jpg?1434293999"
},
"coverImage": {
"small": "https://media.kitsu.io/manga/cover_images/20286/small.jpg?1430793688",
"large": "https://media.kitsu.io/manga/cover_images/20286/large.jpg?1430793688",
"original": "https://media.kitsu.io/manga/cover_images/20286/original.jpg?1430793688"
},
"subtype": "manga",
"chapterCount": null,
"volumeCount": 0,
"serialization": "Young King Ours",
"mangaType": "manga"
},
"relationships": {
"genres": {
"links": {
"self": "https://kitsu.io/api/edge/manga/20286/relationships/genres",
"related": "https://kitsu.io/api/edge/manga/20286/genres"
},
"data": [{
"type": "genres",
"id": "3"
}, {
"type": "genres",
"id": "24"
}, {
"type": "genres",
"id": "16"
}, {
"type": "genres",
"id": "14"
}, {
"type": "genres",
"id": "18"
}]
},
"castings": {
"links": {
"self": "https://kitsu.io/api/edge/manga/20286/relationships/castings",
"related": "https://kitsu.io/api/edge/manga/20286/castings"
}
},
"installments": {
"links": {
"self": "https://kitsu.io/api/edge/manga/20286/relationships/installments",
"related": "https://kitsu.io/api/edge/manga/20286/installments"
}
},
"mappings": {
"links": {
"self": "https://kitsu.io/api/edge/manga/20286/relationships/mappings",
"related": "https://kitsu.io/api/edge/manga/20286/mappings"
},
"data": [{
"type": "mappings",
"id": "48014"
}]
},
"reviews": {
"links": {
"self": "https://kitsu.io/api/edge/manga/20286/relationships/reviews",
"related": "https://kitsu.io/api/edge/manga/20286/reviews"
}
},
"mediaRelationships": {
"links": {
"self": "https://kitsu.io/api/edge/manga/20286/relationships/media-relationships",
"related": "https://kitsu.io/api/edge/manga/20286/media-relationships"
}
}
}
}],
"included": [{
"id": "3",
"type": "genres",
"links": {
"self": "https://kitsu.io/api/edge/genres/3"
},
"attributes": {
"name": "Comedy",
"slug": "comedy",
"description": null
}
}, {
"id": "24",
"type": "genres",
"links": {
"self": "https://kitsu.io/api/edge/genres/24"
},
"attributes": {
"name": "School",
"slug": "school",
"description": null
}
}, {
"id": "16",
"type": "genres",
"links": {
"self": "https://kitsu.io/api/edge/genres/16"
},
"attributes": {
"name": "Slice of Life",
"slug": "slice-of-life",
"description": ""
}
}, {
"id": "14",
"type": "genres",
"links": {
"self": "https://kitsu.io/api/edge/genres/14"
},
"attributes": {
"name": "Romance",
"slug": "romance",
"description": ""
}
}, {
"id": "18",
"type": "genres",
"links": {
"self": "https://kitsu.io/api/edge/genres/18"
},
"attributes": {
"name": "Thriller",
"slug": "thriller",
"description": null
}
}, {
"id": "48014",
"type": "mappings",
"links": {
"self": "https://kitsu.io/api/edge/mappings/48014"
},
"attributes": {
"externalSite": "myanimelist/manga",
"externalId": "26769"
},
"relationships": {
"media": {
"links": {
"self": "https://kitsu.io/api/edge/mappings/48014/relationships/media",
"related": "https://kitsu.io/api/edge/mappings/48014/media"
}
}
}
}],
"meta": {
"count": 1
},
"links": {
"first": "https://kitsu.io/api/edge/manga?filter%5Bslug%5D=bokura-wa-minna-kawaisou&include=genres%2Cmappings&page%5Blimit%5D=10&page%5Boffset%5D=0",
"last": "https://kitsu.io/api/edge/manga?filter%5Bslug%5D=bokura-wa-minna-kawaisou&include=genres%2Cmappings&page%5Blimit%5D=10&page%5Boffset%5D=0"
}
}

View File

@ -0,0 +1,241 @@
[{
"id": "15084773",
"chapters": {
"read": 67,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["Bokura wa Minna Kawaisou"],
"alternate_title": null,
"slug": "bokura-wa-minna-kawaisou",
"url": "https:\/\/kitsu.io\/manga\/bokura-wa-minna-kawaisou",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/20286\/small.jpg?1434293999",
"genres": []
},
"reading_status": "current",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"id": "15085607",
"chapters": {
"read": 17,
"total": 120
},
"volumes": {
"read": "-",
"total": 14
},
"manga": {
"titles": ["Love Hina"],
"alternate_title": null,
"slug": "love-hina",
"url": "https:\/\/kitsu.io\/manga\/love-hina",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/47\/small.jpg?1434249493",
"genres": []
},
"reading_status": "current",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": 7
}, {
"id": "15084529",
"chapters": {
"read": 16,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["Yamada-kun to 7-nin no Majo", "Yamada-kun and the Seven Witches"],
"alternate_title": null,
"slug": "yamada-kun-to-7-nin-no-majo",
"url": "https:\/\/kitsu.io\/manga\/yamada-kun-to-7-nin-no-majo",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/11777\/small.jpg?1438784325",
"genres": []
},
"reading_status": "current",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"id": "15312827",
"chapters": {
"read": 68,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["ReLIFE"],
"alternate_title": null,
"slug": "relife",
"url": "https:\/\/kitsu.io\/manga\/relife",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/27175\/small.jpg?1464379411",
"genres": []
},
"reading_status": "current",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"id": "15084772",
"chapters": {
"read": 28,
"total": 62
},
"volumes": {
"read": "-",
"total": 10
},
"manga": {
"titles": ["Usagi Drop", "Bunny Drop"],
"alternate_title": null,
"slug": "usagi-drop",
"url": "https:\/\/kitsu.io\/manga\/usagi-drop",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/7629\/small.jpg?1434265873",
"genres": []
},
"reading_status": "on_hold",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"id": "15251749",
"chapters": {
"read": 1,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["Shishunki Bitter Change"],
"alternate_title": null,
"slug": "shishunki-bitter-change",
"url": "https:\/\/kitsu.io\/manga\/shishunki-bitter-change",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/25512\/small.jpg?1434305092",
"genres": []
},
"reading_status": "planned",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"id": "15312881",
"chapters": {
"read": 0,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["Kuragehime", "Princess Jellyfish"],
"alternate_title": null,
"slug": "kuragehime",
"url": "https:\/\/kitsu.io\/manga\/kuragehime",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/5531\/small.jpg?1434261214",
"genres": []
},
"reading_status": "planned",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"id": "15315190",
"chapters": {
"read": 0,
"total": 80
},
"volumes": {
"read": "-",
"total": 9
},
"manga": {
"titles": ["Boku wa Mari no Naka", "Inside Mari"],
"alternate_title": null,
"slug": "boku-wa-mari-no-naka",
"url": "https:\/\/kitsu.io\/manga\/boku-wa-mari-no-naka",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/14261\/small.jpg?1434280674",
"genres": []
},
"reading_status": "planned",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"id": "15315189",
"chapters": {
"read": 0,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["Aizawa-san Zoushoku"],
"alternate_title": null,
"slug": "aizawa-san-zoushoku",
"url": "https:\/\/kitsu.io\/manga\/aizawa-san-zoushoku",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/25316\/small.jpg?1434304656",
"genres": []
},
"reading_status": "planned",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"id": "15288185",
"chapters": {
"read": 28,
"total": "-"
},
"volumes": {
"read": "-",
"total": "-"
},
"manga": {
"titles": ["Tonari no Seki-kun", "My Neighbour Seki"],
"alternate_title": null,
"slug": "tonari-no-seki-kun",
"url": "https:\/\/kitsu.io\/manga\/tonari-no-seki-kun",
"type": "manga",
"image": "https:\/\/media.kitsu.io\/manga\/poster_images\/21733\/small.jpg?1434297086",
"genres": []
},
"reading_status": "on_hold",
"notes": "",
"rereading": false,
"reread": 0,
"user_rating": 8
}]

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1 +0,0 @@
[{"id":8710,"mal_id":24705,"slug":"ore-twintails-ni-narimasu","status":"Finished Airing","url":"https://hummingbird.me/anime/ore-twintails-ni-narimasu","title":"Ore, Twintails ni Narimasu.","alternate_title":"Gonna be the Twin-Tails!!","episode_count":12,"episode_length":24,"cover_image":"https://static.hummingbird.me/anime/poster_images/000/008/710/large/ore-twintails-ni-narimasu.jpg?1416244663","synopsis":"Mitsuka Souji is a first year high school student who greatly loves the \"twintails\" hairstyle. One day a beautiful girl, Twoearle, who comes from another world suddenly appeared in front of him and gave him the power to transform into the twintails warrior TailRed. Now Souji, with the help of his childhood friend Tsube Aika who can becomes the twintails warrior TailBlue, must fight in order to protect the peace on earth.\n\n(Source: Wikipedia)","show_type":"TV","started_airing":"2014-10-10","finished_airing":"2014-12-26","community_rating":3.32172789396078,"age_rating":"PG13","genres":[{"name":"Action"},{"name":"Comedy"},{"name":"Fantasy"},{"name":"Romance"},{"name":"School"},{"name":"Gender Bender"}]}]

File diff suppressed because one or more lines are too long

View File

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,961 +0,0 @@
[{
"chapters": {
"read": 6,
"total": 120
},
"volumes": {
"read": 1,
"total": 14
},
"manga": {
"title": "Love Hina",
"alternate_title": null,
"slug": "love-hina",
"url": "https:\/\/hummingbird.me\/manga\/love-hina",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/000\/047\/large\/52139.jpg?1434249493",
"genres": ["Comedy", "Ecchi", "Harem", "Romance"]
},
"id": 401735,
"reading_status": "Currently Reading",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"chapters": {
"read": 0,
"total": "-"
},
"volumes": {
"read": 0,
"total": 2
},
"manga": {
"title": "Murder Incarnation",
"alternate_title": null,
"slug": "murder-incarnation",
"url": "https:\/\/hummingbird.me\/manga\/murder-incarnation",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/026\/632\/large\/115683.jpg?1434307495",
"genres": ["Psychological"]
},
"id": 400982,
"reading_status": "Plan to Read",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"chapters": {
"read": 0,
"total": 17
},
"volumes": {
"read": 0,
"total": 3
},
"manga": {
"title": "DOLL The Hotel Detective",
"alternate_title": null,
"slug": "doll-the-hotel-detective",
"url": "https:\/\/hummingbird.me\/manga\/doll-the-hotel-detective",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/010\/076\/large\/34469.jpg?1434271227",
"genres": ["Action"]
},
"id": 400980,
"reading_status": "Plan to Read",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"chapters": {
"read": 0,
"total": "-"
},
"volumes": {
"read": 0,
"total": "-"
},
"manga": {
"title": "Fuuka",
"alternate_title": null,
"slug": "fuuka",
"url": "https:\/\/hummingbird.me\/manga\/fuuka",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/025\/292\/large\/4407752-04.jpg?1434304607",
"genres": ["Ecchi", "Romance", "School"]
},
"id": 400978,
"reading_status": "Plan to Read",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"chapters": {
"read": 0,
"total": "-"
},
"volumes": {
"read": 0,
"total": "-"
},
"manga": {
"title": "Yamada-kun to 7-nin no Majo",
"alternate_title": "Yamada-kun and the Seven Witches",
"slug": "yamada-kun-to-7-nin-no-majo",
"url": "https:\/\/hummingbird.me\/manga\/yamada-kun-to-7-nin-no-majo",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/011\/777\/large\/82656l.jpg?1438784325",
"genres": ["Comedy", "Ecchi", "Gender Bender", "Romance", "School", "Supernatural"]
},
"id": 400977,
"reading_status": "Plan to Read",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"chapters": {
"read": 86,
"total": "-"
},
"volumes": {
"read": 12,
"total": "-"
},
"manga": {
"title": "Yotsubato!",
"alternate_title": null,
"slug": "yotsubato",
"url": "https:\/\/hummingbird.me\/manga\/yotsubato",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/000\/272\/large\/Yotsuba-Cover-Images-yotsuba-and-5465671-434-600.jpg?1434249971",
"genres": ["Comedy", "Slice of Life"]
},
"id": 400904,
"reading_status": "On Hold",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 111,
"total": 111
},
"volumes": {
"read": 13,
"total": 13
},
"manga": {
"title": "Wagatsuma-san wa Ore no Yome",
"alternate_title": "My Wife Is Wagatsuma-san",
"slug": "wagatsuma-san-wa-ore-no-yome",
"url": "https:\/\/hummingbird.me\/manga\/wagatsuma-san-wa-ore-no-yome",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/005\/516\/large\/48035.jpg?1434261178",
"genres": ["Comedy", "Romance", "School", "Slice of Life"]
},
"id": 400903,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 4,
"total": 4
},
"volumes": {
"read": 1,
"total": 1
},
"manga": {
"title": "Usotsuki Marriage",
"alternate_title": "Deceitful Marriage",
"slug": "usotsuki-marriage",
"url": "https:\/\/hummingbird.me\/manga\/usotsuki-marriage",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/023\/295\/large\/12823.jpg?1434300356",
"genres": ["Comedy", "Romance"]
},
"id": 400902,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 28,
"total": 62
},
"volumes": {
"read": 1,
"total": 10
},
"manga": {
"title": "Usagi Drop",
"alternate_title": "Bunny Drop",
"slug": "usagi-drop",
"url": "https:\/\/hummingbird.me\/manga\/usagi-drop",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/007\/629\/large\/53493.jpg?1434265873",
"genres": ["Comedy", "Drama", "Slice of Life"]
},
"id": 400901,
"reading_status": "Currently Reading",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 269,
"total": 366
},
"volumes": {
"read": 12,
"total": 34
},
"manga": {
"title": "Urusei Yatsura",
"alternate_title": "Those Obnoxious Aliens",
"slug": "urusei-yatsura",
"url": "https:\/\/hummingbird.me\/manga\/urusei-yatsura",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/000\/702\/large\/3465.jpg?1434250836",
"genres": ["Comedy", "Romance", "Sci-Fi"]
},
"id": 400900,
"reading_status": "Dropped",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 6,
"total": 6
},
"volumes": {
"read": 1,
"total": 1
},
"manga": {
"title": "SOLD OUT!",
"alternate_title": null,
"slug": "sold-out",
"url": "https:\/\/hummingbird.me\/manga\/sold-out",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/009\/061\/large\/4134.jpg?1434268999",
"genres": ["Drama", "Romance"]
},
"id": 400899,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 7
}, {
"chapters": {
"read": 190,
"total": "-"
},
"volumes": {
"read": 30,
"total": "-"
},
"manga": {
"title": "Skip Beat!",
"alternate_title": null,
"slug": "skip-beat",
"url": "https:\/\/hummingbird.me\/manga\/skip-beat",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/001\/388\/large\/26110.jpg?1434252296",
"genres": ["Comedy", "Drama", "Romance"]
},
"id": 400898,
"reading_status": "On Hold",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 10
}, {
"chapters": {
"read": 10,
"total": 10
},
"volumes": {
"read": 2,
"total": 2
},
"manga": {
"title": "Samurai Champloo",
"alternate_title": null,
"slug": "samurai-champloo",
"url": "https:\/\/hummingbird.me\/manga\/samurai-champloo",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/001\/171\/large\/56743.jpg?1434251812",
"genres": ["Action", "Adventure", "Comedy"]
},
"id": 400897,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 407,
"total": 407
},
"volumes": {
"read": 38,
"total": 38
},
"manga": {
"title": "Ranma \u00bd",
"alternate_title": null,
"slug": "ranma",
"url": "https:\/\/hummingbird.me\/manga\/ranma",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/000\/062\/large\/5616.jpg?1434249522",
"genres": ["Action", "Comedy", "Ecchi", "Gender Bender", "Harem", "Martial Arts", "Romance", "School"]
},
"id": 400896,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 5,
"total": 5
},
"volumes": {
"read": 1,
"total": 1
},
"manga": {
"title": "Otome no Iroha!",
"alternate_title": null,
"slug": "otome-no-iroha",
"url": "https:\/\/hummingbird.me\/manga\/otome-no-iroha",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/022\/436\/large\/12188.jpg?1434298544",
"genres": ["Comedy", "Ecchi", "School"]
},
"id": 400895,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 7
}, {
"chapters": {
"read": 26,
"total": 26
},
"volumes": {
"read": 4,
"total": 4
},
"manga": {
"title": "Ore no Imouto ga Konnani Kawaii Wake ga Nai",
"alternate_title": "Oreimo",
"slug": "ore-no-imouto-ga-konnani-kawaii-wake-ga-nai",
"url": "https:\/\/hummingbird.me\/manga\/ore-no-imouto-ga-konnani-kawaii-wake-ga-nai",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/000\/714\/large\/71Zqo5clDfL._SL1200_.jpg?1439399108",
"genres": ["Comedy", "Drama", "Ecchi", "School"]
},
"id": 400894,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 10
}, {
"chapters": {
"read": 44,
"total": "-"
},
"volumes": {
"read": 6,
"total": "-"
},
"manga": {
"title": "Onii-chan no Koto nanka Zenzen Suki Janain Dakara ne!!",
"alternate_title": "I don't like you at all, Big Brother!!",
"slug": "onii-chan-no-koto-nanka-zenzen-suki-janain-dakara-ne",
"url": "https:\/\/hummingbird.me\/manga\/onii-chan-no-koto-nanka-zenzen-suki-janain-dakara-ne",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/021\/900\/large\/11793.jpg?1434297436",
"genres": ["Comedy", "Ecchi", "Harem", "Romance", "School"]
},
"id": 400893,
"reading_status": "On Hold",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 3,
"total": 3
},
"volumes": {
"read": 1,
"total": 1
},
"manga": {
"title": "Onegai, Sensei",
"alternate_title": "Please, Teacher",
"slug": "onegai-sensei-manga",
"url": "https:\/\/hummingbird.me\/manga\/onegai-sensei-manga",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/019\/137\/large\/9625.jpg?1434291544",
"genres": ["Romance", "School"]
},
"id": 400892,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 0,
"total": 22
},
"volumes": {
"read": 0,
"total": 4
},
"manga": {
"title": "Obaa-chan wa Idol",
"alternate_title": null,
"slug": "obaa-chan-wa-idol",
"url": "https:\/\/hummingbird.me\/manga\/obaa-chan-wa-idol",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/008\/089\/large\/3634.jpg?1434266860",
"genres": ["Comedy", "Drama", "Romance", "Supernatural"]
},
"id": 400891,
"reading_status": "Dropped",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": "-"
}, {
"chapters": {
"read": 96,
"total": 96
},
"volumes": {
"read": 12,
"total": 12
},
"manga": {
"title": "Nazo no Kanojo X",
"alternate_title": "Mysterious Girlfriend X",
"slug": "nazo-no-kanojo-x",
"url": "https:\/\/hummingbird.me\/manga\/nazo-no-kanojo-x",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/004\/029\/large\/1926.jpg?1434257986",
"genres": ["Ecchi", "Mystery", "Romance", "School"]
},
"id": 400890,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 7,
"total": 7
},
"volumes": {
"read": 2,
"total": 2
},
"manga": {
"title": "Milk to Vitamin",
"alternate_title": null,
"slug": "milk-to-vitamin",
"url": "https:\/\/hummingbird.me\/manga\/milk-to-vitamin",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/007\/616\/large\/3843.jpg?1434265843",
"genres": ["Comedy", "Drama", "Romance"]
},
"id": 400889,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 7
}, {
"chapters": {
"read": 12,
"total": 12
},
"volumes": {
"read": 2,
"total": 2
},
"manga": {
"title": "Mama wa Shougaku 4 Nensei",
"alternate_title": null,
"slug": "mama-wa-shougaku-4-nensei",
"url": "https:\/\/hummingbird.me\/manga\/mama-wa-shougaku-4-nensei",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/007\/618\/large\/3464.jpg?1434265846",
"genres": ["Comedy", "Drama", "Sci-Fi"]
},
"id": 400888,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 13,
"total": 14
},
"volumes": {
"read": 2,
"total": 2
},
"manga": {
"title": "Maburaho",
"alternate_title": null,
"slug": "maburaho-7719eaec-27ba-4375-847e-7b140a29257a",
"url": "https:\/\/hummingbird.me\/manga\/maburaho-7719eaec-27ba-4375-847e-7b140a29257a",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/001\/649\/large\/5194.jpg?1434252868",
"genres": ["Comedy", "Harem", "Magic", "Romance"]
},
"id": 400887,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 49,
"total": 49
},
"volumes": {
"read": 8,
"total": 8
},
"manga": {
"title": "Kono Onee-san wa Fiction desu!?",
"alternate_title": "Is this Girl for Real!?",
"slug": "kono-onee-san-wa-fiction-desu",
"url": "https:\/\/hummingbird.me\/manga\/kono-onee-san-wa-fiction-desu",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/015\/503\/large\/74249.jpg?1434283423",
"genres": ["Comedy", "Ecchi", "Romance", "School"]
},
"id": 400886,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 5,
"total": 5
},
"volumes": {
"read": 1,
"total": 1
},
"manga": {
"title": "Kimi Dake no Devil",
"alternate_title": "A Devil Just for You",
"slug": "kimi-dake-no-devil",
"url": "https:\/\/hummingbird.me\/manga\/kimi-dake-no-devil",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/014\/606\/large\/7082.jpg?1434281449",
"genres": ["Comedy", "Fantasy"]
},
"id": 400885,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 2,
"total": 2
},
"volumes": {
"read": 1,
"total": "-"
},
"manga": {
"title": "Kanaete Aizen",
"alternate_title": null,
"slug": "kanaete-aizen",
"url": "https:\/\/hummingbird.me\/manga\/kanaete-aizen",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/013\/080\/large\/66091.jpg?1434278021",
"genres": ["Ecchi", "Romance", "School", "Supernatural"]
},
"id": 400884,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 34,
"total": "-"
},
"volumes": {
"read": 0,
"total": "-"
},
"manga": {
"title": "Joshikausei",
"alternate_title": null,
"slug": "joshikausei",
"url": "https:\/\/hummingbird.me\/manga\/joshikausei",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/025\/491\/large\/121107.jpg?1434305043",
"genres": ["Comedy", "School", "Slice of Life"]
},
"id": 400883,
"reading_status": "Currently Reading",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 558,
"total": 558
},
"volumes": {
"read": 56,
"total": 56
},
"manga": {
"title": "InuYasha",
"alternate_title": null,
"slug": "inuyasha",
"url": "https:\/\/hummingbird.me\/manga\/inuyasha",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/001\/531\/large\/32468.jpg?1434252597",
"genres": ["Adventure", "Comedy", "Demons", "Drama", "Fantasy", "Historical", "Romance", "Supernatural"]
},
"id": 400882,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 7
}, {
"chapters": {
"read": 19,
"total": 19
},
"volumes": {
"read": 3,
"total": 3
},
"manga": {
"title": "Inumimi",
"alternate_title": null,
"slug": "inumimi",
"url": "https:\/\/hummingbird.me\/manga\/inumimi",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/004\/025\/large\/1924.jpg?1434257977",
"genres": ["Comedy", "Ecchi", "Harem", "Romance"]
},
"id": 400881,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 41,
"total": 41
},
"volumes": {
"read": 5,
"total": 5
},
"manga": {
"title": "Inu Neko Jump",
"alternate_title": "Dog Cat Jump",
"slug": "inu-neko-jump",
"url": "https:\/\/hummingbird.me\/manga\/inu-neko-jump",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/002\/136\/large\/978.jpg?1434253893",
"genres": ["Comedy", "Romance"]
},
"id": 400880,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 9,
"total": 9
},
"volumes": {
"read": 2,
"total": 2
},
"manga": {
"title": "I \u2665 HS",
"alternate_title": "I Love High School",
"slug": "i-hs",
"url": "https:\/\/hummingbird.me\/manga\/i-hs",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/023\/195\/large\/17718.jpg?1434300143",
"genres": ["Comedy", "Drama", "Romance", "School"]
},
"id": 400879,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 7
}, {
"chapters": {
"read": 48,
"total": 48
},
"volumes": {
"read": 8,
"total": 8
},
"manga": {
"title": "Futaba-kun Change\u2661",
"alternate_title": null,
"slug": "futaba-kun-change",
"url": "https:\/\/hummingbird.me\/manga\/futaba-kun-change",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/004\/367\/large\/2114.jpg?1434258719",
"genres": ["Comedy", "Ecchi", "Romance", "School"]
},
"id": 400878,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 58,
"total": 58
},
"volumes": {
"read": 9,
"total": 9
},
"manga": {
"title": "Full Metal Panic!",
"alternate_title": null,
"slug": "full-metal-panic",
"url": "https:\/\/hummingbird.me\/manga\/full-metal-panic",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/001\/735\/large\/5190.jpg?1434253058",
"genres": ["Action", "Comedy", "Mecha", "Military", "Romance"]
},
"id": 400877,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 10
}, {
"chapters": {
"read": 88,
"total": 88
},
"volumes": {
"read": 8,
"total": 8
},
"manga": {
"title": "Chobits",
"alternate_title": null,
"slug": "chobits",
"url": "https:\/\/hummingbird.me\/manga\/chobits",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/000\/278\/large\/19440.jpg?1434249984",
"genres": ["Comedy", "Ecchi", "Psychological", "Romance", "Sci-Fi"]
},
"id": 400876,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 4,
"total": 4
},
"volumes": {
"read": 1,
"total": 1
},
"manga": {
"title": "Change 2!!",
"alternate_title": null,
"slug": "change-2",
"url": "https:\/\/hummingbird.me\/manga\/change-2",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/016\/486\/large\/8072.jpg?1434285566",
"genres": ["Action", "Ecchi", "School"]
},
"id": 400875,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 10
}, {
"chapters": {
"read": 62,
"total": "-"
},
"volumes": {
"read": 6,
"total": "-"
},
"manga": {
"title": "Bokura wa Minna Kawaisou",
"alternate_title": null,
"slug": "bokura-wa-minna-kawaisou",
"url": "https:\/\/hummingbird.me\/manga\/bokura-wa-minna-kawaisou",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/020\/286\/large\/17950117v1.jpg?1434293999",
"genres": ["Comedy", "Romance", "School", "Slice of Life"]
},
"id": 400874,
"reading_status": "Currently Reading",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 9
}, {
"chapters": {
"read": 21,
"total": 21
},
"volumes": {
"read": 5,
"total": 5
},
"manga": {
"title": "Boku ni Natta Watashi",
"alternate_title": "I Became a Boy",
"slug": "boku-ni-natta-watashi",
"url": "https:\/\/hummingbird.me\/manga\/boku-ni-natta-watashi",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/001\/379\/large\/20745.jpg?1434252279",
"genres": ["Drama", "Romance", "School"]
},
"id": 400873,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 30,
"total": 61
},
"volumes": {
"read": 5,
"total": 18
},
"manga": {
"title": "Bishoujo Senshi Sailor Moon",
"alternate_title": "Sailor Moon",
"slug": "bishoujo-senshi-sailor-moon",
"url": "https:\/\/hummingbird.me\/manga\/bishoujo-senshi-sailor-moon",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/000\/241\/large\/6601.jpg?1434249905",
"genres": ["Drama", "Fantasy", "Magic", "Mahou Shoujo", "Romance"]
},
"id": 400872,
"reading_status": "Dropped",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 8
}, {
"chapters": {
"read": 4,
"total": 4
},
"volumes": {
"read": 1,
"total": 1
},
"manga": {
"title": "Anta Nanka Daikirai",
"alternate_title": null,
"slug": "anta-nanka-daikirai",
"url": "https:\/\/hummingbird.me\/manga\/anta-nanka-daikirai",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/024\/984\/large\/14617.jpg?1434303960",
"genres": ["Drama", "Romance", "School"]
},
"id": 400871,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 7
}, {
"chapters": {
"read": 10,
"total": 10
},
"volumes": {
"read": 2,
"total": 2
},
"manga": {
"title": "Akane-chan Overdrive",
"alternate_title": null,
"slug": "akane-chan-overdrive",
"url": "https:\/\/hummingbird.me\/manga\/akane-chan-overdrive",
"type": "Manga",
"image": "https:\/\/static.hummingbird.me\/manga\/poster_images\/000\/001\/961\/large\/900.jpg?1434253542",
"genres": ["Comedy", "Ecchi", "Gender Bender"]
},
"id": 400870,
"reading_status": "Completed",
"notes": null,
"rereading": false,
"reread": 0,
"user_rating": 7
}]

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff