Files
Maashkinoozhe 8dd776b7f4 MangaParty: self-hosted manga/comic/webtoon reader
Zero-dependency Node server + plain HTML/JS frontend.

- Library is a folder tree (Manga/Book/Chapter/pages), indexed in place;
  page roles (cover, double, extra, rotation) are file name tags
- Accounts with per-user progress, reading settings, follows and
  new-chapter notifications (in-app + optional webhook)
- Reader with Flow, Page and Book modes, pointer-anchored page zoom,
  tap zones, auto-hiding controls, chapter picker, page preloading
- Multiple versions per chapter with community votes
- Docker image and compose file
- Demo library generator (tools/make-dummy.js)
- Docs: architecture, library format, import, scraper guide
2026-10-01 09:35:05 +02:00

216 lines
11 KiB
JavaScript

'use strict';
// Builds a demo library that exercises every part of the folder format (see README.md).
// Usage: node tools/make-dummy.js [libraryDir] (default: ./library)
// Only the demo manga folders listed below are (re)created. Nothing else in the target is touched.
const fs = require('fs'), path = require('path');
const { Canvas, hsl } = require('./png');
const OUT = path.resolve(process.argv[2] || 'library');
const PW = 900, PH = 1300; // single page size in px
const WHITE = [255, 255, 255];
const SUFFIX = { C: '_cover', D: '_double', R: '_rot90', E: '_extra', S: '', W: '' };
// Page kinds: C chapter cover, S single, D double (tagged), W wide but untagged, R stored rotated, E extra
const LIBRARY = [
{
dir: 'Moon Garden', hue: 205, type: 'MANGA',
meta: {
title: 'Moon Garden', altTitles: ['Tsuki no Niwa'], authors: ['Aki Tanaka'], status: 'ongoing', year: 2021,
tags: ['Fantasy', 'Slice of Life', 'Demo'], direction: 'rtl',
description: 'Demo manga (right to left) that uses every feature of the folder format: chapter covers, double pages, a page stored sideways, extra pages, an in-between chapter (2.5), an extra chapter, an extra book and two versions of chapter 2 from different sources.',
sources: [
{ name: 'MangaDex', url: 'https://mangadex.org/title/00000000-0000-0000-0000-000000000000' },
{ name: 'Official Web', url: 'https://example.com/moon-garden' },
],
},
books: [
{ dir: 'v01 Spring', meta: { title: 'Spring' }, cover: true, chapters: [
{ dir: 'c0001 The Seed', meta: { title: 'The Seed', source: { name: 'MangaDex', url: 'https://mangadex.org/chapter/demo-1' }, added: '2024-01-10' }, pages: 'C S S S S D S S S S S S' },
{ dir: 'c0002 Roots [MangaDex]', meta: { title: 'Roots', source: { name: 'MangaDex', url: 'https://mangadex.org/chapter/demo-2' }, added: '2024-01-24' }, pages: 'C S S S S S S D S S S S S S' },
{ dir: 'c0002 Roots [Official Web]', meta: { title: 'Roots', source: { name: 'Official Web', url: 'https://example.com/moon-garden/2' }, added: '2024-01-25' }, pages: 'C S S S S D S S S S S', hue: 40 },
{ dir: 'c0002.5 Tea Time', meta: { title: 'Tea Time (omake)', added: '2024-02-01' }, pages: 'S S S S' },
{ dir: 'x01 Character Sheets', meta: { title: 'Character Sheets', added: '2024-02-02' }, pages: 'E E E' },
] },
{ dir: 'v02 Summer', meta: { title: 'Summer' }, cover: true, chapters: [
{ dir: 'c0003 The Tide', meta: { title: 'The Tide', added: '2024-03-01' }, pages: 'C S S D D S S S R S S S S E E' },
{ dir: 'c0004 Storm', meta: { title: 'Storm', added: '2024-03-15' }, pages: 'S S S S S S S S W S S S S S S S S S' },
{ dir: 'c0005 Harvest', meta: { title: 'Harvest', added: '2024-04-01' }, pages: 'C S S S S S S S S S' },
] },
{ dir: 'x01 Specials', meta: { title: 'Specials' }, chapters: [
{ dir: 'x01 Artbook', meta: { title: 'Artbook', added: '2024-05-01' }, pages: 'C S D S S' },
] },
],
},
{
dir: 'Iron Harbor', hue: 20, type: 'COMIC',
meta: {
title: 'Iron Harbor', authors: ['J. Smith', 'R. Lee'], status: 'completed', year: 2019, tags: ['Action', 'Demo'], direction: 'ltr',
description: 'Demo western comic that reads left to right.',
sources: [{ name: 'Comic Vault', url: 'https://example.com/iron-harbor' }],
},
books: [
{ dir: 'v01 Book One', meta: { title: 'Book One' }, chapters: [
{ dir: 'c0001 Arrival', meta: { title: 'Arrival', added: '2023-06-01' }, pages: 'C S S S D S S S S' },
{ dir: 'c0002 Rust', meta: { title: 'Rust', added: '2023-06-08' }, pages: 'S S S S S S S S S S S S' },
{ dir: 'c0003 Departure', meta: { title: 'Departure', added: '2023-06-15' }, pages: 'C S S D S S S S E' },
] },
],
},
{
dir: 'Skyline Tower', hue: 280, type: 'WEBTOON',
meta: {
title: 'Skyline Tower', authors: ['Min-jun Park'], status: 'ongoing', year: 2023, tags: ['Drama', 'Webtoon', 'Demo'], direction: 'ttb',
description: 'Demo webtoon: every chapter is one long vertical strip cut into slices. The numbers on the left edge are the pixel position inside the strip, so gaps between slices are easy to spot.',
sources: [{ name: 'Webtoon Site', url: 'https://example.com/skyline' }, { name: 'Fan TL', url: 'https://example.org/skyline' }],
},
books: [
{ dir: 'v01 Season 1', meta: { title: 'Season 1' }, chapters: [
{ dir: 'c0001 Episode 1', meta: { title: 'Ground Floor', added: '2024-06-01' }, strip: [1800, 2000, 1600, 2200, 1900, 2100, 1700, 2000] },
{ dir: 'c0002 Episode 2 [Official]', meta: { title: 'Elevator', source: { name: 'Webtoon Site', url: 'https://example.com/skyline/2' }, added: '2024-06-08', priority: 1 }, strip: Array(10).fill(1900) },
{ dir: 'c0002 Episode 2 [Fan TL]', meta: { title: 'Elevator', source: { name: 'Fan TL', url: 'https://example.org/skyline/2' }, added: '2024-06-09' }, strip: Array(9).fill(2100), hue: 330 },
{ dir: 'c0003 Episode 3', meta: { title: 'Rooftop', added: '2024-06-15' }, strip: [2400, 1500, 2000, 1800, 2200, 1600, 2000] },
] },
{ dir: 'v02 Season 2', meta: { title: 'Season 2' }, chapters: [
{ dir: 'c0004 Episode 4', meta: { title: 'Basement', added: '2024-09-01' }, strip: [2000, 2000, 2000, 2000, 2000, 2000] },
] },
],
},
{
// No manga.json / book.json / chapter.json at all: everything comes from folder names.
dir: 'Blank Slate', hue: 120,
books: [{ dir: 'v01', chapters: [{ dir: 'c0001', pages: 'S S S S S S' }, { dir: 'c0002', pages: 'S S S S S S' }] }],
},
{
dir: 'Double Trouble', hue: 330, type: 'BOOK MODE TEST',
meta: {
title: 'Double Trouble', authors: ['Demo'], status: 'completed', direction: 'rtl', tags: ['Demo', 'Book mode test'],
description: 'Book mode torture test: odd runs of single pages between double pages and back-to-back doubles.',
},
books: [{ dir: 'v01', chapters: [
{ dir: 'c0001 Odd Runs', meta: { title: 'Odd Runs' }, pages: 'C S S S D S D D S S S S D S' },
{ dir: 'c0002 All Doubles', meta: { title: 'All Doubles' }, pages: 'D D D' },
] }],
},
];
function wrap(s, max = 10) {
const out = [];
for (const w of String(s || '').split(/\s+/).filter(Boolean)) {
const last = out[out.length - 1];
if (last && (last + ' ' + w).length <= max) out[out.length - 1] = last + ' ' + w;
else out.push(w);
}
return out;
}
function cover(hue, lines, sub) {
const c = new Canvas(PW, PH, hsl(hue, 0.55, 0.3));
for (let i = 0; i < 12; i++) c.rect(0, PH * 0.64 + i * 36, PW, 14, hsl(hue + 25, 0.5, 0.2 + i * 0.012));
c.frame(30, 30, PW - 60, PH - 60, 6, hsl(hue, 0.4, 0.75));
let y = 150;
for (const line of lines) {
const size = Math.min(14, Math.floor((PW - 140) / (6 * line.length)));
c.text(line, PW / 2, y, size, WHITE, 'center');
y += size * 7 + 40;
}
c.text(sub, PW / 2, PH * 0.64 - 90, 6, hsl(hue, 0.3, 0.85), 'center');
return c;
}
function panels(n, wide, m, top, iw, ih, g) {
const R = (fx, fy, fw, fh) => [m + fx * iw, top + fy * ih, fw * iw - g, fh * ih - g];
if (wide) return [R(0, 0, 0.6, 1), R(0.6, 0, 0.4, 0.5), R(0.6, 0.5, 0.4, 0.5)];
return [
[R(0, 0, 1, 0.4), R(0, 0.4, 0.5, 0.6), R(0.5, 0.4, 0.5, 0.6)],
[R(0, 0, 1, 1 / 3), R(0, 1 / 3, 1, 1 / 3), R(0, 2 / 3, 1, 1 / 3)],
[R(0, 0, 0.55, 1), R(0.55, 0, 0.45, 0.5), R(0.55, 0.5, 0.45, 0.5)],
][n % 3];
}
function paper(w, n, ctx, wide) {
const ink = hsl(ctx.hue, 0.4, 0.2), c = new Canvas(w, PH, hsl(ctx.hue, 0.3, 0.94));
const m = 50, top = 90, g = 20;
panels(n, wide, m, top, w - 2 * m, PH - 180, g).forEach(([x, y, pw, ph], i) => {
c.rect(x, y, pw, ph, hsl(ctx.hue + 25 * i, 0.35, 0.84));
c.frame(x, y, pw, ph, 7, ink);
});
if (wide) for (let y = top; y < PH - 90; y += 40) c.rect(w / 2 - 2, y, 4, 20, ink); // the fold
c.text(ctx.manga, m, 34, 4, ink);
c.text(ctx.label, w - m, 34, 4, ink, 'right');
c.text(ctx.dir === 'ltr' ? 'READ LEFT TO RIGHT >>>' : '<<< READ RIGHT TO LEFT', w / 2, PH - 60, 4, ink, 'center');
c.label(String(n), w / 2, PH / 2 - 120, 26, ink, WHITE);
return c;
}
function pageImage(kind, n, ctx) {
if (kind === 'C') return cover(ctx.hue, [ctx.label, ...wrap(ctx.title)], 'CHAPTER COVER - PAGE ' + n);
const wide = 'DWR'.includes(kind), w = wide ? PW * 2 : PW;
const c = paper(w, n, ctx, wide);
const band = hsl(ctx.hue, 0.5, 0.3);
if (kind === 'D') c.label('DOUBLE PAGE', w / 2, PH / 2 + 150, 8, WHITE, band);
if (kind === 'W') c.label('WIDE - NOT TAGGED', w / 2, PH / 2 + 150, 8, WHITE, band);
if (kind === 'E') c.label('EXTRA PAGE', w / 2, PH / 2 + 150, 9, WHITE, [190, 40, 50]);
if (kind === 'R') {
c.label('STORED SIDEWAYS', w / 2, PH / 2 + 150, 8, WHITE, [30, 120, 60]);
return c.rotateCCW(); // "_rot90" = the viewer must turn it 90 degrees clockwise
}
return c;
}
function strip(heights, ctx) {
const W = 800, total = heights.reduce((a, b) => a + b, 0), ink = hsl(ctx.hue, 0.5, 0.18);
const c = new Canvas(W, total);
for (let y = 0; y < total; y += 10) c.rect(0, y, W, 10, hsl(ctx.hue + (y / total) * 140, 0.55, 0.86 - 0.22 * Math.sin((y / total) * Math.PI)));
for (let y = 500; y < total; y += 500) {
c.rect(0, y, 40, 4, ink); c.rect(W - 40, y, 40, 4, ink);
c.text(String(y), 48, y - 8, 3, ink);
}
for (let y = 560, s = 1; y < total - 900; s++) {
const ph = 520 + (s % 3) * 160, x = s % 2 ? 70 : 190;
c.rect(x + 14, y + 14, 540, ph, hsl(ctx.hue, 0.3, 0.3));
c.rect(x, y, 540, ph, WHITE);
c.frame(x, y, 540, ph, 8, ink);
c.text('SCENE ' + s, x + 270, y + ph / 2 - 35, 10, ink, 'center');
y += ph + 380;
}
c.label(ctx.manga, W / 2, 110, 7, ink, WHITE);
c.text(ctx.label + ' ' + ctx.title, W / 2, 260, 5, ink, 'center');
c.label('TO BE CONTINUED', W / 2, total - 380, 6, WHITE, ink);
let y0 = 0;
return heights.map(hh => { const s = c.crop(y0, hh); y0 += hh; return s; });
}
const pad = n => String(n).padStart(4, '0');
const json = (file, obj) => fs.writeFileSync(file, JSON.stringify(obj, null, 2) + '\n');
let files = 0;
const png = (file, canvas) => { fs.writeFileSync(file, canvas.png()); files++; };
const t0 = Date.now();
for (const m of LIBRARY) {
const mDir = path.join(OUT, m.dir), title = (m.meta && m.meta.title) || m.dir, dir = (m.meta && m.meta.direction) || 'rtl';
fs.rmSync(mDir, { recursive: true, force: true });
fs.mkdirSync(mDir, { recursive: true });
if (m.meta) {
json(path.join(mDir, 'manga.json'), m.meta);
png(path.join(mDir, 'cover.png'), cover(m.hue, wrap(title), m.type));
}
let hue = m.hue;
for (const b of m.books) {
const bDir = path.join(mDir, b.dir);
fs.mkdirSync(bDir);
if (b.meta) json(path.join(bDir, 'book.json'), b.meta);
if (b.cover) png(path.join(bDir, 'cover.png'), cover(m.hue + 30, [...wrap(title), b.meta.title], b.dir.split(' ')[0].toUpperCase()));
for (const ch of b.chapters) {
const cDir = path.join(bDir, ch.dir), k = /^(x?)[a-z]*([\d.]+)/i.exec(ch.dir);
fs.mkdirSync(cDir);
if (ch.meta) json(path.join(cDir, 'chapter.json'), ch.meta);
hue += 18;
const ctx = { manga: title, dir, hue: ch.hue !== undefined ? ch.hue : hue, label: (k[1] ? 'EXTRA ' : 'CH ') + parseFloat(k[2]), title: (ch.meta && ch.meta.title) || '' };
if (ch.strip) strip(ch.strip, ctx).forEach((img, i) => png(path.join(cDir, pad(i + 1) + '.png'), img));
else ch.pages.split(' ').forEach((kind, i) => png(path.join(cDir, pad(i + 1) + SUFFIX[kind] + '.png'), pageImage(kind, i + 1, ctx)));
}
}
console.log('created', m.dir);
}
console.log(`${files} images in ${((Date.now() - t0) / 1000).toFixed(1)}s -> ${OUT}`);