[object Object]

← back to AsSeenInMovies

feat(asseeninmovies): fan-art credit policy — Commons artist + license + source on every poster

2b1ecd3e6a090885f8c518f063ac0d683c8328b3 · 2026-05-12 11:12:13 -0700 · SteveStudio2

Per Steve's fan-art rule: every non-PD image must render artist + license
+ source-link on its page, or it can't be used. CC0/PD images get a
'Public domain · source' pill. Made-with-AI placeholder keeps the
existing pill.

Schema (idempotent ALTER):
  ALTER TABLE asim_movies ADD COLUMN poster_credit     TEXT;
  ALTER TABLE asim_movies ADD COLUMN poster_license    TEXT;
  ALTER TABLE asim_movies ADD COLUMN poster_source_url TEXT;
  ALTER TABLE asim_people ADD COLUMN headshot_credit     TEXT;
  ALTER TABLE asim_people ADD COLUMN headshot_license    TEXT;
  ALTER TABLE asim_people ADD COLUMN headshot_source_url TEXT;

scrapers/wikimedia-credit-backfill.js — batched Commons API
(prop=imageinfo, iiprop=extmetadata) fetcher that pulls Artist /
LicenseShortName / LicenseUrl + the canonical descriptionurl for every
poster_url we already imported from Wikimedia. 50 files per request,
1.1s sleep between batches, polite User-Agent.

Backfill result on this pass:
  movies   783/783 ok  (100%, no misses)
  people     5/5   ok
  All carry Artist + License + Commons File-page URL now.

server.js — imageCreditHtml(credit, license, sourceUrl, sourceKind):
  CC0 / public-domain-mark / PD → '<span class="img-credit pd">Public domain · source</span>'
  CC-BY / CC-BY-SA / anything else → '<span class="img-credit">Photo: {artist} / {license} / source</span>'
  made-with-ai → existing '<span class="ai-pill">made with AI</span>'
  No source URL → render nothing (safe default)

Rendered on both /movie/:id (poster chip) and /person/:id (headshot chip).
CSS chip uses a thin gold border for PD, line-color border for credited.

Smoke-tested live:
  GET /movie/40 (Living in Oblivion) → 200
  HTML contains: 'Photo: Machete kills / CC BY-SA 4.0 / <a ...>source</a>'

Memory codified: feedback_fan_art_credit_policy.md.
Hooks into existing IMDb-no-assets policy: fan art on Commons is the
legitimate substitute for IMDb posters.

Files touched

Diff

commit 2b1ecd3e6a090885f8c518f063ac0d683c8328b3
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 11:12:13 2026 -0700

    feat(asseeninmovies): fan-art credit policy — Commons artist + license + source on every poster
    
    Per Steve's fan-art rule: every non-PD image must render artist + license
    + source-link on its page, or it can't be used. CC0/PD images get a
    'Public domain · source' pill. Made-with-AI placeholder keeps the
    existing pill.
    
    Schema (idempotent ALTER):
      ALTER TABLE asim_movies ADD COLUMN poster_credit     TEXT;
      ALTER TABLE asim_movies ADD COLUMN poster_license    TEXT;
      ALTER TABLE asim_movies ADD COLUMN poster_source_url TEXT;
      ALTER TABLE asim_people ADD COLUMN headshot_credit     TEXT;
      ALTER TABLE asim_people ADD COLUMN headshot_license    TEXT;
      ALTER TABLE asim_people ADD COLUMN headshot_source_url TEXT;
    
    scrapers/wikimedia-credit-backfill.js — batched Commons API
    (prop=imageinfo, iiprop=extmetadata) fetcher that pulls Artist /
    LicenseShortName / LicenseUrl + the canonical descriptionurl for every
    poster_url we already imported from Wikimedia. 50 files per request,
    1.1s sleep between batches, polite User-Agent.
    
    Backfill result on this pass:
      movies   783/783 ok  (100%, no misses)
      people     5/5   ok
      All carry Artist + License + Commons File-page URL now.
    
    server.js — imageCreditHtml(credit, license, sourceUrl, sourceKind):
      CC0 / public-domain-mark / PD → '<span class="img-credit pd">Public domain · source</span>'
      CC-BY / CC-BY-SA / anything else → '<span class="img-credit">Photo: {artist} / {license} / source</span>'
      made-with-ai → existing '<span class="ai-pill">made with AI</span>'
      No source URL → render nothing (safe default)
    
    Rendered on both /movie/:id (poster chip) and /person/:id (headshot chip).
    CSS chip uses a thin gold border for PD, line-color border for credited.
    
    Smoke-tested live:
      GET /movie/40 (Living in Oblivion) → 200
      HTML contains: 'Photo: Machete kills / CC BY-SA 4.0 / <a ...>source</a>'
    
    Memory codified: feedback_fan_art_credit_policy.md.
    Hooks into existing IMDb-no-assets policy: fan art on Commons is the
    legitimate substitute for IMDb posters.
