'use strict'; const { specialKey } = require('../../chapter-identity'); function localizedTitle(attributes = {}) { const titles = attributes.title || {}; return titles.en || titles['ja-ro'] || titles.ja || Object.values(titles).find(Boolean) || 'Untitled'; } function chapterNumber(value) { const raw = String(value ?? '').trim(); if (!/^\d+(?:\.\d+)?$/.test(raw)) return null; const [whole, decimal = ''] = raw.split('.'); const normalizedWhole = whole.replace(/^0+(?=\d)/, '') || '0'; const normalizedDecimal = decimal.replace(/0+$/, ''); return normalizedDecimal ? `${normalizedWhole}.${normalizedDecimal}` : normalizedWhole; } function groupFor(resource) { const relation = (resource.relationships || []).find(item => item.type === 'scanlation_group'); return relation ? { id: relation.id, name: relation.attributes?.name || 'Unknown group' } : null; } function releaseRank(resource) { const group = groupFor(resource)?.name || ''; const credited = /official|license/i.test(group) ? 0 : group ? 1 : 2; const date = Date.parse(resource.attributes?.publishAt || resource.attributes?.readableAt || resource.attributes?.createdAt || '') || Number.MAX_SAFE_INTEGER; return [credited, date, resource.id]; } function compareRank(left, right) { const a = releaseRank(left), b = releaseRank(right); return a[0] - b[0] || a[1] - b[1] || String(a[2]).localeCompare(String(b[2])); } function selectChapterReleases(resources = [], specialLabel = 'Special') { const selected = new Map(); for (const resource of resources) { if (resource.attributes?.externalUrl || Number(resource.attributes?.pages || 0) < 1) continue; const number = chapterNumber(resource.attributes?.chapter) || specialKey(resource.id); const prior = selected.get(number); if (!prior || compareRank(resource, prior) < 0) selected.set(number, resource); } return [...selected.entries()].map(([number, resource]) => { const group = groupFor(resource); return { id: resource.id, number, title: resource.attributes?.title || '', ...(!chapterNumber(resource.attributes?.chapter) ? { special: true, label: specialLabel, sourceId: resource.id, sourceNumber: resource.attributes?.chapter ?? null } : {}), ...(resource.attributes?.volume ? { volume: chapterNumber(resource.attributes.volume) || String(resource.attributes.volume) } : {}), pages: Number(resource.attributes?.pages || 0), added: resource.attributes?.publishAt || resource.attributes?.readableAt || resource.attributes?.createdAt, group, url: `https://mangadex.org/chapter/${resource.id}`, }; }).sort((a, b) => Number(!!a.special) - Number(!!b.special) || (a.special ? a.number.localeCompare(b.number) : Number(a.number) - Number(b.number) || a.number.localeCompare(b.number))); } function availabilitySummary(attributes, releases, chapters, externalReleases = null) { const numbered = chapters.filter(ref => !ref.special); const expected = chapterNumber(attributes.lastChapter); const maximum = numbered.length ? Math.max(...numbered.map(ref => Number(ref.number))) : null; const expectedCount = expected && /^\d+$/.test(expected) ? Number(expected) : null; const sparse = expectedCount && numbered.length < expectedCount; const reason = chapters.length ? null : externalReleases > 0 ? 'external-only' : Array.isArray(attributes.availableTranslatedLanguages) && !attributes.availableTranslatedLanguages.includes('en') ? 'no-english' : 'no-hosted-pages'; const message = reason === 'external-only' ? `MangaDex lists ${externalReleases} English external-link releases, but no hosted English pages. External links are not downloaded.` : reason === 'no-english' ? 'This MangaDex edition has no English releases listed.' : reason ? 'This MangaDex edition has no hosted English releases with pages.' : null; return { language: 'en', hostedReleases: releases.filter(r => !r.attributes?.externalUrl && Number(r.attributes?.pages) > 0).length, selectedChapters: chapters.length, numberedChapters: numbered.length, specialReleases: chapters.length - numbered.length, externalReleases, expectedLastChapter: expected, highestHostedChapter: maximum, coverage: expected && (maximum === null || maximum < Number(expected) || sparse) ? 'partial' : 'unknown', reason, message }; } function pageUrls(atHome, chapterUrl, expectedPages) { const base = new URL(atHome.baseUrl); if (base.protocol !== 'https:' || !(base.hostname === 'uploads.mangadex.org' || base.hostname.endsWith('.mangadex.network'))) { throw new Error(`Unexpected MangaDex@Home host: ${base.hostname}`); } const hash = String(atHome.chapter?.hash || ''); const files = atHome.chapter?.data; if (!hash || !Array.isArray(files) || !files.length) throw new Error('MangaDex@Home returned no pages'); if (expectedPages && files.length !== expectedPages) throw new Error(`MangaDex page count changed: expected ${expectedPages}, received ${files.length}`); return files.map(file => ({ url: new URL(`/data/${encodeURIComponent(hash)}/${encodeURIComponent(file)}`, base).href, headers: { referer: chapterUrl }, })); } function metadata({ data: manga }) { if (!manga || manga.type !== 'manga') throw new Error('MangaDex title was not found'); const attributes = manga.attributes || {}; const title = localizedTitle(attributes); const specialLabel = /pre.serialization|pilot/i.test(title) ? 'Pilot' : (attributes.tags || []).some(tag => tag.attributes?.name?.en === 'Oneshot') ? 'One-shot' : 'Special'; return { title, originalLanguage: attributes.originalLanguage || null, links: attributes.links || {}, specialLabel, availabilityAttributes: { lastChapter: attributes.lastChapter ?? null, availableTranslatedLanguages: attributes.availableTranslatedLanguages ?? null, } }; } function feed({ data: releases }) { if (!Array.isArray(releases)) throw new Error('Invalid MangaDex feed'); return { releases: releases.map(resource => ({ id: resource.id, attributes: { chapter: resource.attributes?.chapter, volume: resource.attributes?.volume, title: resource.attributes?.title, pages: resource.attributes?.pages, publishAt: resource.attributes?.publishAt, readableAt: resource.attributes?.readableAt, createdAt: resource.attributes?.createdAt, externalUrl: resource.attributes?.externalUrl, }, relationships: (resource.relationships || []).filter(relation => relation.type === 'scanlation_group'), })) }; } function catalog({ data }) { const { attributes, releases, specialLabel, externalReleases } = data; if (!attributes || !Array.isArray(releases)) throw new Error('Invalid MangaDex catalog input'); const chapters = selectChapterReleases(releases, specialLabel); return { chapters, availability: availabilitySummary(attributes, releases, chapters, externalReleases) }; } function atHome({ data }) { const { response, chapterUrl, expectedPages } = data; return { pages: pageUrls(response, chapterUrl, expectedPages) }; } module.exports = { localizedTitle, chapterNumber, selectChapterReleases, availabilitySummary, pageUrls, metadata, feed, catalog, atHome };