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:
1 parent
4ea7cbc984
commit
33053d1c5f
18 files changed
+852
-148
No files matched your search
+134
-1
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user