diff --git a/bridges/MangaDexBridge.php b/bridges/MangaDexBridge.php index 009d2532..579be47a 100644 --- a/bridges/MangaDexBridge.php +++ b/bridges/MangaDexBridge.php @@ -8,7 +8,17 @@ class MangaDexBridge extends BridgeAbstract const DESCRIPTION = 'Returns MangaDex items using the API'; const PARAMETERS = [ - 'global' => [ + 'Title Chapters' => [ + 'url' => [ + 'name' => 'URL to title page', + 'exampleValue' => 'https://mangadex.org/title/f9c33607-9180-4ba6-b85c-e4b5faee7192/official-test-manga', + 'required' => true + ], + 'external' => [ + 'name' => 'Allow external feed items', + 'type' => 'checkbox', + 'title' => 'Some chapters are inaccessible or only available on an external site. Include these?' + ], 'limit' => [ 'name' => 'Item Limit', 'type' => 'number', @@ -32,19 +42,6 @@ class MangaDexBridge extends BridgeAbstract 'Full Quality' => 'yes' ] ] - - ], - 'Title Chapters' => [ - 'url' => [ - 'name' => 'URL to title page', - 'exampleValue' => 'https://mangadex.org/title/f9c33607-9180-4ba6-b85c-e4b5faee7192/official-test-manga', - 'required' => true - ], - 'external' => [ - 'name' => 'Allow external feed items', - 'type' => 'checkbox', - 'title' => 'Some chapters are inaccessible or only available on an external site. Include these?' - ] ], 'Search Chapters' => [ 'chapter' => [ @@ -69,8 +66,31 @@ class MangaDexBridge extends BridgeAbstract 'name' => 'Allow external feed items', 'type' => 'checkbox', 'title' => 'Some chapters are inaccessible or only available on an external site. Include these?' + ], + 'limit' => [ + 'name' => 'Item Limit', + 'type' => 'number', + 'defaultValue' => 10, + 'required' => true + ], + 'lang' => [ + 'name' => 'Chapter Languages (default=all)', + 'title' => 'comma-separated, two-letter language codes (example "en,jp")', + 'exampleValue' => 'en,jp', + 'required' => false + ], + 'images' => [ + 'name' => 'Fetch chapter page images', + 'type' => 'list', + 'title' => 'Places chapter images in feed contents. Entries will consume more bandwidth.', + 'defaultValue' => 'no', + 'values' => [ + 'None' => 'no', + 'Data Saver' => 'saver', + 'Full Quality' => 'yes' + ] ] - ] + ], // Future Manga Contexts: // Manga List (by author or tags): https://api.mangadex.org/swagger.html#/Manga/get-search-manga // Random Manga: https://api.mangadex.org/swagger.html#/Manga/get-manga-random @@ -78,8 +98,65 @@ class MangaDexBridge extends BridgeAbstract // User Lists https://api.mangadex.org/swagger.html#/Feed/get-list-id-feed // // https://api.mangadex.org/docs/get-covers/ + 'New manga' => [ + 'originalLanguages' => [ + 'name' => 'Original languages', + 'type' => 'text', + 'title' => 'Include only chapters originally in these languages', + 'exampleValue' => 'ja,ko,zh', + ], + 'excludeOriginalLanguages' => [ + 'name' => 'Exclude original languages', + 'type' => 'checkbox', + 'title' => 'Invert the selection in original languages', + ], + 'translatedLanguage' => [ + 'name' => 'Translated language', + 'type' => 'list', + 'values' => [ + 'Any' => '', + 'Chinese (simplified)' => 'zh', + 'English' => 'en', + 'French' => 'fr', + 'Korean' => 'ko', + 'Spanish (LATAM)' => 'es-la', + 'Spanish' => 'es', + ], + ], + 'order' => [ + 'name' => 'Sort Order', + 'type' => 'list', + 'values' => [ + 'Recent chapter' => 'recentChapter', + 'Recently added' => 'createdAt', + 'Recently updated' => 'updatedAt', + ], + 'defaultValue' => 'createdAt', + ], + 'safe' => [ + 'name' => 'Safe', + 'type' => 'checkbox', + 'defaultValue' => 'checked' + ], + 'suggestive' => [ + 'name' => 'Suggestive', + 'type' => 'checkbox', + 'defaultValue' => 'checked' + ], + 'erotica' => [ + 'name' => 'Erotica', + 'type' => 'checkbox', + 'defaultValue' => 'checked' + ], + 'pornographic' => [ + 'name' => 'Pornographic', + 'type' => 'checkbox' + ], + ], ]; + private const CDN_URI = 'https://uploads.mangadex.org/'; + private const CUSTOM_TAGS = ['content' => 'contentRating', 'demos' => 'publicationDemographic', 'statuses' => 'status']; const TITLE_REGEX = '#title/(?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})#'; protected $feedName = ''; @@ -108,7 +185,7 @@ class MangaDexBridge extends BridgeAbstract switch ($this->queriedContext) { case 'Title Chapters': preg_match(self::TITLE_REGEX, $this->getInput('url'), $matches) - or returnClientError('Invalid URL Parameter'); + or returnClientError('Invalid URL Parameter'); $this->feedURI = self::URI . 'title/' . $matches['uuid']; $params['order[readableAt]'] = 'desc'; if (!$this->getInput('external')) { @@ -150,6 +227,21 @@ class MangaDexBridge extends BridgeAbstract public function getName() { + if ($this->queriedContext === 'New manga') { + if ($this->getInput('order') !== 'recentChapter') { + return $this->getKey('order') . ' manga - ' . parent::getName(); + } + + $title = 'Latest'; + + $translatedLanguage = $this->getKey('translatedLanguage'); + if ($translatedLanguage !== 'Any') { + $title .= ' ' . $translatedLanguage; + } + + return $title . ' chapters - ' . parent::getName(); + } + switch ($this->queriedContext) { case 'Title Chapters': return $this->feedName . ' Chapters'; @@ -172,6 +264,31 @@ class MangaDexBridge extends BridgeAbstract public function collectData() { + if ($this->queriedContext === 'New manga') { + $queryParts = []; + $queryParts['limit'] = 50; + + if (!empty($this->getInput('originalLanguages'))) { + $queryPart = $this->getInput('excludeOriginalLanguages') === true ? 'excludedOriginalLanguage' : 'originalLanguage'; + foreach (explode(',', $this->getInput('originalLanguages')) as $language) { + $language = trim($language); + $queryParts[$queryPart][] = $language; + } + } + + $queryParts['contentRating'] = array_filter(['safe', 'suggestive', 'erotica', 'pornographic'], function ($rating) { + return $this->getInput($rating) === true; + }); + + if ($this->getInput('order') === 'recentChapter') { + $this->items = $this->collectChapters($queryParts); + } else { + $this->items = $this->collectManga($queryParts); + } + + return; + } + $api_uri = $this->getAPI(); $header = [ 'Content-Type: application/json' @@ -246,7 +363,7 @@ class MangaDexBridge extends BridgeAbstract } } $item['content'] = 'Groups: ' . - (empty($groups) ? 'No Group' : implode(', ', $groups)); + (empty($groups) ? 'No Group' : implode(', ', $groups)); if (!empty($users)) { $item['content'] .= '
Other Users: ' . implode(', ', $users); } @@ -254,7 +371,7 @@ class MangaDexBridge extends BridgeAbstract // Fetch chapter page images if desired and add to content if ($this->getInput('images') !== 'no') { $api_uri = self::API_ROOT . 'at-home/server/' . $item['uid']; - $header = [ 'Content-Type: application/json' ]; + $header = ['Content-Type: application/json']; $pages = json_decode(getContents($api_uri, $header), true); if ($pages['result'] != 'ok') { returnServerError('Could not retrieve API results'); @@ -275,4 +392,388 @@ class MangaDexBridge extends BridgeAbstract $this->items[] = $item; } } + + private function collectChapters(array $queryParts): array + { + if (!empty($this->getInput('translatedLanguage'))) { + $queryParts['translatedLanguage'] = [$this->getInput('translatedLanguage')]; + } + + $queryParts['order'] = ['readableAt' => 'desc']; + $queryParts['includes'] = ['manga']; + + $res = static::getMangaDexContents(static::API_ROOT . 'chapter?' . preg_replace('/\%5B\d+\%5D/', '%5B%5D', http_build_query($queryParts))); + $mangaWithoutCover = []; + $covers = $this->loadCacheValue('covers') ?? []; + foreach ($res['data'] as $chapter) { + foreach ($chapter['relationships'] as $relationship) { + if ($relationship['type'] !== 'manga') { + continue; + } + + if (empty($covers[$relationship['id']])) { + $mangaWithoutCover[$relationship['id']] = $relationship['id']; + } + + break; + } + } + + $queryParts = []; + $queryParts['limit'] = 100; + $queryParts['order'] = ['volume' => 'desc']; + while ($mangaWithoutCover) { + $queryParts['manga'] = array_values($mangaWithoutCover); + $coverRes = static::getMangaDexContents(static::API_ROOT . 'cover?' . preg_replace('/\%5B\d+\%5D/', '%5B%5D', http_build_query($queryParts))); + + foreach ($coverRes['data'] as $cover) { + foreach ($cover['relationships'] as $relationship) { + if ($relationship['type'] !== 'manga') { + continue; + } + + if (empty($covers[$relationship['id']])) { + $covers[$relationship['id']] = $cover; + unset($mangaWithoutCover[$relationship['id']]); + } + + break; + } + } + } + + $this->saveCacheValue('covers', $covers); + + $items = []; + foreach ($res['data'] as $chapter) { + $manga = []; + foreach ($chapter['relationships'] as $relationship) { + if ($relationship['type'] === 'manga') { + $manga = $relationship; + break; + } + } + + $title = static::getItemTitle($manga, $chapter, $this->getInput('translatedLanguage')); + if (!empty($manga['attributes']['originalLanguage'])) { + $title = '[' . $manga['attributes']['originalLanguage'] . '] ' . $title; + } + + $coverURIs = []; + if (!empty($covers[$manga['id']])) { + $coverURIs = [static::CDN_URI . 'covers/' . $manga['id'] . '/' . $covers[$manga['id']]['attributes']['fileName']]; + } + + $categories = static::getMangaCategories($manga); + $item = [ + 'uri' => static::getMangaURI($manga), + 'title' => $title, + 'timestamp' => $chapter['attributes']['readableAt'], + 'content' => static::getMangaContent($manga, $chapter, $coverURIs, $this->getInput('translatedLanguage')), + 'enclosures' => $coverURIs, + 'categories' => $categories, + 'uid' => static::URI . $chapter['id'], + ]; + + $items[] = $item; + } + + return $items; + } + + private function collectManga(array $queryParts): array + { + if (!empty($this->getInput('translatedLanguage'))) { + $queryParts['availableTranslatedLanguage'] = [$this->getInput('translatedLanguage')]; + } + + $queryParts['order'] = [$this->getInput('order') => 'desc']; + $queryParts['includes'] = ['cover_art', 'author', 'artist']; + + $res = static::getMangaDexContents(static::API_ROOT . 'manga?' . preg_replace('/\%5B\d+\%5D/', '%5B%5D', http_build_query($queryParts))); + $items = []; + foreach ($res['data'] as $manga) { + $authors = []; + $covers = []; + foreach ($manga['relationships'] as $relationship) { + switch ($relationship['type']) { + case 'author': + $authors[] = $relationship['attributes']['name']; + break; + case 'cover_art': + $covers[] = static::CDN_URI . 'covers/' . $manga['id'] . '/' . $relationship['attributes']['fileName']; + break; + } + } + + switch ($this->getInput('order')) { + case 'latestUploadedChapter': + $timestamp = $manga['attributes']['updatedAt']; + $uid = static::URI . $manga['attributes']['latestUploadedChapter']; + break; + case 'updatedAt': + $timestamp = $manga['attributes']['updatedAt']; + $uid = static::URI . $manga['id'] . $manga['attributes']['updatedAt']; + break; + default: + $timestamp = $manga['attributes']['createdAt']; + $uid = static::URI . $manga['id']; + break; + } + + $title = static::getItemTitle($manga, [], $this->getInput('translatedLanguage')); + if (!empty($manga['attributes']['originalLanguage'])) { + $title = '[' . $manga['attributes']['originalLanguage'] . '] ' . $title; + } + + $categories = static::getMangaCategories($manga); + $item = [ + 'uri' => static::getMangaURI($manga), + 'title' => $title, + 'timestamp' => $timestamp, + 'author' => reset($authors), + 'content' => static::getMangaContent($manga, [], $covers, $this->getInput('translatedLanguage')), + 'enclosures' => $covers, + 'categories' => $categories, + 'uid' => $uid, + ]; + + $items[] = $item; + } + + return $items; + } + + /** + * @param string $uri api.mangadex.org URL + * + * @return array JSON array + */ + private static function getMangaDexContents(string $uri): array + { + $contents = getContents($uri); + $json = json_decode($contents, true); + + if (($json['result'] ?? null) !== 'ok') { + returnServerError('Invalid response from server.'); + } + + return $json; + } + + /** + * @param array $manga JSON array of manga contents + * + * @return array Manga tags including content rating, demographic and status + */ + private static function getMangaCategories(array $manga): array + { + $categories = []; + foreach (static::CUSTOM_TAGS as $searchFilter => $customTag) { + if (empty($manga['attributes'][$customTag])) { + continue; + } + + $categories[$searchFilter] = htmlspecialchars(ucfirst($manga['attributes'][$customTag]), ENT_XML1); + } + + foreach ($manga['attributes']['tags'] ?? [] as $tag) { + $categories[$tag['id']] = htmlspecialchars(reset($tag['attributes']['name']), ENT_XML1); + } + + return $categories; + } + + /** + * @param array $manga JSON array of manga contents + * + * @return string Canonical URI of manga + */ + private static function getMangaURI(array $manga): string + { + $title = $manga['attributes']['title'] ?? []; + $title = array_reverse($title); + $title = array_pop($title) ?? ''; + $title = preg_replace('/\s*\([^)]*\)\s*/', '', $title); + $title = str_replace('ā', 'a', $title); + $title = str_replace(['é', 'ē'], 'e', $title); + $title = preg_replace('/[^A-Za-z0-9]+/', '-', $title); + $title = strtolower($title); + $title = rtrim($title, '-'); + + if (strlen($title) > 100) { + $title = substr($title, 0, 101); + $title = implode('-', explode('-', $title, -1)); + } + + return static::URI . 'title/' . $manga['id'] . '/' . $title; + } + + /** + * @param array $manga JSON array of manga contents + * @param array $chapter JSON array of chapter contents + * @param string $preferredLanguage Language code, e.g. "en" + * + * @return string Feed item title in preferred language + */ + private static function getItemTitle(array $manga, array $chapter, string $preferredLanguage): string + { + $title = ''; + $chapterTitle = ''; + + if (!empty($chapter['attributes']['volume'])) { + $chapterTitle .= 'Vol. ' . $chapter['attributes']['volume']; + } + + if (!empty($chapter['attributes']['chapter'])) { + if (!empty($chapterTitle)) { + $chapterTitle .= ' '; + } + + $chapterTitle .= 'Ch. ' . $chapter['attributes']['chapter']; + } + + if (!empty($chapter['attributes']['title'])) { + $chapterTitle .= ': ' . $chapter['attributes']['title']; + } + + if (!empty($chapterTitle)) { + $title .= $chapterTitle . ' | '; + } + + $mangaTitles = static::getMangaTitles($manga); + $mangaTitle = ''; + foreach ($mangaTitles as $currentTitle) { + foreach ($currentTitle as $language => $value) { + if (empty($mangaTitle)) { + $mangaTitle = $value; + } + + if (empty($preferredLanguage) || $language === $preferredLanguage) { + return $title . $value; + } + } + } + + return $title . $mangaTitle; + } + + /** + * @param array $manga JSON array of manga contents + * @param array $chapter JSON array of chapter contents + * @param array $coverURIs of manga cover images + * @param string $preferredLanguage for titles, etc. + * + * @return string Feed item content HTML code + */ + private static function getMangaContent(array $manga, array $chapter, array $coverURIs, string $preferredLanguage): string + { + $content = ''; + + if (!empty($coverURIs)) { + foreach ($coverURIs as $coverURI) { + $content .= '

