Reader: stale reading sessions never move "Continue" back
A reader tab left open on one device (or restored by the browser) used to save its old chapter as the newest position as soon as it was shown again. Progress saves now carry the tab id and the newest progress time the tab knows (`seen`); the server refuses a save when another tab or device saved a newer position since. The reader then asks: jump to the newest position, or keep reading here (which makes this chapter the head again). It checks on open, when the tab becomes visible, and on every save. Reader links carry `at` and the reader keeps it in the address bar, so reloads and restored tabs still know what they had seen. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
03767d5704
commit
618992ec14
9 files changed
+228
-24
No files matched your search
@@ -0,0 +1,97 @@
|
||||
'use strict';
|
||||
// Stale reading sessions: a reader tab left open on another device must not move "Continue" back.
|
||||
const test = require('node:test'), assert = require('node:assert/strict');
|
||||
const fs = require('fs'), os = require('os'), path = require('path'), net = require('net');
|
||||
const { spawn } = require('child_process');
|
||||
const { Canvas } = require('../tools/png');
|
||||
|
||||
async function freePort() {
|
||||
const listener = net.createServer();
|
||||
await new Promise(resolve => listener.listen(0, '127.0.0.1', resolve));
|
||||
const port = listener.address().port;
|
||||
await new Promise(resolve => listener.close(resolve));
|
||||
return port;
|
||||
}
|
||||
|
||||
test('a save from a tab that missed a newer position is refused until the reader stays', { timeout: 30000 }, async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mc-stale-'));
|
||||
let child;
|
||||
try {
|
||||
const library = path.join(root, 'library'), data = path.join(root, 'data');
|
||||
for (const ch of ['c001 One', 'c002 Two', 'c003 Three']) {
|
||||
const dir = path.join(library, 'Moon Garden', 'v01', ch);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
for (const n of [1, 2, 3]) fs.writeFileSync(path.join(dir, `00${n}.png`), new Canvas(60, 90, [40 * n, 60, 120]).png());
|
||||
}
|
||||
fs.mkdirSync(data);
|
||||
const port = await freePort(), output = [];
|
||||
child = spawn(process.execPath, ['server.js'], { cwd: path.resolve(__dirname, '..'), windowsHide: true,
|
||||
env: { ...process.env, LIBRARY: library, DATA: data, PORT: String(port), SCAN_INTERVAL: '0', FULL_SCAN_INTERVAL: '0',
|
||||
SCRAPER_URL: 'http://127.0.0.1:9', SCRAPER_TOKEN: 'fixture-token', SCRAPER_TOKEN_FILE: '', BACKUP: '', GREATEST_COVER_WARM: '0' } });
|
||||
child.stdout.on('data', d => output.push(String(d)));
|
||||
child.stderr.on('data', d => output.push(String(d)));
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
let ready = false;
|
||||
for (let n = 0; n < 100 && !ready; n++) {
|
||||
ready = await fetch(base + '/health').then(r => r.ok).catch(() => false);
|
||||
if (!ready) await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
assert.ok(ready, output.join(''));
|
||||
const post = (cookie, url, body) => fetch(base + url, { method: 'POST',
|
||||
headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify(body) }).then(r => r.json());
|
||||
const get = (cookie, url) => fetch(base + url, { headers: { cookie } }).then(r => r.json());
|
||||
const signup = await fetch(base + '/api/signup', { method: 'POST', headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'alice', password: 'alice-password' }) });
|
||||
const alice = signup.headers.get('set-cookie').split(';')[0];
|
||||
await post(alice, '/api/admin/rescan', { full: true });
|
||||
|
||||
const manga = await get(alice, '/api/manga?m=Moon%20Garden');
|
||||
const [one, two, three] = manga.chapters.map(c => ({ c: c.id, v: c.versions[0].id }));
|
||||
const save = (ch, extra) => post(alice, '/api/progress', { m: 'Moon Garden', ...ch, p: 1, n: 3, ...extra });
|
||||
const latest = () => get(alice, '/api/progress/latest?m=Moon%20Garden').then(r => r.latest);
|
||||
|
||||
// Phone reads chapter 1, the tablet then reads on in chapter 3.
|
||||
const first = await save(one, { tab: 'phone-tab-1', seen: 0 });
|
||||
assert.equal(first.ok, true);
|
||||
const phoneSeen = first.t;
|
||||
await new Promise(resolve => setTimeout(resolve, 5));
|
||||
const tablet = await save(three, { tab: 'tablet-tab-1', seen: phoneSeen });
|
||||
assert.equal(tablet.ok, true);
|
||||
assert.equal((await latest()).c, three.c);
|
||||
|
||||
// The phone's old tab wakes up and saves chapter 1 again: refused, the head stays on chapter 3.
|
||||
const refused = await save(one, { tab: 'phone-tab-1', seen: phoneSeen, p: 2 });
|
||||
assert.equal(refused.ok, false);
|
||||
assert.equal(refused.stale, true);
|
||||
assert.equal(refused.latest.c, three.c);
|
||||
assert.equal(refused.latest.w, 'tablet-tab-1');
|
||||
assert.equal(refused.latest.continue.c, three.c);
|
||||
assert.match(refused.latest.continue.text, /Continue/);
|
||||
const after = await get(alice, '/api/manga?m=Moon%20Garden');
|
||||
assert.equal(after.continue.c, three.c);
|
||||
assert.equal(after.continue.at, tablet.t);
|
||||
assert.equal(after.chapters[0].progress.p, 1, 'the stale save changed nothing');
|
||||
|
||||
// The chapter view tells a reader which head it is compared with.
|
||||
const view = await get(alice, `/api/chapter?m=Moon%20Garden&c=${encodeURIComponent(one.c)}`);
|
||||
assert.equal(view.latest.c, three.c);
|
||||
assert.equal(view.latest.t, tablet.t);
|
||||
|
||||
// The tablet's own saves never conflict with themselves, even with an older `seen` still in flight.
|
||||
assert.equal((await save(three, { tab: 'tablet-tab-1', seen: phoneSeen, p: 2 })).ok, true);
|
||||
|
||||
// "Keep reading here": the phone now knows the newest time and its save becomes the head.
|
||||
const head = await latest();
|
||||
const stayed = await save(one, { tab: 'phone-tab-1', seen: head.t, p: 2 });
|
||||
assert.equal(stayed.ok, true);
|
||||
assert.equal((await latest()).c, one.c);
|
||||
assert.equal((await get(alice, '/api/manga?m=Moon%20Garden')).continue.c, one.c);
|
||||
|
||||
// Saves without `seen` (mark as read on the manga page, older clients) behave as before.
|
||||
assert.equal((await save(two, { done: true })).ok, true);
|
||||
assert.equal((await latest()).c, two.c);
|
||||
} finally {
|
||||
if (child && child.exitCode === null) { child.kill(); await new Promise(resolve => child.once('exit', resolve)); }
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user