From 86d6239100d0465178365a03dbe659e728ed80f6 Mon Sep 17 00:00:00 2001
From: Amr Mohammed El-Sheraey <141947355+AmrMsCLL@users.noreply.github.com>
Date: Sat, 26 Sep 2026 08:59:59 +0300
Subject: [PATCH] Add MangaUpdates as the default manga and manhwa source
MangaUpdates covers licensed titles MangaDex lacks, separates manga from
manhwa and manhua by type, and reports the latest chapter for release
checks. It is registered ahead of MangaDex, which stays selectable, and
Discover offers each source's own sorts and statuses.
---
app/frontend/src/pages/DiscoverPage.tsx | 29 +-
app/frontend/src/pages/HomePage.tsx | 2 +-
app/frontend/src/pages/LegalPages.tsx | 17 +-
app/frontend/src/pages/SearchPage.tsx | 2 +-
app/frontend/test/discover.spec.tsx | 90 ++++++
.../src/sources/connector-registry.service.ts | 4 +-
.../mangaupdates/mangaupdates.service.ts | 256 ++++++++++++++++++
app/server/src/sources/sources.module.ts | 2 +
app/server/test/connectors.spec.ts | 12 +-
app/server/test/mangaupdates.service.spec.ts | 234 ++++++++++++++++
app/server/test/release-signals.spec.ts | 2 +
11 files changed, 631 insertions(+), 19 deletions(-)
create mode 100644 app/frontend/test/discover.spec.tsx
create mode 100644 app/server/src/sources/mangaupdates/mangaupdates.service.ts
create mode 100644 app/server/test/mangaupdates.service.spec.ts
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),