'; + } + } + + if (!empty($manga)) { + $content .= '

Manga link: ' . htmlspecialchars(reset($manga['attributes']['title']) ?? '') . '

'; + } + + if (!empty($chapter)) { + $chapterTitle = $chapter['attributes']['title'] ?: 'No chapter title'; + $content .= '

Chapter link: ' . htmlspecialchars($chapterTitle) . '

'; + } + + + $descriptions = $manga['attributes']['description'] ?? []; + $description = ''; + foreach ($descriptions as $key => $value) { + if (empty($description)) { + $description = $value; + } + + if (empty($preferredLanguage) || $key === $preferredLanguage) { + $description = $value; + break; + } + } + + $content .= '

' . nl2br(htmlspecialchars($description), false) . '


'; + + $categories = static::getMangaCategories($manga); + if (!empty($categories)) { + $htmlCategories = []; + foreach ($categories as $key => $value) { + if (empty(static::CUSTOM_TAGS[$key])) { + $href = static::URI . 'tag/' . htmlspecialchars($key); + } else { + $href = static::URI . 'titles?' . htmlspecialchars($key) . '=' . htmlspecialchars(strtolower($value)); + } + + $htmlCategories[] = '' . htmlspecialchars($value) . ''; + } + $content .= '

Tags: ' . implode(', ', $htmlCategories) . '

