@houdoku/extension-mangadex
Version:
{"id":"6b4e9df1-b369-4adc-8d36-fe954dd793e3","name":"MangaDex","url":"https://mangadex.org","version":"1.6.1","translatedLanguage":"MULTI","hasSettings":true}
436 lines (393 loc) • 13.8 kB
text/typescript
import {
GetSeriesFunc,
GetChaptersFunc,
GetPageRequesterDataFunc,
GetPageUrlsFunc,
GetSearchFunc,
GetImageFunc,
ExtensionMetadata,
PageRequesterData,
GetDirectoryFunc,
Chapter,
LanguageKey,
Series,
SeriesStatus,
ExtensionClientAbstract,
GetSettingsFunc,
SetSettingsFunc,
GetSettingTypesFunc,
SettingType,
FilterValues,
FilterCheckbox,
FilterMultiToggle,
FilterSelect,
FilterSeparator,
FilterSort,
SortDirection,
TriState,
MultiToggleValues,
FilterSortValue,
FilterCycle,
} from "houdoku-extension-lib";
import { Response } from "node-fetch";
import metadata from "./metadata.json";
import { parseMetadata } from "../../util/configuring";
import { GetFilterOptionsFunc, UtilFunctions } from "houdoku-extension-lib/dist/interface";
import {
FIELDS_CONTENT_RATINGS,
FIELDS_CONTENT_WARNINGS,
FIELDS_DEMOGRAPHICS,
FIELDS_FORMATS,
FIELDS_GENRES,
FIELDS_ORIGINAL_LANGUAGES,
FIELDS_SORT,
FIELDS_STATUS,
FIELDS_THEMES,
FilterControlIds,
OPTIONS_TAG_MODE,
} from "./filters";
export const METADATA: ExtensionMetadata = parseMetadata(metadata);
const SERIES_STATUS_MAP: { [key: string]: SeriesStatus } = {
ongoing: SeriesStatus.ONGOING,
completed: SeriesStatus.COMPLETED,
hiatus: SeriesStatus.ONGOING,
cancelled: SeriesStatus.CANCELLED,
};
const LANGUAGE_MAP: { [key: string]: LanguageKey } = {
ar: LanguageKey.ARABIC,
bg: LanguageKey.BULGARIAN,
ca: LanguageKey.CATALAN,
zh: LanguageKey.CHINESE_SIMP,
"zh-ro": LanguageKey.CHINESE_TRAD,
cs: LanguageKey.CZECH,
da: LanguageKey.DANISH,
nl: LanguageKey.DUTCH,
en: LanguageKey.ENGLISH,
fi: LanguageKey.FINNISH,
fr: LanguageKey.FRENCH,
de: LanguageKey.GERMAN,
el: LanguageKey.GREEK,
he: LanguageKey.HEBREW,
hi: LanguageKey.HINDI,
hu: LanguageKey.HUNGARIAN,
id: LanguageKey.INDONESIAN,
it: LanguageKey.ITALIAN,
ja: LanguageKey.JAPANESE,
"ja-ro": LanguageKey.JAPANESE,
ko: LanguageKey.KOREAN,
"ko-ro": LanguageKey.KOREAN,
lt: LanguageKey.LITHUANIAN,
ms: LanguageKey.MALAY,
pl: LanguageKey.POLISH,
pt: LanguageKey.PORTUGUESE_PT,
"pt-br": LanguageKey.PORTUGUESE_BR,
ro: LanguageKey.ROMANIAN,
ru: LanguageKey.RUSSIAN,
es: LanguageKey.SPANISH_ES,
"es-la": LanguageKey.SPANISH_LATAM,
sv: LanguageKey.SWEDISH,
th: LanguageKey.THAI,
tr: LanguageKey.TURKISH,
uk: LanguageKey.UKRAINIAN,
vi: LanguageKey.VIETNAMESE,
};
enum SETTING_NAMES {
USE_DATA_SAVER = "Use data saver",
}
const SETTING_TYPES = {
[SETTING_NAMES.USE_DATA_SAVER]: SettingType.BOOLEAN,
};
const DEFAULT_SETTINGS = {
[SETTING_NAMES.USE_DATA_SAVER]: false,
};
const PAGE_SIZE = 48;
type ParsedResults = {
seriesList: Series[];
hasMore: boolean;
};
const _parseManga = (json: any): Series => {
const tags: string[] = json.attributes.tags.map((tag: any) => tag.attributes.name.en);
if (json.attributes.publicationDemographic !== null) {
tags.push(json.attributes.publicationDemographic);
}
const title =
json.attributes.title.en !== undefined
? json.attributes.title.en
: Object.values(json.attributes.title)[0];
const coverRelationship = json.relationships.find(
(relationship: any) =>
relationship.type === "cover_art" && relationship.attributes !== undefined
);
const remoteCoverUrl =
coverRelationship !== undefined
? `https://uploads.mangadex.org/covers/${json.id}/${coverRelationship.attributes.fileName}.512.jpg`
: "";
const series: Series = {
id: undefined,
extensionId: METADATA.id,
sourceId: json.id,
title,
altTitles: json.attributes.altTitles.map((altTitleCont: any) => altTitleCont.en),
description: json.attributes.description.en,
authors: json.relationships
.filter(
(relationship: any) =>
relationship.type === "author" && relationship.attributes !== undefined
)
.map((relationship: any) => relationship.attributes.name),
artists: json.relationships
.filter(
(relationship: any) =>
relationship.type === "artist" && relationship.attributes !== undefined
)
.map((relationship: any) => relationship.attributes.name),
tags: tags,
status: SERIES_STATUS_MAP[json.attributes.status],
originalLanguageKey: LANGUAGE_MAP[json.attributes.originalLanguage],
numberUnread: 0,
remoteCoverUrl,
};
return series;
};
const _parseMangaResults = (json: any): ParsedResults => {
if (!("data" in json) || json.data === undefined || json.data.length === 0) {
return { seriesList: [], hasMore: false };
}
const seriesList = json.data.map((data: any) => _parseManga(data));
const hasMore = json.total > json.offset + seriesList.length;
return {
seriesList,
hasMore,
};
};
export class ExtensionClient extends ExtensionClientAbstract {
constructor(utilFns: UtilFunctions) {
super(utilFns);
this.settings = DEFAULT_SETTINGS;
}
getMetadata: () => ExtensionMetadata = () => {
return METADATA;
};
getSeries: GetSeriesFunc = (id: string) => {
return this.utilFns
.fetchFn(
`https://api.mangadex.org/manga/${id}?includes[]=artist&includes[]=author&includes[]=cover_art`
)
.then((response: Response) => response.json())
.then((json: any) => {
if (!("data" in json) || json.data === undefined) return undefined;
return _parseManga(json.data);
});
};
getChapters: GetChaptersFunc = async (id: string) => {
const chapterList: Chapter[] = [];
let gotAllChapters: boolean = false;
let offset = 0;
while (!gotAllChapters) {
const params = new URLSearchParams({
offset: `${offset}`,
limit: "500",
"includes[]": "scanlation_group",
});
FIELDS_CONTENT_RATINGS.forEach((contentRating) => {
params.append("contentRating[]", contentRating.key);
});
const response = await this.utilFns.fetchFn(
`https://api.mangadex.org/manga/${id}/feed?` + params
);
const json = await response.json();
json.data.forEach((result: any) => {
const groupRelationship: any | undefined = result.relationships.find(
(relationship: any) =>
relationship.type === "scanlation_group" && relationship.attributes !== undefined
);
const groupName = groupRelationship !== undefined ? groupRelationship.attributes.name : "";
chapterList.push({
id: undefined,
seriesId: undefined,
sourceId: result.id,
title: result.attributes.title || "",
chapterNumber: result.attributes.chapter || "0",
volumeNumber: result.attributes.volume || "",
languageKey: LANGUAGE_MAP[result.attributes.translatedLanguage],
groupName,
time: new Date(result.attributes.updatedAt).getTime(),
read: false,
});
});
if (json.total > offset + 500) {
offset += 500;
} else {
gotAllChapters = true;
}
}
return chapterList;
};
getPageRequesterData: GetPageRequesterDataFunc = (
seriesSourceId: string,
chapterSourceId: string
) => {
return this.utilFns
.fetchFn(`https://api.mangadex.org/at-home/server/${chapterSourceId}`)
.then((response: Response) => response.json())
.then((json: any) => {
const pageFilenames = this.settings[SETTING_NAMES.USE_DATA_SAVER]
? json.chapter.dataSaver
: json.chapter.data;
return {
server: json.baseUrl,
hash: json.chapter.hash,
numPages: pageFilenames.length,
pageFilenames,
};
});
};
getPageUrls: GetPageUrlsFunc = (pageRequesterData: PageRequesterData) => {
const dataStr = this.settings[SETTING_NAMES.USE_DATA_SAVER] ? "data-saver" : "data";
return pageRequesterData.pageFilenames.map((filename: string) => {
return `${pageRequesterData.server}/${dataStr}/${pageRequesterData.hash}/${filename}`;
});
};
getImage: GetImageFunc = (series: Series, url: string) => {
return new Promise((resolve, reject) => {
resolve(url);
});
};
getDirectory: GetDirectoryFunc = (page: number, filterValues: FilterValues) => {
return this.getSearch("", page, filterValues);
};
getSearch: GetSearchFunc = (text: string, page: number, filterValues: FilterValues) => {
const params = new URLSearchParams({
title: text,
offset: `${(page - 1) * PAGE_SIZE}`,
limit: `${PAGE_SIZE}`,
});
["artist", "author", "cover_art"].forEach((name) => params.append("includes[]", name));
const _applyTags = (controlId: string) => {
if (controlId in filterValues) {
Object.entries(filterValues[controlId] as MultiToggleValues).forEach(([tagId, value]) => {
if (value === TriState.INCLUDE) params.append("includedTags[]", tagId);
if (value === TriState.EXCLUDE) params.append("excludedTags[]", tagId);
});
}
};
_applyTags(FilterControlIds.Formats);
_applyTags(FilterControlIds.Genres);
_applyTags(FilterControlIds.Themes);
_applyTags(FilterControlIds.ContentWarnings);
if (FilterControlIds.ContentRating in filterValues) {
Object.entries(filterValues[FilterControlIds.ContentRating] as MultiToggleValues).forEach(
([contentRating, value]) => {
if (value === TriState.INCLUDE) params.append("contentRating[]", contentRating);
}
);
}
if (FilterControlIds.Status in filterValues) {
Object.entries(filterValues[FilterControlIds.Status] as MultiToggleValues).forEach(
([status, value]) => {
if (value === TriState.INCLUDE) params.append("status[]", status);
}
);
}
if (FilterControlIds.Sort in filterValues) {
const sort = filterValues[FilterControlIds.Sort] as FilterSortValue;
params.append(
`order[${sort.key}]`,
{ [SortDirection.ASCENDING]: "asc", [SortDirection.DESCENDING]: "desc" }[sort.direction]
);
}
if (FilterControlIds.Demographic in filterValues) {
Object.entries(filterValues[FilterControlIds.Demographic] as MultiToggleValues).forEach(
([demo, value]) => {
if (value === TriState.INCLUDE) params.append("publicationDemographic[]", demo);
}
);
}
if (FilterControlIds.OriginalLanguage in filterValues) {
Object.entries(filterValues[FilterControlIds.OriginalLanguage] as MultiToggleValues).forEach(
([lang, value]) => {
if (value === TriState.INCLUDE) {
params.append("originalLanguage[]", lang);
if (lang === "zh") params.append("originalLanguage[]", "zh-hk");
}
}
);
}
if (FilterControlIds.HasAvailableChapters in filterValues) {
if (filterValues[FilterControlIds.HasAvailableChapters] === true) {
params.append("hasAvailableChapters", "true");
}
}
return this.utilFns
.fetchFn("https://api.mangadex.org/manga?" + params)
.then((response: Response) => response.json())
.then((json: any) => {
const results: ParsedResults = _parseMangaResults(json);
return {
seriesList: results.seriesList,
hasMore: results.hasMore,
};
});
};
getSettingTypes: GetSettingTypesFunc = () => {
return SETTING_TYPES;
};
getSettings: GetSettingsFunc = () => {
return this.settings;
};
setSettings: SetSettingsFunc = (newSettings: { [key: string]: any }) => {
Object.keys(newSettings).forEach((key: string) => {
if (key in this.settings && typeof (this.settings[key] === newSettings[key])) {
this.settings[key] = newSettings[key];
}
});
};
getFilterOptions: GetFilterOptionsFunc = () => {
return [
new FilterCheckbox(FilterControlIds.HasAvailableChapters, "Has available chapters", false),
new FilterSort(FilterControlIds.Sort, "Sort", {
key: "relevance",
direction: SortDirection.DESCENDING,
})
.withFields(FIELDS_SORT)
.withSupportsBothDirections(true),
new FilterSeparator("separator1", "", ""),
new FilterMultiToggle(FilterControlIds.Formats, "Format", {})
.withFields(FIELDS_FORMATS)
.withIsTriState(true),
new FilterMultiToggle(FilterControlIds.Themes, "Theme", {})
.withFields(FIELDS_THEMES)
.withIsTriState(true),
new FilterMultiToggle(FilterControlIds.Genres, "Genre", {})
.withFields(FIELDS_GENRES)
.withIsTriState(true),
new FilterMultiToggle(FilterControlIds.ContentWarnings, "Content Warning", {})
.withFields(FIELDS_CONTENT_WARNINGS)
.withIsTriState(true),
new FilterSeparator("separator2", "", ""),
new FilterMultiToggle(FilterControlIds.OriginalLanguage, "Original Language", {})
.withFields(FIELDS_ORIGINAL_LANGUAGES)
.withIsTriState(false),
new FilterMultiToggle(FilterControlIds.Demographic, "Demographic", {})
.withFields(FIELDS_DEMOGRAPHICS)
.withIsTriState(false),
new FilterMultiToggle(FilterControlIds.ContentRating, "Content Rating", {
safe: TriState.INCLUDE,
suggestive: TriState.INCLUDE,
erotica: TriState.INCLUDE,
})
.withFields(FIELDS_CONTENT_RATINGS)
.withIsTriState(false),
new FilterMultiToggle(FilterControlIds.Status, "Status", {})
.withFields(FIELDS_STATUS)
.withIsTriState(false),
new FilterSeparator("separator3", "", ""),
new FilterCycle(FilterControlIds.IncludedTagsMode, "Included Tags Mode", "AND").withOptions(
OPTIONS_TAG_MODE
),
new FilterCycle(FilterControlIds.ExcludedTagsMode, "Excluded Tags Mode", "OR").withOptions(
OPTIONS_TAG_MODE
),
];
};
}