diff --git a/CHANGELOG.md b/CHANGELOG.md index cab3e75f..7d1fda30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,7 +3,10 @@ ## Version 4 * Updated to use Kitsu API after discontinuation of Hummingbird * 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 +* Added simple integration with MyAnimeList, so an update can cross-post to both Kitsu and MyAnimeList (anime and manga) +* Added console command to sync Kitsu and MyAnimeList data + +* Added character pages ## Version 3 * Converted user configuration to toml files diff --git a/app/appConf/base_config.php b/app/appConf/base_config.php index 710e9ad8..b8b645ad 100644 --- a/app/appConf/base_config.php +++ b/app/appConf/base_config.php @@ -14,6 +14,7 @@ * @link https://github.com/timw4mail/HummingBirdAnimeClient */ +use function Aviat\AnimeClient\loadToml; // ---------------------------------------------------------------------------- // Lower level configuration @@ -23,7 +24,9 @@ $APP_DIR = realpath(__DIR__ . '/../'); $ROOT_DIR = realpath("{$APP_DIR}/../"); -$base_config = [ +$tomlConfig = loadToml(__DIR__); + +$base_config = array_merge($tomlConfig, [ 'asset_dir' => "{$ROOT_DIR}/public", // Template file path @@ -34,6 +37,5 @@ $base_config = [ 'img_cache_path' => "{$ROOT_DIR}/public/images", // Included config files - 'menus' => require 'menus.php', 'routes' => require 'routes.php', -]; \ No newline at end of file +]); \ No newline at end of file diff --git a/app/appConf/menus.php b/app/appConf/menus.php deleted file mode 100644 index 9ff73ea0..00000000 --- a/app/appConf/menus.php +++ /dev/null @@ -1,41 +0,0 @@ - - * @copyright 2015 - 2017 Timothy J. Warren - * @license http://www.opensource.org/licenses/mit-license.html MIT License - * @version 4.0 - * @link https://github.com/timw4mail/HummingBirdAnimeClient - */ - - -return [ - 'anime_list' => [ - 'route_prefix' => '/anime', - 'items' => [ - 'watching' => '/watching', - 'plan_to_watch' => '/plan_to_watch', - 'on_hold' => '/on_hold', - 'dropped' => '/dropped', - 'completed' => '/completed', - 'all' => '/all' - ] - ], - 'manga_list' => [ - 'route_prefix' => '/manga', - 'items' => [ - 'reading' => '/reading', - 'plan_to_read' => '/plan_to_read', - 'on_hold' => '/on_hold', - 'dropped' => '/dropped', - 'completed' => '/completed', - 'all' => '/all' - ] - ] -]; \ No newline at end of file diff --git a/app/appConf/menus.toml b/app/appConf/menus.toml new file mode 100644 index 00000000..fd1df9fa --- /dev/null +++ b/app/appConf/menus.toml @@ -0,0 +1,19 @@ +[anime_list] + route_prefix = "/anime" + [anime_list.items] + watching = '/watching' + plan_to_watch = '/plan_to_watch' + on_hold = '/on_hold' + dropped = '/dropped' + completed = '/completed' + all = '/all' + +[manga_list] + route_prefix = "/manga" + [manga_list.items] + reading = '/reading' + plan_to_read = '/plan_to_read' + on_hold = '/on_hold' + dropped = '/dropped' + completed = '/completed' + all = '/all' \ No newline at end of file diff --git a/app/appConf/route_config.toml b/app/appConf/route_config.toml new file mode 100644 index 00000000..74191a89 --- /dev/null +++ b/app/appConf/route_config.toml @@ -0,0 +1,19 @@ +################################################################################ +# Route config +# +# Default views and paths +################################################################################ + +# Path to public directory, where images/css/javascript are located, +# appended to the url +asset_path = "/public" + +# Which list should be the default? +default_list = "anime" # anime or manga + +# Default pages for anime/manga +default_anime_list_path = "watching" # watching|plan_to_watch|on_hold|dropped|completed|all +default_manga_list_path = "reading" # reading|plan_to_read|on_hold|dropped|completed|all + +# Default view type (cover_view/list_view) +default_view_type = "cover_view" \ No newline at end of file diff --git a/app/appConf/routes.php b/app/appConf/routes.php index 936dd29e..d8b81816 100644 --- a/app/appConf/routes.php +++ b/app/appConf/routes.php @@ -17,216 +17,194 @@ use const Aviat\AnimeClient\{ DEFAULT_CONTROLLER_METHOD, - DEFAULT_CONTROLLER_NAMESPACE + DEFAULT_CONTROLLER }; use Aviat\AnimeClient\AnimeClient; +// ------------------------------------------------------------------------- +// Routing Config +// +// Maps paths to controlers and methods +// ------------------------------------------------------------------------- return [ - // ------------------------------------------------------------------------- - // Routing options - // - // Specify default paths and views - // ------------------------------------------------------------------------- - 'route_config' => [ - // Path to public directory, where images/css/javascript are located, - // appended to the url - 'asset_path' => '/public', - - // Which list should be the default? - 'default_list' => 'anime', // anime or manga - - // Default pages for anime/manga - 'default_anime_list_path' => "watching", // watching|plan_to_watch|on_hold|dropped|completed|all - 'default_manga_list_path' => "reading", // reading|plan_to_read|on_hold|dropped|completed|all - - // Default view type (cover_view/list_view) - 'default_view_type' => 'cover_view', + // --------------------------------------------------------------------- + // Anime List Routes + // --------------------------------------------------------------------- + 'anime.add.get' => [ + 'path' => '/anime/add', + 'action' => 'addForm', + 'verb' => 'get', ], - // ------------------------------------------------------------------------- - // Routing Config - // - // Maps paths to controlers and methods - // ------------------------------------------------------------------------- - 'routes' => [ - // --------------------------------------------------------------------- - // Anime List Routes - // --------------------------------------------------------------------- - 'anime.add.get' => [ - 'path' => '/anime/add', - 'action' => 'addForm', - 'verb' => 'get', + 'anime.add.post' => [ + 'path' => '/anime/add', + 'action' => 'add', + 'verb' => 'post', + ], + 'anime.details' => [ + 'path' => '/anime/details/{id}', + 'action' => 'details', + 'tokens' => [ + 'id' => '[a-z0-9\-]+', ], - 'anime.add.post' => [ - 'path' => '/anime/add', - 'action' => 'add', - 'verb' => 'post', + ], + 'anime.delete' => [ + 'path' => '/anime/delete', + 'action' => 'delete', + 'verb' => 'post', + ], + // --------------------------------------------------------------------- + // Manga Routes + // --------------------------------------------------------------------- + 'manga.search' => [ + 'path' => '/manga/search', + 'action' => 'search', + ], + 'manga.add.get' => [ + 'path' => '/manga/add', + 'action' => 'addForm', + 'verb' => 'get', + ], + 'manga.add.post' => [ + 'path' => '/manga/add', + 'action' => 'add', + 'verb' => 'post', + ], + 'manga.delete' => [ + 'path' => '/manga/delete', + 'action' => 'delete', + 'verb' => 'post', + ], + 'manga.details' => [ + 'path' => '/manga/details/{id}', + 'action' => 'details', + 'tokens' => [ + 'id' => '[a-z0-9\-]+', ], - 'anime.details' => [ - 'path' => '/anime/details/{id}', - 'action' => 'details', - 'tokens' => [ - 'id' => '[a-z0-9\-]+', - ], + ], + // --------------------------------------------------------------------- + // Anime Collection Routes + // --------------------------------------------------------------------- + 'collection.search' => [ + 'path' => '/collection/search', + 'action' => 'search', + ], + 'collection.add.get' => [ + 'path' => '/collection/add', + 'action' => 'form', + 'params' => [], + ], + 'collection.edit.get' => [ + 'path' => '/collection/edit/{id}', + 'action' => 'form', + 'tokens' => [ + 'id' => '[0-9]+', ], - 'anime.delete' => [ - 'path' => '/anime/delete', - 'action' => 'delete', - 'verb' => 'post', + ], + 'collection.add.post' => [ + 'path' => '/collection/add', + 'action' => 'add', + 'verb' => 'post', + ], + 'collection.edit.post' => [ + 'path' => '/collection/edit', + 'action' => 'edit', + 'verb' => 'post', + ], + 'collection.view' => [ + 'path' => '/collection/view{/view}', + 'action' => 'index', + 'params' => [], + 'tokens' => [ + 'view' => '[a-z_]+', ], - // --------------------------------------------------------------------- - // Manga Routes - // --------------------------------------------------------------------- - 'manga.search' => [ - 'path' => '/manga/search', - 'action' => 'search', + ], + 'collection.delete' => [ + 'path' => '/collection/delete', + 'action' => 'delete', + 'verb' => 'post', + ], + // --------------------------------------------------------------------- + // Manga Collection Routes + // --------------------------------------------------------------------- + // --------------------------------------------------------------------- + // Other Routes + // --------------------------------------------------------------------- + 'character' => [ + 'path' => '/character/{slug}', + 'action' => 'index', + 'params' => [], + 'tokens' => [ + 'slug' => '[a-z0-9\-]+' + ] + ], + 'user_info' => [ + 'path' => '/me', + 'action' => 'me', + 'controller' => 'me', + 'verb' => 'get', + ], + // --------------------------------------------------------------------- + // Default / Shared routes + // --------------------------------------------------------------------- + 'cache_purge' => [ + 'path' => '/cache_purge', + 'action' => 'clearCache', + 'controller' => DEFAULT_CONTROLLER, + 'verb' => 'get', + ], + 'login' => [ + 'path' => '/login', + 'action' => 'login', + 'controller' => DEFAULT_CONTROLLER, + 'verb' => 'get', + ], + 'login.post' => [ + 'path' => '/login', + 'action' => 'loginAction', + 'controller' => DEFAULT_CONTROLLER, + 'verb' => 'post', + ], + 'logout' => [ + 'path' => '/logout', + 'action' => 'logout', + 'controller' => DEFAULT_CONTROLLER, + ], + 'update' => [ + 'path' => '/{controller}/update', + 'action' => 'update', + 'verb' => 'post', + 'tokens' => [ + 'controller' => '[a-z_]+', ], - 'manga.add.get' => [ - 'path' => '/manga/add', - 'action' => 'addForm', - 'verb' => 'get', + ], + 'update.post' => [ + 'path' => '/{controller}/update_form', + 'action' => 'formUpdate', + 'verb' => 'post', + 'tokens' => [ + 'controller' => '[a-z_]+', ], - 'manga.add.post' => [ - 'path' => '/manga/add', - 'action' => 'add', - 'verb' => 'post', + ], + 'edit' => [ + 'path' => '/{controller}/edit/{id}/{status}', + 'action' => 'edit', + 'tokens' => [ + 'id' => '[0-9a-z_]+', + 'status' => '([a-zA-Z\-_]|%20)+', ], - 'manga.delete' => [ - 'path' => '/manga/delete', - 'action' => 'delete', - 'verb' => 'post', - ], - 'manga.details' => [ - 'path' => '/manga/details/{id}', - 'action' => 'details', - 'tokens' => [ - 'id' => '[a-z0-9\-]+', - ], - ], - // --------------------------------------------------------------------- - // Anime Collection Routes - // --------------------------------------------------------------------- - 'collection.search' => [ - 'path' => '/collection/search', - 'action' => 'search', - ], - 'collection.add.get' => [ - 'path' => '/collection/add', - 'action' => 'form', - 'params' => [], - ], - 'collection.edit.get' => [ - 'path' => '/collection/edit/{id}', - 'action' => 'form', - 'tokens' => [ - 'id' => '[0-9]+', - ], - ], - 'collection.add.post' => [ - 'path' => '/collection/add', - 'action' => 'add', - 'verb' => 'post', - ], - 'collection.edit.post' => [ - 'path' => '/collection/edit', - 'action' => 'edit', - 'verb' => 'post', - ], - 'collection.view' => [ - 'path' => '/collection/view{/view}', - 'action' => 'index', - 'params' => [], - 'tokens' => [ - 'view' => '[a-z_]+', - ], - ], - 'collection.delete' => [ - 'path' => '/collection/delete', - 'action' => 'delete', - 'verb' => 'post', - ], - // --------------------------------------------------------------------- - // Manga Collection Routes - // --------------------------------------------------------------------- - // --------------------------------------------------------------------- - // Other Routes - // --------------------------------------------------------------------- - 'character' => [ - 'path' => '/character/{slug}', - 'action' => 'index', - 'params' => [], - 'tokens' => [ - 'slug' => '[a-z0-9\-]+' - ] - ], - 'user_info' => [ - 'path' => '/me', - 'action' => 'me', - 'controller' => 'me', - 'verb' => 'get', - ], - // --------------------------------------------------------------------- - // Default / Shared routes - // --------------------------------------------------------------------- - 'cache_purge' => [ - 'path' => '/cache_purge', - 'action' => 'clearCache', - 'controller' => DEFAULT_CONTROLLER_NAMESPACE, - 'verb' => 'get', - ], - 'login' => [ - 'path' => '/login', - 'action' => 'login', - 'controller' => DEFAULT_CONTROLLER_NAMESPACE, - 'verb' => 'get', - ], - 'login.post' => [ - 'path' => '/login', - 'action' => 'loginAction', - 'controller' => DEFAULT_CONTROLLER_NAMESPACE, - 'verb' => 'post', - ], - 'logout' => [ - 'path' => '/logout', - 'action' => 'logout', - 'controller' => DEFAULT_CONTROLLER_NAMESPACE, - ], - 'update' => [ - 'path' => '/{controller}/update', - 'action' => 'update', - 'verb' => 'post', - 'tokens' => [ - 'controller' => '[a-z_]+', - ], - ], - 'update.post' => [ - 'path' => '/{controller}/update_form', - 'action' => 'formUpdate', - 'verb' => 'post', - 'tokens' => [ - 'controller' => '[a-z_]+', - ], - ], - 'edit' => [ - 'path' => '/{controller}/edit/{id}/{status}', - 'action' => 'edit', - 'tokens' => [ - 'id' => '[0-9a-z_]+', - 'status' => '([a-zA-Z\-_]|%20)+', - ], - ], - 'list' => [ - 'path' => '/{controller}/{type}{/view}', - 'action' => DEFAULT_CONTROLLER_METHOD, - 'tokens' => [ - 'type' => '[a-z_]+', - 'view' => '[a-z_]+', - ], - ], - 'index_redirect' => [ - 'path' => '/', - 'controller' => DEFAULT_CONTROLLER_NAMESPACE, - 'action' => 'redirectToDefaultRoute', + ], + 'list' => [ + 'path' => '/{controller}/{type}{/view}', + 'action' => DEFAULT_CONTROLLER_METHOD, + 'tokens' => [ + 'type' => '[a-z_]+', + 'view' => '[a-z_]+', ], ], + 'index_redirect' => [ + 'path' => '/', + 'controller' => DEFAULT_CONTROLLER, + 'action' => 'redirectToDefaultRoute', + ], ]; \ No newline at end of file diff --git a/app/config/route_config.toml.example b/app/config/route_config.toml.example new file mode 100644 index 00000000..74191a89 --- /dev/null +++ b/app/config/route_config.toml.example @@ -0,0 +1,19 @@ +################################################################################ +# Route config +# +# Default views and paths +################################################################################ + +# Path to public directory, where images/css/javascript are located, +# appended to the url +asset_path = "/public" + +# Which list should be the default? +default_list = "anime" # anime or manga + +# Default pages for anime/manga +default_anime_list_path = "watching" # watching|plan_to_watch|on_hold|dropped|completed|all +default_manga_list_path = "reading" # reading|plan_to_read|on_hold|dropped|completed|all + +# Default view type (cover_view/list_view) +default_view_type = "cover_view" \ No newline at end of file diff --git a/app/views/anime/details.php b/app/views/anime/details.php index 63232096..eea521c2 100644 --- a/app/views/anime/details.php +++ b/app/views/anime/details.php @@ -1,4 +1,4 @@ -
+
@@ -74,30 +74,27 @@ - - - */ ?>
-
+ 0): ?>

Characters

-
+
-
+
+ -
-
+
\ No newline at end of file diff --git a/app/views/anime/list.php b/app/views/anime/list.php index a9ff92b6..f898125a 100644 --- a/app/views/anime/list.php +++ b/app/views/anime/list.php @@ -30,7 +30,11 @@ isAuthenticated()): ?> - ">Edit + Edit diff --git a/app/views/character.php b/app/views/character.php index 26ad4bc7..41a63087 100644 --- a/app/views/character.php +++ b/app/views/character.php @@ -1,7 +1,7 @@ -
+
- +

diff --git a/app/views/collection/cover.php b/app/views/collection/cover.php index 996d246a..c6b936e9 100644 --- a/app/views/collection/cover.php +++ b/app/views/collection/cover.php @@ -21,8 +21,11 @@
isAuthenticated()): ?>
- ">Edit - ">Delete */ ?> + + Edit +
diff --git a/app/views/collection/list.php b/app/views/collection/list.php index 0bb4daee..9aadb119 100644 --- a/app/views/collection/list.php +++ b/app/views/collection/list.php @@ -1,6 +1,6 @@
isAuthenticated()): ?> -Add Item +Add Item

There's nothing here!

@@ -26,12 +26,11 @@ isAuthenticated()): ?> - ">Edit - fullUrl("collection/delete/{$item['hummingbird_id']}") ?>">Delete*/ ?> + Edit - + " . $item['alternate_title'] . "" : "" ?> diff --git a/app/views/main-menu.php b/app/views/main-menu.php index d37bae3b..249c1eb9 100644 --- a/app/views/main-menu.php +++ b/app/views/main-menu.php @@ -6,11 +6,11 @@ get('whose_list') ?>'s List get("show_{$url_type}_collection")): ?> - [ Collection] + [ Collection] [ List] - + get('whose_list') ?>'s Collection [Anime List] diff --git a/app/views/manga/cover.php b/app/views/manga/cover.php index 7f9ebdbd..fc4fa9ad 100644 --- a/app/views/manga/cover.php +++ b/app/views/manga/cover.php @@ -1,6 +1,6 @@
isAuthenticated()): ?> -Add Item +Add Item

There's nothing here!

@@ -10,10 +10,11 @@

html($name) ?>

-
+
isAuthenticated()): ?> @@ -29,13 +30,38 @@ isAuthenticated()): ?>
Rating: / 10
+ + +
+ + + + + +
+ + + 0): ?> +
+
Reread time(s)
+
+ +
Chapters: / diff --git a/app/views/manga/details.php b/app/views/manga/details.php index 11790bfe..a4dbf48d 100644 --- a/app/views/manga/details.php +++ b/app/views/manga/details.php @@ -1,4 +1,4 @@ -
+
<?= $data['title'] ?> cover image @@ -35,26 +35,25 @@

-
+ 0): ?>

Characters

-
+
-
+
+
-
-
- +
\ No newline at end of file diff --git a/app/views/manga/edit.php b/app/views/manga/edit.php index e74a1331..22bf95ca 100644 --- a/app/views/manga/edit.php +++ b/app/views/manga/edit.php @@ -74,6 +74,7 @@   + @@ -92,6 +93,7 @@   + diff --git a/app/views/manga/list.php b/app/views/manga/list.php index 1c28fdcb..8edbc7db 100644 --- a/app/views/manga/list.php +++ b/app/views/manga/list.php @@ -1,6 +1,6 @@
isAuthenticated()): ?> -Add Item +Add Item

There's nothing here!

@@ -17,7 +17,9 @@ Rating Completed Chapters # of Volumes + Attributes Type + Genres @@ -25,7 +27,11 @@ isAuthenticated()): ?> - ">Edit + Edit @@ -39,7 +45,22 @@ / 10 / + +
    + 0): ?> +
  • Reread time(s)
  • + + + +
  • + + +
+ + + + diff --git a/app/views/me.php b/app/views/me.php index 47434cc1..f0f26405 100644 --- a/app/views/me.php +++ b/app/views/me.php @@ -1,11 +1,22 @@ -
+ +
-

- +
+

+ + + +

+ +


