- Chapter rows show when you first finished a chapter (setting: date + hour, date, off) from a new durable progress doneAt; older reads fall back to the reading clock's earliest remembered completion. - chapter.json keeps added (arrival in this library, now kept on replace) plus x-dates.source (source upload, bulk imports flagged), x-dates.release (official, for a later metadata job) and x-dates.ecosystem (first entry into any Manga Circus library; carried over peering, earliest wins, clamped). - Rows show one release date by tier: official > source upload > ecosystem > library, with all known dates in the tooltip (setting to hide). - Fixes: WeebCentral dates came from the chapter page, which has none (every chapter got the download time); they now come from the full chapter list. MangaDex no longer writes a group's publishAt into added. Peering sends dates. - tools/backfill-chapter-dates.js migrates an existing library (dry run default). - Coverage dry run on the server: docs/chapter-dates-coverage-2026-10-24.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
136 lines
7.4 KiB
JavaScript
136 lines
7.4 KiB
JavaScript
'use strict';
|
|
|
|
const { specialKey } = require('../../chapter-identity');
|
|
const { batchFlags } = require('../../chapter-dates');
|
|
|
|
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);
|
|
}
|
|
const chosen = [...selected.entries()];
|
|
const uploaded = chosen.map(([, resource]) => resource.attributes?.publishAt || resource.attributes?.readableAt || resource.attributes?.createdAt);
|
|
const batches = batchFlags(uploaded);
|
|
return chosen.map(([number, resource], index) => {
|
|
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),
|
|
// A group's upload time, not an official release; bulk uploads are flagged.
|
|
...(uploaded[index] ? { uploadedAt: uploaded[index], uploadBatch: !!batches[index] } : {}),
|
|
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 };
|