MangaDex-specific manga source client. Extends the base client with MangaDex-specific API parsing and URL building. Makes direct HTTP requests (CORS-enabled by MangaDex API).

export class MangaDexClient extends BaseMangaSourceClient<
MangaDexManga,
MangaDexMangaDetail
> {
constructor() {
super(MANGADEX_CONFIG);
}

/**
* Search for manga on MangaDex API.
* Results are cached for 30 minutes by default.
* @param query - The search query string.
* @param limit - Maximum number of results to return (default: 10).
* @returns Promise resolving to array of MangaDex manga entries.
* @source
*/
public async searchManga(
query: string,
limit: number = 10,
): Promise<MangaDexManga[]> {
return withGroupAsync(`[MangaDex] Search: "${query}"`, async () => {
// Check cache first
const cacheKey = `search:${query.toLowerCase()}:${limit}`;
const cached = this.getCachedData<MangaDexManga[]>(cacheKey);

if (cached) {
console.debug(`[MangaDex] 📦 Using cached results for "${query}"`);
return cached;
}
console.info(
`[MangaDex] 🔍 Searching MangaDex for: "${query}" (limit: ${limit})`,
);

try {
// Make direct HTTP request using the base client's functionality
const url = this.buildSearchUrl(query, limit);
const data = await this.makeRequest(url);

const results = this.parseSearchResponse(data);
this.setCachedData(cacheKey, results);

console.info(
`[MangaDex] ✅ MangaDex search found ${results?.length || 0} results for "${query}"`,
);
return results;
} catch (error) {
console.error(
`[MangaDex] ❌ MangaDex search failed for "${query}":`,
error,
);
return [];
}
});
}

/**
* Get detailed information about a specific MangaDex manga.
* @param slug - The MangaDex manga ID.
* @returns Promise resolving to manga detail or null if not found.
* @source
*/
public async getMangaDetail(
slug: string,
): Promise<MangaDexMangaDetail | null> {
return withGroupAsync(`[MangaDex] Detail: "${slug}"`, async () => {
try {
console.debug(
`[MangaDex] 📖 Getting MangaDex manga details for: ${slug}`,
);

// Make direct HTTP request using the base client's functionality
const url = this.buildDetailUrl(slug);
const rawData = await this.makeRequest(url);
const detail = this.parseDetailResponse(rawData);
if (detail) {
console.info(`[MangaDex] ✅ Retrieved details for manga ${slug}`);
}
return detail;
} catch (error) {
console.error(
`[MangaDex] ❌ Failed to get MangaDex manga details for ${slug}:`,
error,
);
return null;
}
});
}

/**
* Extract primary title from MangaDex title object.
* Prefers: English > Romanized Japanese > Japanese > first available.
* @param title - Title object with language-keyed values.
* @returns The selected primary title or "Unknown Title".
* @source
*/
private extractPrimaryTitle(title: Record<string, string>): string {
return (
title.en ||
title["ja-ro"] ||
title.ja ||
Object.values(title)[0] ||
"Unknown Title"
);
}

/**
* Parse alternative titles from MangaDex title and altTitles objects.
* Excludes the primary title from alternatives to avoid duplication.
* @param title - Main title object with language-keyed values.
* @param altTitles - Array of alternative title objects.
* @param primaryTitle - The primary title to exclude.
* @returns Array of alternative titles with language codes.
* @source
*/
private parseAlternativeTitles(
title: Record<string, string>,
altTitles: Record<string, string>[],
primaryTitle: string,
): Array<{ title: string; lang: string }> {
const alternativeTitles: Array<{ title: string; lang: string }> = [];

// Add all title variants (excluding the primary title)
for (const [lang, titleText] of Object.entries(title)) {
if (titleText && titleText !== primaryTitle) {
alternativeTitles.push({ title: titleText, lang });
}
}

// Add alt titles
for (const altTitle of altTitles) {
for (const [lang, titleText] of Object.entries(altTitle)) {
if (titleText) {
alternativeTitles.push({ title: titleText, lang });
}
}
}

return alternativeTitles;
}

/**
* Extract and parse tags from MangaDex attributes with type validation.
* @param tagsArray - Raw tags array from API response.
* @returns Parsed tags array with validated structure.
* @source
*/
private parseTags(tagsArray: unknown):
| Array<{
id: string;
type: string;
attributes: {
name: { en: string; [key: string]: string };
description: { en: string; [key: string]: string };
group: string;
version: number;
};
}>
| undefined {
if (!Array.isArray(tagsArray)) {
return undefined;
}

return tagsArray
.map((tag: unknown) => {
if (
!tag ||
typeof tag !== "object" ||
!("id" in tag) ||
!("type" in tag) ||
!("attributes" in tag)
) {
return null;
}

const tagRecord = tag as Record<string, unknown>;
const tagAttributes = tagRecord.attributes;

if (!tagAttributes || typeof tagAttributes !== "object") {
return null;
}

const tagAttrObj = tagAttributes as Record<string, unknown>;
const name = tagAttrObj.name;
const description = tagAttrObj.description;

// Ensure name and description have required structure
if (
!name ||
typeof name !== "object" ||
!("en" in name) ||
!description ||
typeof description !== "object" ||
!("en" in description)
) {
return null;
}

return {
id: String(tagRecord.id),
type: String(tagRecord.type),
attributes: {
name: name as { en: string; [key: string]: string },
description: description as {
en: string;
[key: string]: string;
},
group: String(tagAttrObj.group),
version:
typeof tagAttrObj.version === "number" ? tagAttrObj.version : 0,
},
};
})
.filter(
(
tag: {
id: string;
type: string;
attributes: {
name: { en: string; [key: string]: string };
description: { en: string; [key: string]: string };
group: string;
version: number;
};
} | null,
): tag is {
id: string;
type: string;
attributes: {
name: { en: string; [key: string]: string };
description: { en: string; [key: string]: string };
group: string;
version: number;
};
} => tag !== null,
) as Array<{
id: string;
type: string;
attributes: {
name: { en: string; [key: string]: string };
description: { en: string; [key: string]: string };
group: string;
version: number;
};
}>;
}

/**
* Extract attributes from a MangaDex item with type validation.
* @param item - The raw item object.
* @returns Attributes object or null if invalid.
* @source
*/
private extractAttributes(
item: Record<string, unknown>,
): Record<string, unknown> | null {
const attributes = item.attributes;
return attributes && typeof attributes === "object"
? (attributes as Record<string, unknown>)
: {};
}

/**
* Validate and extract MangaDex item ID and type.
* @param item - The raw item object.
* @returns { id, type } or null if invalid.
* @source
*/
private extractItemIdAndType(
item: Record<string, unknown>,
): { id: unknown; type: unknown } | null {
if (!item.id || !item.type) {
console.warn("[MangaDex] Skipping item missing id or type", item);
return null;
}
return { id: item.id, type: item.type };
}

/**
* Build a MangaDex manga entry from an item object with type validation.
* @param item - Raw item from API response.
* @returns Parsed manga entry or null if invalid.
* @source
*/
private buildMangaFromItem(item: unknown): MangaDexManga | null {
// Validate required fields
if (!item || typeof item !== "object") {
console.warn("[MangaDex] Skipping invalid search result item", item);
return null;
}

const itemObj = item as Record<string, unknown>;
const idAndType = this.extractItemIdAndType(itemObj);
if (!idAndType) return null;

const attributes = this.extractAttributes(itemObj) || {};

const titleObj =
attributes.title && typeof attributes.title === "object"
? (attributes.title as Record<string, string>)
: {};

const altTitles = Array.isArray(attributes.altTitles)
? (attributes.altTitles as Record<string, string>[])
: [];

const primaryTitle = this.extractPrimaryTitle(titleObj);
const alternativeTitles = this.parseAlternativeTitles(
titleObj,
altTitles,
primaryTitle,
);

return {
id: String(idAndType.id),
title: primaryTitle,
slug: String(idAndType.id),
year: typeof attributes.year === "number" ? attributes.year : undefined,
status:
typeof attributes.status === "string"
? this.mapStatus(attributes.status)
: 0,
country:
typeof attributes.originalLanguage === "string"
? attributes.originalLanguage
: undefined,
alternativeTitles,
source: MangaSource.MangaDex,
type: String(idAndType.type),
links:
attributes.links && typeof attributes.links === "object"
? (attributes.links as Record<string, string>)
: undefined,
originalLanguage:
typeof attributes.originalLanguage === "string"
? attributes.originalLanguage
: undefined,
lastVolume:
typeof attributes.lastVolume === "string"
? attributes.lastVolume
: undefined,
lastChapter:
typeof attributes.lastChapter === "string"
? attributes.lastChapter
: undefined,
publicationDemographic:
typeof attributes.publicationDemographic === "string"
? attributes.publicationDemographic
: undefined,
contentRating:
typeof attributes.contentRating === "string"
? attributes.contentRating
: undefined,
tags: this.parseTags(attributes.tags),
};
}

/**
* Parse raw search response into MangaDex manga entries.
* Extracts primary and alternative titles, status mapping, and platform links.
* @param rawResponse - The raw API response object.
* @returns Array of parsed manga entries.
* @source
*/
protected parseSearchResponse(rawResponse: unknown): MangaDexManga[] {
if (
!rawResponse ||
typeof rawResponse !== "object" ||
!Array.isArray((rawResponse as Record<string, unknown>).data)
) {
console.warn("[MangaDex] 🔍 Invalid search response format");
return [];
}

const response = rawResponse as Record<string, unknown>;
const data = response.data as unknown[];

return data
.map((item: unknown) => this.buildMangaFromItem(item))
.filter((item): item is MangaDexManga => item !== null);
}

/**
* Parse raw detail response into MangaDex manga detail.
* Extracts metadata, authors, artists, genres, and external links from relationships.
* @param rawResponse - The raw API response object.
* @returns Parsed manga detail or null if invalid.
* @source
*/
// eslint-disable-next-line
protected parseDetailResponse(rawResponse: any): MangaDexMangaDetail | null {
if (!rawResponse?.data) {
console.warn("[MangaDex] 📖 Invalid detail response format");
return null;
}
const data = rawResponse.data;
const attributes = data.attributes ?? {};
const titleObj = attributes.title ?? {};
const altTitles = attributes.altTitles ?? [];

const primaryTitle = this.extractPrimaryTitle(titleObj);
const alternativeTitles = this.parseAlternativeTitles(
titleObj,
altTitles,
primaryTitle,
);

// Parse authors and artists from relationships
const authors: Array<{ id: string; name: string; slug?: string }> = [];
const artists: Array<{ id: string; name: string; slug?: string }> = [];

for (const rel of data.relationships ?? []) {
if (rel.type === "author" && rel.attributes?.name) {
authors.push({ id: rel.id, name: rel.attributes.name, slug: rel.id });
continue;
}
if (rel.type === "artist" && rel.attributes?.name) {
artists.push({ id: rel.id, name: rel.attributes.name, slug: rel.id });
}
}

const genres =
attributes.tags
// eslint-disable-next-line
?.filter((tag: any) => tag.attributes?.group === "genre")
// eslint-disable-next-line
.map((tag: any) => ({
id: tag.id,
name: tag.attributes.name.en || Object.values(tag.attributes.name)[0],
slug: tag.id,
})) || [];

return {
id: data.id,
title: primaryTitle,
slug: data.id,
description:
attributes.description?.en ||
(Object.values(attributes.description || {})[0] as string),
status: this.mapStatus(attributes.status),
year: attributes.year,
country: attributes.originalLanguage,
createdAt: attributes.createdAt,
updatedAt: attributes.updatedAt,
authors,
artists,
genres,
alternativeTitles,
externalLinks: attributes.links
? {
anilist: attributes.links.al,
myAnimeList: attributes.links.mal,
mangaUpdates: attributes.links.mu,
...attributes.links,
}
: undefined,
source: MangaSource.MangaDex,
data: rawResponse.data,
} as MangaDexMangaDetail;
}

/**
* Extract AniList ID from MangaDex manga detail.
* Parses the 'al' field from external links section.
* @param detail - The MangaDex manga detail object.
* @returns The AniList ID as a number or null if not found.
* @source
*/
protected extractAniListIdFromDetail(
detail: MangaDexMangaDetail,
): number | null {
try {
// Check if external links exist
const links = detail.data?.attributes?.links;
if (!links) {
console.debug(
`[MangaDex] 🔗 No external links found for MangaDex manga: ${detail.title}`,
);
return null;
}

// Look for AniList ID - 'al' is the key for AniList in MangaDex API
const anilistId = links.al;

if (!anilistId) {
console.debug(
`[MangaDex] 🔗 No AniList ID found for MangaDex manga: ${detail.title}`,
{ availableLinks: Object.keys(links) },
);
return null;
}

// Convert to number
const parsedAnilistId = Number.parseInt(anilistId, 10);

if (Number.isNaN(parsedAnilistId)) {
console.warn(
`[MangaDex] 🔗 Invalid AniList ID format for MangaDex manga: ${detail.title}`,
{ anilistId },
);
return null;
}

console.debug(
`[MangaDex] 🎯 Found AniList ID ${parsedAnilistId} for MangaDex manga: ${detail.title}`,
);

return parsedAnilistId;
} catch (error) {
console.error(
`❌ Failed to extract AniList ID for MangaDex manga ${detail.title}:`,
error,
);
return null;
}
}

/**
* Map MangaDex status string to numeric status code.
* @param status - The MangaDex status string (ongoing, completed, hiatus, cancelled).
* @returns Numeric status: 1=ongoing, 2=completed, 3=hiatus, 4=cancelled, 0=unknown.
* @source
*/
private mapStatus(status: string): number {
switch (status?.toLowerCase()) {
case "ongoing":
return 1;
case "completed":
return 2;
case "hiatus":
return 3;
case "cancelled":
return 4;
default:
return 0; // Unknown
}
}

/**
* Extract AniList ID from a MangaDex manga entry.
* Prefer using the existing links on the lightweight `MangaDexManga` object
* and fall back to fetching detail data if required.
* @param manga - The MangaDex manga entry.
* @returns AniList ID number or null.
*/
public async extractAniListId(manga: MangaDexManga): Promise<number | null> {
if (manga.links && typeof manga.links === "object" && manga.links.al) {
const parsed = Number.parseInt(String(manga.links.al), 10);
if (!Number.isNaN(parsed)) return parsed;
}

// Fallback: fetch detail and attempt to extract from there
try {
const detail = await this.getMangaDetail(manga.id);
if (!detail) return null;
return this.extractAniListIdFromDetail(detail);
} catch (err) {
console.warn(
"[MangaDex] Failed to extract AniList ID by fetching details",
err,
);
return null;
}
}
/**
* Build search URL with parameters for MangaDex API.
* Includes content rating filters and relevance ordering.
* @param query - The search query string.
* @param limit - Maximum number of results.
* @returns The formatted search URL.
* @source
*/
protected buildSearchUrl(query: string, limit: number): string {
const encodedQuery = encodeURIComponent(query);
// MangaDex uses offset-based pagination, starting at offset 0
return `${this.config.baseUrl}/manga?title=${encodedQuery}&limit=${limit}&offset=0&contentRating[]=safe&contentRating[]=suggestive&contentRating[]=erotica&order[relevance]=desc`;
}

/**
* Build detail URL for a specific manga.
* Includes author, artist, and cover art includes.
* @param id - The MangaDex manga ID.
* @returns The formatted detail URL.
* @source
*/
protected buildDetailUrl(slug: string): string {
return `${this.config.baseUrl}/manga/${slug}?includes[]=author&includes[]=artist&includes[]=cover_art`;
}
}

