From c90b5063f14d3ddd8fc22d561de38cf258980b39 Mon Sep 17 00:00:00 2001 From: Nicolas Fryder Date: Tue, 30 Jun 2026 23:06:20 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20locked=5Ffields,=20genres=20splitt?= =?UTF-8?q?=C3=A9s,=20conflits,=20g=C3=A9ocodage,=20jobs=20dashboard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 007 : - bands.locked_fields : champs édités manuellement = ne jamais réécrire - bands.crawler_pending : valeurs MA différentes des valeurs verrouillées - job_triggers : déclenchement de jobs depuis l'admin sans docker socket - DELETE non-EU bands (6852 bands US/BR/CA/… entrés via crawl incrémental) Crawler : - upsert_band_enriched respecte locked_fields et stocke crawler_pending - run_incremental filtre les bands hors Europe (EU-only désormais confirmé) - update_crawl_run_progress toutes les 10 bands (granularité améliorée) - main loop consomme job_triggers (enrich / incremental / full_crawl) - retry sur JSONDecodeError dans get_json (FlareSolverr corps vide) Admin PATCH bands : - les champs édités sont automatiquement ajoutés à locked_fields Dashboard : - Genres découpés en mots-clés (Doom/Death Metal → Doom, Death Metal…) - Listes pays/statut/genre sans limite, avec scroll interne Bands admin : - Colonne Thèmes + champ recherche thèmes_q - Indicateur 🔒 sur les bands avec champs verrouillés Nouvelles pages admin : - Conflits : résolution champ par champ (garder ma valeur / accepter MA) - Géocodage : stats queue, barre de progression, 20 derniers géocodages - Jobs : boutons pour déclencher enrich/incremental/full_crawl manuellement Co-Authored-By: Claude Sonnet 4.6 --- apps/admin/site/app.js | 222 +++++++++++++++++- apps/admin/site/index.html | 3 + .../migrations/007_locked_fields_and_jobs.sql | 33 +++ apps/api/src/adminRoutes.js | 141 ++++++++++- apps/crawler/src/db.py | 117 +++++++-- apps/crawler/src/jobs.py | 8 +- apps/crawler/src/main.py | 28 +++ 7 files changed, 514 insertions(+), 38 deletions(-) create mode 100644 apps/api/migrations/007_locked_fields_and_jobs.sql diff --git a/apps/admin/site/app.js b/apps/admin/site/app.js index 4a4f928..e1683ea 100644 --- a/apps/admin/site/app.js +++ b/apps/admin/site/app.js @@ -94,7 +94,7 @@ document.getElementById("logout-btn").addEventListener("click", async () => { // ------------------------------------------------------------------ // Router // ------------------------------------------------------------------ -const VIEWS = ["dashboard", "bands", "queue", "runs", "logs", "checkpoints", "audit"]; +const VIEWS = ["dashboard", "bands", "queue", "runs", "logs", "geocoding", "conflicts", "jobs", "checkpoints", "audit"]; function router() { const hash = (location.hash || "#/dashboard").replace("#/", ""); @@ -108,9 +108,12 @@ function router() { const renderers = { dashboard: renderDashboard, bands: renderBands, + conflicts: renderConflicts, queue: renderQueue, runs: renderRuns, logs: renderLogs, + geocoding: renderGeocoding, + jobs: renderJobs, checkpoints: renderCheckpoints, audit: renderAudit, }; @@ -131,6 +134,26 @@ const content = () => document.getElementById("content"); // ------------------------------------------------------------------ // Dashboard // ------------------------------------------------------------------ +// Découpe un genre complexe ("Doom/Death Metal; Gothic/Progressive") en mots-clés +function splitGenreElements(genreRows) { + const counts = new Map(); + for (const { genre, total } of genreRows) { + if (!genre) continue; + // Retire les parenthèses et leur contenu (ex: "(early)", "(later)") + const cleaned = genre.replace(/\([^)]*\)/g, ""); + // Découpe sur / ; , + const tokens = cleaned.split(/[\/;,]+/); + for (const tok of tokens) { + const kw = tok.trim(); + if (kw.length < 3) continue; + counts.set(kw, (counts.get(kw) || 0) + total); + } + } + return [...counts.entries()] + .map(([kw, n]) => ({ kw, n })) + .sort((a, b) => b.n - a.n); +} + async function renderDashboard() { content().innerHTML = `
Chargement…
`; try { @@ -138,7 +161,8 @@ async function renderDashboard() { const t = r.totals; const maxStatus = Math.max(1, ...r.by_status.map((x) => x.total)); const maxCountry = Math.max(1, ...r.by_country.map((x) => x.total)); - const maxGenre = Math.max(1, ...r.by_genre.map((x) => x.total)); + const elements = splitGenreElements(r.by_genre); + const maxEl = Math.max(1, ...elements.map((x) => x.n)); content().innerHTML = `
@@ -160,9 +184,10 @@ async function renderDashboard() {

Par pays (${r.by_country.length})

${r.by_country.map((x) => barRow(x.country, x.total, maxCountry)).join("") || emptyRow()}
-
-

Par genre (${r.by_genre.length})

- ${r.by_genre.map((x) => barRow(x.genre, x.total, maxGenre)).join("") || emptyRow()} +
+

Éléments de genre (${elements.length} mots-clés)

+

Chaque genre complexe (ex: "Doom/Death Metal; Gothic") est découpé en mots-clés.

+ ${elements.map((x) => barRow(x.kw, x.n, maxEl)).join("") || emptyRow()}
`; @@ -280,6 +305,7 @@ async function renderBands() { +