Add refresh from source for entries and whole lists

- Every provider can look an entry up again by its ID (TMDB, TheTVDB,
  IGDB, Deezer, MusicBrainz, Wikipedia); search and lookup share parsing
- POST /api/items/{id}/refresh re-applies title, subtitle, year,
  description, link, details and artwork. Kept: the "why" note, rank,
  theater production/venue/cast and a theater entry's year, the Deezer
  preview match. Empty source fields don't erase existing values
- New items.image_custom column (migration 4; existing uploads marked)
  records artwork the owner chose; refresh keeps it unless asked
- Refused for custom lists and hand-added entries; entries the source
  dropped are reported, and a refresh whose title shares no words with
  the current one is rejected as a likely bad ID
- Editor: refresh button per row (asks about the owner's own artwork),
  and "Refresh all" with a confirm step, replace-my-art checkbox,
  progress with Stop, and a summary of problems

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
cdnutterandClaude Opus 5.5 committed 2026-09-25 12:41:22 -07:00
1 parent 4ea7cbc984
commit 33053d1c5f
18 files changed
+852 -148

No files matched your search

+134 -1
View File
@@ -1,5 +1,5 @@
// List editor: search providers, add entries, rank and edit them.
import { h, icon, api, readBoot, toast, confirmDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
import { h, icon, api, readBoot, toast, confirmDialog, chooseDialog, openDialog, dialogHead, debounce, busy, fmt } from "./common.js";
import { sortable } from "./sortable.js";
import { openListForm } from "./list-form.js";
import { previewButton } from "./player.js";
@@ -123,12 +123,144 @@ function row(it) {
canPreview(it) && previewButton(`/preview/${it.id}`, `Play a preview of ${it.title}`, "play-btn play-btn--inline hide-sm"),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move up", title: "Move up", disabled: it.rank === 1, onclick: () => moveTo(it, it.rank - 1) }, icon("chevron-up")),
h("button", { type: "button", class: "icon-btn hide-sm", "aria-label": "Move down", title: "Move down", disabled: it.rank === list.itemCount, onclick: () => moveTo(it, it.rank + 1) }, icon("chevron-down")),
canRefresh(it) && h("button", { type: "button", class: "icon-btn", "aria-label": `Refresh ${it.title} from ${sourceName(it)}`, title: `Refresh from ${sourceName(it)}`, onclick: (e) => refreshOne(it, e.currentTarget) }, icon("refresh")),
h("button", { type: "button", class: "icon-btn", "aria-label": `Edit ${it.title}`, title: "Edit", onclick: () => openItemForm({ item: it }) }, icon("pencil")),
h("button", { type: "button", class: "icon-btn icon-btn--danger", "aria-label": `Remove ${it.title}`, title: "Remove", onclick: () => removeItem(it) }, icon("trash")),
),
);
}
const SOURCES = { tmdb: "TMDB", tvdb: "TheTVDB", igdb: "IGDB", deezer: "Deezer", musicbrainz: "MusicBrainz", wikipedia: "Wikipedia" };
const sourceName = (it) => SOURCES[it.provider] || "its source";
// Entries added from a search can be refreshed from that service. Custom
// lists and hand-added entries have no source.
function canRefresh(it) {
return cat.key !== "custom" && !!it.provider;
}
// ---------- refresh from source ----------
async function refreshOne(it, button) {
let replaceCustomArt = false;
if (it.customArt) {
const choice = await chooseDialog({
title: "Replace your artwork?",
message: `“${it.title}” uses artwork you chose yourself. Refreshing updates its details from ${sourceName(it)}. Should it also switch to ${sourceName(it)}'s artwork?`,
choices: [{ label: "Keep my artwork", value: "keep" }, { label: `Use ${sourceName(it)}'s`, value: "replace", primary: true }],
});
if (!choice) return;
replaceCustomArt = choice === "replace";
}
busy(button, true);
try {
const res = await api("POST", `/api/items/${it.id}/refresh`, { replaceCustomArt });
toast(res.changed.length ? `Updated ${res.changed.join(", ")} for “${res.item.title}”` : `“${res.item.title}” is already up to date`);
if (res.warning) toast(res.warning, { error: true });
await load(page, it.id);
} catch (err) {
toast(err.message, { error: true });
busy(button, false);
}
}
async function allItems() {
const out = [];
for (let offset = 0; ; offset += 500) {
const batch = await api("GET", `/api/lists/${list.id}/items?offset=${offset}&limit=500`);
out.push(...batch);
if (batch.length < 500) return out;
}
}
async function refreshAll() {
let all;
try {
all = await allItems();
} catch (err) {
toast(err.message, { error: true });
return;
}
const targets = all.filter(canRefresh);
if (!targets.length) {
toast("Nothing to refresh: every entry here was added by hand.", { error: true });
return;
}
const custom = targets.filter((it) => it.customArt).length;
const manual = all.length - targets.length;
const sources = new Intl.ListFormat("en", { type: "conjunction" }).format([...new Set(targets.map(sourceName))]);
// Step 1: confirm, and ask about the owner's own artwork.
const replaceBox = h("input", { type: "checkbox" });
let start = false;
let dlg = openDialog(h("div", {},
dialogHead("Refresh all from source", () => dlg),
h("div", { class: "dialog-body stack" },
h("p", {}, `Re-pull ${fmt.format(targets.length)} ${targets.length === 1 ? "entry" : "entries"} from ${sources}. Titles, years, descriptions, links, details and artwork are updated wherever the source changed.`),
h("p", { class: "muted" }, "Your “why” notes, the ranking, and theater productions stay exactly as they are." +
(manual ? ` ${fmt.format(manual)} added by hand ${manual === 1 ? "is" : "are"} skipped.` : "")),
custom > 0 && h("label", { class: "check refresh-art-choice" }, replaceBox,
h("span", {}, h("b", {}, "Also replace artwork you chose yourself"),
h("br"), h("span", { class: "muted small" }, `${fmt.format(custom)} ${custom === 1 ? "entry uses" : "entries use"} an image you uploaded, pasted or removed. Leave this off to keep ${custom === 1 ? "it" : "them"}.`))),
),
h("div", { class: "dialog-foot" },
h("button", { type: "button", class: "btn", onclick: () => dlg.close() }, "Cancel"),
h("button", { type: "button", class: "btn btn--primary", onclick: () => { start = true; dlg.close(); } }, icon("refresh"), "Refresh all"),
),
), { onClose: () => start && runRefreshAll(targets, replaceBox.checked) });
dlg.style.maxWidth = "520px";
}
async function runRefreshAll(targets, replaceCustomArt) {
let stopped = false;
let finished = false;
const bar = h("span", { style: { width: "0%" } });
const status = h("p", { class: "muted small", "aria-live": "polite" }, "Starting…");
const body = h("div", { class: "dialog-body stack" }, h("div", { class: "meter", style: { margin: 0 } }, bar), status);
const stop = h("button", { type: "button", class: "btn", onclick: () => { stopped = true; stop.disabled = true; stop.textContent = "Stopping…"; } }, "Stop");
const foot = h("div", { class: "dialog-foot" }, stop);
const dlg = openDialog(h("div", {}, h("div", { class: "dialog-head" }, h("h2", {}, "Refreshing…")), body, foot), {
onClose: () => { stopped = true; if (finished) load(page); },
});
dlg.style.maxWidth = "520px";
let updated = 0, current = 0, processed = 0;
const failed = [];
for (let i = 0; i < targets.length && !stopped; i++) {
const it = targets[i];
status.textContent = `${fmt.format(i + 1)} of ${fmt.format(targets.length)} · ${it.title}`;
processed++;
try {
const res = await api("POST", `/api/items/${it.id}/refresh`, { replaceCustomArt });
if (res.changed.length) updated++; else current++;
if (res.warning) failed.push({ title: it.title, error: res.warning });
} catch (err) {
failed.push({ title: it.title, error: err.message });
// Missing keys affect every entry from that service: stop early.
if (err.status === 424) break;
}
bar.style.width = `${Math.round((processed / targets.length) * 100)}%`;
}
finished = true;
dlg.querySelector(".dialog-head h2").textContent = processed < targets.length ? "Refresh stopped" : "Refresh finished";
body.replaceChildren(...[
h("p", {}, [
`${fmt.format(updated)} updated`,
`${fmt.format(current)} already up to date`,
failed.length && `${fmt.format(failed.length)} with problems`,
processed < targets.length && `${fmt.format(targets.length - processed)} not reached`,
].filter(Boolean).join(" · ")),
failed.length > 0 && h("ul", { class: "refresh-failures" },
failed.slice(0, 12).map((f) => h("li", {}, h("b", {}, f.title), " — ", f.error)),
failed.length > 12 && h("li", { class: "muted" }, `…and ${failed.length - 12} more`)),
].filter(Boolean));
foot.replaceChildren(h("button", { type: "button", class: "btn btn--primary", onclick: () => dlg.close() }, "Done"));
await load(page);
}
document.querySelector("[data-action=refresh-all]")?.addEventListener("click", refreshAll);
// Songs and albums from Deezer, or matched to Deezer when saved, can play.
function canPreview(it) {
return (cat.key === "song" || cat.key === "album") && (it.provider === "deezer" || !!it.extra?.deezer);
@@ -491,6 +623,7 @@ function openItemForm({ item, result }) {
} else {
Object.assign(body, {
provider: result?.provider || "", providerId: result?.providerId || "", details: !!result,
customArt: !!result && body.imageUrl !== "" && body.imageUrl !== (result.imageUrl || ""),
});
const created = await createItem(body);
if (created && pendingFile) {
+24
View File
@@ -137,6 +137,30 @@ export function confirmDialog({ title, message, confirm = "Confirm", danger = fa
});
}
/**
* Ask a question with several answers. Resolves to the chosen value, or
* null if dismissed. choices: [{ label, value, primary }]
*/
export function chooseDialog({ title, message, choices }) {
return new Promise((resolve) => {
let picked = null;
let dlg;
const content = h("div", {},
dialogHead(title, () => dlg),
h("div", { class: "dialog-body" }, h("p", { class: "muted" }, message)),
h("div", { class: "dialog-foot" },
h("button", { type: "button", class: "btn btn--ghost", onclick: () => dlg.close() }, "Cancel"),
choices.map((c) => h("button", {
type: "button", class: "btn" + (c.primary ? " btn--primary" : ""),
onclick: () => { picked = c.value; dlg.close(); },
}, c.label)),
),
);
dlg = openDialog(content, { onClose: () => resolve(picked) });
dlg.style.maxWidth = "480px";
});
}
export function debounce(fn, ms) {
let t;
return (...args) => {