diff --git a/app/frontend/src/pages/DiscoverPage.tsx b/app/frontend/src/pages/DiscoverPage.tsx
index 659fef3..223feff 100644
--- a/app/frontend/src/pages/DiscoverPage.tsx
+++ b/app/frontend/src/pages/DiscoverPage.tsx
@@ -28,7 +28,7 @@ const sections: Array<{ id: Exclude; label: string }>
const filterKeys = ['genre', 'year', 'status', 'sort', 'source'] as const;
-function statusOptions(category: DiscoverCategory, section: CatalogSection) {
+function statusOptions(category: DiscoverCategory, section: CatalogSection, source: string) {
if ((category === 'tv' || category === 'anime') && section !== 'search') {
return [
['returning', 'Returning'],
@@ -39,7 +39,7 @@ function statusOptions(category: DiscoverCategory, section: CatalogSection) {
['pilot', 'Pilot'],
];
}
- if (category === 'manga' || category === 'manhwa') {
+ if ((category === 'manga' || category === 'manhwa') && source === 'mangadex') {
return [
['ongoing', 'Ongoing'],
['completed', 'Completed'],
@@ -50,12 +50,18 @@ function statusOptions(category: DiscoverCategory, section: CatalogSection) {
return [];
}
-function sortOptions(category: DiscoverCategory, section: CatalogSection) {
+function sortOptions(category: DiscoverCategory, section: CatalogSection, source: string) {
if (category === 'manga' || category === 'manhwa') {
- return [
- ['followedCount', 'Most followed'],
- ['latestUploadedChapter', 'Latest update'],
- ];
+ return source === 'mangadex'
+ ? [
+ ['followedCount', 'Most followed'],
+ ['latestUploadedChapter', 'Latest update'],
+ ]
+ : [
+ ['list_reading', 'Most read'],
+ ['month1_pos', 'Trending'],
+ ['rating', 'Rating'],
+ ];
}
const newest = category === 'movie' ? 'primary_release_date.desc' : 'first_air_date.desc';
return [
@@ -124,7 +130,7 @@ export function DiscoverPage({
attributionUrl: results.data.attributionUrl,
},
);
- const statuses = statusOptions(category, section);
+ const statuses = statusOptions(category, section, source);
const activeFilters = filterKeys.filter((key) => searchParams.get(key)).length;
const hasFilters = activeFilters > 0;
@@ -133,9 +139,12 @@ export function DiscoverPage({
const form = new FormData(event.currentTarget);
const next = new URLSearchParams();
if (section === 'search') next.set('q', query);
+ const sourceChanged = form.has('source') && form.get('source') !== source;
for (const key of filterKeys) {
const value = String(form.get(key) ?? '').trim();
- if (value) next.set(key, value);
+ if (value && !(sourceChanged && ['genre', 'status', 'sort'].includes(key))) {
+ next.set(key, value);
+ }
}
navigate(`/discover/${category}/${section}?${next.toString()}`);
}
@@ -279,7 +288,7 @@ export function DiscoverPage({
Sort
{section === 'search' ? 'Relevance' : 'Default'}
- {sortOptions(category, section).map(([value, label]) => (
+ {sortOptions(category, section, source).map(([value, label]) => (
{label}
))}
diff --git a/app/frontend/src/pages/HomePage.tsx b/app/frontend/src/pages/HomePage.tsx
index ca12517..efbe53b 100644
--- a/app/frontend/src/pages/HomePage.tsx
+++ b/app/frontend/src/pages/HomePage.tsx
@@ -50,7 +50,7 @@ export function HomePage() {
aria-label="Title"
name="query"
onInput={form.field('query').onInput}
- placeholder="Search every medium, or paste a TMDB, MangaDex, IGDB or RAWG link"
+ placeholder="Search every medium, or paste a TMDB, MangaUpdates, MangaDex, IGDB or RAWG link"
type="search"
/>
diff --git a/app/frontend/src/pages/LegalPages.tsx b/app/frontend/src/pages/LegalPages.tsx
index 850a781..17c22c5 100644
--- a/app/frontend/src/pages/LegalPages.tsx
+++ b/app/frontend/src/pages/LegalPages.tsx
@@ -61,9 +61,10 @@ export function PrivacyPage() {
Other services
- The server looks titles up at TMDB, MangaDex, IGDB and RAWG without sending anything about
- you. Posters and artwork load straight from those services, so they see your browser's
- request like any other image on the web. There are no ads, analytics or tracking cookies.
+ The server looks titles up at TMDB, MangaUpdates, MangaDex, IGDB and RAWG without sending
+ anything about you. Posters and artwork load straight from those services, so they see
+ your browser's request like any other image on the web. There are no ads, analytics or
+ tracking cookies.
@@ -147,10 +148,18 @@ export function CreditsPage() {
not endorsed or certified by TMDB.
+
MangaDex
- Manga and manhwa data and covers are provided by{' '}
+ When you choose MangaDex as a source, manga and manhwa data and covers are provided by{' '}
MangaDex . Graphite Tracker is not
affiliated with MangaDex. It links to MangaDex titles and never hosts chapters or offers a
reader.
diff --git a/app/frontend/src/pages/SearchPage.tsx b/app/frontend/src/pages/SearchPage.tsx
index d2af96a..69ce2d5 100644
--- a/app/frontend/src/pages/SearchPage.tsx
+++ b/app/frontend/src/pages/SearchPage.tsx
@@ -83,7 +83,7 @@ export function SearchPage() {
defaultValue={query}
name="query"
onInput={form.field('query').onInput}
- placeholder="Search every medium, or paste a TMDB, MangaDex, IGDB or RAWG link"
+ placeholder="Search every medium, or paste a TMDB, MangaUpdates, MangaDex, IGDB or RAWG link"
type="search"
/>
diff --git a/app/frontend/test/discover.spec.tsx b/app/frontend/test/discover.spec.tsx
new file mode 100644
index 0000000..98f975d
--- /dev/null
+++ b/app/frontend/test/discover.spec.tsx
@@ -0,0 +1,90 @@
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { MemoryRouter } from 'react-router';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { App } from '../src/App';
+import { AuthProvider } from '../src/auth';
+
+(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const json = (body: unknown) =>
+ new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } });
+
+const mangaSource = (key: string, displayName: string) => ({
+ key,
+ displayName,
+ categories: ['manga', 'manhwa'],
+ attribution: displayName,
+ enabled: true,
+});
+
+describe('Discover filters', () => {
+ let container: HTMLDivElement;
+ let root: Root;
+
+ beforeEach(() => {
+ container = document.createElement('div');
+ document.body.append(container);
+ root = createRoot(container);
+ });
+
+ afterEach(() => {
+ act(() => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+ });
+
+ it('offers the sorts and statuses of the manga source in use and drops them when the source changes', async () => {
+ const fetchMock = vi.fn((input: string) => {
+ const url = String(input);
+ if (url.includes('/catalog/sources')) {
+ return Promise.resolve(
+ json([mangaSource('mangaupdates', 'MangaUpdates'), mangaSource('mangadex', 'MangaDex')]),
+ );
+ }
+ if (url.includes('/genres')) return Promise.resolve(json(['Romance']));
+ if (url.includes('/catalog/')) {
+ return Promise.resolve(
+ json({ page: 1, totalPages: 1, totalResults: 0, results: [], attribution: 'Test' }),
+ );
+ }
+ return Promise.resolve(new Response(null, { status: 401 }));
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ const browses = () =>
+ fetchMock.mock.calls
+ .map(([url]) => String(url))
+ .filter((url) => url.includes('/catalog/manga/popular'));
+ const sorts = () =>
+ [...container.querySelectorAll('select[name="sort"] option')].map(
+ ({ value }) => value,
+ );
+
+ await act(async () =>
+ root.render(
+
+
+
+
+ ,
+ ),
+ );
+
+ expect(browses()).toEqual([
+ '/api/v1/catalog/manga/popular?page=1&genre=Romance&year=2020&sort=list_reading',
+ ]);
+ expect(sorts()).toEqual(['', 'list_reading', 'month1_pos', 'rating']);
+ expect(container.querySelector('select[name="status"]')).toBeNull();
+
+ const form = container.querySelector('#discover-filters');
+ form!.querySelector('select[name="source"]')!.value = 'mangadex';
+ await act(async () => {
+ form!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
+ await new Promise((resolve) => setTimeout(resolve, 30));
+ });
+
+ expect(browses().at(-1)).toBe('/api/v1/catalog/manga/popular?page=1&year=2020&source=mangadex');
+ expect(sorts()).toEqual(['', 'followedCount', 'latestUploadedChapter']);
+ expect(container.querySelector('select[name="status"]')).not.toBeNull();
+ });
+});
diff --git a/app/server/src/sources/connector-registry.service.ts b/app/server/src/sources/connector-registry.service.ts
index ae60172..5ce4471 100644
--- a/app/server/src/sources/connector-registry.service.ts
+++ b/app/server/src/sources/connector-registry.service.ts
@@ -4,6 +4,7 @@ import { withoutAdult } from './adult-content';
import { ConnectorCacheService } from './connector-cache.service';
import { IgdbService } from './igdb/igdb.service';
import { MangaDexService } from './mangadex/mangadex.service';
+import { MangaUpdatesService } from './mangaupdates/mangaupdates.service';
import { RawgService } from './rawg/rawg.service';
import {
CatalogCategory,
@@ -21,6 +22,7 @@ export class ConnectorRegistryService {
constructor(
config: ConfigService,
tmdb: TmdbService,
+ mangaUpdates: MangaUpdatesService,
mangadex: MangaDexService,
igdb: IgdbService,
rawg: RawgService,
@@ -36,7 +38,7 @@ export class ConnectorRegistryService {
gameSource === 'igdb' && igdb.descriptor.enabled;
rawg.descriptor.enabled =
gameSource === 'rawg' && rawg.descriptor.enabled;
- this.connectors = [tmdb, mangadex, igdb, rawg];
+ this.connectors = [tmdb, mangaUpdates, mangadex, igdb, rawg];
const disabled = new Set(
(config.get('DISABLED_SOURCES') ?? '')
.split(',')
diff --git a/app/server/src/sources/mangaupdates/mangaupdates.service.ts b/app/server/src/sources/mangaupdates/mangaupdates.service.ts
new file mode 100644
index 0000000..610d9b7
--- /dev/null
+++ b/app/server/src/sources/mangaupdates/mangaupdates.service.ts
@@ -0,0 +1,256 @@
+import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import { looksAdult } from '../adult-content';
+import { ConnectorCacheService } from '../connector-cache.service';
+import { ConnectorHttpService } from '../connector-http.service';
+import { creditGroups } from '../credits';
+import { today } from '../release-signals';
+import {
+ CatalogCandidate,
+ CatalogCategory,
+ CatalogDetails,
+ CatalogFilters,
+ CatalogPage,
+ CatalogSection,
+ ConnectorDescriptor,
+ ReleaseSignal,
+} from '../source.types';
+
+interface MangaUpdatesSeries {
+ series_id: number;
+ title: string;
+ url: string;
+ description: string | null;
+ image: { url: { original: string | null } };
+ type: string;
+ year: string;
+ bayesian_rating: number | null;
+ rating_votes: number;
+ genres: Array<{ genre: string }>;
+ associated?: Array<{ title: string }>;
+ latest_chapter?: number;
+ status?: string | null;
+ authors?: Array<{ name: string; type: string }>;
+}
+
+interface MangaUpdatesSearch {
+ total_hits: number;
+ per_page: number;
+ results: Array<{ record: MangaUpdatesSeries; hit_title: string }>;
+}
+
+const adultGenres = ['Adult', 'Hentai', 'Lolicon', 'Shotacon', 'Smut'];
+
+@Injectable()
+export class MangaUpdatesService {
+ readonly descriptor: ConnectorDescriptor = {
+ key: 'mangaupdates',
+ displayName: 'MangaUpdates',
+ categories: ['manga', 'manhwa'],
+ languages: [],
+ attribution: 'Manga data provided by MangaUpdates',
+ attributionUrl: 'https://www.mangaupdates.com/',
+ capabilities: ['SEARCH', 'DETAILS', 'RELEASES', 'CHAPTERS', 'DEEP_LINK'],
+ outboundDomains: ['mangaupdates.com'],
+ requestIntervalMs: 500,
+ enabled: true,
+ };
+
+ constructor(
+ private readonly cache: ConnectorCacheService,
+ private readonly http: ConnectorHttpService,
+ ) {}
+
+ search(
+ category: CatalogCategory,
+ query: string,
+ page: number,
+ filters: CatalogFilters,
+ ): Promise {
+ return this.list(category, page, filters, query, undefined);
+ }
+
+ browse(
+ category: CatalogCategory,
+ section: CatalogSection,
+ page: number,
+ filters: CatalogFilters,
+ ): Promise {
+ return this.list(
+ category,
+ page,
+ section === 'recent' && !filters.year
+ ? { ...filters, year: new Date().getUTCFullYear() }
+ : filters,
+ undefined,
+ section === 'recent' ? 'month1_pos' : 'list_reading',
+ );
+ }
+
+ async details(category: CatalogCategory, externalId: string): Promise {
+ const series = await this.series(externalId);
+ if (series.type !== (category === 'manhwa' ? 'Manhwa' : 'Manga')) {
+ throw new NotFoundException('MangaUpdates title does not match this category');
+ }
+ const people = (type: string) =>
+ (series.authors ?? []).filter((author) => author.type === type).map(({ name }) => name);
+ return {
+ ...this.normalize(series, series.associated?.map(({ title }) => title) ?? []),
+ credits: creditGroups([
+ ['Story by', people('Author')],
+ ['Art by', people('Artist')],
+ ]),
+ attribution: this.descriptor.attribution,
+ attributionUrl: this.descriptor.attributionUrl,
+ };
+ }
+
+ async releases(category: CatalogCategory, externalId: string): Promise {
+ const chapter = (await this.series(externalId)).latest_chapter;
+ return chapter
+ ? [
+ {
+ key: `chapter:${chapter}`,
+ kind: 'chapter',
+ label: `Chapter ${chapter}`,
+ ordinal: chapter,
+ occurredAt: today(),
+ },
+ ]
+ : [];
+ }
+
+ async genres() {
+ const { value } = await this.cache.getOrLoad(
+ 'connector:mangaupdates:genres',
+ 604_800,
+ 2_592_000,
+ () => this.request>('/genres'),
+ );
+ return value
+ .map(({ genre }) => genre)
+ .filter((genre) => !adultGenres.includes(genre))
+ .sort((left, right) => left.localeCompare(right));
+ }
+
+ async recognize(
+ url: URL,
+ ): Promise<{ category: CatalogCategory; externalId: string } | null> {
+ if (!/(^|\.)mangaupdates\.com$/.test(url.hostname)) {
+ return null;
+ }
+ const match = url.pathname.match(/^\/series\/([0-9a-z]+)/i);
+ if (!match?.[1]) {
+ return null;
+ }
+ const externalId = String(parseInt(match[1], 36));
+ const { type } = await this.series(externalId);
+ return type === 'Manhwa'
+ ? { category: 'manhwa', externalId }
+ : type === 'Manga'
+ ? { category: 'manga', externalId }
+ : null;
+ }
+
+ private async list(
+ category: CatalogCategory,
+ page: number,
+ filters: CatalogFilters,
+ query: string | undefined,
+ order: string | undefined,
+ ): Promise {
+ const orderby = filters.sort ?? order;
+ if (orderby && !['list_reading', 'month1_pos', 'rating'].includes(orderby)) {
+ throw new BadRequestException('Sort does not match this media category');
+ }
+ if (filters.status) {
+ throw new BadRequestException('MangaUpdates does not filter by status');
+ }
+ const genre = filters.genre
+ ? (await this.genres()).find(
+ (name) => name.toLowerCase() === filters.genre?.toLowerCase(),
+ )
+ : undefined;
+ if (filters.genre && !genre) {
+ throw new BadRequestException('Genre does not match this media category');
+ }
+ const response = await this.request('/series/search', {
+ page,
+ perpage: 25,
+ type: [category === 'manhwa' ? 'Manhwa' : 'Manga'],
+ ...(query ? { search: query } : {}),
+ ...(orderby ? { orderby } : {}),
+ ...(filters.year ? { year: String(filters.year) } : {}),
+ ...(genre ? { genre: [genre] } : {}),
+ ...(filters.adult ? {} : { exclude_genre: adultGenres }),
+ });
+ return {
+ page,
+ totalPages: Math.max(1, Math.ceil(response.total_hits / response.per_page)),
+ totalResults: response.total_hits,
+ results: response.results.map(({ record, hit_title }) =>
+ this.normalize(record, [hit_title]),
+ ),
+ attribution: this.descriptor.attribution,
+ attributionUrl: this.descriptor.attributionUrl,
+ };
+ }
+
+ private normalize(series: MangaUpdatesSeries, alternates: string[]): CatalogCandidate {
+ const genres = series.genres.map(({ genre }) => genre);
+ const alternateTitles = [...new Set(alternates)].filter(
+ (alternate) => alternate && alternate !== series.title,
+ );
+ const synopsis = series.description ?? '';
+ const year = series.year.match(/^\d{4}/)?.[0];
+ return {
+ source: 'mangaupdates',
+ externalId: String(series.series_id),
+ category: series.type === 'Manhwa' ? 'manhwa' : 'manga',
+ title: series.title,
+ originalTitle: series.title,
+ alternateTitles,
+ synopsis,
+ posterUrl: series.image.url.original,
+ backdropUrl: null,
+ releaseDate: year ? `${year}-01-01` : null,
+ language: series.type === 'Manhwa' ? 'ko' : 'ja',
+ genres,
+ runtimeMinutes: null,
+ status: series.status?.match(/\(([^)]+)\)/)?.[1] ?? null,
+ tagline: null,
+ rating: series.bayesian_rating || null,
+ ratingCount: series.rating_votes,
+ adult:
+ genres.some((genre) => adultGenres.includes(genre)) ||
+ looksAdult([series.title, ...alternateTitles], synopsis, []),
+ chapterCount: series.latest_chapter || null,
+ volumeCount: Number(series.status?.match(/(\d+) Volumes?/)?.[1]) || null,
+ deepLinks: [{ label: 'View on MangaUpdates', url: series.url }],
+ capabilities: {
+ progressUnits: ['chapter', 'volume'],
+ hasEpisodes: false,
+ hasSeasons: false,
+ hasPlatforms: false,
+ supportsReleaseNotifications: true,
+ },
+ };
+ }
+
+ private series(externalId: string) {
+ return this.request(`/series/${encodeURIComponent(externalId)}`);
+ }
+
+ private request(path: string, body?: object) {
+ return this.http.json(
+ this.descriptor,
+ new URL(`https://api.mangaupdates.com/v1${path}`),
+ body
+ ? {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ }
+ : {},
+ );
+ }
+}
diff --git a/app/server/src/sources/sources.module.ts b/app/server/src/sources/sources.module.ts
index 4b97d72..3a2b3cd 100644
--- a/app/server/src/sources/sources.module.ts
+++ b/app/server/src/sources/sources.module.ts
@@ -5,6 +5,7 @@ import { ConnectorHttpService } from './connector-http.service';
import { ConnectorRegistryService } from './connector-registry.service';
import { IgdbService } from './igdb/igdb.service';
import { MangaDexService } from './mangadex/mangadex.service';
+import { MangaUpdatesService } from './mangaupdates/mangaupdates.service';
import { RawgService } from './rawg/rawg.service';
import { SourceSettingsController } from './source-settings.controller';
import { SourceSettingsService } from './source-settings.service';
@@ -18,6 +19,7 @@ import { TmdbService } from './tmdb/tmdb.service';
ConnectorRegistryService,
IgdbService,
MangaDexService,
+ MangaUpdatesService,
RawgService,
SourceSettingsService,
TmdbService,
diff --git a/app/server/test/connectors.spec.ts b/app/server/test/connectors.spec.ts
index fd4a04d..b52ca15 100644
--- a/app/server/test/connectors.spec.ts
+++ b/app/server/test/connectors.spec.ts
@@ -5,6 +5,7 @@ import { ConnectorHttpService } from '../src/sources/connector-http.service';
import { ConnectorRegistryService } from '../src/sources/connector-registry.service';
import { IgdbService } from '../src/sources/igdb/igdb.service';
import { MangaDexService } from '../src/sources/mangadex/mangadex.service';
+import { MangaUpdatesService } from '../src/sources/mangaupdates/mangaupdates.service';
import { RawgService } from '../src/sources/rawg/rawg.service';
import { TmdbService } from '../src/sources/tmdb/tmdb.service';
@@ -279,6 +280,7 @@ describe('Source connectors', () => {
return new ConnectorRegistryService(
config,
new TmdbService(config, http),
+ new MangaUpdatesService(cache as never, http),
new MangaDexService(cache as never, http),
new IgdbService(config, cache as never, http),
new RawgService(config, http),
@@ -432,6 +434,7 @@ describe('Source connectors', () => {
const registry = new ConnectorRegistryService(
config,
new TmdbService(config, http),
+ new MangaUpdatesService(igdbCache() as never, http),
new MangaDexService(igdbCache() as never, http),
new IgdbService(config, igdbCache() as never, http),
new RawgService(config, http),
@@ -464,6 +467,7 @@ describe('Source connectors', () => {
const registry = new ConnectorRegistryService(
config,
new TmdbService(config, http),
+ new MangaUpdatesService(igdbCache() as never, http),
new MangaDexService(igdbCache() as never, http),
new IgdbService(config, igdbCache() as never, http),
new RawgService(config, http),
@@ -509,19 +513,23 @@ describe('Source connectors', () => {
});
it('disables connectors named in DISABLED_SOURCES without removing them', () => {
- const config = new ConfigService({ DISABLED_SOURCES: ' MangaDex ,unknown' });
+ const config = new ConfigService({ DISABLED_SOURCES: ' MangaDex ,MangaUpdates,unknown' });
const cache = { getOrLoad: vi.fn() };
const http = new ConnectorHttpService();
const registry = new ConnectorRegistryService(
config,
new TmdbService(config, http),
+ new MangaUpdatesService(cache as never, http),
new MangaDexService(cache as never, http),
new IgdbService(config, cache as never, http),
new RawgService(config, http),
cache as never,
);
- expect(registry.list('manga')).toMatchObject([{ key: 'mangadex', enabled: false }]);
+ expect(registry.list('manga')).toMatchObject([
+ { key: 'mangaupdates', enabled: false },
+ { key: 'mangadex', enabled: false },
+ ]);
expect(() => registry.resolve('manga')).toThrow(NotFoundException);
expect(registry.resolve('movie').descriptor.key).toBe('tmdb');
});
diff --git a/app/server/test/mangaupdates.service.spec.ts b/app/server/test/mangaupdates.service.spec.ts
new file mode 100644
index 0000000..af15b67
--- /dev/null
+++ b/app/server/test/mangaupdates.service.spec.ts
@@ -0,0 +1,234 @@
+import { BadRequestException, NotFoundException } from '@nestjs/common';
+import { ConfigService } from '@nestjs/config';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import { ConnectorHttpService } from '../src/sources/connector-http.service';
+import { ConnectorRegistryService } from '../src/sources/connector-registry.service';
+import { IgdbService } from '../src/sources/igdb/igdb.service';
+import { MangaDexService } from '../src/sources/mangadex/mangadex.service';
+import { MangaUpdatesService } from '../src/sources/mangaupdates/mangaupdates.service';
+import { RawgService } from '../src/sources/rawg/rawg.service';
+import { TmdbService } from '../src/sources/tmdb/tmdb.service';
+
+const record = {
+ series_id: 50369844984,
+ title: 'Omniscient Reader',
+ url: 'https://www.mangaupdates.com/series/n50wl4o/omniscient-reader',
+ description: 'Dokja was an average office worker.',
+ image: { url: { original: 'https://cdn.mangaupdates.com/image/i519012.jpg' } },
+ type: 'Manhwa',
+ year: '2020',
+ bayesian_rating: 8.82,
+ rating_votes: 940,
+ genres: [{ genre: 'Action' }, { genre: 'Fantasy' }],
+};
+
+const series = {
+ ...record,
+ associated: [{ title: 'Jeonjijeok Dokja Sijeom' }, { title: 'Omniscient Reader' }],
+ latest_chapter: 308,
+ status: '311 Chapters + Prologue (Hiatus)\n20 Volumes (Ongoing)',
+ authors: [
+ { name: 'Redice', type: 'Author' },
+ { name: 'Sleepy-C', type: 'Artist' },
+ ],
+};
+
+const json = (body: unknown) =>
+ new Response(JSON.stringify(body), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ });
+
+const genreCache = () => ({
+ getOrLoad: vi.fn().mockResolvedValue({
+ value: [{ genre: 'Romance' }, { genre: 'Action' }, { genre: 'Smut' }, { genre: 'Hentai' }],
+ stale: false,
+ }),
+});
+
+const searchResponse = {
+ total_hits: 51,
+ page: 1,
+ per_page: 25,
+ results: [{ record, hit_title: "Omniscient Reader's Viewpoint" }],
+};
+
+const body = (request: ReturnType, call = 0) =>
+ JSON.parse(String((request.mock.calls[call] as [URL, RequestInit])[1].body)) as Record<
+ string,
+ unknown
+ >;
+
+describe('MangaUpdatesService', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('browses the most read and this year trending titles of one type without adult genres', async () => {
+ const request = vi.fn().mockImplementation(() => Promise.resolve(json(searchResponse)));
+ vi.stubGlobal('fetch', request);
+ const service = new MangaUpdatesService(genreCache() as never, new ConnectorHttpService());
+
+ const popular = await service.browse('manhwa', 'popular', 1, {});
+ await service.browse('manga', 'recent', 2, {});
+
+ const [url, init] = request.mock.calls[0] as [URL, RequestInit];
+ expect(url.toString()).toBe('https://api.mangaupdates.com/v1/series/search');
+ expect(init.method).toBe('POST');
+ expect(body(request)).toEqual({
+ page: 1,
+ perpage: 25,
+ type: ['Manhwa'],
+ orderby: 'list_reading',
+ exclude_genre: ['Adult', 'Hentai', 'Lolicon', 'Shotacon', 'Smut'],
+ });
+ expect(body(request, 1)).toMatchObject({
+ page: 2,
+ type: ['Manga'],
+ orderby: 'month1_pos',
+ year: String(new Date().getUTCFullYear()),
+ });
+ expect(popular).toMatchObject({ page: 1, totalPages: 3, totalResults: 51 });
+ expect(popular.results[0]).toMatchObject({
+ source: 'mangaupdates',
+ externalId: '50369844984',
+ category: 'manhwa',
+ title: 'Omniscient Reader',
+ alternateTitles: ["Omniscient Reader's Viewpoint"],
+ posterUrl: 'https://cdn.mangaupdates.com/image/i519012.jpg',
+ releaseDate: '2020-01-01',
+ language: 'ko',
+ genres: ['Action', 'Fantasy'],
+ rating: 8.82,
+ ratingCount: 940,
+ adult: false,
+ deepLinks: [{ label: 'View on MangaUpdates', url: record.url }],
+ });
+ });
+
+ it('searches by title with the selected genre and lets adult readers opt in', async () => {
+ const request = vi.fn().mockImplementation(() =>
+ Promise.resolve(
+ json({
+ ...searchResponse,
+ results: [{ record: { ...record, genres: [{ genre: 'Smut' }] }, hit_title: record.title }],
+ }),
+ ),
+ );
+ vi.stubGlobal('fetch', request);
+ const service = new MangaUpdatesService(genreCache() as never, new ConnectorHttpService());
+
+ const result = await service.search('manhwa', 'omniscient', 1, {
+ genre: 'romance',
+ adult: true,
+ });
+
+ expect(body(request)).toEqual({
+ page: 1,
+ perpage: 25,
+ type: ['Manhwa'],
+ search: 'omniscient',
+ genre: ['Romance'],
+ });
+ expect(result.results[0]).toMatchObject({ adult: true, alternateTitles: [] });
+ await expect(service.genres()).resolves.toEqual(['Action', 'Romance']);
+ });
+
+ it.each([
+ [{ genre: 'Smut' }],
+ [{ sort: 'followedCount' }],
+ [{ status: 'ongoing' }],
+ ])('rejects filters it cannot apply: %o', async (filters) => {
+ const request = vi.fn();
+ vi.stubGlobal('fetch', request);
+ const service = new MangaUpdatesService(genreCache() as never, new ConnectorHttpService());
+
+ await expect(service.browse('manga', 'popular', 1, filters)).rejects.toBeInstanceOf(
+ BadRequestException,
+ );
+ expect(request).not.toHaveBeenCalled();
+ });
+
+ it('loads details with alternate titles, credits, origin status and progress markers', async () => {
+ const request = vi.fn().mockImplementation(() => Promise.resolve(json(series)));
+ vi.stubGlobal('fetch', request);
+ const service = new MangaUpdatesService(genreCache() as never, new ConnectorHttpService());
+
+ const result = await service.details('manhwa', '50369844984');
+
+ expect((request.mock.calls[0] as [URL])[0].toString()).toBe(
+ 'https://api.mangaupdates.com/v1/series/50369844984',
+ );
+ expect(result).toMatchObject({
+ alternateTitles: ['Jeonjijeok Dokja Sijeom'],
+ status: 'Hiatus',
+ chapterCount: 308,
+ volumeCount: 20,
+ credits: [
+ { role: 'Story by', names: ['Redice'] },
+ { role: 'Art by', names: ['Sleepy-C'] },
+ ],
+ attribution: 'Manga data provided by MangaUpdates',
+ attributionUrl: 'https://www.mangaupdates.com/',
+ });
+ await expect(service.details('manga', '50369844984')).rejects.toBeInstanceOf(
+ NotFoundException,
+ );
+ });
+
+ it('reports the latest chapter and nothing before the first one', async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValueOnce(json(series))
+ .mockResolvedValueOnce(json({ ...series, latest_chapter: 0 }));
+ vi.stubGlobal('fetch', request);
+ const service = new MangaUpdatesService(genreCache() as never, new ConnectorHttpService());
+
+ await expect(service.releases('manhwa', '50369844984')).resolves.toEqual([
+ expect.objectContaining({
+ key: 'chapter:308',
+ kind: 'chapter',
+ label: 'Chapter 308',
+ ordinal: 308,
+ }),
+ ]);
+ await expect(service.releases('manhwa', '50369844984')).resolves.toEqual([]);
+ });
+
+ it('recognizes series links by their base-36 ID and only for manga and manhwa', async () => {
+ const request = vi
+ .fn()
+ .mockResolvedValueOnce(json(series))
+ .mockResolvedValueOnce(json({ ...series, type: 'Manhua' }));
+ vi.stubGlobal('fetch', request);
+ const service = new MangaUpdatesService(genreCache() as never, new ConnectorHttpService());
+ const link = new URL('https://www.mangaupdates.com/series/n50wl4o/omniscient-reader');
+
+ await expect(service.recognize(link)).resolves.toEqual({
+ category: 'manhwa',
+ externalId: '50369844984',
+ });
+ await expect(service.recognize(link)).resolves.toBeNull();
+ await expect(
+ service.recognize(new URL('https://mangadex.org/title/n50wl4o')),
+ ).resolves.toBeNull();
+ expect(request).toHaveBeenCalledTimes(2);
+ });
+
+ it('is the default manga source while MangaDex stays selectable', () => {
+ const config = new ConfigService();
+ const cache = { getOrLoad: vi.fn() };
+ const http = new ConnectorHttpService();
+ const registry = new ConnectorRegistryService(
+ config,
+ new TmdbService(config, http),
+ new MangaUpdatesService(cache as never, http),
+ new MangaDexService(cache as never, http),
+ new IgdbService(config, cache as never, http),
+ new RawgService(config, http),
+ cache as never,
+ );
+
+ expect(registry.resolve('manga').descriptor.key).toBe('mangaupdates');
+ expect(registry.resolve('manhwa').descriptor.key).toBe('mangaupdates');
+ expect(registry.resolve('manhwa', 'mangadex').descriptor.key).toBe('mangadex');
+ });
+});
diff --git a/app/server/test/release-signals.spec.ts b/app/server/test/release-signals.spec.ts
index e88b50d..6e50ea8 100644
--- a/app/server/test/release-signals.spec.ts
+++ b/app/server/test/release-signals.spec.ts
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import { ConnectorHttpService } from '../src/sources/connector-http.service';
import { IgdbService } from '../src/sources/igdb/igdb.service';
import { MangaDexService } from '../src/sources/mangadex/mangadex.service';
+import { MangaUpdatesService } from '../src/sources/mangaupdates/mangaupdates.service';
import { RawgService } from '../src/sources/rawg/rawg.service';
import { SourceConnector } from '../src/sources/source.types';
import { TmdbService } from '../src/sources/tmdb/tmdb.service';
@@ -104,6 +105,7 @@ describe('Release signals', () => {
const http = new ConnectorHttpService();
const connectors: SourceConnector[] = [
new TmdbService(config, http),
+ new MangaUpdatesService(cache as never, http),
new MangaDexService(cache as never, http),
new IgdbService(config, cache as never, http),
new RawgService(config, http),