Podcast-Artikel: Reservierung mit Gerätenamen (claim/release, deviceId pro Browser oder App)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
mrwnslz 2026-11-09 10:03:19 +01:00
parent cedc14f714
commit d4a47064b2
2 changed files with 21 additions and 7 deletions

View file

@ -260,6 +260,20 @@ const send = (url, method, body) => fetch(url, {
body: body === undefined ? undefined : JSON.stringify(body),
});
// This device's name for reserving a wish (/article/claim/), kept per browser or app.
let memDevice = '';
export function deviceId() {
try {
let id = localStorage.getItem('diora-device-id');
if (!id) { id = 'd-' + Math.random().toString(36).slice(2, 12); localStorage.setItem('diora-device-id', id); }
return id;
} catch (e) {
return memDevice || (memDevice = 'd-' + Math.random().toString(36).slice(2, 12));
}
}
export const claimWish = id => send('/podcasts/episodes/' + id + '/article/claim/', 'POST', {device: deviceId()});
export const release = id => send('/podcasts/episodes/' + id + '/article/claim/', 'DELETE', {device: deviceId()});
async function getJson(url) {
const r = await fetch(url, {cache: 'no-store'});
return {status: r.status, ok: r.ok, data: r.ok ? await r.json() : null};
@ -330,7 +344,7 @@ export async function pickUp({onState} = {}) {
for (const wish of pending) {
if (gaveUp.has(wish.episode_id)) { result.skipped++; continue; }
let claim;
try { claim = await send('/podcasts/episodes/' + wish.episode_id + '/article/claim/', 'POST', {}); } catch (e) { return result; }
try { claim = await claimWish(wish.episode_id); } catch (e) { return result; }
if (!claim.ok) { result.skipped++; continue; } // 409: another device has it
try {
const saved = await write(wish.episode_id, {onState});
@ -339,7 +353,7 @@ export async function pickUp({onState} = {}) {
} catch (e) {
result.failed++;
gaveUp.add(wish.episode_id);
try { await send('/podcasts/episodes/' + wish.episode_id + '/article/claim/', 'DELETE'); } catch (e2) { /* it runs out in 15 minutes */ }
try { await release(wish.episode_id); } catch (e2) { /* it runs out in 15 minutes */ }
}
}
} finally {
@ -359,7 +373,7 @@ export function watch(on) {
export const _reset = () => { running = false; gaveUp.clear(); };
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
window.DioraPodcastArticle = {write, pickUp, watch, notifyDone, markdownToHtml, transcriptToText};
window.DioraPodcastArticle = {write, pickUp, watch, notifyDone, claim: claimWish, release, deviceId, markdownToHtml, transcriptToText};
window.dispatchEvent(new Event('diora-podcast-article'));
// At the start of diora.
setTimeout(() => { pickUp(); }, 4000);

View file

@ -135,10 +135,10 @@ test('pickUp: claim, 409 skipped, failure releases the claim, notifier per succe
});
const r = await pa.pickUp();
assert.deepEqual(r, {done: 1, failed: 1, skipped: 1});
assert.ok(w.calls.includes('POST /podcasts/episodes/2/article/claim/ {}'));
assert.ok(w.calls.some(c => /^POST \/podcasts\/episodes\/2\/article\/claim\/ \{"device":"d-/.test(c)));
assert.ok(!w.calls.some(c => c.startsWith('GET /podcasts/episodes/2/'))); // 409: not written
assert.ok(w.calls.includes('DELETE /podcasts/episodes/4/article/claim/')); // failed: released
assert.ok(!w.calls.includes('DELETE /podcasts/episodes/1/article/claim/'));
assert.ok(w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/4/article/claim/'))); // failed: released
assert.ok(!w.calls.some(c => c.startsWith('DELETE /podcasts/episodes/1/article/claim/')));
assert.deepEqual(w.notices, ['Artikel fertig: A']);
// The failed one is not tried again in this visit.
const w2 = stubWorld({pending: [{episode_id: 4, title: 'Vier'}]});
@ -168,5 +168,5 @@ test('pickUp: no key does nothing, not even a request; no second run at the same
const b = await pa.pickUp();
assert.deepEqual(b, {busy: true});
assert.equal((await a).done, 1);
assert.equal(w2.calls.filter(c => c.startsWith('POST') && c.endsWith('/claim/ {}')).length, 1);
assert.equal(w2.calls.filter(c => c.startsWith('POST') && c.includes('/claim/ {"device":')).length, 1);
});