Hierarchy (View Summary)

Constructors

Methods

  • Search for manga on this source and enrich results with AniList data. Fetches source results, extracts AniList IDs, and merges AniList data.

    Parameters

    • query: string

      The search query string.

    • accessToken: string

      AniList OAuth access token for fetching manga details.

    • limit: number = 1

      Maximum number of results to return (default: 1).

    Returns Promise<EnhancedAniListManga[]>

    Promise resolving to enhanced AniList manga entries with source info.

      public async searchAndGetAniListManga(
    query: string,
    accessToken: string,
    limit: number = 1,
    ): Promise<EnhancedAniListManga[]> {
    try {
    console.info(
    `[MangaSourceBase] 🔍 Starting ${this.config.name} search for "${query}" with limit ${limit}`,
    );

    // Search on this source
    const sourceResults = await this.searchManga(query, limit);
    if (!sourceResults?.length) {
    console.debug(
    `[MangaSourceBase] 📦 No ${this.config.name} results found for "${query}"`,
    );
    return [];
    }

    console.debug(
    `[MangaSourceBase] 📦 Found ${sourceResults.length} ${this.config.name} results, extracting AniList IDs...`,
    );

    const anilistIds: number[] = [];
    const sourceMap = new Map<number, TMangaEntry>();

    for (const sourceManga of sourceResults) {
    const anilistId = await this.extractAniListId(sourceManga);
    if (!anilistId) continue;
    anilistIds.push(anilistId);
    sourceMap.set(anilistId, sourceManga);
    }

    if (!anilistIds.length) {
    console.debug(
    `[MangaSourceBase] 🔗 No AniList links found in ${this.config.name} results for "${query}"`,
    );
    return [];
    }

    console.info(
    `[MangaSourceBase] 🎯 Found ${anilistIds.length} AniList IDs from ${this.config.name}: [${anilistIds.join(", ")}]`,
    );

    const anilistManga = await getMangaByIds(anilistIds, accessToken);
    if (!anilistManga?.length) {
    console.warn(
    `[MangaSourceBase] ❌ Failed to fetch AniList manga for IDs: [${anilistIds.join(", ")}]`,
    );
    return [];
    }

    // Enhance AniList manga with source info
    const enhancedManga: EnhancedAniListManga[] = anilistManga.map(
    (manga) => {
    const sourceInfo = sourceMap.get(manga.id);

    return {
    ...manga,
    sourceInfo: sourceInfo
    ? {
    title: sourceInfo.title,
    slug: sourceInfo.slug,
    sourceId: sourceInfo.id,
    source: this.config.source,
    isFoundViaAlternativeSearch: true,
    }
    : undefined,
    };
    },
    );

    console.info(
    `[MangaSourceBase] ✅ Successfully enhanced ${enhancedManga.length} AniList manga with ${this.config.name} source info`,
    );
    return enhancedManga;
    } catch (error) {
    console.error(
    `[MangaSourceBase] ❌ ${this.config.name} search and AniList fetch failed for "${query}":`,
    error,
    );
    return [];
    }
    }
  • Clear cache entries for specific search queries. Removes all cache entries matching the query pattern across different limits.

    Parameters

    • queries: string[]

      Array of search queries to clear from cache.

    Returns number

    Number of cache entries cleared.

      public clearCache(queries: string[]): number {
    let clearedCount = 0;

    for (const query of queries) {
    // Clear cache entries matching query (different limits may exist)
    const keysToDelete = Object.keys(this.cache).filter((key) =>
    key.startsWith(`search:${query.toLowerCase()}:`),
    );

    for (const key of keysToDelete) {
    delete this.cache[key];
    clearedCount++;
    }
    }

    console.info(
    `[MangaSourceBase] 🧹 Cleared ${clearedCount} ${this.config.name} cache entries`,
    );
    return clearedCount;
    }
  • Get cache status and statistics for debugging.

    Returns {
        source: string;
        totalEntries: number;
        activeEntries: number;
        expiredEntries: number;
    }

    Object with total entries, active entries, and expired entries counts.

      public getCacheStatus() {
    const totalEntries = Object.keys(this.cache).length;
    const expiredEntries = Object.keys(this.cache).filter(
    (key) => Date.now() - this.cache[key].timestamp > this.cacheExpiryMs,
    ).length;

    return {
    source: this.config.name,
    totalEntries,
    activeEntries: totalEntries - expiredEntries,
    expiredEntries,
    };
    }
  • Search for manga on MangaDex API. Results are cached for 30 minutes by default.

    Parameters

    • query: string

      The search query string.

    • limit: number = 10

      Maximum number of results to return (default: 10).

    Returns Promise<MangaDexManga[]>

    Promise resolving to array of MangaDex manga entries.

      public async searchManga(
    query: string,
    limit: number = 10,
    ): Promise<MangaDexManga[]> {
    return withGroupAsync(`[MangaDex] Search: "${query}"`, async () => {
    // Check cache first
    const cacheKey = `search:${query.toLowerCase()}:${limit}`;
    const cached = this.getCachedData<MangaDexManga[]>(cacheKey);

    if (cached) {
    console.debug(`[MangaDex] 📦 Using cached results for "${query}"`);
    return cached;
    }
    console.info(
    `[MangaDex] 🔍 Searching MangaDex for: "${query}" (limit: ${limit})`,
    );

    try {
    // Make direct HTTP request using the base client's functionality
    const url = this.buildSearchUrl(query, limit);
    const data = await this.makeRequest(url);

    const results = this.parseSearchResponse(data);
    this.setCachedData(cacheKey, results);

    console.info(
    `[MangaDex] ✅ MangaDex search found ${results?.length || 0} results for "${query}"`,
    );
    return results;
    } catch (error) {
    console.error(
    `[MangaDex] ❌ MangaDex search failed for "${query}":`,
    error,
    );
    return [];
    }
    });
    }
  • Get detailed information about a specific MangaDex manga.

    Parameters

    • slug: string

      The MangaDex manga ID.

    Returns Promise<null | MangaDexMangaDetail>

    Promise resolving to manga detail or null if not found.

      public async getMangaDetail(
    slug: string,
    ): Promise<MangaDexMangaDetail | null> {
    return withGroupAsync(`[MangaDex] Detail: "${slug}"`, async () => {
    try {
    console.debug(
    `[MangaDex] 📖 Getting MangaDex manga details for: ${slug}`,
    );

    // Make direct HTTP request using the base client's functionality
    const url = this.buildDetailUrl(slug);
    const rawData = await this.makeRequest(url);
    const detail = this.parseDetailResponse(rawData);
    if (detail) {
    console.info(`[MangaDex] ✅ Retrieved details for manga ${slug}`);
    }
    return detail;
    } catch (error) {
    console.error(
    `[MangaDex] ❌ Failed to get MangaDex manga details for ${slug}:`,
    error,
    );
    return null;
    }
    });
    }