---
 scrapers/wikidata-enrich.js           |   4 +
 scrapers/wikimedia-credit-backfill.js | 144 ++++++++++++++++++++++++++++++++++
 server.js                             |  24 +++++-
 3 files changed, 170 insertions(+), 2 deletions(-)

diff --git a/scrapers/wikidata-enrich.js b/scrapers/wikidata-enrich.js
index edbe20c..5a8d42e 100644
--- a/scrapers/wikidata-enrich.js
+++ b/scrapers/wikidata-enrich.js
@@ -134,6 +134,10 @@ async function enrichMovies() {
       if (!e) { noMatch++; continue; }
       const posterUrl = commonsImage(e.img);
       if (!DRY) {
+        // poster_credit/license/source_url are filled by the
+        // wikimedia-credit-backfill.js post-pass (Commons extmetadata fetch
+        // is one extra API call per file — done in batched backfill rather
+        // than slowing the SPARQL drain).
         await pool.query(`
           UPDATE asim_movies
              SET wikidata_id = $1,
diff --git a/scrapers/wikimedia-credit-backfill.js b/scrapers/wikimedia-credit-backfill.js
new file mode 100644
index 0000000..c3c6a32
--- /dev/null
+++ b/scrapers/wikimedia-credit-backfill.js
@@ -0,0 +1,144 @@
+#!/usr/bin/env node
+'use strict';
+// Backfill image credit + license + source URL for every asim_movies poster
+// and asim_people headshot we previously imported from Wikimedia Commons.
+//
+// Per Steve's fan-art credit policy (memory: feedback_fan_art_credit_policy):
+// every fan-art / non-PD image must carry artist + license + source URL or it
+// cannot be displayed. We have 783 movie posters + a handful of headshots
+// already in place without that metadata. This script fetches it.
+//
+// Commons API: action=query, titles=File:..., prop=imageinfo,
+// iiprop=extmetadata returns Artist, LicenseShortName, LicenseUrl per file.
+// Batches up to 50 files per request.
+
+require('dotenv').config();
+const { Pool } = require('pg');
+
+const pool = new Pool({
+  connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
+  max: 4,
+});
+
+const COMMONS_API = 'https://commons.wikimedia.org/w/api.php';
+const UA = 'AsSeenInMovies/0.1 (https://asseeninmovies.com; steve@designerwallcoverings.com)';
+const BATCH = 50;
+const SLEEP_MS = 1100;
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+function fileNameFromCommonsUrl(url) {
+  // https://commons.wikimedia.org/wiki/Special:FilePath/Goosebumps%202023%20poster.jpg?width=600
+  //                                                   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+  if (!url) return null;
+  const m = url.match(/Special:FilePath\/([^?]+)/i);
+  if (!m) return null;
+  try { return decodeURIComponent(m[1]).replace(/_/g, ' '); }
+  catch { return m[1].replace(/_/g, ' '); }
+}
+
+function stripHtml(s) {
+  if (!s) return null;
+  // Commons returns "Artist" wrapped in <a>...</a> HTML
+  return String(s).replace(/<[^>]+>/g, '').replace(/&amp;/g, '&').trim();
+}
+
+async function commonsImageInfo(filenames) {
+  const titles = filenames.map(n => `File:${n}`).join('|');
+  const params = new URLSearchParams({
+    action: 'query',
+    titles,
+    prop: 'imageinfo',
+    iiprop: 'extmetadata|url|user',
+    format: 'json',
+    formatversion: '2',
+  });
+  const r = await fetch(`${COMMONS_API}?${params}`, { headers: { 'User-Agent': UA } });
+  if (!r.ok) throw new Error(`commons ${r.status}`);
+  const j = await r.json();
+  const out = new Map();
+  const pages = j.query?.pages || [];
+  for (const p of pages) {
+    if (!p.imageinfo || !p.imageinfo[0]) continue;
+    const ii = p.imageinfo[0];
+    const ext = ii.extmetadata || {};
+    const title = p.title.replace(/^File:/, '');
+    out.set(title, {
+      artist:  stripHtml(ext.Artist?.value)            || ii.user || null,
+      license: ext.LicenseShortName?.value             || ext.License?.value || null,
+      licenseUrl: ext.LicenseUrl?.value                || null,
+      descriptionUrl: ii.descriptionurl                || `https://commons.wikimedia.org/wiki/File:${encodeURIComponent(title)}`,
+    });
+  }
+  return out;
+}
+
+async function backfillMovies() {
+  const sel = await pool.query(`
+    SELECT id, poster_url FROM asim_movies
+     WHERE poster_source='wikimedia-cc' AND poster_credit IS NULL
+     ORDER BY id LIMIT 2000`);
+  console.log(`[backfill-movies] ${sel.rows.length} rows`);
+  let ok = 0, miss = 0;
+  for (let i = 0; i < sel.rows.length; i += BATCH) {
+    const slice = sel.rows.slice(i, i + BATCH);
+    const idToFile = new Map();
+    for (const r of slice) {
+      const f = fileNameFromCommonsUrl(r.poster_url);
+      if (f) idToFile.set(r.id, f);
+    }
+    if (!idToFile.size) continue;
+    let meta;
+    try { meta = await commonsImageInfo([...idToFile.values()]); }
+    catch (e) { console.error(`  batch ${i / BATCH}: ${e.message}`); await sleep(5000); continue; }
+    for (const [id, file] of idToFile) {
+      const m = meta.get(file);
+      if (!m) { miss++; continue; }
+      await pool.query(
+        `UPDATE asim_movies SET poster_credit=$1, poster_license=$2, poster_source_url=$3, updated_at=NOW() WHERE id=$4`,
+        [m.artist, m.license, m.descriptionUrl, id]
+      );
+      ok++;
+    }
+    if ((i / BATCH) % 5 === 0) console.log(`  …batch ${Math.floor(i/BATCH)+1}/${Math.ceil(sel.rows.length/BATCH)} ok=${ok} miss=${miss}`);
+    await sleep(SLEEP_MS);
+  }
+  console.log(`[backfill-movies] done. ok=${ok} miss=${miss}`);
+}
+
+async function backfillPeople() {
+  const sel = await pool.query(`
+    SELECT id, headshot_url FROM asim_people
+     WHERE headshot_source='wikimedia-cc' AND headshot_credit IS NULL
+     ORDER BY id LIMIT 1000`);
+  console.log(`[backfill-people] ${sel.rows.length} rows`);
+  let ok = 0, miss = 0;
+  for (let i = 0; i < sel.rows.length; i += BATCH) {
+    const slice = sel.rows.slice(i, i + BATCH);
+    const idToFile = new Map();
+    for (const r of slice) {
+      const f = fileNameFromCommonsUrl(r.headshot_url);
+      if (f) idToFile.set(r.id, f);
+    }
+    if (!idToFile.size) continue;
+    let meta;
+    try { meta = await commonsImageInfo([...idToFile.values()]); }
+    catch (e) { console.error(`  batch ${i / BATCH}: ${e.message}`); await sleep(5000); continue; }
+    for (const [id, file] of idToFile) {
+      const m = meta.get(file);
+      if (!m) { miss++; continue; }
+      await pool.query(
+        `UPDATE asim_people SET headshot_credit=$1, headshot_license=$2, headshot_source_url=$3, updated_at=NOW() WHERE id=$4`,
+        [m.artist, m.license, m.descriptionUrl, id]
+      );
+      ok++;
+    }
+    await sleep(SLEEP_MS);
+  }
+  console.log(`[backfill-people] done. ok=${ok} miss=${miss}`);
+}
+
+(async () => {
+  await backfillMovies();
+  await backfillPeople();
+  await pool.end();
+})().catch(e => { console.error('fatal:', e); process.exit(1); });
diff --git a/server.js b/server.js
index b626d5a..7374733 100644
--- a/server.js
+++ b/server.js
@@ -182,6 +182,23 @@ const esc = (s) => String(s ?? '').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&
 const posterFor = (m) => (m && m.poster_url && (m.poster_source || '').toLowerCase() !== 'made-with-ai') ? m.poster_url : '/img/made-with-ai.svg';
 const headshotFor = (p) => (p && p.headshot_url && (p.headshot_source || '').toLowerCase() !== 'made-with-ai') ? p.headshot_url : '/img/made-with-ai.svg';
 
