diff --git a/src/mangaplus/manifest.json b/src/mangaplus/manifest.json index bd27e5d..868a3ca 100644 --- a/src/mangaplus/manifest.json +++ b/src/mangaplus/manifest.json @@ -1,6 +1,6 @@ { "name": "mangaplus", - "version": "1.2.0", + "version": "1.2.1", "publoader_api": "^2.0.0", "runtime": "node", "entrypoint": "index.mjs", diff --git a/src/mangaplus/package.json b/src/mangaplus/package.json index 65036ed..6d51455 100644 --- a/src/mangaplus/package.json +++ b/src/mangaplus/package.json @@ -1,6 +1,6 @@ { "name": "publoader-ext-mangaplus", - "version": "1.2.0", + "version": "1.2.1", "private": true, "type": "module", "description": "publoader extension for MangaPlus by Shueisha (extension API v2)", diff --git a/src/mangaplus/src/expiry.test.ts b/src/mangaplus/src/expiry.test.ts new file mode 100644 index 0000000..ce95313 --- /dev/null +++ b/src/mangaplus/src/expiry.test.ts @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { hasExpired } from "./normalise.ts"; + +const NOW = 1_757_332_800; // 2026-09-08T12:00:00Z, epoch seconds. + +test("a stated expiry in the past has closed", () => { + assert.equal(hasExpired(NOW - 86_400, NOW), true); +}); + +test("a stated expiry in the future has not", () => { + assert.equal(hasExpired(NOW + 86_400, NOW), false); +}); + +test("an absent expiry never expires", () => { + // The regression this guards: proto3 drops zero values, so a chapter that + // never rotates out arrives with endTimeStamp absent. It used to be stood in + // as DEFAULT_TIMESTAMP (epoch second 1), which read as "expired in 1970" -- + // skipping the chapter from every upload, and, once the platform started + // hard-deleting still-listed expired chapters as paywalled, marking the + // permanently-free chapters for deletion. + assert.equal(hasExpired(null, NOW), false); +}); + +test("an expiry exactly at now has not closed yet", () => { + assert.equal(hasExpired(NOW, NOW), false); +}); diff --git a/src/mangaplus/src/index.ts b/src/mangaplus/src/index.ts index 663bf59..f4e8180 100644 --- a/src/mangaplus/src/index.ts +++ b/src/mangaplus/src/index.ts @@ -38,6 +38,7 @@ import { type PbTitle, } from "./proto"; import { + hasExpired, normaliseChapters, numberWordsPattern, type OverrideOptions, @@ -187,7 +188,7 @@ function isoFromEpoch(seconds: number): string | null { function toChapterInput(chapter: RawChapter): ChapterInput { return { chapterTimestamp: isoFromEpoch(chapter.chapterTimestamp), - chapterExpire: isoFromEpoch(chapter.chapterExpire), + chapterExpire: chapter.chapterExpire === null ? null : isoFromEpoch(chapter.chapterExpire), chapterLanguage: chapter.chapterLanguage, chapterNumber: chapter.chapterNumber, chapterTitle: chapter.chapterTitle, @@ -310,7 +311,7 @@ class MangaPlus implements ExtensionRuntime { // MangaPlus rotates free chapters out; anything already expired, or // older than the update window, is not a new upload. if (postedChapterIds.has(chapter.chapterId)) continue; - if (chapter.chapterExpire < now) continue; + if (hasExpired(chapter.chapterExpire, now)) continue; if (chapter.chapterTimestamp < now - UPDATE_WINDOW_SECONDS) continue; updatedChapters.push(chapter); } @@ -880,7 +881,14 @@ class MangaPlus implements ExtensionRuntime { chapterId, chapterUrl: CHAPTER_URL(chapterId), chapterTimestamp: chapter.startTimeStamp ?? DEFAULT_TIMESTAMP, - chapterExpire: chapter.endTimeStamp ?? DEFAULT_TIMESTAMP, + // null, NOT DEFAULT_TIMESTAMP: a missing endTimeStamp means "no expiry + // known", and proto3 drops zero values, so a chapter that never expires + // arrives here with the field absent. Standing that in as epoch second + // 1 reads as "expired in 1970" — which skipped the chapter from every + // upload below, and now that the platform deletes still-listed expired + // chapters as paywalled, would delete exactly the chapters that are + // free forever. Unknown has to stay unknown. + chapterExpire: chapter.endTimeStamp ?? null, chapterTitle: chapter.subTitle ?? null, chapterNumber: chapter.name ?? null, chapterLanguage: manga.language, diff --git a/src/mangaplus/src/normalise.ts b/src/mangaplus/src/normalise.ts index 7e1a20a..fcc0fe9 100644 --- a/src/mangaplus/src/normalise.ts +++ b/src/mangaplus/src/normalise.ts @@ -38,7 +38,8 @@ export interface RawChapter { listSlot?: "first" | "mid" | "last"; chapterUrl: string; chapterTimestamp: number; - chapterExpire: number; + /** null when the publisher states no expiry, i.e. the chapter stays free. */ + chapterExpire: number | null; chapterTitle: string | null; chapterNumber: string | null; chapterLanguage: string; @@ -82,6 +83,22 @@ export interface OverrideOptions { verify_pages?: boolean; } +/** + * Has the publisher's free window closed? + * + * Only a stated expiry can close it. A null expiry means MANGA Plus named no + * end date — proto3 drops zero values, so a chapter that never rotates out + * arrives with the field absent — and "unknown" must not read as "expired". + * Getting this backwards skips the chapter from every upload, and, because the + * platform now hard-deletes still-listed expired chapters as paywalled, would + * delete the chapters that are free forever. + * + * Both arguments are epoch SECONDS, matching the wire format. + */ +export function hasExpired(expire: number | null, now: number): boolean { + return expire !== null && expire < now; +} + /** publoader's chapter-number validator (utils/utils.py). */ const CHAPTER_NUMBER_RE = /^(0|[1-9]\d*)((\.\d+){1,2})?[a-z]?$/i;