'; + } + + $authors = []; + $artists = []; + foreach ($manga['relationships'] ?? [] as $relationship) { + switch ($relationship['type']) { + case 'author': + $authors[$relationship['id']] = $relationship['attributes']['name']; + break; + case 'artist': + $artists[$relationship['id']] = $relationship['attributes']['name']; + break; + } + } + + foreach (['Authors' => $authors, 'Artists' => $artists] as $description => $items) { + if (empty($items)) { + continue; + } + + asort($items); + + $htmlItems = []; + foreach ($items as $id => $name) { + $htmlItems[] = '' . htmlspecialchars($name) . ''; + } + + $content .= '

' . substr($description, 0, 1 < count($htmlItems) ? PHP_INT_MAX : -1) . ': ' . implode(', ', $htmlItems) . '

'; + } + + $mangaTitles = static::getMangaTitles($manga); + if (!empty($mangaTitles)) { + $htmlAltTitles = []; + foreach ($mangaTitles as $altTitle) { + foreach ($altTitle as $language => $title) { + $htmlAltTitles[] = 'Title (' . htmlspecialchars($language) . '): ' . htmlspecialchars($title); + } + } + + sort($htmlAltTitles); + $content .= '

' . implode('
', $htmlAltTitles) . '

'; + } + + $content = str_replace('', '', $content); + + return $content; + } + + private static function getMangaTitles(array $manga): array + { + $mangaTitles = []; + + if (!empty($manga['attributes']['title'])) { + $mangaTitles[] = $manga['attributes']['title']; + } + + if (!empty($manga['attributes']['altTitles'])) { + $mangaTitles = array_merge($mangaTitles, $manga['attributes']['altTitles']); + } + + return $mangaTitles; + } }