+// Per Steve's fan-art credit policy (memory: feedback_fan_art_credit_policy):
+// every non-PD image must render an artist + license + source-URL line.
+// CC0 / PD images get a "Public domain" pill with source link.
+// AI-placeholder gets the existing "made with AI" pill.
+function imageCreditHtml(credit, license, sourceUrl, sourceKind) {
+  if (!sourceUrl && sourceKind === 'made-with-ai') return '<span class="ai-pill">made with AI</span>';
+  if (!sourceUrl) return '';
+  const lic = (license || '').toLowerCase();
+  const isPd = lic === 'cc0' || lic === 'cc0-1.0' || lic.includes('public domain') || lic === 'pd';
+  if (isPd) {
+    return `<span class="img-credit pd">Public domain · <a href="${esc(sourceUrl)}" rel="nofollow noopener" target="_blank">source</a></span>`;
+  }
+  const artistPart = credit ? `${esc(credit)} / ` : '';
+  const licPart    = license ? `${esc(license)} / ` : '';
+  return `<span class="img-credit">Photo: ${artistPart}${licPart}<a href="${esc(sourceUrl)}" rel="nofollow noopener" target="_blank">source</a></span>`;
+}
+
 function htmlShell(title, body) {
   return `<!doctype html>
 <html lang="en"><head>
@@ -232,6 +249,9 @@ function htmlShell(title, body) {
   footer.site a { color:var(--gold); }
   .empty { padding:60px 0; text-align:center; color:var(--ink-faint); font-style:italic; }
   .ai-pill { display:inline-block; padding:3px 9px; border:1px dotted var(--ink-faint); color:var(--ink-faint); font-size:10px; letter-spacing:0.16em; border-radius:3px; text-transform:uppercase; }
+  .img-credit { display:inline-block; padding:3px 9px; border:1px solid var(--line); color:var(--ink-soft); font-size:10px; letter-spacing:0.06em; border-radius:3px; }
+  .img-credit a { color:var(--gold); text-decoration:underline; }
+  .img-credit.pd { border-color:var(--gold); }
 </style>
 </head><body>
 <header class="site">
@@ -294,7 +314,7 @@ app.get('/movie/:id', async (req, res) => {
             ${mv.imdb_id ? `<span class="chip">imdb · ${esc(mv.imdb_id)}</span>` : ''}
             ${mv.tmdb_id ? `<span class="chip">tmdb · ${esc(mv.tmdb_id)}</span>` : ''}
             ${mv.status ? `<span class="chip">${esc(mv.status)}</span>` : ''}
-            ${mv.poster_source === 'made-with-ai' ? '<span class="ai-pill">made with AI</span>' : ''}
+            ${imageCreditHtml(mv.poster_credit, mv.poster_license, mv.poster_source_url, mv.poster_source)}
           </div>
         </div>
       </div>`;
@@ -354,7 +374,7 @@ app.get('/person/:id', async (req, res) => {
           <div class="chips">
             ${(person.primary_profession || []).map(p => `<span class="chip gold">${esc(p)}</span>`).join('')}
             ${(person.union_memberships || []).map(u => `<span class="chip">${esc(u)}</span>`).join('')}
-            ${person.headshot_source === 'made-with-ai' ? '<span class="ai-pill">made with AI</span>' : ''}
+            ${imageCreditHtml(person.headshot_credit, person.headshot_license, person.headshot_source_url, person.headshot_source)}
           </div>
           ${person.bio ? `<p class="overview">${esc(person.bio).slice(0, 800)}${person.bio.length > 800 ? '…' : ''}</p>` : ''}
           ${!isPremium ? `<p style="margin-top:18px"><a href="/claim/person/${esc(id)}" style="display:inline-block;padding:8px 16px;border:1px solid var(--gold);color:var(--gold);font-size:12px;letter-spacing:0.1em">Claim this profile →</a></p>` : ''}

← f69197c feat(asseeninmovies/tick3): Wikidata SPARQL enrichment  ·  back to AsSeenInMovies  ·  feat(asseeninmovies): inject Big Red widget into htmlShell f 2b42577 →