+ + + @@ -18,7 +29,7 @@ + + + + + + + + + + + + + + +
General
Location
html($attributes['waifuOrHusbando']) ?> - a( $url->generate('character', ['slug' => $character['slug']]), @@ -28,6 +39,21 @@
User Stats
# of Posts
# of Comments
# of Media Rated
@@ -35,9 +61,72 @@
About:
html($attributes['bio']) ?>
- -
-
*/ ?> + + +

Favorite Characters

+
+ + + + + +
+ + +

Favorite Anime

+
+ +
+ generate('anime.details', ['id' => $anime['slug']]); + $titles = Kitsu::filterTitles($anime); + ?> + + + + +
+ +
+ + +

Favorite Manga

+
+ +
+ generate('manga.details', ['id' => $manga['slug']]); + $titles = Kitsu::filterTitles($manga); + ?> + + + + +
+ +
+ +
\ No newline at end of file diff --git a/build/update_header_comments.php b/build/update_header_comments.php index 18562f53..32ec3eb5 100644 --- a/build/update_header_comments.php +++ b/build/update_header_comments.php @@ -7,7 +7,9 @@ $file_patterns = [ 'src/**/*.php', 'src/*.php', 'tests/**/*.php', - 'tests/*.php' + 'tests/*.php', + 'index.php', + 'Robofile.php' ]; if ( ! function_exists('glob_recursive')) diff --git a/composer.json b/composer.json index e71d64ee..26e7ae82 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "aura/router": "^3.0", "aura/session": "^2.0", "aviat/banker": "^1.0.0", - "aviat/ion": "dev-master", + "aviat/ion": "^2.0.0", "monolog/monolog": "^1.0", "psr/http-message": "~1.0", "psr/log": "~1.0", diff --git a/index.php b/index.php index 48958f44..b89d9fc0 100644 --- a/index.php +++ b/index.php @@ -1,23 +1,23 @@ - - * @copyright 2015 - 2016 Timothy J. Warren + * @copyright 2015 - 2017 Timothy J. Warren * @license http://www.opensource.org/licenses/mit-license.html MIT License - * @version 3.1 - * @link https://github.com/timw4mail/HummingBirdAnimeClient + * @version 4.0 + * @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient */ + namespace Aviat\AnimeClient; use function Aviat\AnimeClient\loadToml; - -use Aviat\AnimeClient\AnimeClient; +use function Aviat\Ion\_dir; // Work around the silly timezone error $timezone = ini_get('date.timezone'); diff --git a/public/css.php b/public/css.php index 72870935..81e38ccc 100644 --- a/public/css.php +++ b/public/css.php @@ -14,7 +14,6 @@ * @link https://github.com/timw4mail/HummingBirdAnimeClient */ - namespace Aviat\EasyMin; require_once('./min.php'); diff --git a/public/css/base.css b/public/css/base.css index fe404c9e..a003688e 100644 --- a/public/css/base.css +++ b/public/css/base.css @@ -1025,7 +1025,7 @@ a:hover, a:active { Base list styles ------------------------------------------------------------------------------*/ -.media { +.media, .character, .small_character { position:relative; vertical-align:top; display:inline-block; @@ -1035,7 +1035,9 @@ a:hover, a:active { margin:0.25em 0.125em; } -.media > img { +.media > img, +.character > img, +.small_character > img { width: 100%; } @@ -1076,7 +1078,9 @@ a:hover, a:active { top: 0; } -.media:hover > .name, +.small_character:hover > .name, + .character:hover > .name, + .media:hover > .name, .media:hover > .media_metadata > div, .media:hover > .medium_metadata > div, .media:hover > .table .row @@ -1094,7 +1098,11 @@ a:hover, a:active { display:block; } -.media > .name a, +.small_character > .name a, + .small_character > .name a small, + .character > .name a, + .character > .name a small, + .media > .name a, .media > .name a small { background:none; @@ -1250,17 +1258,20 @@ a:hover, a:active { .details { margin:15px auto 0 auto; margin: 1.5rem auto 0 auto; - max-width:930px; - max-width:93rem; padding:10px; padding:1rem; font-size:inherit; } +.details.fixed { + max-width:930px; + max-width:93rem; +} + .details .cover { display: block; width: 284px; - height: 402px; + /* height: 402px; */ } .details h2 { @@ -1295,6 +1306,58 @@ a:hover, a:active { text-align:left; } +.character, +.small_character { + background: rgba(0, 0, 0, .5); + width: 225px; + height: 350px; + vertical-align: middle; + white-space: nowrap; +} + +.small_character a { + display:inline-block; + width: 100%; + height: 100%; + } + +.small_character .name, + .character .name { + position: absolute; + bottom: 0; + left: 0; + z-index: 10; + } + +.small_character img, + .character img { + position: relative; + top: 50%; + -webkit-transform: translateY(-50%); + transform: translateY(-50%); + z-index: 5; + width: 100%; + } + +/* ---------------------------------------------------------------------------- + User page styles +-----------------------------------------------------------------------------*/ + +.small_character { + width: 160px; + height: 250px; +} + +.user-page .media-wrap { + text-align: left; +} + +.media a { + display: inline-block; + width: 100%; + height: 100%; +} + /* ---------------------------------------------------------------------------- Viewport-based styles -----------------------------------------------------------------------------*/ diff --git a/public/css/base.myth.css b/public/css/base.myth.css index 1179e421..60fae728 100644 --- a/public/css/base.myth.css +++ b/public/css/base.myth.css @@ -297,7 +297,7 @@ a:hover, a:active { Base list styles ------------------------------------------------------------------------------*/ -.media { +.media, .character, .small_character { position:relative; vertical-align:top; display:inline-block; @@ -307,7 +307,9 @@ a:hover, a:active { margin: var(--normal-padding); } -.media > img { +.media > img, +.character > img, +.small_character > img { width: 100%; } @@ -347,7 +349,8 @@ a:hover, a:active { position:absolute; top: 0; } - + .small_character:hover > .name, + .character:hover > .name, .media:hover > .name, .media:hover > .media_metadata > div, .media:hover > .medium_metadata > div, @@ -364,6 +367,10 @@ a:hover, a:active { display:block; } + .small_character > .name a, + .small_character > .name a small, + .character > .name a, + .character > .name a small, .media > .name a, .media > .name a small { @@ -510,15 +517,18 @@ a:hover, a:active { -----------------------------------------------------------------------------*/ .details { margin: 1.5rem auto 0 auto; - max-width:93rem; padding:1rem; font-size:inherit; } +.details.fixed { + max-width:93rem; +} + .details .cover { display: block; width: 284px; - height: 402px; + /* height: 402px; */ } .details h2 { @@ -549,6 +559,55 @@ a:hover, a:active { text-align:left; } +.character, +.small_character { + background: rgba(0,0,0,0.5); + width: 225px; + height: 350px; + vertical-align: middle; + white-space: nowrap; +} + .small_character a { + display:inline-block; + width: 100%; + height: 100%; + } + + .small_character .name, + .character .name { + position: absolute; + bottom: 0; + left: 0; + z-index: 10; + } + + .small_character img, + .character img { + position: relative; + top: 50%; + transform: translateY(-50%); + z-index: 5; + width: 100%; + } + +/* ---------------------------------------------------------------------------- + User page styles +-----------------------------------------------------------------------------*/ +.small_character { + width: 160px; + height: 250px; +} + +.user-page .media-wrap { + text-align: left; +} + +.media a { + display: inline-block; + width: 100%; + height: 100%; +} + /* ---------------------------------------------------------------------------- Viewport-based styles -----------------------------------------------------------------------------*/ diff --git a/public/js/manga_collection.js b/public/js/manga_collection.js index 1d3dccbf..6d1c0c31 100644 --- a/public/js/manga_collection.js +++ b/public/js/manga_collection.js @@ -8,6 +8,11 @@ searchResults = JSON.parse(searchResults); _.$('.cssload-loader')[0].setAttribute('hidden', 'hidden'); + // Give mustache a key to iterate over + searchResults = { + data: searchResults.data + }; + Mustache.parse(tempHtml); _.$('#series_list')[0].innerHTML = Mustache.render(tempHtml, searchResults); }); diff --git a/public/js/manga_edit.js b/public/js/manga_edit.js index 7fa89a66..e010d681 100644 --- a/public/js/manga_edit.js +++ b/public/js/manga_edit.js @@ -8,7 +8,6 @@ _.on('.manga.list', 'click', '.edit_buttons button', (e) => { let thisSel = e.target; let parentSel = _.closestParent(e.target, 'article'); - let mangaId = parentSel.id.replace('manga-', ''); let type = thisSel.classList.contains('plus_one_chapter') ? 'chapter' : 'volume'; let completed = parseInt(_.$(`.${type}s_read`, parentSel)[0].textContent, 10); let total = parseInt(_.$(`.${type}_count`, parentSel)[0].textContent, 10); @@ -20,7 +19,8 @@ // Setup the update data let data = { - id: mangaId, + id: parentSel.dataset.kitsuId, + mal_id: parentSel.dataset.malId, data: { progress: completed } diff --git a/src/API/JsonAPI.php b/src/API/JsonAPI.php index 7ca78704..105d377a 100644 --- a/src/API/JsonAPI.php +++ b/src/API/JsonAPI.php @@ -39,17 +39,212 @@ class JsonAPI { * @var array */ protected $data = []; - + + /** + * Inline all included data + * + * @param array $data - The raw JsonAPI response data + * @return data + */ + public static function organizeData(array $data): array + { + // relationships that have singular data + $singular = [ + 'waifu' + ]; + + // Reorganize included data + $included = static::organizeIncluded($data['included']); + + // Inline organized data + foreach($data['data'] as $i => $item) + { + if (array_key_exists('relationships', $item)) + { + foreach($item['relationships'] as $relType => $props) + { + + if (array_keys($props) === ['links']) + { + unset($data['data'][$i]['relationships'][$relType]); + + if (empty($data['data'][$i]['relationships'])) + { + unset($data['data'][$i]['relationships']); + } + + continue; + } + + if (array_key_exists('links', $props)) + { + unset($data['data'][$i]['relationships'][$relType]['links']); + } + + if (array_key_exists('data', $props)) + { + if (empty($props['data'])) + { + unset($data['data'][$i]['relationships'][$relType]['data']); + + if (empty($data['data'][$i]['relationships'][$relType])) + { + unset($data['data'][$i]['relationships'][$relType]); + } + + continue; + } + // Single data item + else if (array_key_exists('id', $props['data'])) + { + $idKey = $props['data']['id']; + $typeKey = $props['data']['type']; + $relationship =& $data['data'][$i]['relationships'][$relType]; + unset($relationship['data']); + + if (in_array($relType, $singular)) + { + $relationship = $included[$typeKey][$idKey]; + continue; + } + + if ($relType === $typeKey) + { + $relationship[$idKey] = $included[$typeKey][$idKey]; + continue; + } + + $relationship[$typeKey][$idKey] = $included[$typeKey][$idKey]; + } + // Multiple data items + else + { + foreach($props['data'] as $j => $datum) + { + $idKey = $props['data'][$j]['id']; + $typeKey = $props['data'][$j]['type']; + $relationship =& $data['data'][$i]['relationships'][$relType]; + + unset($relationship['data'][$j]); + + if (empty($relationship['data'])) + { + unset($relationship['data']); + } + + if ($relType === $typeKey) + { + $relationship[$idKey] = $included[$typeKey][$idKey]; + continue; + } + + $relationship[$typeKey][$idKey] = array_merge( + $included[$typeKey][$idKey], + $relationship[$typeKey][$idKey] ?? [] + ); + } + } + } + } + } + } + + return $data['data']; + } + + /** + * Restructure included data to make it simpler to inline + * + * @param array $included + * @return array + */ + public static function organizeIncluded(array $included): array + { + $organized = []; + + // First pass, create [ type => items[] ] structure + foreach($included as &$item) + { + $type = $item['type']; + $id = $item['id']; + $organized[$type] = $organized[$type] ?? []; + $newItem = []; + + foreach(['attributes', 'relationships'] as $key) + { + if (array_key_exists($key, $item)) + { + // Remove 'links' type relationships + if ($key === 'relationships') + { + foreach($item['relationships'] as $relType => $props) + { + if (array_keys($props) === ['links']) + { + unset($item['relationships'][$relType]); + if (empty($item['relationships'])) + { + continue 2; + } + } + } + } + + $newItem[$key] = $item[$key]; + } + } + + $organized[$type][$id] = $newItem; + } + + // Second pass, go through and fill missing relationships in the first pass + foreach($organized as $type => $items) + { + foreach($items as $id => $item) + { + if (array_key_exists('relationships', $item)) + { + foreach($item['relationships'] as $relType => $props) + { + if (array_key_exists('data', $props)) + { + if (array_key_exists($props['data']['id'], $organized[$props['data']['type']])) + { + $idKey = $props['data']['id']; + $typeKey = $props['data']['type']; + + + $relationship =& $organized[$type][$id]['relationships'][$relType]; + unset($relationship['links']); + unset($relationship['data']); + + if ($relType === $typeKey) + { + $relationship[$idKey] = $included[$typeKey][$idKey]; + continue; + } + + $relationship[$typeKey][$idKey] = $organized[$typeKey][$idKey]; + } + } + } + } + } + } + + return $organized; + } + public static function inlineRawIncludes(array &$data, string $key): array { foreach($data['data'] as $i => &$item) { $item[$key] = $data['included'][$i]; } - + return $data['data']; } - + /** * Take organized includes and inline them, where applicable * @@ -62,12 +257,12 @@ class JsonAPI { $inlined = [ $key => [] ]; - + foreach ($included[$key] as $itemId => $item) { // Duplicate the item for the output $inlined[$key][$itemId] = $item; - + foreach($item['relationships'] as $type => $ids) { $inlined[$key][$itemId]['relationships'][$type] = []; @@ -77,7 +272,7 @@ class JsonAPI { } } } - + return $inlined; } @@ -109,36 +304,16 @@ class JsonAPI { return $organized; } - + /** - * Reorganize 'included' data + * Reorganize 'included' data * * @param array $includes * @return array */ public static function lightlyOrganizeIncludes(array $includes): array { - $organized = []; - - foreach($includes as $item) - { - $type = $item['type']; - $id = $item['id']; - $organized[$type] = $organized[$type] ?? []; - $newItem = []; - - foreach(['attributes', 'relationships'] as $key) - { - if (array_key_exists($key, $item)) - { - $newItem[$key] = $item[$key]; - } - } - - $organized[$type][$id] = $newItem; - } - - return $organized; + return static::organizeIncluded($includes); } /** @@ -174,11 +349,11 @@ class JsonAPI { return $organized; } - + public static function fillRelationshipsFromIncludes(array $relationships, array $includes): array { $output = []; - + foreach ($relationships as $key => $block) { if (array_key_exists('data', $block) && is_array($block['data']) && ! empty($block['data'])) @@ -197,7 +372,7 @@ class JsonAPI { } } } - + return $output; } } \ No newline at end of file diff --git a/src/API/Kitsu/ListItem.php b/src/API/Kitsu/ListItem.php index 003308a9..bd852daa 100644 --- a/src/API/Kitsu/ListItem.php +++ b/src/API/Kitsu/ListItem.php @@ -33,6 +33,8 @@ class ListItem extends AbstractListItem { private function getAuthHeader() { + $cache = $this->getContainer()->get('cache'); + $cacheItem = $cache->getItem('kitsu-auth-token'); $sessionSegment = $this->getContainer() ->get('session') ->getSegment(SESSION_SEGMENT); @@ -43,6 +45,12 @@ class ListItem extends AbstractListItem { return "bearer {$token}"; } + if ($cacheItem->isHit()) + { + $token = $cacheItem->get(); + return "bearer {$token}"; + } + return FALSE; } diff --git a/src/API/Kitsu/Model.php b/src/API/Kitsu/Model.php index 7f4f5457..db34552a 100644 --- a/src/API/Kitsu/Model.php +++ b/src/API/Kitsu/Model.php @@ -19,7 +19,12 @@ namespace Aviat\AnimeClient\API\Kitsu; use function Amp\{all, wait}; use Amp\Artax\{Client, Request}; -use Aviat\AnimeClient\API\{CacheTrait, JsonAPI, Kitsu as K}; +use Aviat\AnimeClient\API\{ + CacheTrait, + JsonAPI, + Kitsu as K, + ParallelAPIRequest +}; use Aviat\AnimeClient\API\Enum\{ AnimeWatchingStatus\Title, AnimeWatchingStatus\Kitsu as KitsuWatchingStatus, @@ -73,7 +78,6 @@ class Model { */ protected $mangaListTransformer; - /** * Constructor * @@ -88,6 +92,34 @@ class Model { $this->mangaListTransformer = new MangaListTransformer(); } + /** + * Get the access token from the Kitsu API + * + * @param string $username + * @param string $password + * @return bool|string + */ + public function authenticate(string $username, string $password) + { + $response = $this->getResponse('POST', K::AUTH_URL, [ + 'headers' => [], + 'form_params' => [ + 'grant_type' => 'password', + 'username' => $username, + 'password' => $password + ] + ]); + + $data = Json::decode((string)$response->getBody()); + + if (array_key_exists('access_token', $data)) + { + return $data; + } + + return FALSE; + } + /** * Get the userid for a username from Kitsu * @@ -132,7 +164,7 @@ class Model { $data = $this->getRequest('/characters', [ 'query' => [ 'filter' => [ - 'slug' => $slug + 'name' => $slug ], // 'include' => 'primaryMedia,castings' ] @@ -149,45 +181,86 @@ class Model { */ public function getUserData(string $username): array { - $userId = $this->getUserIdByUsername($username); - $data = $this->getRequest("/users/{$userId}", [ + // $userId = $this->getUserIdByUsername($username); + $data = $this->getRequest("/users", [ 'query' => [ - 'include' => 'waifu,pinnedPost,blocks,linkedAccounts,profileLinks,profileLinks.profileLinkSite,mediaFollows,userRoles' + 'filter' => [ + 'name' => $username, + ], + 'fields' => [ + // 'anime' => 'slug,name,canonicalTitle', + 'characters' => 'slug,name,image' + ], + 'include' => 'waifu,pinnedPost,blocks,linkedAccounts,profileLinks,profileLinks.profileLinkSite,mediaFollows,userRoles,favorites.item' ] ]); - // $data['included'] = JsonAPI::organizeIncludes($data['included']); return $data; } /** - * Get the access token from the Kitsu API + * Search for an anime or manga * - * @param string $username - * @param string $password - * @return bool|string + * @param string $type - 'anime' or 'manga' + * @param string $query - name of the item to search for + * @return array */ - public function authenticate(string $username, string $password) + public function search(string $type, string $query): array { - $response = $this->getResponse('POST', K::AUTH_URL, [ - 'headers' => [], - 'form_params' => [ - 'grant_type' => 'password', - 'username' => $username, - 'password' => $password + $options = [ + 'query' => [ + 'filter' => [ + 'text' => $query + ], + 'page' => [ + 'offset' => 0, + 'limit' => 20 + ], ] - ]); + ]; - $data = Json::decode((string)$response->getBody()); + $raw = $this->getRequest($type, $options); - if (array_key_exists('access_token', $data)) + foreach ($raw['data'] as &$item) { - return $data; + $item['attributes']['titles'] = K::filterTitles($item['attributes']); + array_shift($item['attributes']['titles']); } - return FALSE; + return $raw; } + /** + * Find a media item on Kitsu by its associated MAL id + * + * @param string $malId + * @param string $type "anime" or "manga" + * @return string + */ + public function getKitsuIdFromMALId(string $malId, string $type="anime"): string + { + $options = [ + 'query' => [ + 'filter' => [ + 'external_site' => "myanimelist/{$type}", + 'external_id' => $malId + ], + 'fields' => [ + 'media' => 'id,slug' + ], + 'include' => 'media' + ] + ]; + + $raw = $this->getRequest('mappings', $options); + + return $raw['included'][0]['id']; + } + + // ------------------------------------------------------------------------- + // ! Anime-specific methods + // ------------------------------------------------------------------------- + /** * Get information about a particular anime * @@ -220,202 +293,6 @@ class Model { 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 - * - * @param string $mangaId - * @return array - */ - public function getManga(string $mangaId): array - { - $baseData = $this->getRawMediaData('manga', $mangaId); - - if (empty($baseData)) - { - return []; - } - - $transformed = $this->mangaTransformer->transform($baseData); - $transformed['included'] = $baseData['included']; - return $transformed; - } - - /** - * Get the number of anime list items - * - * @param string $status - Optional status to filter by - * @return int - */ - public function getAnimeListCount(string $status = '') : int - { - $options = [ - 'query' => [ - 'filter' => [ - 'user_id' => $this->getUserIdByUsername(), - 'media_type' => 'Anime' - ], - 'page' => [ - 'limit' => 1 - ], - 'sort' => '-updated_at' - ] - ]; - - if ( ! empty($status)) - { - $options['query']['filter']['status'] = $status; - } - - $response = $this->getRequest('library-entries', $options); - - return $response['meta']['count']; - - } - - /** - * Get the full anime list in paginated form - * - * @param int $limit - * @param int $offset - * @param array $options - * @return Request - */ - public function getPagedAnimeList(int $limit = 100, int $offset = 0, array $options = [ - 'include' => 'anime.mappings' - ]): Request - { - $defaultOptions = [ - 'filter' => [ - 'user_id' => $this->getUserIdByUsername($this->getUsername()), - 'media_type' => 'Anime' - ], - 'page' => [ - 'offset' => $offset, - 'limit' => $limit - ], - 'sort' => '-updated_at' - ]; - $options = array_merge($defaultOptions, $options); - - return $this->setUpRequest('GET', 'library-entries', ['query' => $options]); - } - - /** - * Get the full anime list - * - * @param array $options - * @return array - */ - public function getFullAnimeList(array $options = [ - 'include' => 'anime.mappings' - ]): array - { - $status = $options['filter']['status'] ?? ''; - $count = $this->getAnimeListCount($status); - $size = 100; - $pages = ceil($count / $size); - - $requests = []; - - // Set up requests - for ($i = 0; $i < $pages; $i++) - { - $offset = $i * $size; - $requests[] = $this->getPagedAnimeList($size, $offset, $options); - } - - $promiseArray = (new Client())->requestMulti($requests); - - $responses = wait(all($promiseArray)); - $output = []; - - foreach($responses as $response) - { - $data = Json::decode($response->getBody()); - $output = array_merge_recursive($output, $data); - } - - return $output; - } - - /** - * Get the raw (unorganized) anime list for the configured user - * - * @param string $status - The watching status to filter the list with - * @return array - */ - public function getRawAnimeList(string $status): array - { - - $options = [ - 'filter' => [ - 'user_id' => $this->getUserIdByUsername($this->getUsername()), - 'media_type' => 'Anime', - 'status' => $status, - ], - 'include' => 'media,media.genres,media.mappings,anime.streamingLinks', - 'sort' => '-updated_at' - ]; - - return $this->getFullAnimeList($options); - } - - /** - * Get all the anine entries, that are organized for output to html - * - * @return array - */ - public function getFullOrganizedAnimeList(): array - { - $cacheItem = $this->cache->getItem(self::FULL_TRANSFORMED_LIST_CACHE_KEY); - - if ( ! $cacheItem->isHit()) - { - $output = []; - - $statuses = KitsuWatchingStatus::getConstList(); - - foreach ($statuses as $key => $status) - { - $mappedStatus = AnimeWatchingStatus::KITSU_TO_TITLE[$status]; - $output[$mappedStatus] = $this->getAnimeList($status) ?? []; - } - - $cacheItem->set($output); - $cacheItem->save(); - } - - return $cacheItem->get(); - } - /** * Get the anime list for the configured user * @@ -455,23 +332,200 @@ class Model { } /** - * Get all Manga lists + * Get the number of anime list items * + * @param string $status - Optional status to filter by + * @return int + */ + public function getAnimeListCount(string $status = '') : int + { + $options = [ + 'query' => [ + 'filter' => [ + 'user_id' => $this->getUserIdByUsername(), + 'media_type' => 'Anime' + ], + 'page' => [ + 'limit' => 1 + ], + 'sort' => '-updated_at' + ] + ]; + + if ( ! empty($status)) + { + $options['query']['filter']['status'] = $status; + } + + $response = $this->getRequest('library-entries', $options); + + return $response['meta']['count']; + } + + /** + * Get the full anime list + * + * @param array $options * @return array */ - public function getFullOrganizedMangaList(): array + public function getFullAnimeList(array $options = [ + 'include' => 'anime.mappings' + ]): array { - $statuses = KitsuReadingStatus::getConstList(); - $output = []; - foreach ($statuses as $status) + $status = $options['filter']['status'] ?? ''; + $count = $this->getAnimeListCount($status); + $size = 100; + $pages = ceil($count / $size); + + $requester = new ParallelAPIRequest(); + + // Set up requests + for ($i = 0; $i < $pages; $i++) { - $mappedStatus = MangaReadingStatus::KITSU_TO_TITLE[$status]; - $output[$mappedStatus] = $this->getMangaList($status); + $offset = $i * $size; + $requester->addRequest($this->getPagedAnimeList($size, $offset, $options)); + } + + $responses = $requester->makeRequests(); + $output = []; + + foreach($responses as $response) + { + $data = Json::decode($response->getBody()); + $output = array_merge_recursive($output, $data); } return $output; } + /** + * Get all the anine entries, that are organized for output to html + * + * @return array + */ + public function getFullOrganizedAnimeList(): array + { + $output = []; + + $statuses = KitsuWatchingStatus::getConstList(); + + foreach ($statuses as $key => $status) + { + $mappedStatus = AnimeWatchingStatus::KITSU_TO_TITLE[$status]; + $output[$mappedStatus] = $this->getAnimeList($status) ?? []; + } + + return $output; + } + + /** + * 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); + + if ( ! array_key_exists('included', $data)) + { + return NULL; + } + + $mappings = array_column($data['included'], 'attributes'); + + foreach($mappings as $map) + { + if ($map['externalSite'] === 'myanimelist/anime') + { + return $map['externalId']; + } + } + + return NULL; + } + + /** + * Get the full anime list in paginated form + * + * @param int $limit + * @param int $offset + * @param array $options + * @return Request + */ + public function getPagedAnimeList(int $limit = 100, int $offset = 0, array $options = [ + 'include' => 'anime.mappings' + ]): Request + { + $defaultOptions = [ + 'filter' => [ + 'user_id' => $this->getUserIdByUsername($this->getUsername()), + 'media_type' => 'Anime' + ], + 'page' => [ + 'offset' => $offset, + 'limit' => $limit + ], + 'sort' => '-updated_at' + ]; + $options = array_merge($defaultOptions, $options); + + return $this->setUpRequest('GET', 'library-entries', ['query' => $options]); + } + + /** + * Get the raw (unorganized) anime list for the configured user + * + * @param string $status - The watching status to filter the list with + * @return array + */ + public function getRawAnimeList(string $status): array + { + + $options = [ + 'filter' => [ + 'user_id' => $this->getUserIdByUsername($this->getUsername()), + 'media_type' => 'Anime', + 'status' => $status, + ], + 'include' => 'media,media.genres,media.mappings,anime.streamingLinks', + 'sort' => '-updated_at' + ]; + + return $this->getFullAnimeList($options); + } + + // ------------------------------------------------------------------------- + // ! Manga-specific methods + // ------------------------------------------------------------------------- + + /** + * Get information about a particular manga + * + * @param string $slug + * @return array + */ + public function getManga(string $slug): array + { + $baseData = $this->getRawMediaData('manga', $slug); + + if (empty($baseData)) + { + return []; + } + + $transformed = $this->mangaTransformer->transform($baseData); + $transformed['included'] = $baseData['included']; + return $transformed; + } + /** * Get the manga list for the configured user * @@ -489,7 +543,7 @@ class Model { 'media_type' => 'Manga', 'status' => $status, ], - 'include' => 'media', + 'include' => 'media,media.genres,media.mappings', 'page' => [ 'offset' => $offset, 'limit' => $limit @@ -503,9 +557,16 @@ class Model { if ( ! $cacheItem->isHit()) { $data = $this->getRequest('library-entries', $options); - $data = JsonAPI::inlineRawIncludes($data, 'manga'); - $transformed = $this->mangaListTransformer->transformCollection($data); + $included = JsonAPI::organizeIncludes($data['included']); + $included = JsonAPI::inlineIncludedRelationships($included, 'manga'); + + foreach($data['data'] as $i => &$item) + { + $item['included'] = $included; + } + + $transformed = $this->mangaListTransformer->transformCollection($data['data']); $cacheItem->set($transformed); $cacheItem->save(); @@ -515,37 +576,150 @@ class Model { } /** - * Search for an anime or manga + * Get the number of manga list items * - * @param string $type - 'anime' or 'manga' - * @param string $query - name of the item to search for - * @return array + * @param string $status - Optional status to filter by + * @return int */ - public function search(string $type, string $query): array + public function getMangaListCount(string $status = '') : int { $options = [ 'query' => [ 'filter' => [ - 'text' => $query + 'user_id' => $this->getUserIdByUsername(), + 'media_type' => 'Manga' ], 'page' => [ - 'offset' => 0, - 'limit' => 20 + 'limit' => 1 ], + 'sort' => '-updated_at' ] ]; - $raw = $this->getRequest($type, $options); - - foreach ($raw['data'] as &$item) + if ( ! empty($status)) { - $item['attributes']['titles'] = K::filterTitles($item['attributes']); - array_shift($item['attributes']['titles']); + $options['query']['filter']['status'] = $status; } - return $raw; + $response = $this->getRequest('library-entries', $options); + + return $response['meta']['count']; } + /** + * Get the full manga list + * + * @param array $options + * @return array + */ + public function getFullMangaList(array $options = [ + 'include' => 'manga.mappings' + ]): array + { + $status = $options['filter']['status'] ?? ''; + $count = $this->getMangaListCount($status); + $size = 100; + $pages = ceil($count / $size); + + $requester = new ParallelAPIRequest(); + + // Set up requests + for ($i = 0; $i < $pages; $i++) + { + $offset = $i * $size; + $requester->addRequest($this->getPagedMangaList($size, $offset, $options)); + } + + $responses = $requester->makeRequests(); + $output = []; + + foreach($responses as $response) + { + $data = Json::decode($response->getBody()); + $output = array_merge_recursive($output, $data); + } + + return $output; + } + + /** + * Get all Manga lists + * + * @return array + */ + public function getFullOrganizedMangaList(): array + { + $statuses = KitsuReadingStatus::getConstList(); + $output = []; + foreach ($statuses as $status) + { + $mappedStatus = MangaReadingStatus::KITSU_TO_TITLE[$status]; + $output[$mappedStatus] = $this->getMangaList($status); + } + + return $output; + } + + /** + * Get the full manga list in paginated form + * + * @param int $limit + * @param int $offset + * @param array $options + * @return Request + */ + public function getPagedMangaList(int $limit = 100, int $offset = 0, array $options = [ + 'include' => 'manga.mappings' + ]): Request + { + $defaultOptions = [ + 'filter' => [ + 'user_id' => $this->getUserIdByUsername($this->getUsername()), + 'media_type' => 'Manga' + ], + 'page' => [ + 'offset' => $offset, + 'limit' => $limit + ], + 'sort' => '-updated_at' + ]; + $options = array_merge($defaultOptions, $options); + + return $this->setUpRequest('GET', 'library-entries', ['query' => $options]); + } + + /** + * Get the mal id for the manga 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 getMalIdForManga(string $kitsuMangaId) + { + $options = [ + 'query' => [ + 'include' => 'mappings' + ] + ]; + $data = $this->getRequest("manga/{$kitsuMangaId}", $options); + $mappings = array_column($data['included'], 'attributes'); + + foreach($mappings as $map) + { + if ($map['externalSite'] === 'myanimelist/manga') + { + return $map['externalId']; + } + } + + return NULL; + } + + // ------------------------------------------------------------------------- + // ! Generic API calls + // ------------------------------------------------------------------------- + /** * Create a list item * @@ -665,6 +839,9 @@ class Model { 'filter' => [ 'slug' => $slug ], + 'fields' => [ + 'characters' => 'slug,name,image' + ], 'include' => ($type === 'anime') ? 'genres,mappings,streamingLinks,animeCharacters.character' : 'genres,mappings,mangaCharacters.character,castings.character', diff --git a/src/API/Kitsu/Transformer/AnimeListTransformer.php b/src/API/Kitsu/Transformer/AnimeListTransformer.php index 0ecf35cd..9578a0c2 100644 --- a/src/API/Kitsu/Transformer/AnimeListTransformer.php +++ b/src/API/Kitsu/Transformer/AnimeListTransformer.php @@ -39,13 +39,13 @@ class AnimeListTransformer extends AbstractTransformer { $genres = array_column($anime['relationships']['genres'], 'name') ?? []; sort($genres); - + $rating = (int) 2 * $item['attributes']['rating']; $total_episodes = array_key_exists('episodeCount', $anime) && (int) $anime['episodeCount'] !== 0 ? (int) $anime['episodeCount'] : '-'; - + $MALid = NULL; if (array_key_exists('mappings', $anime['relationships'])) @@ -59,7 +59,7 @@ class AnimeListTransformer extends AbstractTransformer { } } } - + $streamingLinks = (array_key_exists('streamingLinks', $anime['relationships'])) ? Kitsu::parseListItemStreamingLinks($included, $animeId) : []; @@ -122,8 +122,8 @@ class AnimeListTransformer extends AbstractTransformer { 'private' => $privacy ] ]; - - if ( ! empty($item['user_rating'])) + + if (is_numeric($item['user_rating'])) { $untransformed['data']['rating'] = $item['user_rating'] / 2; } diff --git a/src/API/Kitsu/Transformer/MangaListTransformer.php b/src/API/Kitsu/Transformer/MangaListTransformer.php index 6a63b970..209bb1c6 100644 --- a/src/API/Kitsu/Transformer/MangaListTransformer.php +++ b/src/API/Kitsu/Transformer/MangaListTransformer.php @@ -35,22 +35,42 @@ class MangaListTransformer extends AbstractTransformer { */ public function transform($item) { - $manga =& $item['manga']; + $included = $item['included']; + $mangaId = $item['relationships']['media']['data']['id']; + $manga = $included['manga'][$mangaId]; + + $genres = array_column($manga['relationships']['genres'], 'name') ?? []; + sort($genres); $rating = (is_numeric($item['attributes']['rating'])) ? intval(2 * $item['attributes']['rating']) : '-'; - $totalChapters = ($manga['attributes']['chapterCount'] > 0) - ? $manga['attributes']['chapterCount'] + $totalChapters = ($manga['chapterCount'] > 0) + ? $manga['chapterCount'] : '-'; - $totalVolumes = ($manga['attributes']['volumeCount'] > 0) - ? $manga['attributes']['volumeCount'] + $totalVolumes = ($manga['volumeCount'] > 0) + ? $manga['volumeCount'] : '-'; + $MALid = NULL; + + if (array_key_exists('mappings', $manga['relationships'])) + { + foreach ($manga['relationships']['mappings'] as $mapping) + { + if ($mapping['externalSite'] === 'myanimelist/manga') + { + $MALid = $mapping['externalId']; + break; + } + } + } + $map = [ 'id' => $item['id'], + 'mal_id' => $MALid, 'chapters' => [ 'read' => $item['attributes']['progress'], 'total' => $totalChapters @@ -60,13 +80,13 @@ class MangaListTransformer extends AbstractTransformer { 'total' => $totalVolumes ], 'manga' => [ - 'titles' => Kitsu::filterTitles($manga['attributes']), + 'titles' => Kitsu::filterTitles($manga), 'alternate_title' => NULL, - 'slug' => $manga['attributes']['slug'], - 'url' => 'https://kitsu.io/manga/' . $manga['attributes']['slug'], - 'type' => $manga['attributes']['mangaType'], - 'image' => $manga['attributes']['posterImage']['small'], - 'genres' => [], //$manga['genres'], + 'slug' => $manga['slug'], + 'url' => 'https://kitsu.io/manga/' . $manga['slug'], + 'type' => $manga['mangaType'], + 'image' => $manga['posterImage']['small'], + 'genres' => $genres, ], 'reading_status' => $item['attributes']['status'], 'notes' => $item['attributes']['notes'], @@ -90,16 +110,21 @@ class MangaListTransformer extends AbstractTransformer { $map = [ 'id' => $item['id'], + 'mal_id' => $item['mal_id'], 'data' => [ 'status' => $item['status'], 'progress' => (int)$item['chapters_read'], 'reconsuming' => $rereading, 'reconsumeCount' => (int)$item['reread_count'], 'notes' => $item['notes'], - 'rating' => $item['new_rating'] / 2 ], ]; + if (is_numeric($item['new_rating'])) + { + $map['data']['rating'] = $item['new_rating'] / 2; + } + return $map; } } diff --git a/src/API/MAL/ListItem.php b/src/API/MAL/ListItem.php index 20c285f9..e46199cf 100644 --- a/src/API/MAL/ListItem.php +++ b/src/API/MAL/ListItem.php @@ -30,7 +30,14 @@ class ListItem { use ContainerAware; use MALTrait; - public function create(array $data): Request + /** + * 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 = [ @@ -42,17 +49,24 @@ class ListItem { $config = $this->container->get('config'); - return $this->requestBuilder->newRequest('POST', "animelist/add/{$id}.xml") + return $this->requestBuilder->newRequest('POST', "{$type}list/add/{$id}.xml") ->setFormFields($createData) ->setBasicAuth($config->get(['mal','username']), $config->get(['mal', 'password'])) ->getFullRequest(); } - public function delete(string $id): Request + /** + * 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', "animelist/delete/{$id}.xml") + return $this->requestBuilder->newRequest('DELETE', "{$type}list/delete/{$id}.xml") ->setFormFields([ 'id' => $id ]) @@ -67,7 +81,15 @@ class ListItem { return []; } - public function update(string $id, array $data): Request + /** + * Update a list item + * + * @param string $id + * @param array $data + * @param string $type + * @return Request + */ + public function update(string $id, array $data, string $type = 'anime'): Request { $config = $this->container->get('config'); @@ -76,7 +98,7 @@ class ListItem { ->addField('id', $id) ->addField('data', $xml); - return $this->requestBuilder->newRequest('POST', "animelist/update/{$id}.xml") + return $this->requestBuilder->newRequest('POST', "{$type}list/update/{$id}.xml") ->setFormFields([ 'id' => $id, 'data' => $xml diff --git a/src/API/MAL/MALTrait.php b/src/API/MAL/MALTrait.php index 430f2a15..32bd299c 100644 --- a/src/API/MAL/MALTrait.php +++ b/src/API/MAL/MALTrait.php @@ -64,21 +64,6 @@ trait MALTrait { return $this; } - /** - * Unencode the dual-encoded ampersands in the body - * - * 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 - */ - private function fixBody(FormBody $formBody): string - { - $rawBody = \Amp\wait($formBody->getBody()); - return html_entity_decode($rawBody, \ENT_HTML5, 'UTF-8'); - } - /** * Create a request object * diff --git a/src/API/MAL/Model.php b/src/API/MAL/Model.php index e4a98591..d9677769 100644 --- a/src/API/MAL/Model.php +++ b/src/API/MAL/Model.php @@ -17,10 +17,13 @@ namespace Aviat\AnimeClient\API\MAL; use Amp\Artax\Request; -use Aviat\AnimeClient\API\MAL\ListItem; -use Aviat\AnimeClient\API\MAL\Transformer\AnimeListTransformer; +use Aviat\AnimeClient\API\MAL\{ + ListItem, + Transformer\AnimeListTransformer, + Transformer\MangaListTransformer +}; use Aviat\AnimeClient\API\XML; -use Aviat\AnimeClient\API\Mapping\AnimeWatchingStatus; +use Aviat\AnimeClient\API\Mapping\{AnimeWatchingStatus, MangaReadingStatus}; use Aviat\Ion\Di\ContainerAware; /** @@ -34,7 +37,7 @@ class Model { * @var AnimeListTransformer */ protected $animeListTransformer; - + /** * @var ListItem */ @@ -48,27 +51,81 @@ class Model { public function __construct(ListItem $listItem) { $this->animeListTransformer = new AnimeListTransformer(); + $this->mangaListTransformer = new MangaListTransformer(); $this->listItem = $listItem; } - - public function createFullListItem(array $data): Request + + /** + * Create a list item on MAL + * + * @param array $data + * @param string $type "anime" or "manga" + * @return Request + */ + public function createFullListItem(array $data, string $type = 'anime'): Request { - return $this->listItem->create($data); + return $this->listItem->create($data, $type); } - public function createListItem(array $data): Request + public function createListItem(array $data, string $type = 'anime'): Request { - $createData = [ - 'id' => $data['id'], - 'data' => [ - 'status' => AnimeWatchingStatus::KITSU_TO_MAL[$data['status']] - ] - ]; + if ($type === 'anime') + { + $createData = [ + 'id' => $data['id'], + 'data' => [ + 'status' => AnimeWatchingStatus::KITSU_TO_MAL[$data['status']] + ] + ]; + } + elseif ($type === 'manga') + { + $createData = [ + 'id' => $data['id'], + 'data' => [ + 'status' => MangaReadingStatus::KITSU_TO_MAL[$data['status']] + ] + ]; + } - return $this->listItem->create($createData); + return $this->listItem->create($createData, $type); } - public function getFullList(): array + public function getMangaList(): array + { + return $this->getList('manga'); + } + + public function getAnimeList(): array + { + return $this->getList('anime'); + } + + public function getListItem(string $listId): array + { + return []; + } + + public function updateListItem(array $data, string $type = 'anime'): Request + { + if ($type === 'anime') + { + $updateData = $this->animeListTransformer->untransform($data); + } + else if ($type === 'manga') + { + $updateData = $this->mangaListTransformer->untransform($data); + } + + return $this->listItem->update($updateData['id'], $updateData['data'], $type); + } + + public function deleteListItem(string $id, string $type = 'anime'): Request + { + return $this->listItem->delete($id, $type); + } + + private function getList(string $type): array { $config = $this->container->get('config'); $userName = $config->get(['mal', 'username']); @@ -78,26 +135,11 @@ class Model { ], 'query' => [ 'u' => $userName, - 'status' => 'all' + 'status' => 'all', + 'type' => $type ] ]); - return $list['myanimelist']['anime']; - } - - public function getListItem(string $listId): array - { - return []; - } - - public function updateListItem(array $data): Request - { - $updateData = $this->animeListTransformer->untransform($data); - return $this->listItem->update($updateData['id'], $updateData['data']); - } - - public function deleteListItem(string $id): Request - { - return $this->listItem->delete($id); + return $list['myanimelist'][$type]; } } \ No newline at end of file diff --git a/src/API/MAL/Transformer/AnimeListTransformer.php b/src/API/MAL/Transformer/AnimeListTransformer.php index ac06da02..222a3600 100644 --- a/src/API/MAL/Transformer/AnimeListTransformer.php +++ b/src/API/MAL/Transformer/AnimeListTransformer.php @@ -24,26 +24,14 @@ use Aviat\Ion\Transformer\AbstractTransformer; */ class AnimeListTransformer extends AbstractTransformer { /** - * Transform MAL episode data to Kitsu episode data + * Identity transformation * * @param array $item * @return array */ public function transform($item) { - $rewatching = (array_key_exists('rewatching', $item) && $item['rewatching']); - - return [ - 'id' => $item['mal_id'], - 'data' => [ - 'status' => AnimeWatchingStatus::KITSU_TO_MAL[$item['watching_status']], - 'rating' => $item['user_rating'], - 'rewatch_value' => (int) $rewatching, - 'times_rewatched' => $item['rewatched'], - 'comments' => $item['notes'], - 'episode' => $item['episodes_watched'] - ] - ]; + return $item; } /** diff --git a/src/API/MAL/Transformer/MangaListTransformer.php b/src/API/MAL/Transformer/MangaListTransformer.php new file mode 100644 index 00000000..2a44e1f3 --- /dev/null +++ b/src/API/MAL/Transformer/MangaListTransformer.php @@ -0,0 +1,85 @@ + + * @copyright 2015 - 2017 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\Transformer; + +use Aviat\AnimeClient\API\Mapping\MangaReadingStatus; +use Aviat\Ion\Transformer\AbstractTransformer; + +/** + * Transformer for updating MAL List + */ +class MangaListTransformer extends AbstractTransformer { + /** + * Identity transformation + * + * @param array $item + * @return array + */ + public function transform($item) + { + return $item; + } + + /** + * Transform Kitsu data to MAL data + * + * @param array $item + * @return array + */ + public function untransform(array $item): array + { + $map = [ + 'id' => $item['mal_id'], + 'data' => [ + 'chapter' => $item['data']['progress'] + ] + ]; + + $data =& $item['data']; + + foreach($item['data'] as $key => $value) + { + switch($key) + { + case 'notes': + $map['data']['comments'] = $value; + break; + + case 'rating': + $map['data']['score'] = $value * 2; + break; + + case 'reconsuming': + $map['data']['enable_rereading'] = (bool) $value; + break; + + case 'reconsumeCount': + $map['data']['times_reread'] = $value; + break; + + case 'status': + $map['data']['status'] = MangaReadingStatus::KITSU_TO_MAL[$value]; + break; + + default: + break; + } + } + + return $map; + } +} \ No newline at end of file diff --git a/src/API/Mapping/MangaReadingStatus.php b/src/API/Mapping/MangaReadingStatus.php index 7b01c2ce..363483da 100644 --- a/src/API/Mapping/MangaReadingStatus.php +++ b/src/API/Mapping/MangaReadingStatus.php @@ -29,7 +29,7 @@ use Aviat\Ion\Enum; * and url route segments */ class MangaReadingStatus extends Enum { - const MAL_TO_KITSU = [ + const KITSU_TO_MAL = [ Kitsu::READING => MAL::READING, Kitsu::PLAN_TO_READ => MAL::PLAN_TO_READ, Kitsu::COMPLETED => MAL::COMPLETED, @@ -37,12 +37,17 @@ class MangaReadingStatus extends Enum { Kitsu::DROPPED => MAL::DROPPED ]; - const KITSU_TO_MAL = [ + const MAL_TO_KITSU = [ + '1' => Kitsu::READING, + '2' => Kitsu::COMPLETED, + '3' => Kitsu::ON_HOLD, + '4' => Kitsu::DROPPED, + '6' => Kitsu::PLAN_TO_READ, MAL::READING => Kitsu::READING, - MAL::PLAN_TO_READ => Kitsu::PLAN_TO_READ, MAL::COMPLETED => Kitsu::COMPLETED, MAL::ON_HOLD => Kitsu::ON_HOLD, - MAL::DROPPED => Kitsu::DROPPED + MAL::DROPPED => Kitsu::DROPPED, + MAL::PLAN_TO_READ => Kitsu::PLAN_TO_READ, ]; const KITSU_TO_TITLE = [ @@ -50,7 +55,7 @@ class MangaReadingStatus extends Enum { Kitsu::PLAN_TO_READ => Title::PLAN_TO_READ, Kitsu::COMPLETED => Title::COMPLETED, Kitsu::ON_HOLD => Title::ON_HOLD, - Kitsu::DROPPED => Title::DROPPED + Kitsu::DROPPED => Title::DROPPED, ]; const ROUTE_TO_KITSU = [ @@ -58,7 +63,7 @@ class MangaReadingStatus extends Enum { Route::READING => Kitsu::READING, Route::COMPLETED => Kitsu::COMPLETED, Route::DROPPED => Kitsu::DROPPED, - Route::ON_HOLD => Kitsu::ON_HOLD + Route::ON_HOLD => Kitsu::ON_HOLD, ]; const ROUTE_TO_TITLE = [ @@ -67,7 +72,7 @@ class MangaReadingStatus extends Enum { Route::READING => Title::READING, Route::COMPLETED => Title::COMPLETED, Route::DROPPED => Title::DROPPED, - Route::ON_HOLD => Title::ON_HOLD + Route::ON_HOLD => Title::ON_HOLD, ]; const TITLE_TO_KITSU = [ @@ -75,6 +80,6 @@ class MangaReadingStatus extends Enum { Title::READING => Kitsu::READING, Title::COMPLETED => Kitsu::COMPLETED, Title::DROPPED => Kitsu::DROPPED, - Title::ON_HOLD => Kitsu::ON_HOLD + Title::ON_HOLD => Kitsu::ON_HOLD, ]; } \ No newline at end of file diff --git a/src/AnimeClient.php b/src/AnimeClient.php index 849a0615..f185e8a6 100644 --- a/src/AnimeClient.php +++ b/src/AnimeClient.php @@ -21,25 +21,14 @@ use Yosymfony\Toml\Toml; define('SRC_DIR', realpath(__DIR__)); const SESSION_SEGMENT = 'Aviat\AnimeClient\Auth'; +const DEFAULT_CONTROLLER = 'Aviat\AnimeClient\Controller\Index'; const DEFAULT_CONTROLLER_NAMESPACE = 'Aviat\AnimeClient\Controller'; -const DEFAULT_CONTROLLER = 'Aviat\AnimeClient\Controller\Anime'; +const DEFAULT_LIST_CONTROLLER = 'Aviat\AnimeClient\Controller\Anime'; const DEFAULT_CONTROLLER_METHOD = 'index'; const NOT_FOUND_METHOD = 'notFound'; const ERROR_MESSAGE_METHOD = 'errorPage'; const SRC_DIR = SRC_DIR; -/** - * Joins paths together. Variadic to take an - * arbitrary number of arguments - * - * @param string[] ...$args - * @return string - */ -function _dir(...$args) -{ - return implode(DIRECTORY_SEPARATOR, $args); -} - /** * Load configuration options from .toml files * diff --git a/src/Command/SyncKitsuWithMal.php b/src/Command/SyncKitsuWithMal.php index 441396f7..706a4aaa 100644 --- a/src/Command/SyncKitsuWithMal.php +++ b/src/Command/SyncKitsuWithMal.php @@ -19,8 +19,16 @@ namespace Aviat\AnimeClient\Command; use function Amp\{all, wait}; use Amp\Artax\Client; -use Aviat\AnimeClient\API\{JsonAPI, Mapping\AnimeWatchingStatus}; -use Aviat\AnimeClient\API\MAL\Transformer\AnimeListTransformer as ALT; +use Aviat\AnimeClient\API\{ + JsonAPI, + ParallelAPIRequest, + Mapping\AnimeWatchingStatus, + Mapping\MangaReadingStatus +}; +use Aviat\AnimeClient\API\MAL\Transformer\{ + AnimeListTransformer as ALT, + MangaListTransformer as MLT +}; use Aviat\Ion\Json; /** @@ -33,7 +41,7 @@ class SyncKitsuWithMal extends BaseCommand { * @var \Aviat\AnimeClient\API\Kitsu\Model */ protected $kitsuModel; - + /** * Model for making requests to MAL API * @var \Aviat\AnimeClient\API\MAL\Model @@ -55,63 +63,71 @@ class SyncKitsuWithMal extends BaseCommand { $this->kitsuModel = $this->container->get('kitsu-model'); $this->malModel = $this->container->get('mal-model'); - $malCount = count($this->getMALAnimeList()); - $kitsuCount = $this->getKitsuAnimeListPageCount(); + $this->syncAnime(); + $this->syncManga(); + } - $this->echoBox("Number of MAL list items: {$malCount}"); - $this->echoBox("Number of Kitsu list items: {$kitsuCount}"); + public function syncAnime() + { + $malCount = count($this->malModel->getAnimeList()); + $kitsuCount = $this->kitsuModel->getAnimeListCount(); + + $this->echoBox("Number of MAL anime list items: {$malCount}"); + $this->echoBox("Number of Kitsu anime list items: {$kitsuCount}"); $data = $this->diffAnimeLists(); - $this->echoBox("Number of items that need to be added to MAL: " . count($data)); + + $this->echoBox("Number of anime items that need to be added to MAL: " . count($data['addToMAL'])); if ( ! empty($data['addToMAL'])) { - $this->echoBox("Adding missing list items to MAL"); - $this->createMALAnimeListItems($data['addToMAL']); + $this->echoBox("Adding missing anime list items to MAL"); + $this->createMALListItems($data['addToMAL'], 'anime'); } - } - public function getKitsuAnimeList() - { - $count = $this->getKitsuAnimeListPageCount(); - $size = 100; - $pages = ceil($count / $size); + $this->echoBox('Number of anime items that need to be added to Kitsu: ' . count($data['addToKitsu'])); - $requests = []; - - // Set up requests - for ($i = 0; $i < $pages; $i++) + if ( ! empty($data['addToKitsu'])) { - $offset = $i * $size; - $requests[] = $this->kitsuModel->getPagedAnimeList($size, $offset); + $this->echoBox("Adding missing anime list items to Kitsu"); + $this->createKitusListItems($data['addToKitsu'], 'anime'); } - - $promiseArray = (new Client())->requestMulti($requests); - - $responses = wait(all($promiseArray)); - $output = []; - - foreach($responses as $response) - { - $data = Json::decode($response->getBody()); - $output = array_merge_recursive($output, $data); - } - - return $output; } - public function getMALAnimeList() + public function syncManga() { - return $this->malModel->getFullList(); + $malCount = count($this->malModel->getMangaList()); + $kitsuCount = $this->kitsuModel->getMangaListCount(); + + $this->echoBox("Number of MAL manga list items: {$malCount}"); + $this->echoBox("Number of Kitsu manga list items: {$kitsuCount}"); + + $data = $this->diffMangaLists(); + + $this->echoBox("Number of manga items that need to be added to MAL: " . count($data['addToMAL'])); + + if ( ! empty($data['addToMAL'])) + { + $this->echoBox("Adding missing manga list items to MAL"); + $this->createMALListItems($data['addToMAL'], 'manga'); + } + + $this->echoBox('Number of manga items that need to be added to Kitsu: ' . count($data['addToKitsu'])); + + if ( ! empty($data['addToKitsu'])) + { + $this->echoBox("Adding missing manga list items to Kitsu"); + $this->createKitsuListItems($data['addToKitsu'], 'manga'); + } } - public function filterMappings(array $includes): array + public function filterMappings(array $includes, string $type = 'anime'): array { $output = []; foreach($includes as $id => $mapping) { - if ($mapping['externalSite'] === 'myanimelist/anime') + if ($mapping['externalSite'] === "myanimelist/{$type}") { $output[$id] = $mapping; } @@ -122,7 +138,7 @@ class SyncKitsuWithMal extends BaseCommand { public function formatMALAnimeList() { - $orig = $this->getMALAnimeList(); + $orig = $this->malModel->getAnimeList(); $output = []; foreach($orig as $item) @@ -137,7 +153,37 @@ class SyncKitsuWithMal extends BaseCommand { ? $item['times_rewatched'] : 0, // 'notes' => , - 'rating' => $item['my_score'], + 'rating' => $item['my_score'] / 2, + 'updatedAt' => (new \DateTime()) + ->setTimestamp((int)$item['my_last_updated']) + ->format(\DateTime::W3C), + ] + ]; + } + + return $output; + } + + public function formatMALMangaList() + { + $orig = $this->malModel->getMangaList(); + $output = []; + + foreach($orig as $item) + { + $output[$item['series_mangadb_id']] = [ + 'id' => $item['series_mangadb_id'], + 'data' => [ + 'my_status' => $item['my_status'], + 'status' => MangaReadingStatus::MAL_TO_KITSU[$item['my_status']], + 'progress' => $item['my_read_chapters'], + 'volumes' => $item['my_read_volumes'], + 'reconsuming' => (bool) $item['my_rereadingg'], + /* 'reconsumeCount' => array_key_exists('times_rewatched', $item) + ? $item['times_rewatched'] + : 0, */ + // 'notes' => , + 'rating' => $item['my_score'] / 2, 'updatedAt' => (new \DateTime()) ->setTimestamp((int)$item['my_last_updated']) ->format(\DateTime::W3C), @@ -186,9 +232,84 @@ class SyncKitsuWithMal extends BaseCommand { return $output; } - public function getKitsuAnimeListPageCount() + public function filterKitsuMangaList() { - return $this->kitsuModel->getAnimeListCount(); + $data = $this->kitsuModel->getFullMangaList(); + $includes = JsonAPI::organizeIncludes($data['included']); + $includes['mappings'] = $this->filterMappings($includes['mappings'], 'manga'); + + $output = []; + + foreach($data['data'] as $listItem) + { + $mangaId = $listItem['relationships']['manga']['data']['id']; + $potentialMappings = $includes['manga'][$mangaId]['relationships']['mappings']; + $malId = NULL; + + foreach ($potentialMappings as $mappingId) + { + if (array_key_exists($mappingId, $includes['mappings'])) + { + $malId = $includes['mappings'][$mappingId]['externalId']; + } + } + + // Skip to the next item if there isn't a MAL ID + if (is_null($malId)) + { + continue; + } + + $output[$listItem['id']] = [ + 'id' => $listItem['id'], + 'malId' => $malId, + 'data' => $listItem['attributes'], + ]; + } + + return $output; + } + + public function diffMangaLists() + { + $kitsuList = $this->filterKitsuMangaList(); + $malList = $this->formatMALMangaList(); + + $itemsToAddToMAL = []; + $itemsToAddToKitsu = []; + + $malIds = array_column($malList, 'id'); + $kitsuMalIds = array_column($kitsuList, 'malId'); + $missingMalIds = array_diff($malIds, $kitsuMalIds); + + foreach($missingMalIds as $mid) + { + $itemsToAddToKitsu[] = array_merge($malList[$mid]['data'], [ + 'id' => $this->kitsuModel->getKitsuIdFromMALId($mid, 'manga'), + 'type' => 'manga' + ]); + } + + foreach($kitsuList as $kitsuItem) + { + if (in_array($kitsuItem['malId'], $malIds)) + { + // Eventually, compare the list entries, and determine which + // needs to be updated + continue; + } + + // Looks like this item only exists on Kitsu + $itemsToAddToMAL[] = [ + 'mal_id' => $kitsuItem['malId'], + 'data' => $kitsuItem['data'] + ]; + } + + return [ + 'addToMAL' => $itemsToAddToMAL, + 'addToKitsu' => $itemsToAddToKitsu + ]; } public function diffAnimeLists() @@ -201,10 +322,26 @@ class SyncKitsuWithMal extends BaseCommand { $malList = $this->formatMALAnimeList(); $itemsToAddToMAL = []; + $itemsToAddToKitsu = []; + $malUpdateItems = []; + $kitsuUpdateItems = []; + + $malIds = array_column($malList, 'id'); + $kitsuMalIds = array_column($kitsuList, 'malId'); + $missingMalIds = array_diff($malIds, $kitsuMalIds); + + foreach($missingMalIds as $mid) + { + // print_r($malList[$mid]); + $itemsToAddToKitsu[] = array_merge($malList[$mid]['data'], [ + 'id' => $this->kitsuModel->getKitsuIdFromMALId($mid), + 'type' => 'anime' + ]); + } foreach($kitsuList as $kitsuItem) { - if (array_key_exists($kitsuItem['malId'], $malList)) + if (in_array($kitsuItem['malId'], $malIds)) { // Eventually, compare the list entries, and determine which // needs to be updated @@ -230,34 +367,60 @@ class SyncKitsuWithMal extends BaseCommand { return [ 'addToMAL' => $itemsToAddToMAL, + 'updateMAL' => $malUpdateItems, + 'addToKitsu' => $itemsToAddToKitsu, + 'updateKitsu' => $kitsuUpdateItems ]; } - public function createMALAnimeListItems($itemsToAdd) + public function createKitusAnimeListItems($itemsToAdd, $type = 'anime') + { + $requester = new ParallelAPIRequest(); + foreach($itemsToAdd as $item) + { + $requester->addRequest($this->kitsuModel->createListItem($item)); + } + + $responses = $requester->makeRequests(); + + foreach($responses as $key => $response) + { + $id = $itemsToAdd[$key]['id']; + if ($response->getStatus() === 201) + { + $this->echoBox("Successfully created Kitsu {$type} list item with id: {$id}"); + } + else + { + echo $response->getBody(); + $this->echoBox("Failed to create Kitsu {$type} list item with id: {$id}"); + } + } + } + + public function createMALListItems($itemsToAdd, $type = 'anime') { $transformer = new ALT(); - $requests = []; + $requester = new ParallelAPIRequest(); foreach($itemsToAdd as $item) { $data = $transformer->untransform($item); - $requests[] = $this->malModel->createFullListItem($data); + $requester->addRequest($this->malModel->createFullListItem($data, $type)); } - $promiseArray = (new Client())->requestMulti($requests); - - $responses = wait(all($promiseArray)); + $responses = $requester->makeRequests(); foreach($responses as $key => $response) { $id = $itemsToAdd[$key]['mal_id']; if ($response->getBody() === 'Created') { - $this->echoBox("Successfully create list item with id: {$id}"); + $this->echoBox("Successfully created MAL {$type} list item with id: {$id}"); } else { - $this->echoBox("Failed to create list item with id: {$id}"); + $this->echoBox("Failed to create MAL {$type} list item with id: {$id}"); } } } diff --git a/src/Controller.php b/src/Controller.php index 48d11e4a..5d655914 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -18,7 +18,7 @@ namespace Aviat\AnimeClient; use const Aviat\AnimeClient\SESSION_SEGMENT; -use function Aviat\AnimeClient\_dir; +use function Aviat\Ion\_dir; use Aviat\AnimeClient\API\JsonAPI; use Aviat\Ion\Di\{ContainerAware, ContainerInterface}; @@ -31,7 +31,66 @@ use InvalidArgumentException; * @property Response object $response */ class Controller { - use ControllerTrait; + + use ContainerAware; + + /** + * Cache manager + * @var \Psr\Cache\CacheItemPoolInterface + */ + protected $cache; + + /** + * The global configuration object + * @var \Aviat\Ion\ConfigInterface $config + */ + public $config; + + /** + * Request object + * @var object $request + */ + protected $request; + + /** + * Response object + * @var object $response + */ + public $response; + + /** + * The api model for the current controller + * @var object + */ + protected $model; + + /** + * Url generation class + * @var UrlGenerator + */ + protected $urlGenerator; + + /** + * Aura url generator + * @var \Aura\Router\Generator + */ + protected $url; + + /** + * Session segment + * @var \Aura\Session\Segment + */ + protected $session; + + /** + * Common data to be sent to views + * @var array + */ + protected $baseData = [ + 'url_type' => 'anime', + 'other_type' => 'manga', + 'menu_name' => '' + ]; /** * Constructor @@ -55,6 +114,7 @@ class Controller { 'config' => $this->config ]); + $this->url = $auraUrlGenerator; $this->urlGenerator = $urlGenerator; $session = $container->get('session'); @@ -72,81 +132,267 @@ class Controller { } /** - * Show the user profile page + * Redirect to the previous page * * @return void */ - public function me() + public function redirectToPrevious() { - $username = $this->config->get(['kitsu_username']); - $model = $this->container->get('kitsu-model'); - $data = $model->getUserData($username); - $included = JsonAPI::lightlyOrganizeIncludes($data['included']); - $relationships = JsonAPI::fillRelationshipsFromIncludes($data['data']['relationships'], $included); - $this->outputHTML('me', [ - 'title' => 'About' . $this->config->get('whose_list'), - 'attributes' => $data['data']['attributes'], - 'relationships' => $relationships, - 'included' => $included + $previous = $this->session->getFlash('previous'); + $this->redirect($previous, 303); + } + + /** + * Set the current url in the session as the target of a future redirect + * + * @param string|null $url + * @return void + */ + public function setSessionRedirect(string $url = NULL) + { + $serverParams = $this->request->getServerParams(); + + if ( ! array_key_exists('HTTP_REFERER', $serverParams)) + { + return; + } + + $util = $this->container->get('util'); + $doubleFormPage = $serverParams['HTTP_REFERER'] === $this->request->getUri(); + + // Don't attempt to set the redirect url if + // the page is one of the form type pages, + // and the previous page is also a form type page_segments + if ($doubleFormPage) + { + return; + } + + if (is_null($url)) + { + $url = $util->isViewPage() + ? $this->request->url->get() + : $serverParams['HTTP_REFERER']; + } + + $this->session->set('redirect_url', $url); + } + + /** + * Redirect to the url previously set in the session + * + * @return void + */ + public function sessionRedirect() + { + $target = $this->session->get('redirect_url'); + if (empty($target)) + { + $this->notFound(); + } + else + { + $this->redirect($target, 303); + $this->session->set('redirect_url', NULL); + } + } + + /** + * Get the string output of a partial template + * + * @param HtmlView $view + * @param string $template + * @param array $data + * @throws InvalidArgumentException + * @return string + */ + protected function loadPartial($view, string $template, array $data = []) + { + $router = $this->container->get('dispatcher'); + + if (isset($this->baseData)) + { + $data = array_merge($this->baseData, $data); + } + + $route = $router->getRoute(); + $data['route_path'] = $route ? $router->getRoute()->path : ''; + + + $templatePath = _dir($this->config->get('view_path'), "{$template}.php"); + + if ( ! is_file($templatePath)) + { + throw new InvalidArgumentException("Invalid template : {$template}"); + } + + return $view->renderTemplate($templatePath, (array)$data); + } + + /** + * Render a template with header and footer + * + * @param HtmlView $view + * @param string $template + * @param array $data + * @return void + */ + protected function renderFullPage($view, string $template, array $data) + { + $view->appendOutput($this->loadPartial($view, 'header', $data)); + + if (array_key_exists('message', $data) && is_array($data['message'])) + { + $view->appendOutput($this->loadPartial($view, 'message', $data['message'])); + } + + $view->appendOutput($this->loadPartial($view, $template, $data)); + $view->appendOutput($this->loadPartial($view, 'footer', $data)); + } + + /** + * 404 action + * + * @return void + */ + public function notFound( + string $title = 'Sorry, page not found', + string $message = 'Page Not Found' + ) + { + $this->outputHTML('404', [ + 'title' => $title, + 'message' => $message, + ], NULL, 404); + } + + /** + * Display a generic error page + * + * @param int $httpCode + * @param string $title + * @param string $message + * @param string $long_message + * @return void + */ + public function errorPage(int $httpCode, string $title, string $message, string $long_message = "") + { + $this->outputHTML('error', [ + 'title' => $title, + 'message' => $message, + 'long_message' => $long_message + ], NULL, $httpCode); + } + + /** + * Redirect to the default controller/url from an empty path + * + * @return void + */ + public function redirectToDefaultRoute() + { + $defaultType = $this->config->get(['routes', 'route_config', 'default_list']) ?? 'anime'; + $this->redirect($this->urlGenerator->defaultUrl($defaultType), 303); + } + + /** + * Set a session flash variable to display a message on + * next page load + * + * @param string $message + * @param string $type + * @return void + */ + public function setFlashMessage(string $message, string $type = "info") + { + static $messages; + + if ( ! $messages) + { + $messages = []; + } + + $messages[] = [ + 'message_type' => $type, + 'message' => $message + ]; + + $this->session->setFlash('message', $messages); + } + + /** + * Helper for consistent page titles + * + * @param string ...$parts Title segements + * @return string + */ + public function formatTitle(string ...$parts) : string + { + return implode(' · ', $parts); + } + + /** + * Add a message box to the page + * + * @param HtmlView $view + * @param string $type + * @param string $message + * @return string + */ + protected function showMessage($view, string $type, string $message): string + { + return $this->loadPartial($view, 'message', [ + 'message_type' => $type, + 'message' => $message ]); } /** - * Show the login form + * Output a template to HTML, using the provided data * - * @param string $status + * @param string $template + * @param array $data + * @param HtmlView|null $view + * @param int $code * @return void */ - public function login(string $status = '') + protected function outputHTML(string $template, array $data = [], $view = NULL, int $code = 200) { - $message = ''; - - $view = new HtmlView($this->container); - - if ($status !== '') + if (is_null($view)) { - $message = $this->showMessage($view, 'error', $status); + $view = new HtmlView($this->container); } - // Set the redirect url - $this->setSessionRedirect(); - - $this->outputHTML('login', [ - 'title' => 'Api login', - 'message' => $message - ], $view); + $view->setStatusCode($code); + $this->renderFullPage($view, $template, $data); } /** - * Attempt login authentication + * Output a JSON Response * + * @param mixed $data + * @param int $code - the http status code * @return void */ - public function loginAction() + protected function outputJSON($data = 'Empty response', int $code = 200) { - $auth = $this->container->get('auth'); - $post = $this->request->getParsedBody(); - if ($auth->authenticate($post['password'])) - { - $this->sessionRedirect(); - return; - } - - $this->setFlashMessage('Invalid username or password.'); - $this->redirect($this->urlGenerator->url('login'), 303); + (new JsonView($this->container)) + ->setStatusCode($code) + ->setOutput($data) + ->send(); } /** - * Deauthorize the current user + * Redirect to the selected page * + * @param string $url + * @param int $code * @return void */ - public function logout() + protected function redirect(string $url, int $code) { - $auth = $this->container->get('auth'); - $auth->logout(); - - $this->redirectToDefaultRoute(); + $http = new HttpView($this->container); + $http->redirect($url, $code); } } // End of BaseController.php \ No newline at end of file diff --git a/src/Controller/Anime.php b/src/Controller/Anime.php index 514a039c..48fa800d 100644 --- a/src/Controller/Anime.php +++ b/src/Controller/Anime.php @@ -116,7 +116,7 @@ class Anime extends BaseController { $this->config->get('whose_list') . "'s Anime List", 'Add' ), - 'action_url' => $this->urlGenerator->url('anime/add'), + 'action_url' => $this->url->generate('anime.add.post'), 'status_list' => AnimeWatchingStatus::KITSU_TO_TITLE ]); } @@ -168,8 +168,9 @@ class Anime extends BaseController { ), 'item' => $item, 'statuses' => AnimeWatchingStatus::KITSU_TO_TITLE, - 'action' => $this->container->get('url-generator') - ->url('/anime/update_form'), + 'action' => $this->url->generate('update.post', [ + 'controller' => 'anime' + ]), ]); } diff --git a/src/Controller/Character.php b/src/Controller/Character.php index 4d60d0e4..46bf2585 100644 --- a/src/Controller/Character.php +++ b/src/Controller/Character.php @@ -1,19 +1,19 @@ - - * @copyright 2015 - 2017 Timothy J. Warren - * @license http://www.opensource.org/licenses/mit-license.html MIT License - * @version 4.0 - * @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient - */ - + + * @copyright 2015 - 2017 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\Controller; use Aviat\AnimeClient\Controller as BaseController; diff --git a/src/Controller/Collection.php b/src/Controller/Collection.php index 14c43993..e8d229a9 100644 --- a/src/Controller/Collection.php +++ b/src/Controller/Collection.php @@ -41,18 +41,6 @@ class Collection extends BaseController { */ private $animeModel; - /** - * Data to be sent to all routes in this controller - * @var array $baseData - */ - protected $baseData; - - /** - * Url Generator class - * @var UrlGenerator - */ - protected $urlGenerator; - /** * Constructor * @@ -62,7 +50,6 @@ class Collection extends BaseController { { parent::__construct($container); - $this->urlGenerator = $container->get('url-generator'); $this->animeModel = $container->get('anime-model'); $this->animeCollectionModel = $container->get('anime-collection-model'); $this->baseData = array_merge($this->baseData, [ @@ -118,10 +105,11 @@ class Collection extends BaseController { $this->setSessionRedirect(); $action = (is_null($id)) ? "Add" : "Edit"; + $urlAction = strtolower($action); - $this->outputHTML('collection/' . strtolower($action), [ + $this->outputHTML('collection/' . $urlAction, [ 'action' => $action, - 'action_url' => $this->urlGenerator->fullUrl('collection/' . strtolower($action)), + 'action_url' => $this->url->generate("collection.{$urlAction}.post"), 'title' => $this->formatTitle( $this->config->get('whose_list') . "'s Anime Collection", $action diff --git a/src/Controller/Index.php b/src/Controller/Index.php new file mode 100644 index 00000000..b9a9f08f --- /dev/null +++ b/src/Controller/Index.php @@ -0,0 +1,138 @@ + + * @copyright 2015 - 2017 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\Controller; + +use Aviat\AnimeClient\Controller as BaseController; +use Aviat\AnimeClient\API\JsonAPI; +use Aviat\Ion\View\HtmlView; + +class Index extends BaseController { + + /** + * Purges the API cache + * + * @return void + */ + public function clearCache() + { + $this->cache->clear(); + $this->outputHTML('blank', [ + 'title' => 'Cache cleared' + ], NULL, 200); + } + + /** + * Show the login form + * + * @param string $status + * @return void + */ + public function login(string $status = '') + { + $message = ''; + + $view = new HtmlView($this->container); + + if ($status !== '') + { + $message = $this->showMessage($view, 'error', $status); + } + + // Set the redirect url + $this->setSessionRedirect(); + + $this->outputHTML('login', [ + 'title' => 'Api login', + 'message' => $message + ], $view); + } + + /** + * Attempt login authentication + * + * @return void + */ + public function loginAction() + { + $auth = $this->container->get('auth'); + $post = $this->request->getParsedBody(); + if ($auth->authenticate($post['password'])) + { + $this->sessionRedirect(); + return; + } + + $this->setFlashMessage('Invalid username or password.'); + $this->redirect($this->url->generate('login'), 303); + } + + /** + * Deauthorize the current user + * + * @return void + */ + public function logout() + { + $auth = $this->container->get('auth'); + $auth->logout(); + + $this->redirectToDefaultRoute(); + } + + /** + * Show the user profile page + * + * @return void + */ + public function me() + { + $username = $this->config->get(['kitsu_username']); + $model = $this->container->get('kitsu-model'); + $data = $model->getUserData($username); + $orgData = JsonAPI::organizeData($data); + $this->outputHTML('me', [ + 'title' => 'About' . $this->config->get('whose_list'), + 'data' => $orgData[0], + 'attributes' => $orgData[0]['attributes'], + 'relationships' => $orgData[0]['relationships'], + 'favorites' => $this->organizeFavorites($orgData[0]['relationships']['favorites']), + ]); + } + + private function organizeFavorites(array $rawfavorites): array + { + // return $rawfavorites; + $output = []; + + foreach($rawfavorites as $item) + { + $rank = $item['attributes']['favRank']; + foreach($item['relationships']['item'] as $key => $fav) + { + $output[$key] = $output[$key] ?? []; + foreach ($fav as $id => $data) + { + $output[$key][$rank] = $data['attributes']; + } + } + + ksort($output[$key]); + } + + return $output; + } +} \ No newline at end of file diff --git a/src/Controller/Manga.php b/src/Controller/Manga.php index e9bc0f01..6e490c12 100644 --- a/src/Controller/Manga.php +++ b/src/Controller/Manga.php @@ -98,17 +98,7 @@ class Manga extends Controller { */ public function addForm() { - $raw_status_list = MangaReadingStatus::getConstList(); - - $statuses = []; - - foreach ($raw_status_list as $status_item) - { - $statuses[$status_item] = (string)$this->string($status_item) - ->underscored() - ->humanize() - ->titleize(); - } + $statuses = MangaReadingStatus::KITSU_TO_TITLE; $this->setSessionRedirect(); $this->outputHTML('manga/add', [ @@ -116,7 +106,7 @@ class Manga extends Controller { $this->config->get('whose_list') . "'s Manga List", 'Add' ), - 'action_url' => $this->urlGenerator->url('manga/add'), + 'action_url' => $this->url->generate('manga.add.post'), 'status_list' => $statuses ]); } @@ -169,8 +159,9 @@ class Manga extends Controller { 'title' => $title, 'status_list' => MangaReadingStatus::KITSU_TO_TITLE, 'item' => $item, - 'action' => $this->container->get('url-generator') - ->url('/manga/update_form'), + 'action' => $this->url->generate('update.post', [ + 'controller' => 'manga' + ]), ]); } @@ -221,7 +212,7 @@ class Manga extends Controller { */ public function update() { - if ($this->request->getHeader('content-type')[0] === 'application/json') + if (stripos($this->request->getHeader('content-type')[0], 'application/json') !== FALSE) { $data = Json::decode((string)$this->request->getBody()); } @@ -245,7 +236,8 @@ class Manga extends Controller { { $body = $this->request->getParsedBody(); $id = $body['id']; - $response = $this->model->deleteLibraryItem($id); + $malId = $body['mal_id']; + $response = $this->model->deleteLibraryItem($id, $malId); if ($response) { diff --git a/src/ControllerTrait.php b/src/ControllerTrait.php deleted file mode 100644 index 86474a3d..00000000 --- a/src/ControllerTrait.php +++ /dev/null @@ -1,378 +0,0 @@ - - * @copyright 2015 - 2017 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; - -use const Aviat\AnimeClient\SESSION_SEGMENT; - -use function Aviat\AnimeClient\_dir; - -use Aviat\AnimeClient\API\JsonAPI; -use Aviat\Ion\Di\{ContainerAware, ContainerInterface}; -use Aviat\Ion\View\{HtmlView, HttpView, JsonView}; -use InvalidArgumentException; - -trait ControllerTrait { - - use ContainerAware; - - /** - * Cache manager - * @var \Psr\Cache\CacheItemPoolInterface - */ - protected $cache; - - /** - * The global configuration object - * @var \Aviat\Ion\ConfigInterface $config - */ - protected $config; - - /** - * Request object - * @var object $request - */ - protected $request; - - /** - * Response object - * @var object $response - */ - protected $response; - - /** - * The api model for the current controller - * @var object - */ - protected $model; - - /** - * Url generation class - * @var UrlGenerator - */ - protected $urlGenerator; - - /** - * Session segment - * @var \Aura\Session\Segment - */ - protected $session; - - /** - * Common data to be sent to views - * @var array - */ - protected $baseData = [ - 'url_type' => 'anime', - 'other_type' => 'manga', - 'menu_name' => '' - ]; - - /** - * Redirect to the default controller/url from an empty path - * - * @return void - */ - public function redirectToDefaultRoute() - { - $defaultType = $this->config->get(['routes', 'route_config', 'default_list']); - $this->redirect($this->urlGenerator->defaultUrl($defaultType), 303); - } - - /** - * Redirect to the previous page - * - * @return void - */ - public function redirectToPrevious() - { - $previous = $this->session->getFlash('previous'); - $this->redirect($previous, 303); - } - - /** - * Set the current url in the session as the target of a future redirect - * - * @param string|null $url - * @return void - */ - public function setSessionRedirect($url = NULL) - { - $serverParams = $this->request->getServerParams(); - - if ( ! array_key_exists('HTTP_REFERER', $serverParams)) - { - return; - } - - $util = $this->container->get('util'); - $doubleFormPage = $serverParams['HTTP_REFERER'] === $this->request->getUri(); - - // Don't attempt to set the redirect url if - // the page is one of the form type pages, - // and the previous page is also a form type page_segments - if ($doubleFormPage) - { - return; - } - - if (is_null($url)) - { - $url = $util->isViewPage() - ? $this->request->url->get() - : $serverParams['HTTP_REFERER']; - } - - $this->session->set('redirect_url', $url); - } - - /** - * Redirect to the url previously set in the session - * - * @return void - */ - public function sessionRedirect() - { - $target = $this->session->get('redirect_url'); - if (empty($target)) - { - $this->notFound(); - } - else - { - $this->redirect($target, 303); - $this->session->set('redirect_url', NULL); - } - } - - /** - * Get a class member - * - * @param string $key - * @return mixed - */ - public function __get(string $key) - { - $allowed = ['response', 'config']; - - if (in_array($key, $allowed)) - { - return $this->$key; - } - - return NULL; - } - - /** - * Get the string output of a partial template - * - * @param HtmlView $view - * @param string $template - * @param array $data - * @throws InvalidArgumentException - * @return string - */ - protected function loadPartial($view, $template, array $data = []) - { - $router = $this->container->get('dispatcher'); - - if (isset($this->baseData)) - { - $data = array_merge($this->baseData, $data); - } - - $route = $router->getRoute(); - $data['route_path'] = $route ? $router->getRoute()->path : ''; - - - $templatePath = _dir($this->config->get('view_path'), "{$template}.php"); - - if ( ! is_file($templatePath)) - { - throw new InvalidArgumentException("Invalid template : {$template}"); - } - - return $view->renderTemplate($templatePath, (array)$data); - } - - /** - * Render a template with header and footer - * - * @param HtmlView $view - * @param string $template - * @param array $data - * @return void - */ - protected function renderFullPage($view, $template, array $data) - { - $view->appendOutput($this->loadPartial($view, 'header', $data)); - - if (array_key_exists('message', $data) && is_array($data['message'])) - { - $view->appendOutput($this->loadPartial($view, 'message', $data['message'])); - } - - $view->appendOutput($this->loadPartial($view, $template, $data)); - $view->appendOutput($this->loadPartial($view, 'footer', $data)); - } - - /** - * 404 action - * - * @return void - */ - public function notFound( - string $title = 'Sorry, page not found', - string $message = 'Page Not Found' - ) - { - $this->outputHTML('404', [ - 'title' => $title, - 'message' => $message, - ], NULL, 404); - } - - /** - * Display a generic error page - * - * @param int $httpCode - * @param string $title - * @param string $message - * @param string $long_message - * @return void - */ - public function errorPage($httpCode, $title, $message, $long_message = "") - { - $this->outputHTML('error', [ - 'title' => $title, - 'message' => $message, - 'long_message' => $long_message - ], NULL, $httpCode); - } - - /** - * Set a session flash variable to display a message on - * next page load - * - * @param string $message - * @param string $type - * @return void - */ - public function setFlashMessage($message, $type = "info") - { - static $messages; - - if ( ! $messages) - { - $messages = []; - } - - $messages[] = [ - 'message_type' => $type, - 'message' => $message - ]; - - $this->session->setFlash('message', $messages); - } - - /** - * Purges the API cache - * - * @return void - */ - public function clearCache() - { - $this->cache->clear(); - $this->outputHTML('blank', [ - 'title' => 'Cache cleared' - ], NULL, 200); - } - - /** - * Helper for consistent page titles - * - * @param string ...$parts Title segements - * @return string - */ - public function formatTitle(string ...$parts) : string - { - return implode(' · ', $parts); - } - - /** - * Add a message box to the page - * - * @param HtmlView $view - * @param string $type - * @param string $message - * @return string - */ - protected function showMessage($view, $type, $message) - { - return $this->loadPartial($view, 'message', [ - 'message_type' => $type, - 'message' => $message - ]); - } - - /** - * Output a template to HTML, using the provided data - * - * @param string $template - * @param array $data - * @param HtmlView|null $view - * @param int $code - * @return void - */ - protected function outputHTML($template, array $data = [], $view = NULL, $code = 200) - { - if (is_null($view)) - { - $view = new HtmlView($this->container); - } - - $view->setStatusCode($code); - $this->renderFullPage($view, $template, $data); - } - - /** - * Output a JSON Response - * - * @param mixed $data - * @param int $code - the http status code - * @return void - */ - protected function outputJSON($data = 'Empty response', int $code = 200) - { - (new JsonView($this->container)) - ->setStatusCode($code) - ->setOutput($data) - ->send(); - } - - /** - * Redirect to the selected page - * - * @param string $url - * @param int $code - * @return void - */ - protected function redirect($url, $code) - { - $http = new HttpView($this->container); - $http->redirect($url, $code); - } -} \ No newline at end of file diff --git a/src/Dispatcher.php b/src/Dispatcher.php index 3f3dffd2..2e092c7d 100644 --- a/src/Dispatcher.php +++ b/src/Dispatcher.php @@ -24,7 +24,7 @@ use const Aviat\AnimeClient\{ SRC_DIR }; -use function Aviat\AnimeClient\_dir; +use function Aviat\Ion\_dir; use Aviat\Ion\Di\ContainerInterface; use Aviat\Ion\Friend; diff --git a/src/MenuGenerator.php b/src/MenuGenerator.php index ec5d9639..d6615d86 100644 --- a/src/MenuGenerator.php +++ b/src/MenuGenerator.php @@ -16,10 +16,7 @@ namespace Aviat\AnimeClient; -use Aviat\Ion\ -{ - ArrayWrapper, StringWrapper -}; +use Aviat\Ion\{ArrayWrapper, StringWrapper}; use Aviat\Ion\Di\ContainerInterface; /** diff --git a/src/Model/API.php b/src/Model/API.php index a7508255..e13e0ee4 100644 --- a/src/Model/API.php +++ b/src/Model/API.php @@ -21,6 +21,13 @@ namespace Aviat\AnimeClient\Model; */ class API extends AbstractModel { + /** + * Whether to use the MAL api + * + * @var boolean + */ + protected $useMALAPI; + /** * Sort the list entries by their title * diff --git a/src/Model/Anime.php b/src/Model/Anime.php index 060f92c7..eccc2b3f 100644 --- a/src/Model/Anime.php +++ b/src/Model/Anime.php @@ -39,13 +39,6 @@ class Anime extends API { */ protected $malModel; - /** - * Whether to use the MAL api - * - * @var boolean - */ - protected $useMALAPI; - /** * Anime constructor. * @@ -53,10 +46,10 @@ class Anime extends API { */ public function __construct(ContainerInterface $container) { - $config = $container->get('config'); $this->kitsuModel = $container->get('kitsu-model'); $this->malModel = $container->get('mal-model'); + $config = $container->get('config'); $this->useMALAPI = $config->get(['use_mal_api']) === TRUE; } @@ -66,7 +59,7 @@ class Anime extends API { * @param string $status * @return array */ - public function getList($status) + public function getList($status): array { $data = $this->kitsuModel->getAnimeList($status); $this->sortByName($data, 'anime'); @@ -79,7 +72,12 @@ class Anime extends API { return $output; } - public function getAllLists() + /** + * Get data for the 'all' anime page + * + * @return array + */ + public function getAllLists(): array { $data = $this->kitsuModel->getFullOrganizedAnimeList(); @@ -97,7 +95,7 @@ class Anime extends API { * @param string $slug * @return array */ - public function getAnime($slug) + public function getAnime(string $slug): array { return $this->kitsuModel->getAnime($slug); } @@ -108,7 +106,7 @@ class Anime extends API { * @param string $animeId * @return array */ - public function getAnimeById($animeId) + public function getAnimeById(string $animeId): array { return $this->kitsuModel->getAnimeById($animeId); } @@ -119,7 +117,7 @@ class Anime extends API { * @param string $name * @return array */ - public function search($name) + public function search(string $name): array { return $this->kitsuModel->search('anime', $name); } diff --git a/src/Model/Manga.php b/src/Model/Manga.php index 627e6542..e909089a 100644 --- a/src/Model/Manga.php +++ b/src/Model/Manga.php @@ -16,9 +16,13 @@ namespace Aviat\AnimeClient\Model; -use Aviat\AnimeClient\API\Enum\MangaReadingStatus\Title; -use Aviat\AnimeClient\API\Mapping\MangaReadingStatus; +use Aviat\AnimeClient\API\{ + Enum\MangaReadingStatus\Title, + Mapping\MangaReadingStatus, + ParallelAPIRequest +}; use Aviat\Ion\Di\ContainerInterface; +use Aviat\Ion\Json; /** * Model for handling requests dealing with the manga list @@ -46,6 +50,9 @@ class Manga extends API { $this->kitsuModel = $container->get('kitsu-model'); $this->malModel = $container->get('mal-model'); + + $config = $container->get('config'); + $this->useMALAPI = $config->get(['use_mal_api']) === TRUE; } /** @@ -60,7 +67,7 @@ class Manga extends API { return $this->kitsuModel->getFullOrganizedMangaList(); } - + $APIstatus = MangaReadingStatus::TITLE_TO_KITSU[$status]; $data = $this->kitsuModel->getMangaList($APIstatus); return $this->mapByStatus($data)[$status]; @@ -77,17 +84,6 @@ class Manga extends API return $this->kitsuModel->getManga($manga_id); } - /** - * Create a new manga list item - * - * @param array $data - * @return bool - */ - public function createLibraryItem(array $data): bool - { - return $this->kitsuModel->createListItem($data); - } - /** * Get information about a specific list item * for editing/updating that item @@ -100,6 +96,35 @@ class Manga extends API return $this->kitsuModel->getListItem($itemId); } + /** + * Create a new manga list item + * + * @param array $data + * @return bool + */ + public function createLibraryItem(array $data): bool + { + $requester = new ParallelAPIRequest(); + + if ($this->useMALAPI) + { + $malData = $data; + $malId = $this->kitsuModel->getMalIdForManga($malData['id']); + + if ( ! is_null($malId)) + { + $malData['id'] = $malId; + $requester->addRequest($this->malModel->createListItem($malData, 'manga'), 'mal'); + } + } + + $requester->addRequest($this->kitsuModel->createListItem($data), 'kitsu'); + + $results = $requester->makeRequests(TRUE); + + return count($results[1]) > 0; + } + /** * Update a list entry * @@ -108,18 +133,44 @@ class Manga extends API */ public function updateLibraryItem(array $data): array { - return $this->kitsuModel->updateListItem($data); + $requester = new ParallelAPIRequest(); + + if ($this->useMALAPI) + { + $requester->addRequest($this->malModel->updateListItem($data, 'manga'), 'mal'); + } + + $requester->addRequest($this->kitsuModel->updateListItem($data), 'kitsu'); + + $results = $requester->makeRequests(TRUE); + + return [ + 'body' => Json::decode($results[1]['kitsu']->getBody()), + 'statusCode' => $results[1]['kitsu']->getStatus() + ]; } /** - * Remove a list entry + * Delete a list entry * - * @param string $itemId + * @param string $id + * @param string|null $malId * @return bool */ - public function deleteLibraryItem(string $itemId): bool + public function deleteLibraryItem(string $id, string $malId = NULL): bool { - return $this->kitsuModel->deleteListItem($itemId); + $requester = new ParallelAPIRequest(); + + if ($this->useMALAPI && ! is_null($malId)) + { + $requester->addRequest($this->malModel->deleteListItem($malId, 'manga'), 'MAL'); + } + + $requester->addRequest($this->kitsuModel->deleteListItem($id), 'kitsu'); + + $results = $requester->makeRequests(TRUE); + + return count($results[1]) > 0; } /** diff --git a/src/RoutingBase.php b/src/RoutingBase.php index 9103a07d..eaf247ed 100644 --- a/src/RoutingBase.php +++ b/src/RoutingBase.php @@ -59,9 +59,8 @@ class RoutingBase { { $this->container = $container; $this->config = $container->get('config'); - $baseRoutes = $this->config->get('routes'); - $this->routes = $baseRoutes['routes']; - $this->routeConfig = $baseRoutes['route_config']; + $this->routes = $this->config->get('routes'); + $this->routeConfig = $this->config->get('route_config'); } /** @@ -72,11 +71,9 @@ class RoutingBase { */ public function __get($key) { - $routingConfig =& $this->routeConfig; - - if (array_key_exists($key, $routingConfig)) + if (array_key_exists($key, $this->routeConfig)) { - return $routingConfig[$key]; + return $this->routeConfig[$key]; } } diff --git a/src/UrlGenerator.php b/src/UrlGenerator.php index 741b5e2b..b26399b7 100644 --- a/src/UrlGenerator.php +++ b/src/UrlGenerator.php @@ -108,32 +108,5 @@ class UrlGenerator extends RoutingBase { throw new InvalidArgumentException("Invalid default type: '{$type}'"); } - - /** - * Generate full url path from the route path based on config - * - * @param string $path - (optional) The route path - * @param string $type - (optional) The controller (anime or manga), defaults to anime - * @return string - */ - public function fullUrl(string $path = "", string $type = "anime"): string - { - $configDefaultRoute = $this->__get("default_{$type}_path"); - - // Remove beginning/trailing slashes - $path = trim($path, '/'); - - // Set the default view - if ($path === '') - { - $path .= trim($configDefaultRoute, '/'); - if ($this->__get('default_to_list_view')) - { - $path .= '/list'; - } - } - - return $this->url($path); - } } // End of UrlGenerator.php \ No newline at end of file diff --git a/tests/API/Kitsu/Transformer/MangaListTransformerTest.php b/tests/API/Kitsu/Transformer/MangaListTransformerTest.php index b6caa5f6..9cbfc539 100644 --- a/tests/API/Kitsu/Transformer/MangaListTransformerTest.php +++ b/tests/API/Kitsu/Transformer/MangaListTransformerTest.php @@ -22,39 +22,51 @@ use Aviat\AnimeClient\Tests\AnimeClientTestCase; use Aviat\Ion\Json; class MangaListTransformerTest extends AnimeClientTestCase { - + protected $dir; protected $rawBefore; protected $beforeTransform; protected $afterTransform; protected $transformer; - public function setUp() + public function setUp() { parent::setUp(); + + $kitsuModel = $this->container->get('kitsu-model'); + $this->dir = AnimeClientTestCase::TEST_DATA_DIR . '/Kitsu'; - + + // Prep for transform $rawBefore = Json::decodeFile("{$this->dir}/mangaListBeforeTransform.json"); - $this->beforeTransform = JsonAPI::inlineRawIncludes($rawBefore, 'manga'); + $included = JsonAPI::organizeIncludes($rawBefore['included']); + $included = JsonAPI::inlineIncludedRelationships($included, 'manga'); + foreach($rawBefore['data'] as $i => &$item) + { + $item['included'] = $included; + } + + $this->beforeTransform = $rawBefore['data']; $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", + 'id' => '15084773', + 'mal_id' => '26769', 'chapters_read' => 67, 'manga' => [ 'titles' => ["Bokura wa Minna Kawaisou"], @@ -71,10 +83,11 @@ class MangaListTransformerTest extends AnimeClientTestCase { 'reread_count' => 0, 'new_rating' => 9, ]; - + $actual = $this->transformer->untransform($input); $expected = [ 'id' => '15084773', + 'mal_id' => '26769', 'data' => [ 'status' => 'current', 'progress' => 67, @@ -84,7 +97,7 @@ class MangaListTransformerTest extends AnimeClientTestCase { 'rating' => 4.5 ] ]; - + $this->assertEquals($expected, $actual); } diff --git a/tests/API/MAL/ListItemTest.php b/tests/API/MAL/ListItemTest.php new file mode 100644 index 00000000..510010fb --- /dev/null +++ b/tests/API/MAL/ListItemTest.php @@ -0,0 +1,40 @@ + + * @copyright 2015 - 2017 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\Tests\API\MAL; + +use Aviat\AnimeClient\API\MAL\ListItem; +use Aviat\AnimeClient\API\MAL\MALRequestBuilder; +use Aviat\AnimeClient\Tests\AnimeClientTestCase; +use Aviat\Ion\Di\ContainerAware; + +class ListItemTest extends AnimeClientTestCase { + + protected $listItem; + + public function setUp() + { + parent::setUp(); + $this->listItem = new ListItem(); + $this->listItem->setContainer($this->container); + $this->listItem->setRequestBuilder(new MALRequestBuilder()); + } + + public function testGet() + { + $this->assertEquals([], $this->listItem->get('foo')); + } +} \ No newline at end of file diff --git a/tests/API/MAL/MALTraitTest.php b/tests/API/MAL/MALTraitTest.php new file mode 100644 index 00000000..10ec1151 --- /dev/null +++ b/tests/API/MAL/MALTraitTest.php @@ -0,0 +1,51 @@ + + * @copyright 2015 - 2017 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\Tests\API\MAL; + +use Aviat\AnimeClient\API\MAL\MALRequestBuilder; +use Aviat\AnimeClient\API\MAL\MALTrait; +use Aviat\AnimeClient\Tests\AnimeClientTestCase; +use Aviat\Ion\Di\ContainerAware; + +class MALTraitTest extends AnimeClientTestCase { + + protected $obj; + + public function setUp() + { + parent::setUp(); + $this->obj = new class { + use ContainerAware; + use MALTrait; + }; + $this->obj->setContainer($this->container); + $this->obj->setRequestBuilder(new MALRequestBuilder()); + } + + public function testSetupRequest() + { + $request = $this->obj->setUpRequest('GET', 'foo', [ + 'query' => [ + 'foo' => 'bar' + ], + 'body' => '' + ]); + $this->assertInstanceOf(\Amp\Artax\Request::class, $request); + $this->assertEquals($request->getUri(), 'https://myanimelist.net/api/foo?foo=bar'); + $this->assertEquals($request->getBody(), ''); + } +} \ No newline at end of file diff --git a/tests/ControllerTraitTest.php b/tests/API/MAL/ModelTest.php similarity index 61% rename from tests/ControllerTraitTest.php rename to tests/API/MAL/ModelTest.php index 256acafd..b626bb07 100644 --- a/tests/ControllerTraitTest.php +++ b/tests/API/MAL/ModelTest.php @@ -14,26 +14,22 @@ * @link https://git.timshomepage.net/timw4mail/HummingBirdAnimeClient */ -namespace Aviat\AnimeClient\Tests; +namespace Aviat\AnimeClient\Tests\API\MAL; -use Aviat\AnimeClient\ControllerTrait; +use Aviat\AnimeClient\Tests\AnimeClientTestCase; -class ControllerTraitTest extends AnimeClientTestCase { +class ModelTest extends AnimeClientTestCase { + + protected $model; public function setUp() { parent::setUp(); - - $this->controller = new class { - use ControllerTrait; - }; + $this->model = $this->container->get('mal-model'); } - public function testFormatTitle() + public function testGetListItem() { - $this->assertEquals( - $this->controller->formatTitle('foo', 'bar', 'baz'), - 'foo · bar · baz' - ); + $this->assertEquals([], $this->model->getListItem('foo')); } } \ No newline at end of file diff --git a/tests/AnimeClientTest.php b/tests/AnimeClientTest.php deleted file mode 100644 index 6725c830..00000000 --- a/tests/AnimeClientTest.php +++ /dev/null @@ -1,29 +0,0 @@ - - * @copyright 2015 - 2017 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\Tests; - -use function Aviat\AnimeClient\_dir; - -class AnimeClientTest extends AnimeClientTestCase { - /** - * Basic sanity test for _dir function - */ - public function testDir() - { - $this->assertEquals('foo' . \DIRECTORY_SEPARATOR . 'bar', _dir('foo', 'bar')); - } -} diff --git a/tests/AnimeClientTestCase.php b/tests/AnimeClientTestCase.php index d4b2d429..f531273f 100644 --- a/tests/AnimeClientTestCase.php +++ b/tests/AnimeClientTestCase.php @@ -18,7 +18,7 @@ namespace Aviat\AnimeClient\Tests; use const Aviat\AnimeClient\SRC_DIR; -use function Aviat\AnimeClient\_dir; +use function Aviat\Ion\_dir; use Aura\Web\WebFactory; use Aviat\Ion\Json; @@ -95,13 +95,15 @@ class AnimeClientTestCase extends TestCase { 'file' => ':memory:', ] ], + 'route_config' => [ + 'asset_path' => '/assets' + ], 'routes' => [ - 'route_config' => [ - 'asset_path' => '/assets' - ], - 'routes' => [ - ] + ], + 'mal' => [ + 'username' => 'foo', + 'password' => 'bar' ] ]; diff --git a/tests/ControllerTest.php b/tests/ControllerTest.php index 57ba8dee..2215b1e2 100644 --- a/tests/ControllerTest.php +++ b/tests/ControllerTest.php @@ -80,31 +80,12 @@ class ControllerTest extends AnimeClientTestCase { $this->assertTrue(is_object($this->BaseController)); } - public function dataGet() + public function testFormatTitle() { - return [ - 'response' => [ - 'key' => 'response', - ], - 'config' => [ - 'key' => 'config', - ] - ]; - } - - /** - * @dataProvider dataGet - */ - public function testGet($key) - { - $result = $this->BaseController->__get($key); - $this->assertEquals($this->container->get($key), $result); - } - - public function testGetNull() - { - $result = $this->BaseController->__get('foo'); - $this->assertNull($result); + $this->assertEquals( + $this->BaseController->formatTitle('foo', 'bar', 'baz'), + 'foo · bar · baz' + ); } } \ No newline at end of file diff --git a/tests/DispatcherTest.php b/tests/DispatcherTest.php index bf3dff2b..ce1e3f1a 100644 --- a/tests/DispatcherTest.php +++ b/tests/DispatcherTest.php @@ -72,39 +72,37 @@ class DispatcherTest extends AnimeClientTestCase { { $defaultConfig = [ 'routes' => [ - 'routes' => [ - 'login_form' => [ - 'path' => '/login', - 'action' => 'login', - 'verb' => 'get' - ], - 'watching' => [ - 'path' => '/anime/watching{/view}', - 'action' => 'anime_list', - 'params' => [ - 'type' => 'currently-watching', - ], - 'tokens' => [ - 'view' => '[a-z_]+' - ] - ], - 'plan_to_read' => [ - 'path' => '/manga/plan_to_read{/view}', - 'action' => 'manga_list', - 'params' => [ - 'type' => 'Plan to Read', - ], - 'tokens' => [ - 'view' => '[a-z_]+' - ] - ], + 'login_form' => [ + 'path' => '/login', + 'action' => 'login', + 'verb' => 'get' + ], + 'watching' => [ + 'path' => '/anime/watching{/view}', + 'action' => 'anime_list', + 'params' => [ + 'type' => 'currently-watching', + ], + 'tokens' => [ + 'view' => '[a-z_]+' + ] + ], + 'plan_to_read' => [ + 'path' => '/manga/plan_to_read{/view}', + 'action' => 'manga_list', + 'params' => [ + 'type' => 'Plan to Read', + ], + 'tokens' => [ + 'view' => '[a-z_]+' + ] ], - 'route_config' => [ - 'anime_path' => 'anime', - 'manga_path' => 'manga', - 'default_list' => 'anime' - ] ], + 'route_config' => [ + 'anime_path' => 'anime', + 'manga_path' => 'manga', + 'default_list' => 'anime' + ] ]; $data = [ @@ -134,8 +132,8 @@ class DispatcherTest extends AnimeClientTestCase { ] ]; - $data['manga_default_routing_anime']['config']['routes']['route_config']['default_list'] = 'manga'; - $data['manga_default_routing_manga']['config']['routes']['route_config']['default_list'] = 'manga'; + $data['manga_default_routing_anime']['config']['route_config']['default_list'] = 'manga'; + $data['manga_default_routing_manga']['config']['route_config']['default_list'] = 'manga'; return $data; } @@ -169,36 +167,34 @@ class DispatcherTest extends AnimeClientTestCase { public function testDefaultRoute() { $config = [ + 'route_config' => [ + 'anime_path' => 'anime', + 'manga_path' => 'manga', + 'default_anime_list_path' => "watching", + 'default_manga_list_path' => 'all', + 'default_list' => 'manga' + ], 'routes' => [ - 'route_config' => [ - 'anime_path' => 'anime', - 'manga_path' => 'manga', - 'default_anime_list_path' => "watching", - 'default_manga_list_path' => 'all', - 'default_list' => 'manga' + 'login_form' => [ + 'path' => '/login', + 'action' => ['login'], + 'verb' => 'get' ], - 'routes' => [ - 'login_form' => [ - 'path' => '/login', - 'action' => ['login'], - 'verb' => 'get' - ], - 'index' => [ - 'path' => '/', - 'action' => ['redirect'], - 'params' => [ - 'url' => '', // Determined by config - 'code' => '301' - ] - ], - 'index' => [ - 'path' => '/', - 'action' => ['redirect'], - 'params' => [ - 'url' => '', // Determined by config - 'code' => '301', - 'type' => 'manga' - ] + 'index' => [ + 'path' => '/', + 'action' => ['redirect'], + 'params' => [ + 'url' => '', // Determined by config + 'code' => '301' + ] + ], + 'index' => [ + 'path' => '/', + 'action' => ['redirect'], + 'params' => [ + 'url' => '', // Determined by config + 'code' => '301', + 'type' => 'manga' ] ] ] @@ -218,45 +214,43 @@ class DispatcherTest extends AnimeClientTestCase { 'controller_list_sanity_check' => [ 'config' => [ 'routes' => [ - 'routes' => [ - ], - 'route_config' => [ - 'anime_path' => 'anime', - 'manga_path' => 'manga', - 'default_anime_list_path' => "watching", - 'default_manga_list_path' => 'all', - 'default_list' => 'manga' - ], - ] + ], + 'route_config' => [ + 'anime_path' => 'anime', + 'manga_path' => 'manga', + 'default_anime_list_path' => "watching", + 'default_manga_list_path' => 'all', + 'default_list' => 'manga' + ], ], 'expected' => [ 'anime' => 'Aviat\AnimeClient\Controller\Anime', 'manga' => 'Aviat\AnimeClient\Controller\Manga', 'collection' => 'Aviat\AnimeClient\Controller\Collection', 'character' => 'Aviat\AnimeClient\Controller\Character', + 'index' => 'Aviat\AnimeClient\Controller\Index', ] ], 'empty_controller_list' => [ 'config' => [ 'routes' => [ - 'routes' => [ - ], - 'route_config' => [ - 'anime_path' => 'anime', - 'manga_path' => 'manga', - 'default_anime_path' => "/anime/watching", - 'default_manga_path' => '/manga/all', - 'default_list' => 'manga' - ], - ] + ], + 'route_config' => [ + 'anime_path' => 'anime', + 'manga_path' => 'manga', + 'default_anime_path' => "/anime/watching", + 'default_manga_path' => '/manga/all', + 'default_list' => 'manga' + ], ], 'expected' => [ 'anime' => 'Aviat\AnimeClient\Controller\Anime', 'manga' => 'Aviat\AnimeClient\Controller\Manga', 'collection' => 'Aviat\AnimeClient\Controller\Collection', 'character' => 'Aviat\AnimeClient\Controller\Character', + 'index' => 'Aviat\AnimeClient\Controller\Index', ] ] ]; diff --git a/tests/UrlGeneratorTest.php b/tests/UrlGeneratorTest.php index e7de3488..48d91cfd 100644 --- a/tests/UrlGeneratorTest.php +++ b/tests/UrlGeneratorTest.php @@ -49,60 +49,4 @@ class UrlGeneratorTest extends AnimeClientTestCase { $result = $urlGenerator->assetUrl(...$args); $this->assertEquals($expected, $result); } - - public function dataFullUrl() - { - return [ - 'default_view' => [ - 'config' => [ - 'routes' => [ - 'routes' => [], - 'route_config' => [ - 'anime_path' => 'anime', - 'manga_path' => 'manga', - 'default_list' => 'manga', - 'default_anime_path' => '/anime/watching', - 'default_manga_path' => '/manga/all', - 'default_to_list_view' => FALSE, - ] - ], - ], - 'path' => '', - 'type' => 'manga', - 'expected' => '//localhost/manga/all', - ], - 'default_view_list' => [ - 'config' => [ - 'routes' => [ - 'routes' => [], - 'route_config' => [ - 'anime_path' => 'anime', - 'manga_path' => 'manga', - 'default_list' => 'manga', - 'default_anime_path' => '/anime/watching', - 'default_manga_path' => '/manga/all', - 'default_to_list_view' => TRUE, - ] - ], - ], - 'path' => '', - 'type' => 'manga', - 'expected' => '//localhost/manga/all/list', - ] - ]; - } - - /** - * @dataProvider dataFullUrl - */ - public function testFullUrl($config, $path, $type, $expected) - { - $config = new Config($config); - $this->container->setInstance('config', $config); - $urlGenerator = new UrlGenerator($this->container); - - $result = $urlGenerator->fullUrl($path, $type); - - $this->assertEquals($expected, $result); - } } \ No newline at end of file diff --git a/tests/test_data/Kitsu/mangaListAfterTransform.json b/tests/test_data/Kitsu/mangaListAfterTransform.json index 9b4f65f1..50ab7a9c 100644 --- a/tests/test_data/Kitsu/mangaListAfterTransform.json +++ b/tests/test_data/Kitsu/mangaListAfterTransform.json @@ -1,5 +1,6 @@ [{ "id": "15084773", + "mal_id": "26769", "chapters": { "read": 67, "total": "-" @@ -15,7 +16,7 @@ "url": "https:\/\/kitsu.io\/manga\/bokura-wa-minna-kawaisou", "type": "manga", "image": "https:\/\/media.kitsu.io\/manga\/poster_images\/20286\/small.jpg?1434293999", - "genres": [] + "genres": ["Comedy", "Romance", "School", "Slice of Life", "Thriller"] }, "reading_status": "current", "notes": "", @@ -24,6 +25,7 @@ "user_rating": 9 }, { "id": "15085607", + "mal_id": "16", "chapters": { "read": 17, "total": 120 @@ -39,7 +41,7 @@ "url": "https:\/\/kitsu.io\/manga\/love-hina", "type": "manga", "image": "https:\/\/media.kitsu.io\/manga\/poster_images\/47\/small.jpg?1434249493", - "genres": [] + "genres": ["Comedy", "Ecchi", "Harem", "Romance", "Sports"] }, "reading_status": "current", "notes": "", @@ -48,6 +50,7 @@ "user_rating": 7 }, { "id": "15084529", + "mal_id": "35003", "chapters": { "read": 16, "total": "-" @@ -63,7 +66,7 @@ "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": [] + "genres": ["Comedy", "Ecchi", "Gender Bender", "Romance", "School", "Sports", "Supernatural"] }, "reading_status": "current", "notes": "", @@ -72,6 +75,7 @@ "user_rating": 9 }, { "id": "15312827", + "mal_id": "78523", "chapters": { "read": 68, "total": "-" @@ -87,7 +91,7 @@ "url": "https:\/\/kitsu.io\/manga\/relife", "type": "manga", "image": "https:\/\/media.kitsu.io\/manga\/poster_images\/27175\/small.jpg?1464379411", - "genres": [] + "genres": ["Romance", "School", "Slice of Life"] }, "reading_status": "current", "notes": "", @@ -95,33 +99,10 @@ "reread": 0, "user_rating": "-" }, { - "id": "15084772", + "id": "15084769", + "mal_id": "60815", "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, + "read": 43, "total": "-" }, "volumes": { @@ -129,111 +110,15 @@ "total": "-" }, "manga": { - "titles": ["Shishunki Bitter Change"], + "titles": ["Joshikausei"], "alternate_title": null, - "slug": "shishunki-bitter-change", - "url": "https:\/\/kitsu.io\/manga\/shishunki-bitter-change", + "slug": "joshikausei", + "url": "https:\/\/kitsu.io\/manga\/joshikausei", "type": "manga", - "image": "https:\/\/media.kitsu.io\/manga\/poster_images\/25512\/small.jpg?1434305092", - "genres": [] + "image": "https:\/\/media.kitsu.io\/manga\/poster_images\/25491\/small.jpg?1434305043", + "genres": ["Comedy", "School", "Slice of Life"] }, - "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", + "reading_status": "current", "notes": "", "rereading": false, "reread": 0, diff --git a/tests/test_data/Kitsu/mangaListBeforeTransform.json b/tests/test_data/Kitsu/mangaListBeforeTransform.json index cc7fc657..9380f523 100644 --- a/tests/test_data/Kitsu/mangaListBeforeTransform.json +++ b/tests/test_data/Kitsu/mangaListBeforeTransform.json @@ -1,1648 +1,1245 @@ { - "data": [ - { - "id": "15084773", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084773" - }, - "attributes": { - "status": "current", - "progress": 67, - "reconsuming": false, - "reconsumeCount": 0, - "notes": "", - "private": false, - "rating": "4.5", - "updatedAt": "2017-01-09T17:51:16.691Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15084773/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15084773/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15084773/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15084773/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15084773/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15084773/media" - }, - "data": { - "type": "manga", - "id": "20286" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15084773/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15084773/next-unit" - } - } - } + "data": [ + { + "id": "15084773", + "type": "libraryEntries", + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084773" + }, + "attributes": { + "status": "current", + "progress": 67, + "reconsuming": false, + "reconsumeCount": 0, + "notes": "", + "private": false, + "rating": "4.5", + "updatedAt": "2017-01-09T17:51:16.691Z" + }, + "relationships": { + "user": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/user", + "related": "https://kitsu.io/api/edge/library-entries/15084773/user" + } }, - { - "id": "15085607", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15085607" - }, - "attributes": { - "status": "current", - "progress": 17, - "reconsuming": false, - "reconsumeCount": 0, - "notes": "", - "private": false, - "rating": "3.5", - "updatedAt": "2017-01-09T17:50:19.594Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15085607/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15085607/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15085607/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15085607/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15085607/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15085607/media" - }, - "data": { - "type": "manga", - "id": "47" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15085607/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15085607/next-unit" - } - } - } + "anime": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/anime", + "related": "https://kitsu.io/api/edge/library-entries/15084773/anime" + } }, - { - "id": "15084529", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084529" - }, - "attributes": { - "status": "current", - "progress": 16, - "reconsuming": false, - "reconsumeCount": 0, - "notes": "", - "private": false, - "rating": "4.5", - "updatedAt": "2016-04-07T17:10:13.022Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15084529/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15084529/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15084529/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15084529/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15084529/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15084529/media" - }, - "data": { - "type": "manga", - "id": "11777" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15084529/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15084529/next-unit" - } - } - } + "manga": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/manga", + "related": "https://kitsu.io/api/edge/library-entries/15084773/manga" + } }, - { - "id": "15312827", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312827" - }, - "attributes": { - "status": "current", - "progress": 68, - "reconsuming": false, - "reconsumeCount": 0, - "notes": "", - "private": false, - "rating": null, - "updatedAt": "2016-03-08T15:45:45.818Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15312827/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15312827/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15312827/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15312827/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15312827/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15312827/media" - }, - "data": { - "type": "manga", - "id": "27175" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15312827/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15312827/next-unit" - } - } - } + "drama": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/drama", + "related": "https://kitsu.io/api/edge/library-entries/15084773/drama" + } }, - { - "id": "15084772", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084772" - }, - "attributes": { - "status": "on_hold", - "progress": 28, - "reconsuming": false, - "reconsumeCount": 0, - "notes": "", - "private": false, - "rating": "4.0", - "updatedAt": "2016-03-08T14:29:42.005Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084772/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15084772/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084772/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15084772/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084772/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15084772/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084772/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15084772/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084772/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15084772/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084772/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15084772/media" - }, - "data": { - "type": "manga", - "id": "7629" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084772/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15084772/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15084772/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15084772/next-unit" - } - } - } + "review": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/review", + "related": "https://kitsu.io/api/edge/library-entries/15084773/review" + } }, - { - "id": "15251749", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15251749" - }, - "attributes": { - "status": "planned", - "progress": 1, - "reconsuming": false, - "reconsumeCount": 0, - "notes": "", - "private": false, - "rating": null, - "updatedAt": "2016-03-08T14:29:30.561Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15251749/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15251749/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15251749/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15251749/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15251749/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15251749/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15251749/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15251749/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15251749/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15251749/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15251749/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15251749/media" - }, - "data": { - "type": "manga", - "id": "25512" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15251749/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15251749/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15251749/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15251749/next-unit" - } - } - } + "media": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/media", + "related": "https://kitsu.io/api/edge/library-entries/15084773/media" + }, + "data": { + "type": "manga", + "id": "20286" + } }, - { - "id": "15312881", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312881" - }, - "attributes": { - "status": "planned", - "progress": 0, - "reconsuming": false, - "reconsumeCount": 0, - "notes": "", - "private": false, - "rating": null, - "updatedAt": "2016-02-10T02:03:01.147Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312881/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15312881/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312881/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15312881/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312881/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15312881/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312881/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15312881/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312881/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15312881/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312881/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15312881/media" - }, - "data": { - "type": "manga", - "id": "5531" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312881/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15312881/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15312881/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15312881/next-unit" - } - } - } + "unit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/unit", + "related": "https://kitsu.io/api/edge/library-entries/15084773/unit" + } }, - { - "id": "15315190", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315190" - }, - "attributes": { - "status": "planned", - "progress": 0, - "reconsuming": false, - "reconsumeCount": 0, - "notes": null, - "private": false, - "rating": null, - "updatedAt": "2016-02-08T20:39:12.475Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315190/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15315190/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315190/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15315190/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315190/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15315190/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315190/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15315190/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315190/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15315190/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315190/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15315190/media" - }, - "data": { - "type": "manga", - "id": "14261" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315190/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15315190/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315190/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15315190/next-unit" - } - } - } - }, - { - "id": "15315189", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315189" - }, - "attributes": { - "status": "planned", - "progress": 0, - "reconsuming": false, - "reconsumeCount": 0, - "notes": null, - "private": false, - "rating": null, - "updatedAt": "2016-02-08T20:37:23.233Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315189/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15315189/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315189/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15315189/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315189/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15315189/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315189/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15315189/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315189/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15315189/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315189/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15315189/media" - }, - "data": { - "type": "manga", - "id": "25316" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315189/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15315189/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15315189/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15315189/next-unit" - } - } - } - }, - { - "id": "15288185", - "type": "libraryEntries", - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15288185" - }, - "attributes": { - "status": "on_hold", - "progress": 28, - "reconsuming": false, - "reconsumeCount": 0, - "notes": "", - "private": false, - "rating": "4.0", - "updatedAt": "2016-02-02T15:06:29.222Z" - }, - "relationships": { - "user": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15288185/relationships/user", - "related": "https://kitsu.io/api/edge/library-entries/15288185/user" - } - }, - "anime": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15288185/relationships/anime", - "related": "https://kitsu.io/api/edge/library-entries/15288185/anime" - } - }, - "manga": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15288185/relationships/manga", - "related": "https://kitsu.io/api/edge/library-entries/15288185/manga" - } - }, - "drama": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15288185/relationships/drama", - "related": "https://kitsu.io/api/edge/library-entries/15288185/drama" - } - }, - "review": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15288185/relationships/review", - "related": "https://kitsu.io/api/edge/library-entries/15288185/review" - } - }, - "media": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15288185/relationships/media", - "related": "https://kitsu.io/api/edge/library-entries/15288185/media" - }, - "data": { - "type": "manga", - "id": "21733" - } - }, - "unit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15288185/relationships/unit", - "related": "https://kitsu.io/api/edge/library-entries/15288185/unit" - } - }, - "nextUnit": { - "links": { - "self": "https://kitsu.io/api/edge/library-entries/15288185/relationships/next-unit", - "related": "https://kitsu.io/api/edge/library-entries/15288185/next-unit" - } - } - } + "nextUnit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084773/relationships/next-unit", + "related": "https://kitsu.io/api/edge/library-entries/15084773/next-unit" + } } - ], - "included": [ - { - "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.1214661870211, - "ratingFrequencies": { - "0.5": "0", - "1.0": "1", - "1.5": "0", - "2.0": "1", - "2.5": "2", - "3.0": "6", - "3.5": "21", - "4.0": "37", - "4.5": "34", - "5.0": "43", - "nil": "16" - }, - "favoritesCount": 0, - "startDate": "2010-01-01", - "endDate": null, - "popularityRank": 262, - "ratingRank": 129, - "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" - } - }, - "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" - } - }, - "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" - } - } - } - }, - { - "id": "47", - "type": "manga", - "links": { - "self": "https://kitsu.io/api/edge/manga/47" - }, - "attributes": { - "slug": "love-hina", - "synopsis": "Keitaro has had great difficulty getting into the university of his choice and no luck in meeting women. In a desperate effort to go into seclusion and study for his entrance exams, he volunteers to take over running his grandmother's hotel. His plans are ruined when he discovers that the \"hotel\" is actually an all-girls dormitory ... and some serious distractions ensue.\r\n(Source: Tokyopop)", - "coverImageTopOffset": 75, - "titles": { - "en": null, - "en_jp": "Love Hina" - }, - "canonicalTitle": "Love Hina", - "abbreviatedTitles": null, - "averageRating": 3.92065928118344, - "ratingFrequencies": { - "0.5": "1", - "1.0": "5", - "1.5": "4", - "2.0": "13", - "2.5": "23", - "3.0": "35", - "3.5": "74", - "4.0": "89", - "4.5": "68", - "5.0": "102", - "nil": "19" - }, - "favoritesCount": 0, - "startDate": "1998-10-21", - "endDate": "2001-10-31", - "popularityRank": 139, - "ratingRank": 386, - "ageRating": "R", - "ageRatingGuide": "Ecchi", - "posterImage": { - "tiny": "https://media.kitsu.io/manga/poster_images/47/tiny.jpg?1434249493", - "small": "https://media.kitsu.io/manga/poster_images/47/small.jpg?1434249493", - "medium": "https://media.kitsu.io/manga/poster_images/47/medium.jpg?1434249493", - "large": "https://media.kitsu.io/manga/poster_images/47/large.jpg?1434249493", - "original": "https://media.kitsu.io/manga/poster_images/47/original.jpg?1434249493" - }, - "coverImage": { - "small": "https://media.kitsu.io/manga/cover_images/47/small.jpg?1446826380", - "large": "https://media.kitsu.io/manga/cover_images/47/large.jpg?1446826380", - "original": "https://media.kitsu.io/manga/cover_images/47/original.jpg?1446826380" - }, - "subtype": "manga", - "chapterCount": 120, - "volumeCount": 14, - "serialization": "Shounen Magazine (Weekly)", - "mangaType": "manga" - }, - "relationships": { - "genres": { - "links": { - "self": "https://kitsu.io/api/edge/manga/47/relationships/genres", - "related": "https://kitsu.io/api/edge/manga/47/genres" - } - }, - "castings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/47/relationships/castings", - "related": "https://kitsu.io/api/edge/manga/47/castings" - } - }, - "installments": { - "links": { - "self": "https://kitsu.io/api/edge/manga/47/relationships/installments", - "related": "https://kitsu.io/api/edge/manga/47/installments" - } - }, - "mappings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/47/relationships/mappings", - "related": "https://kitsu.io/api/edge/manga/47/mappings" - } - }, - "reviews": { - "links": { - "self": "https://kitsu.io/api/edge/manga/47/relationships/reviews", - "related": "https://kitsu.io/api/edge/manga/47/reviews" - } - }, - "mediaRelationships": { - "links": { - "self": "https://kitsu.io/api/edge/manga/47/relationships/media-relationships", - "related": "https://kitsu.io/api/edge/manga/47/media-relationships" - } - } - } - }, - { - "id": "11777", - "type": "manga", - "links": { - "self": "https://kitsu.io/api/edge/manga/11777" - }, - "attributes": { - "slug": "yamada-kun-to-7-nin-no-majo", - "synopsis": "Ryuu Yamada is a second-year student at Suzaku High. Ryuu is always late for school, naps in class and gets abysmal grades. His life is a dead bore. The beautiful Urara Shiraishi, on the other hand, is Suzaku High's brightest student. One day, without explanation, their bodies are swapped! Ryuu ends up in Urara's body, and Urara in Ryuu's.\r\n\r\n(Source: MU)", - "coverImageTopOffset": 75, - "titles": { - "en": "Yamada-kun and the Seven Witches", - "en_jp": "Yamada-kun to 7-nin no Majo" - }, - "canonicalTitle": "Yamada-kun to 7-nin no Majo", - "abbreviatedTitles": null, - "averageRating": 4.09245146923059, - "ratingFrequencies": { - "0.5": "3", - "1.0": "4", - "1.5": "2", - "2.0": "3", - "2.5": "19", - "3.0": "45", - "3.5": "85", - "4.0": "150", - "4.5": "137", - "5.0": "153", - "nil": "31" - }, - "favoritesCount": 0, - "startDate": "2012-02-22", - "endDate": null, - "popularityRank": 36, - "ratingRank": 152, - "ageRating": "R", - "ageRatingGuide": "Ecchi", - "posterImage": { - "tiny": "https://media.kitsu.io/manga/poster_images/11777/tiny.jpg?1438784325", - "small": "https://media.kitsu.io/manga/poster_images/11777/small.jpg?1438784325", - "medium": "https://media.kitsu.io/manga/poster_images/11777/medium.jpg?1438784325", - "large": "https://media.kitsu.io/manga/poster_images/11777/large.jpg?1438784325", - "original": "https://media.kitsu.io/manga/poster_images/11777/original.jpg?1438784325" - }, - "coverImage": { - "small": "https://media.kitsu.io/manga/cover_images/11777/small.jpg?1438784293", - "large": "https://media.kitsu.io/manga/cover_images/11777/large.jpg?1438784293", - "original": "https://media.kitsu.io/manga/cover_images/11777/original.jpg?1438784293" - }, - "subtype": "manga", - "chapterCount": null, - "volumeCount": 0, - "serialization": "Shounen Magazine (Weekly)", - "mangaType": "manga" - }, - "relationships": { - "genres": { - "links": { - "self": "https://kitsu.io/api/edge/manga/11777/relationships/genres", - "related": "https://kitsu.io/api/edge/manga/11777/genres" - } - }, - "castings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/11777/relationships/castings", - "related": "https://kitsu.io/api/edge/manga/11777/castings" - } - }, - "installments": { - "links": { - "self": "https://kitsu.io/api/edge/manga/11777/relationships/installments", - "related": "https://kitsu.io/api/edge/manga/11777/installments" - } - }, - "mappings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/11777/relationships/mappings", - "related": "https://kitsu.io/api/edge/manga/11777/mappings" - } - }, - "reviews": { - "links": { - "self": "https://kitsu.io/api/edge/manga/11777/relationships/reviews", - "related": "https://kitsu.io/api/edge/manga/11777/reviews" - } - }, - "mediaRelationships": { - "links": { - "self": "https://kitsu.io/api/edge/manga/11777/relationships/media-relationships", - "related": "https://kitsu.io/api/edge/manga/11777/media-relationships" - } - } - } - }, - { - "id": "27175", - "type": "manga", - "links": { - "self": "https://kitsu.io/api/edge/manga/27175" - }, - "attributes": { - "slug": "relife", - "synopsis": "The story follows Kaizaki Arata, a 27-year-old jobless man, who fails at every job interview he had after quitting his last company. His life changes after he met Yoake Ryou of the ReLife Research Institute, who offered him a drug that can change his appearance to 17-years-old and to become a subject in an experiment for one year. Thus, he begins his life as a high school student once more.\n\n(Source: MU)", - "coverImageTopOffset": 0, - "titles": { - "en": null, - "en_jp": "ReLIFE" - }, - "canonicalTitle": "ReLIFE", - "abbreviatedTitles": null, - "averageRating": 4.26302991575155, - "ratingFrequencies": { - "0.5": "0", - "1.0": "1", - "1.5": "0", - "2.0": "1", - "2.5": "8", - "3.0": "14", - "3.5": "31", - "4.0": "88", - "4.5": "84", - "5.0": "121", - "nil": "60" - }, - "favoritesCount": 0, - "startDate": "2013-10-12", - "endDate": null, - "popularityRank": 99, - "ratingRank": 52, - "ageRating": "PG", - "ageRatingGuide": null, - "posterImage": { - "tiny": "https://media.kitsu.io/manga/poster_images/27175/tiny.jpg?1464379411", - "small": "https://media.kitsu.io/manga/poster_images/27175/small.jpg?1464379411", - "medium": "https://media.kitsu.io/manga/poster_images/27175/medium.jpg?1464379411", - "large": "https://media.kitsu.io/manga/poster_images/27175/large.jpg?1464379411", - "original": "https://media.kitsu.io/manga/poster_images/27175/original.jpg?1464379411" - }, - "coverImage": { - "small": "https://media.kitsu.io/manga/cover_images/27175/small.jpg?1464379413", - "large": "https://media.kitsu.io/manga/cover_images/27175/large.jpg?1464379413", - "original": "https://media.kitsu.io/manga/cover_images/27175/original.jpg?1464379413" - }, - "subtype": "manga", - "chapterCount": null, - "volumeCount": 0, - "serialization": null, - "mangaType": "manga" - }, - "relationships": { - "genres": { - "links": { - "self": "https://kitsu.io/api/edge/manga/27175/relationships/genres", - "related": "https://kitsu.io/api/edge/manga/27175/genres" - } - }, - "castings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/27175/relationships/castings", - "related": "https://kitsu.io/api/edge/manga/27175/castings" - } - }, - "installments": { - "links": { - "self": "https://kitsu.io/api/edge/manga/27175/relationships/installments", - "related": "https://kitsu.io/api/edge/manga/27175/installments" - } - }, - "mappings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/27175/relationships/mappings", - "related": "https://kitsu.io/api/edge/manga/27175/mappings" - } - }, - "reviews": { - "links": { - "self": "https://kitsu.io/api/edge/manga/27175/relationships/reviews", - "related": "https://kitsu.io/api/edge/manga/27175/reviews" - } - }, - "mediaRelationships": { - "links": { - "self": "https://kitsu.io/api/edge/manga/27175/relationships/media-relationships", - "related": "https://kitsu.io/api/edge/manga/27175/media-relationships" - } - } - } - }, - { - "id": "7629", - "type": "manga", - "links": { - "self": "https://kitsu.io/api/edge/manga/7629" - }, - "attributes": { - "slug": "usagi-drop", - "synopsis": "When 30-year-old Daikichi returns home for his grandfather's funeral he meets an unfamiliar child in the garden. His mother explains that Rin is his grandfather's illegitimate daughter by an unknown mother. The girl is an embarrassment to all his relatives and nobody wants to take her in because of the scandal. Annoyed by their attitude, Daikichi decides to take care of Rin himself, even though he is single and has no experience raising a child.", - "coverImageTopOffset": 0, - "titles": { - "en": "Bunny Drop", - "en_jp": "Usagi Drop" - }, - "canonicalTitle": "Usagi Drop", - "abbreviatedTitles": null, - "averageRating": 3.76554247856562, - "ratingFrequencies": { - "0.5": "3", - "1.0": "5", - "1.5": "2", - "2.0": "8", - "2.5": "22", - "3.0": "48", - "3.5": "65", - "4.0": "86", - "4.5": "61", - "5.0": "48", - "nil": "19" - }, - "favoritesCount": 0, - "startDate": "2005-10-08", - "endDate": "2011-12-08", - "popularityRank": 169, - "ratingRank": 713, - "ageRating": null, - "ageRatingGuide": null, - "posterImage": { - "tiny": "https://media.kitsu.io/manga/poster_images/7629/tiny.jpg?1434265873", - "small": "https://media.kitsu.io/manga/poster_images/7629/small.jpg?1434265873", - "medium": "https://media.kitsu.io/manga/poster_images/7629/medium.jpg?1434265873", - "large": "https://media.kitsu.io/manga/poster_images/7629/large.jpg?1434265873", - "original": "https://media.kitsu.io/manga/poster_images/7629/original.jpg?1434265873" - }, - "coverImage": null, - "subtype": "manga", - "chapterCount": 62, - "volumeCount": 10, - "serialization": "Feel Young", - "mangaType": "manga" - }, - "relationships": { - "genres": { - "links": { - "self": "https://kitsu.io/api/edge/manga/7629/relationships/genres", - "related": "https://kitsu.io/api/edge/manga/7629/genres" - } - }, - "castings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/7629/relationships/castings", - "related": "https://kitsu.io/api/edge/manga/7629/castings" - } - }, - "installments": { - "links": { - "self": "https://kitsu.io/api/edge/manga/7629/relationships/installments", - "related": "https://kitsu.io/api/edge/manga/7629/installments" - } - }, - "mappings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/7629/relationships/mappings", - "related": "https://kitsu.io/api/edge/manga/7629/mappings" - } - }, - "reviews": { - "links": { - "self": "https://kitsu.io/api/edge/manga/7629/relationships/reviews", - "related": "https://kitsu.io/api/edge/manga/7629/reviews" - } - }, - "mediaRelationships": { - "links": { - "self": "https://kitsu.io/api/edge/manga/7629/relationships/media-relationships", - "related": "https://kitsu.io/api/edge/manga/7629/media-relationships" - } - } - } - }, - { - "id": "25512", - "type": "manga", - "links": { - "self": "https://kitsu.io/api/edge/manga/25512" - }, - "attributes": { - "slug": "shishunki-bitter-change", - "synopsis": "Yuuta and Yui had their body swapped when Yuuta fell down from a tree. How will they co-operate with dealing this as they grow up?\r\n\r\n(Source: MU)", - "coverImageTopOffset": 0, - "titles": { - "en": null, - "en_jp": "Shishunki Bitter Change" - }, - "canonicalTitle": "Shishunki Bitter Change", - "abbreviatedTitles": null, - "averageRating": 3.93631688288892, - "ratingFrequencies": { - "0.5": "0", - "1.0": "0", - "1.5": "0", - "2.0": "0", - "2.5": "2", - "3.0": "7", - "3.5": "8", - "4.0": "16", - "4.5": "10", - "5.0": "14", - "nil": "13" - }, - "favoritesCount": 0, - "startDate": "2012-10-25", - "endDate": null, - "popularityRank": 732, - "ratingRank": 359, - "ageRating": "PG", - "ageRatingGuide": null, - "posterImage": { - "tiny": "https://media.kitsu.io/manga/poster_images/25512/tiny.jpg?1434305092", - "small": "https://media.kitsu.io/manga/poster_images/25512/small.jpg?1434305092", - "medium": "https://media.kitsu.io/manga/poster_images/25512/medium.jpg?1434305092", - "large": "https://media.kitsu.io/manga/poster_images/25512/large.jpg?1434305092", - "original": "https://media.kitsu.io/manga/poster_images/25512/original.jpg?1434305092" - }, - "coverImage": null, - "subtype": "manga", - "chapterCount": null, - "volumeCount": 0, - "serialization": "Comic Polaris", - "mangaType": "manga" - }, - "relationships": { - "genres": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25512/relationships/genres", - "related": "https://kitsu.io/api/edge/manga/25512/genres" - } - }, - "castings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25512/relationships/castings", - "related": "https://kitsu.io/api/edge/manga/25512/castings" - } - }, - "installments": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25512/relationships/installments", - "related": "https://kitsu.io/api/edge/manga/25512/installments" - } - }, - "mappings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25512/relationships/mappings", - "related": "https://kitsu.io/api/edge/manga/25512/mappings" - } - }, - "reviews": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25512/relationships/reviews", - "related": "https://kitsu.io/api/edge/manga/25512/reviews" - } - }, - "mediaRelationships": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25512/relationships/media-relationships", - "related": "https://kitsu.io/api/edge/manga/25512/media-relationships" - } - } - } - }, - { - "id": "5531", - "type": "manga", - "links": { - "self": "https://kitsu.io/api/edge/manga/5531" - }, - "attributes": { - "slug": "kuragehime", - "synopsis": "The story centers around Tsukimi Kurashita, a huge fan of jellyfish (kurage, a wordplay on the \"kura/mi\" and \"tsuki/ge\" in her name) and a girl who moves to Tokyo to become an illustrator. She moves into \"Amamizukan,\" an apartment complex that is full of fujoshi (diehard female otaku) with a no-men-allowed rule. However, one day, Tsukimi invites a stylishly fashionable woman to stay at her room at Amamizukan—only to discover that the guest is not who \"she\" seems to be.\r\n(Source: ANN)", - "coverImageTopOffset": 150, - "titles": { - "en": "Princess Jellyfish", - "en_jp": "Kuragehime" - }, - "canonicalTitle": "Kuragehime", - "abbreviatedTitles": null, - "averageRating": 4.13073462956278, - "ratingFrequencies": { - "0.5": "0", - "1.0": "0", - "1.5": "0", - "2.0": "0", - "2.5": "2", - "3.0": "6", - "3.5": "17", - "4.0": "44", - "4.5": "25", - "5.0": "41", - "nil": "18" - }, - "favoritesCount": 0, - "startDate": "2008-10-25", - "endDate": null, - "popularityRank": 237, - "ratingRank": 121, - "ageRating": "PG", - "ageRatingGuide": null, - "posterImage": { - "tiny": "https://media.kitsu.io/manga/poster_images/5531/tiny.jpg?1434261214", - "small": "https://media.kitsu.io/manga/poster_images/5531/small.jpg?1434261214", - "medium": "https://media.kitsu.io/manga/poster_images/5531/medium.jpg?1434261214", - "large": "https://media.kitsu.io/manga/poster_images/5531/large.jpg?1434261214", - "original": "https://media.kitsu.io/manga/poster_images/5531/original.jpg?1434261214" - }, - "coverImage": { - "small": "https://media.kitsu.io/manga/cover_images/5531/small.jpg?1442186125", - "large": "https://media.kitsu.io/manga/cover_images/5531/large.jpg?1442186125", - "original": "https://media.kitsu.io/manga/cover_images/5531/original.jpg?1442186125" - }, - "subtype": "manga", - "chapterCount": null, - "volumeCount": 0, - "serialization": "Kiss", - "mangaType": "manga" - }, - "relationships": { - "genres": { - "links": { - "self": "https://kitsu.io/api/edge/manga/5531/relationships/genres", - "related": "https://kitsu.io/api/edge/manga/5531/genres" - } - }, - "castings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/5531/relationships/castings", - "related": "https://kitsu.io/api/edge/manga/5531/castings" - } - }, - "installments": { - "links": { - "self": "https://kitsu.io/api/edge/manga/5531/relationships/installments", - "related": "https://kitsu.io/api/edge/manga/5531/installments" - } - }, - "mappings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/5531/relationships/mappings", - "related": "https://kitsu.io/api/edge/manga/5531/mappings" - } - }, - "reviews": { - "links": { - "self": "https://kitsu.io/api/edge/manga/5531/relationships/reviews", - "related": "https://kitsu.io/api/edge/manga/5531/reviews" - } - }, - "mediaRelationships": { - "links": { - "self": "https://kitsu.io/api/edge/manga/5531/relationships/media-relationships", - "related": "https://kitsu.io/api/edge/manga/5531/media-relationships" - } - } - } - }, - { - "id": "14261", - "type": "manga", - "links": { - "self": "https://kitsu.io/api/edge/manga/14261" - }, - "attributes": { - "slug": "boku-wa-mari-no-naka", - "synopsis": "The manga follows an \"angel-like\" beautiful high school girl with a certain secret she has been hiding from everyone, and an idle hikikomori (one who abnormally avoids social contact) young man; the highlight of his day is seeing Mari at the convenience store. The story begins as Mari wakes up in her room, but something feel amiss about the room and even her own body.\r\n\r\n(Source: ANN)", - "coverImageTopOffset": 50, - "titles": { - "en": "Inside Mari", - "en_jp": "Boku wa Mari no Naka" - }, - "canonicalTitle": "Boku wa Mari no Naka", - "abbreviatedTitles": null, - "averageRating": 3.73702689275209, - "ratingFrequencies": { - "0.5": "2", - "1.0": "2", - "1.5": "3", - "2.0": "8", - "2.5": "11", - "3.0": "30", - "3.5": "57", - "4.0": "67", - "4.5": "36", - "5.0": "28", - "nil": "9" - }, - "favoritesCount": 0, - "startDate": "2012-03-06", - "endDate": "2016-09-06", - "popularityRank": 159, - "ratingRank": 787, - "ageRating": "PG", - "ageRatingGuide": null, - "posterImage": { - "tiny": "https://media.kitsu.io/manga/poster_images/14261/tiny.jpg?1434280674", - "small": "https://media.kitsu.io/manga/poster_images/14261/small.jpg?1434280674", - "medium": "https://media.kitsu.io/manga/poster_images/14261/medium.jpg?1434280674", - "large": "https://media.kitsu.io/manga/poster_images/14261/large.jpg?1434280674", - "original": "https://media.kitsu.io/manga/poster_images/14261/original.jpg?1434280674" - }, - "coverImage": { - "small": "https://media.kitsu.io/manga/cover_images/14261/small.jpg?1431611464", - "large": "https://media.kitsu.io/manga/cover_images/14261/large.jpg?1431611464", - "original": "https://media.kitsu.io/manga/cover_images/14261/original.jpg?1431611464" - }, - "subtype": "manga", - "chapterCount": 80, - "volumeCount": 9, - "serialization": "Manga Action", - "mangaType": "manga" - }, - "relationships": { - "genres": { - "links": { - "self": "https://kitsu.io/api/edge/manga/14261/relationships/genres", - "related": "https://kitsu.io/api/edge/manga/14261/genres" - } - }, - "castings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/14261/relationships/castings", - "related": "https://kitsu.io/api/edge/manga/14261/castings" - } - }, - "installments": { - "links": { - "self": "https://kitsu.io/api/edge/manga/14261/relationships/installments", - "related": "https://kitsu.io/api/edge/manga/14261/installments" - } - }, - "mappings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/14261/relationships/mappings", - "related": "https://kitsu.io/api/edge/manga/14261/mappings" - } - }, - "reviews": { - "links": { - "self": "https://kitsu.io/api/edge/manga/14261/relationships/reviews", - "related": "https://kitsu.io/api/edge/manga/14261/reviews" - } - }, - "mediaRelationships": { - "links": { - "self": "https://kitsu.io/api/edge/manga/14261/relationships/media-relationships", - "related": "https://kitsu.io/api/edge/manga/14261/media-relationships" - } - } - } - }, - { - "id": "25316", - "type": "manga", - "links": { - "self": "https://kitsu.io/api/edge/manga/25316" - }, - "attributes": { - "slug": "aizawa-san-zoushoku", - "synopsis": "Mizutani Souta is confessed to by the school's famous frail beauty Aizawa Shino. Then came another one of her! And another! And another! Each with different attitudes, but all of them are Aizawa Shino herself. What will this sort-of harem love story come to!?\r\n\r\n(Source: Batoto.net)", - "coverImageTopOffset": 0, - "titles": { - "en": null, - "en_jp": "Aizawa-san Zoushoku" - }, - "canonicalTitle": "Aizawa-san Zoushoku", - "abbreviatedTitles": null, - "averageRating": 3.83515594577564, - "ratingFrequencies": { - "0.5": "0", - "1.0": "1", - "1.5": "0", - "2.0": "0", - "2.5": "2", - "3.0": "7", - "3.5": "10", - "4.0": "17", - "4.5": "4", - "5.0": "11", - "nil": "5" - }, - "favoritesCount": 0, - "startDate": "2013-12-05", - "endDate": null, - "popularityRank": 810, - "ratingRank": 558, - "ageRating": "PG", - "ageRatingGuide": null, - "posterImage": { - "tiny": "https://media.kitsu.io/manga/poster_images/25316/tiny.jpg?1434304656", - "small": "https://media.kitsu.io/manga/poster_images/25316/small.jpg?1434304656", - "medium": "https://media.kitsu.io/manga/poster_images/25316/medium.jpg?1434304656", - "large": "https://media.kitsu.io/manga/poster_images/25316/large.jpg?1434304656", - "original": "https://media.kitsu.io/manga/poster_images/25316/original.jpg?1434304656" - }, - "coverImage": null, - "subtype": "manga", - "chapterCount": null, - "volumeCount": 0, - "serialization": "GanGan Online", - "mangaType": "manga" - }, - "relationships": { - "genres": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25316/relationships/genres", - "related": "https://kitsu.io/api/edge/manga/25316/genres" - } - }, - "castings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25316/relationships/castings", - "related": "https://kitsu.io/api/edge/manga/25316/castings" - } - }, - "installments": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25316/relationships/installments", - "related": "https://kitsu.io/api/edge/manga/25316/installments" - } - }, - "mappings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25316/relationships/mappings", - "related": "https://kitsu.io/api/edge/manga/25316/mappings" - } - }, - "reviews": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25316/relationships/reviews", - "related": "https://kitsu.io/api/edge/manga/25316/reviews" - } - }, - "mediaRelationships": { - "links": { - "self": "https://kitsu.io/api/edge/manga/25316/relationships/media-relationships", - "related": "https://kitsu.io/api/edge/manga/25316/media-relationships" - } - } - } - }, - { - "id": "21733", - "type": "manga", - "links": { - "self": "https://kitsu.io/api/edge/manga/21733" - }, - "attributes": { - "slug": "tonari-no-seki-kun", - "synopsis": "The boy who sits next to Yokoi in class, Seki-kun, is always up to something at his desk. He manages to somehow play very elaborate games without attracting the teacher's attention. From dominos, to shogi, to go, to mecha models, to playing with cats and more, he always has something new to do. Yokoi often finds herself getting reluctantly interested in his games, even though they always seem to end up getting HER in trouble with the teacher! \n(Source: MU)", - "coverImageTopOffset": 0, - "titles": { - "en": "My Neighbour Seki", - "en_jp": "Tonari no Seki-kun" - }, - "canonicalTitle": "Tonari no Seki-kun", - "abbreviatedTitles": null, - "averageRating": 3.96307547642757, - "ratingFrequencies": { - "0.5": "0", - "1.0": "1", - "1.5": "0", - "2.0": "1", - "2.5": "5", - "3.0": "6", - "3.5": "24", - "4.0": "37", - "4.5": "22", - "5.0": "25", - "nil": "12" - }, - "favoritesCount": 0, - "startDate": "2010-07-05", - "endDate": null, - "popularityRank": 366, - "ratingRank": 304, - "ageRating": null, - "ageRatingGuide": null, - "posterImage": { - "tiny": "https://media.kitsu.io/manga/poster_images/21733/tiny.jpg?1434297086", - "small": "https://media.kitsu.io/manga/poster_images/21733/small.jpg?1434297086", - "medium": "https://media.kitsu.io/manga/poster_images/21733/medium.jpg?1434297086", - "large": "https://media.kitsu.io/manga/poster_images/21733/large.jpg?1434297086", - "original": "https://media.kitsu.io/manga/poster_images/21733/original.jpg?1434297086" - }, - "coverImage": null, - "subtype": "manga", - "chapterCount": null, - "volumeCount": 0, - "serialization": "Comic Flapper", - "mangaType": "manga" - }, - "relationships": { - "genres": { - "links": { - "self": "https://kitsu.io/api/edge/manga/21733/relationships/genres", - "related": "https://kitsu.io/api/edge/manga/21733/genres" - } - }, - "castings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/21733/relationships/castings", - "related": "https://kitsu.io/api/edge/manga/21733/castings" - } - }, - "installments": { - "links": { - "self": "https://kitsu.io/api/edge/manga/21733/relationships/installments", - "related": "https://kitsu.io/api/edge/manga/21733/installments" - } - }, - "mappings": { - "links": { - "self": "https://kitsu.io/api/edge/manga/21733/relationships/mappings", - "related": "https://kitsu.io/api/edge/manga/21733/mappings" - } - }, - "reviews": { - "links": { - "self": "https://kitsu.io/api/edge/manga/21733/relationships/reviews", - "related": "https://kitsu.io/api/edge/manga/21733/reviews" - } - }, - "mediaRelationships": { - "links": { - "self": "https://kitsu.io/api/edge/manga/21733/relationships/media-relationships", - "related": "https://kitsu.io/api/edge/manga/21733/media-relationships" - } - } - } - } - ], - "meta": { - "count": 45 + } }, - "links": { - "first": "https://kitsu.io/api/edge/library-entries?filter%5Bmedia_type%5D=Manga&filter%5Buser_id%5D=2644&include=media&page%5Blimit%5D=10&page%5Boffset%5D=0&sort=-updated_at", - "next": "https://kitsu.io/api/edge/library-entries?filter%5Bmedia_type%5D=Manga&filter%5Buser_id%5D=2644&include=media&page%5Blimit%5D=10&page%5Boffset%5D=10&sort=-updated_at", - "last": "https://kitsu.io/api/edge/library-entries?filter%5Bmedia_type%5D=Manga&filter%5Buser_id%5D=2644&include=media&page%5Blimit%5D=10&page%5Boffset%5D=35&sort=-updated_at" + { + "id": "15085607", + "type": "libraryEntries", + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15085607" + }, + "attributes": { + "status": "current", + "progress": 17, + "reconsuming": false, + "reconsumeCount": 0, + "notes": "", + "private": false, + "rating": "3.5", + "updatedAt": "2017-01-09T17:50:19.594Z" + }, + "relationships": { + "user": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/user", + "related": "https://kitsu.io/api/edge/library-entries/15085607/user" + } + }, + "anime": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/anime", + "related": "https://kitsu.io/api/edge/library-entries/15085607/anime" + } + }, + "manga": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/manga", + "related": "https://kitsu.io/api/edge/library-entries/15085607/manga" + } + }, + "drama": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/drama", + "related": "https://kitsu.io/api/edge/library-entries/15085607/drama" + } + }, + "review": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/review", + "related": "https://kitsu.io/api/edge/library-entries/15085607/review" + } + }, + "media": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/media", + "related": "https://kitsu.io/api/edge/library-entries/15085607/media" + }, + "data": { + "type": "manga", + "id": "47" + } + }, + "unit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/unit", + "related": "https://kitsu.io/api/edge/library-entries/15085607/unit" + } + }, + "nextUnit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15085607/relationships/next-unit", + "related": "https://kitsu.io/api/edge/library-entries/15085607/next-unit" + } + } + } + }, + { + "id": "15084529", + "type": "libraryEntries", + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084529" + }, + "attributes": { + "status": "current", + "progress": 16, + "reconsuming": false, + "reconsumeCount": 0, + "notes": "", + "private": false, + "rating": "4.5", + "updatedAt": "2016-04-07T17:10:13.022Z" + }, + "relationships": { + "user": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/user", + "related": "https://kitsu.io/api/edge/library-entries/15084529/user" + } + }, + "anime": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/anime", + "related": "https://kitsu.io/api/edge/library-entries/15084529/anime" + } + }, + "manga": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/manga", + "related": "https://kitsu.io/api/edge/library-entries/15084529/manga" + } + }, + "drama": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/drama", + "related": "https://kitsu.io/api/edge/library-entries/15084529/drama" + } + }, + "review": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/review", + "related": "https://kitsu.io/api/edge/library-entries/15084529/review" + } + }, + "media": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/media", + "related": "https://kitsu.io/api/edge/library-entries/15084529/media" + }, + "data": { + "type": "manga", + "id": "11777" + } + }, + "unit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/unit", + "related": "https://kitsu.io/api/edge/library-entries/15084529/unit" + } + }, + "nextUnit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084529/relationships/next-unit", + "related": "https://kitsu.io/api/edge/library-entries/15084529/next-unit" + } + } + } + }, + { + "id": "15312827", + "type": "libraryEntries", + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15312827" + }, + "attributes": { + "status": "current", + "progress": 68, + "reconsuming": false, + "reconsumeCount": 0, + "notes": "", + "private": false, + "rating": null, + "updatedAt": "2016-03-08T15:45:45.818Z" + }, + "relationships": { + "user": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/user", + "related": "https://kitsu.io/api/edge/library-entries/15312827/user" + } + }, + "anime": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/anime", + "related": "https://kitsu.io/api/edge/library-entries/15312827/anime" + } + }, + "manga": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/manga", + "related": "https://kitsu.io/api/edge/library-entries/15312827/manga" + } + }, + "drama": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/drama", + "related": "https://kitsu.io/api/edge/library-entries/15312827/drama" + } + }, + "review": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/review", + "related": "https://kitsu.io/api/edge/library-entries/15312827/review" + } + }, + "media": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/media", + "related": "https://kitsu.io/api/edge/library-entries/15312827/media" + }, + "data": { + "type": "manga", + "id": "27175" + } + }, + "unit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/unit", + "related": "https://kitsu.io/api/edge/library-entries/15312827/unit" + } + }, + "nextUnit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15312827/relationships/next-unit", + "related": "https://kitsu.io/api/edge/library-entries/15312827/next-unit" + } + } + } + }, + { + "id": "15084769", + "type": "libraryEntries", + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084769" + }, + "attributes": { + "status": "current", + "progress": 43, + "reconsuming": false, + "reconsumeCount": 0, + "notes": "", + "private": false, + "rating": "4.0", + "updatedAt": "2016-02-02T15:06:07.166Z" + }, + "relationships": { + "user": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084769/relationships/user", + "related": "https://kitsu.io/api/edge/library-entries/15084769/user" + } + }, + "anime": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084769/relationships/anime", + "related": "https://kitsu.io/api/edge/library-entries/15084769/anime" + } + }, + "manga": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084769/relationships/manga", + "related": "https://kitsu.io/api/edge/library-entries/15084769/manga" + } + }, + "drama": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084769/relationships/drama", + "related": "https://kitsu.io/api/edge/library-entries/15084769/drama" + } + }, + "review": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084769/relationships/review", + "related": "https://kitsu.io/api/edge/library-entries/15084769/review" + } + }, + "media": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084769/relationships/media", + "related": "https://kitsu.io/api/edge/library-entries/15084769/media" + }, + "data": { + "type": "manga", + "id": "25491" + } + }, + "unit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084769/relationships/unit", + "related": "https://kitsu.io/api/edge/library-entries/15084769/unit" + } + }, + "nextUnit": { + "links": { + "self": "https://kitsu.io/api/edge/library-entries/15084769/relationships/next-unit", + "related": "https://kitsu.io/api/edge/library-entries/15084769/next-unit" + } + } + } } + ], + "included": [ + { + "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.12518266974679, + "ratingFrequencies": { + "0.5": "0", + "1.0": "1", + "1.5": "0", + "2.0": "1", + "2.5": "2", + "3.0": "6", + "3.5": "22", + "4.0": "39", + "4.5": "39", + "5.0": "44", + "nil": "16" + }, + "favoritesCount": 32, + "startDate": "2010-01-01", + "endDate": null, + "popularityRank": 263, + "ratingRank": 124, + "ageRating": "PG", + "ageRatingGuide": null, + "subtype": "manga", + "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": { + "tiny": "https://media.kitsu.io/manga/cover_images/20286/tiny.jpg?1430793688", + "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" + }, + "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": "21" + }, + { + "type": "genres", + "id": "24" + }, + { + "type": "genres", + "id": "16" + }, + { + "type": "genres", + "id": "14" + } + ] + }, + "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" + } + }, + "mangaCharacters": { + "links": { + "self": "https://kitsu.io/api/edge/manga/20286/relationships/manga-characters", + "related": "https://kitsu.io/api/edge/manga/20286/manga-characters" + } + }, + "mangaStaff": { + "links": { + "self": "https://kitsu.io/api/edge/manga/20286/relationships/manga-staff", + "related": "https://kitsu.io/api/edge/manga/20286/manga-staff" + } + } + } + }, + { + "id": "47", + "type": "manga", + "links": { + "self": "https://kitsu.io/api/edge/manga/47" + }, + "attributes": { + "slug": "love-hina", + "synopsis": "Keitaro has had great difficulty getting into the university of his choice and no luck in meeting women. In a desperate effort to go into seclusion and study for his entrance exams, he volunteers to take over running his grandmother's hotel. His plans are ruined when he discovers that the \"hotel\" is actually an all-girls dormitory ... and some serious distractions ensue.\r\n(Source: Tokyopop)", + "coverImageTopOffset": 75, + "titles": { + "en": null, + "en_jp": "Love Hina" + }, + "canonicalTitle": "Love Hina", + "abbreviatedTitles": null, + "averageRating": 3.90477702884008, + "ratingFrequencies": { + "0.5": "1", + "1.0": "6", + "1.5": "4", + "2.0": "16", + "2.5": "24", + "3.0": "40", + "3.5": "79", + "4.0": "93", + "4.5": "73", + "5.0": "104", + "nil": "19" + }, + "favoritesCount": 64, + "startDate": "1998-10-21", + "endDate": "2001-10-31", + "popularityRank": 145, + "ratingRank": 420, + "ageRating": "R", + "ageRatingGuide": "Ecchi", + "subtype": "manga", + "posterImage": { + "tiny": "https://media.kitsu.io/manga/poster_images/47/tiny.jpg?1434249493", + "small": "https://media.kitsu.io/manga/poster_images/47/small.jpg?1434249493", + "medium": "https://media.kitsu.io/manga/poster_images/47/medium.jpg?1434249493", + "large": "https://media.kitsu.io/manga/poster_images/47/large.jpg?1434249493", + "original": "https://media.kitsu.io/manga/poster_images/47/original.jpg?1434249493" + }, + "coverImage": { + "tiny": "https://media.kitsu.io/manga/cover_images/47/tiny.jpg?1446826380", + "small": "https://media.kitsu.io/manga/cover_images/47/small.jpg?1446826380", + "large": "https://media.kitsu.io/manga/cover_images/47/large.jpg?1446826380", + "original": "https://media.kitsu.io/manga/cover_images/47/original.jpg?1446826380" + }, + "chapterCount": 120, + "volumeCount": 14, + "serialization": "Shounen Magazine (Weekly)", + "mangaType": "manga" + }, + "relationships": { + "genres": { + "links": { + "self": "https://kitsu.io/api/edge/manga/47/relationships/genres", + "related": "https://kitsu.io/api/edge/manga/47/genres" + }, + "data": [ + { + "type": "genres", + "id": "3" + }, + { + "type": "genres", + "id": "13" + }, + { + "type": "genres", + "id": "34" + }, + { + "type": "genres", + "id": "14" + }, + { + "type": "genres", + "id": "25" + } + ] + }, + "castings": { + "links": { + "self": "https://kitsu.io/api/edge/manga/47/relationships/castings", + "related": "https://kitsu.io/api/edge/manga/47/castings" + } + }, + "installments": { + "links": { + "self": "https://kitsu.io/api/edge/manga/47/relationships/installments", + "related": "https://kitsu.io/api/edge/manga/47/installments" + } + }, + "mappings": { + "links": { + "self": "https://kitsu.io/api/edge/manga/47/relationships/mappings", + "related": "https://kitsu.io/api/edge/manga/47/mappings" + }, + "data": [ + { + "type": "mappings", + "id": "27633" + } + ] + }, + "reviews": { + "links": { + "self": "https://kitsu.io/api/edge/manga/47/relationships/reviews", + "related": "https://kitsu.io/api/edge/manga/47/reviews" + } + }, + "mediaRelationships": { + "links": { + "self": "https://kitsu.io/api/edge/manga/47/relationships/media-relationships", + "related": "https://kitsu.io/api/edge/manga/47/media-relationships" + } + }, + "mangaCharacters": { + "links": { + "self": "https://kitsu.io/api/edge/manga/47/relationships/manga-characters", + "related": "https://kitsu.io/api/edge/manga/47/manga-characters" + } + }, + "mangaStaff": { + "links": { + "self": "https://kitsu.io/api/edge/manga/47/relationships/manga-staff", + "related": "https://kitsu.io/api/edge/manga/47/manga-staff" + } + } + } + }, + { + "id": "11777", + "type": "manga", + "links": { + "self": "https://kitsu.io/api/edge/manga/11777" + }, + "attributes": { + "slug": "yamada-kun-to-7-nin-no-majo", + "synopsis": "Ryuu Yamada is a second-year student at Suzaku High. Ryuu is always late for school, naps in class and gets abysmal grades. His life is a dead bore. The beautiful Urara Shiraishi, on the other hand, is Suzaku High's brightest student. One day, without explanation, their bodies are swapped! Ryuu ends up in Urara's body, and Urara in Ryuu's.\r\n\r\n(Source: MU)", + "coverImageTopOffset": 75, + "titles": { + "en": "Yamada-kun and the Seven Witches", + "en_jp": "Yamada-kun to 7-nin no Majo" + }, + "canonicalTitle": "Yamada-kun to 7-nin no Majo", + "abbreviatedTitles": null, + "averageRating": 4.08701725994085, + "ratingFrequencies": { + "0.5": "3", + "1.0": "4", + "1.5": "2", + "2.0": "5", + "2.5": "20", + "3.0": "51", + "3.5": "98", + "4.0": "167", + "4.5": "146", + "5.0": "170", + "nil": "31" + }, + "favoritesCount": 156, + "startDate": "2012-02-22", + "endDate": null, + "popularityRank": 34, + "ratingRank": 156, + "ageRating": "R", + "ageRatingGuide": "Ecchi", + "subtype": "manga", + "posterImage": { + "tiny": "https://media.kitsu.io/manga/poster_images/11777/tiny.jpg?1438784325", + "small": "https://media.kitsu.io/manga/poster_images/11777/small.jpg?1438784325", + "medium": "https://media.kitsu.io/manga/poster_images/11777/medium.jpg?1438784325", + "large": "https://media.kitsu.io/manga/poster_images/11777/large.jpg?1438784325", + "original": "https://media.kitsu.io/manga/poster_images/11777/original.jpg?1438784325" + }, + "coverImage": { + "tiny": "https://media.kitsu.io/manga/cover_images/11777/tiny.jpg?1438784293", + "small": "https://media.kitsu.io/manga/cover_images/11777/small.jpg?1438784293", + "large": "https://media.kitsu.io/manga/cover_images/11777/large.jpg?1438784293", + "original": "https://media.kitsu.io/manga/cover_images/11777/original.jpg?1438784293" + }, + "chapterCount": null, + "volumeCount": 0, + "serialization": "Shounen Magazine (Weekly)", + "mangaType": "manga" + }, + "relationships": { + "genres": { + "links": { + "self": "https://kitsu.io/api/edge/manga/11777/relationships/genres", + "related": "https://kitsu.io/api/edge/manga/11777/genres" + }, + "data": [ + { + "type": "genres", + "id": "3" + }, + { + "type": "genres", + "id": "9" + }, + { + "type": "genres", + "id": "13" + }, + { + "type": "genres", + "id": "24" + }, + { + "type": "genres", + "id": "45" + }, + { + "type": "genres", + "id": "14" + }, + { + "type": "genres", + "id": "25" + } + ] + }, + "castings": { + "links": { + "self": "https://kitsu.io/api/edge/manga/11777/relationships/castings", + "related": "https://kitsu.io/api/edge/manga/11777/castings" + } + }, + "installments": { + "links": { + "self": "https://kitsu.io/api/edge/manga/11777/relationships/installments", + "related": "https://kitsu.io/api/edge/manga/11777/installments" + } + }, + "mappings": { + "links": { + "self": "https://kitsu.io/api/edge/manga/11777/relationships/mappings", + "related": "https://kitsu.io/api/edge/manga/11777/mappings" + }, + "data": [ + { + "type": "mappings", + "id": "19012" + } + ] + }, + "reviews": { + "links": { + "self": "https://kitsu.io/api/edge/manga/11777/relationships/reviews", + "related": "https://kitsu.io/api/edge/manga/11777/reviews" + } + }, + "mediaRelationships": { + "links": { + "self": "https://kitsu.io/api/edge/manga/11777/relationships/media-relationships", + "related": "https://kitsu.io/api/edge/manga/11777/media-relationships" + } + }, + "mangaCharacters": { + "links": { + "self": "https://kitsu.io/api/edge/manga/11777/relationships/manga-characters", + "related": "https://kitsu.io/api/edge/manga/11777/manga-characters" + } + }, + "mangaStaff": { + "links": { + "self": "https://kitsu.io/api/edge/manga/11777/relationships/manga-staff", + "related": "https://kitsu.io/api/edge/manga/11777/manga-staff" + } + } + } + }, + { + "id": "27175", + "type": "manga", + "links": { + "self": "https://kitsu.io/api/edge/manga/27175" + }, + "attributes": { + "slug": "relife", + "synopsis": "The story follows Kaizaki Arata, a 27-year-old jobless man, who fails at every job interview he had after quitting his last company. His life changes after he met Yoake Ryou of the ReLife Research Institute, who offered him a drug that can change his appearance to 17-years-old and to become a subject in an experiment for one year. Thus, he begins his life as a high school student once more.\n\n(Source: MU)", + "coverImageTopOffset": 0, + "titles": { + "en": null, + "en_jp": "ReLIFE" + }, + "canonicalTitle": "ReLIFE", + "abbreviatedTitles": null, + "averageRating": 4.23866998116768, + "ratingFrequencies": { + "0.5": "0", + "1.0": "1", + "1.5": "1", + "2.0": "1", + "2.5": "10", + "3.0": "14", + "3.5": "41", + "4.0": "103", + "4.5": "95", + "5.0": "127", + "nil": "60" + }, + "favoritesCount": 127, + "startDate": "2013-10-12", + "endDate": null, + "popularityRank": 92, + "ratingRank": 58, + "ageRating": "PG", + "ageRatingGuide": null, + "subtype": "manga", + "posterImage": { + "tiny": "https://media.kitsu.io/manga/poster_images/27175/tiny.jpg?1464379411", + "small": "https://media.kitsu.io/manga/poster_images/27175/small.jpg?1464379411", + "medium": "https://media.kitsu.io/manga/poster_images/27175/medium.jpg?1464379411", + "large": "https://media.kitsu.io/manga/poster_images/27175/large.jpg?1464379411", + "original": "https://media.kitsu.io/manga/poster_images/27175/original.jpg?1464379411" + }, + "coverImage": { + "tiny": "https://media.kitsu.io/manga/cover_images/27175/tiny.jpg?1464379413", + "small": "https://media.kitsu.io/manga/cover_images/27175/small.jpg?1464379413", + "large": "https://media.kitsu.io/manga/cover_images/27175/large.jpg?1464379413", + "original": "https://media.kitsu.io/manga/cover_images/27175/original.jpg?1464379413" + }, + "chapterCount": null, + "volumeCount": 0, + "serialization": null, + "mangaType": "manga" + }, + "relationships": { + "genres": { + "links": { + "self": "https://kitsu.io/api/edge/manga/27175/relationships/genres", + "related": "https://kitsu.io/api/edge/manga/27175/genres" + }, + "data": [ + { + "type": "genres", + "id": "24" + }, + { + "type": "genres", + "id": "16" + }, + { + "type": "genres", + "id": "14" + } + ] + }, + "castings": { + "links": { + "self": "https://kitsu.io/api/edge/manga/27175/relationships/castings", + "related": "https://kitsu.io/api/edge/manga/27175/castings" + } + }, + "installments": { + "links": { + "self": "https://kitsu.io/api/edge/manga/27175/relationships/installments", + "related": "https://kitsu.io/api/edge/manga/27175/installments" + } + }, + "mappings": { + "links": { + "self": "https://kitsu.io/api/edge/manga/27175/relationships/mappings", + "related": "https://kitsu.io/api/edge/manga/27175/mappings" + }, + "data": [ + { + "type": "mappings", + "id": "19896" + } + ] + }, + "reviews": { + "links": { + "self": "https://kitsu.io/api/edge/manga/27175/relationships/reviews", + "related": "https://kitsu.io/api/edge/manga/27175/reviews" + } + }, + "mediaRelationships": { + "links": { + "self": "https://kitsu.io/api/edge/manga/27175/relationships/media-relationships", + "related": "https://kitsu.io/api/edge/manga/27175/media-relationships" + } + }, + "mangaCharacters": { + "links": { + "self": "https://kitsu.io/api/edge/manga/27175/relationships/manga-characters", + "related": "https://kitsu.io/api/edge/manga/27175/manga-characters" + } + }, + "mangaStaff": { + "links": { + "self": "https://kitsu.io/api/edge/manga/27175/relationships/manga-staff", + "related": "https://kitsu.io/api/edge/manga/27175/manga-staff" + } + } + } + }, + { + "id": "25491", + "type": "manga", + "links": { + "self": "https://kitsu.io/api/edge/manga/25491" + }, + "attributes": { + "slug": "joshikausei", + "synopsis": "Who needs dialog when you're this cute? The beautiful (but unlucky) Momoko, the cool, collected Shibumi, and the refreshingly innocent Mayumi star in a \"silent manga.\" No speeches, no dialog! Just pictures, sound effects, and three high school girls living their daily lives.\r\n\r\n(Source: Crunchyroll)", + "coverImageTopOffset": 0, + "titles": { + "en": "Joshi Kausei", + "en_jp": "Joshikausei" + }, + "canonicalTitle": "Joshikausei", + "abbreviatedTitles": null, + "averageRating": 3.75321939030155, + "ratingFrequencies": { + "0.5": "0", + "1.0": "1", + "1.5": "0", + "2.0": "1", + "2.5": "5", + "3.0": "13", + "3.5": "17", + "4.0": "23", + "4.5": "11", + "5.0": "9", + "nil": "11" + }, + "favoritesCount": 3, + "startDate": "2013-09-27", + "endDate": null, + "popularityRank": 621, + "ratingRank": 750, + "ageRating": null, + "ageRatingGuide": null, + "subtype": "manga", + "posterImage": { + "tiny": "https://media.kitsu.io/manga/poster_images/25491/tiny.jpg?1434305043", + "small": "https://media.kitsu.io/manga/poster_images/25491/small.jpg?1434305043", + "medium": "https://media.kitsu.io/manga/poster_images/25491/medium.jpg?1434305043", + "large": "https://media.kitsu.io/manga/poster_images/25491/large.jpg?1434305043", + "original": "https://media.kitsu.io/manga/poster_images/25491/original.jpg?1434305043" + }, + "coverImage": null, + "chapterCount": null, + "volumeCount": 0, + "serialization": "Web Comic Action", + "mangaType": "manga" + }, + "relationships": { + "genres": { + "links": { + "self": "https://kitsu.io/api/edge/manga/25491/relationships/genres", + "related": "https://kitsu.io/api/edge/manga/25491/genres" + }, + "data": [ + { + "type": "genres", + "id": "3" + }, + { + "type": "genres", + "id": "24" + }, + { + "type": "genres", + "id": "16" + } + ] + }, + "castings": { + "links": { + "self": "https://kitsu.io/api/edge/manga/25491/relationships/castings", + "related": "https://kitsu.io/api/edge/manga/25491/castings" + } + }, + "installments": { + "links": { + "self": "https://kitsu.io/api/edge/manga/25491/relationships/installments", + "related": "https://kitsu.io/api/edge/manga/25491/installments" + } + }, + "mappings": { + "links": { + "self": "https://kitsu.io/api/edge/manga/25491/relationships/mappings", + "related": "https://kitsu.io/api/edge/manga/25491/mappings" + }, + "data": [ + { + "type": "mappings", + "id": "29065" + } + ] + }, + "reviews": { + "links": { + "self": "https://kitsu.io/api/edge/manga/25491/relationships/reviews", + "related": "https://kitsu.io/api/edge/manga/25491/reviews" + } + }, + "mediaRelationships": { + "links": { + "self": "https://kitsu.io/api/edge/manga/25491/relationships/media-relationships", + "related": "https://kitsu.io/api/edge/manga/25491/media-relationships" + } + }, + "mangaCharacters": { + "links": { + "self": "https://kitsu.io/api/edge/manga/25491/relationships/manga-characters", + "related": "https://kitsu.io/api/edge/manga/25491/manga-characters" + } + }, + "mangaStaff": { + "links": { + "self": "https://kitsu.io/api/edge/manga/25491/relationships/manga-staff", + "related": "https://kitsu.io/api/edge/manga/25491/manga-staff" + } + } + } + }, + { + "id": "3", + "type": "genres", + "links": { + "self": "https://kitsu.io/api/edge/genres/3" + }, + "attributes": { + "name": "Comedy", + "slug": "comedy", + "description": null + } + }, + { + "id": "21", + "type": "genres", + "links": { + "self": "https://kitsu.io/api/edge/genres/21" + }, + "attributes": { + "name": "Thriller", + "slug": "thriller", + "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": "13", + "type": "genres", + "links": { + "self": "https://kitsu.io/api/edge/genres/13" + }, + "attributes": { + "name": "Sports", + "slug": "sports", + "description": null + } + }, + { + "id": "34", + "type": "genres", + "links": { + "self": "https://kitsu.io/api/edge/genres/34" + }, + "attributes": { + "name": "Harem", + "slug": "harem", + "description": null + } + }, + { + "id": "25", + "type": "genres", + "links": { + "self": "https://kitsu.io/api/edge/genres/25" + }, + "attributes": { + "name": "Ecchi", + "slug": "ecchi", + "description": "" + } + }, + { + "id": "9", + "type": "genres", + "links": { + "self": "https://kitsu.io/api/edge/genres/9" + }, + "attributes": { + "name": "Supernatural", + "slug": "supernatural", + "description": null + } + }, + { + "id": "45", + "type": "genres", + "links": { + "self": "https://kitsu.io/api/edge/genres/45" + }, + "attributes": { + "name": "Gender Bender", + "slug": "gender-bender", + "description": "" + } + }, + { + "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" + } + } + } + }, + { + "id": "27633", + "type": "mappings", + "links": { + "self": "https://kitsu.io/api/edge/mappings/27633" + }, + "attributes": { + "externalSite": "myanimelist/manga", + "externalId": "16" + }, + "relationships": { + "media": { + "links": { + "self": "https://kitsu.io/api/edge/mappings/27633/relationships/media", + "related": "https://kitsu.io/api/edge/mappings/27633/media" + } + } + } + }, + { + "id": "19012", + "type": "mappings", + "links": { + "self": "https://kitsu.io/api/edge/mappings/19012" + }, + "attributes": { + "externalSite": "myanimelist/manga", + "externalId": "35003" + }, + "relationships": { + "media": { + "links": { + "self": "https://kitsu.io/api/edge/mappings/19012/relationships/media", + "related": "https://kitsu.io/api/edge/mappings/19012/media" + } + } + } + }, + { + "id": "19896", + "type": "mappings", + "links": { + "self": "https://kitsu.io/api/edge/mappings/19896" + }, + "attributes": { + "externalSite": "myanimelist/manga", + "externalId": "78523" + }, + "relationships": { + "media": { + "links": { + "self": "https://kitsu.io/api/edge/mappings/19896/relationships/media", + "related": "https://kitsu.io/api/edge/mappings/19896/media" + } + } + } + }, + { + "id": "29065", + "type": "mappings", + "links": { + "self": "https://kitsu.io/api/edge/mappings/29065" + }, + "attributes": { + "externalSite": "myanimelist/manga", + "externalId": "60815" + }, + "relationships": { + "media": { + "links": { + "self": "https://kitsu.io/api/edge/mappings/29065/relationships/media", + "related": "https://kitsu.io/api/edge/mappings/29065/media" + } + } + } + } + ], + "meta": { + "count": 5 + }, + "links": { + "first": "https://kitsu.io/api/edge/library-entries?fields%5Busers%5D=id&filter%5Bmedia_type%5D=Manga&filter%5Bstatus%5D=1&filter%5Buser_id%5D=2644&include=media%2Cmedia.genres%2Cmedia.mappings&page%5Blimit%5D=200&page%5Boffset%5D=0&sort=-updated_at", + "last": "https://kitsu.io/api/edge/library-entries?fields%5Busers%5D=id&filter%5Bmedia_type%5D=Manga&filter%5Bstatus%5D=1&filter%5Buser_id%5D=2644&include=media%2Cmedia.genres%2Cmedia.mappings&page%5Blimit%5D=200&page%5Boffset%5D=0&sort=-updated_at" + } } \ No newline at end of file