Manuel d'instructions de cette app. A LIRE avant toute intervention, et a METTRE A JOUR apres chaque modification (ajouter une ligne au CHANGELOG en bas).
Derniere MAJ : 2026-09-27 · Statut : en ligne (v1)
1. Idees & production : un board (colonnes par statut idée → validé → scripté → tourné → publié) pour capturer des idees de video et suivre leur avancement jusqu'au tournage et la publication.
2. Resultats YouTube : la liste des videos publiees avec leurs stats (vues, likes/commentaires si dispo, date, duree), triable, pour voir ce qui marche. Rafraichi par scraping yt-dlp.
3. Analyse concurrentielle : par concurrent (Nomad Capitalist pour l'instant), leurs ~80 videos recentes avec vues + likes, tri par defaut sur les vues (leurs cartons), triable par colonne. Sert a voir ce qui performe chez eux.
4. Recherche de miniatures : une barre de recherche par thematique qui affiche une galerie de miniatures YouTube (a la ViewStats / MrBeast), triable par vues, clic pour agrandir (lightbox maxres), titre = lien vers la video. Sert a s'inspirer des miniatures qui performent sur un sujet. Recherche live via yt-dlp ytsearch (pas de cle).
6. Scripts : l'atelier d'ecriture central. Un seul endroit ou Matt ecrit ses scripts, quelle que soit l'origine : une idee de son board (bouton "✍️ Ecrire le script" dans la fiche idee) OU une video d'un autre createur a copier/adapter (bouton ✍ au survol d'une carte Shorts). Liste des scripts a gauche, editeur plein largeur avec sauvegarde automatique a droite, et un volet "source" qui affiche la video a copier (embed) ou les notes/structure/pilier de l'idee pendant qu'il ecrit. Statuts brouillon -> a tourner -> tourne -> publie. Ecrire un script depuis une idee la fait passer en "scripte" sur le board.
5. Strategie : un onglet de reference avec DEUX strategies separees par un switch en haut de l'onglet : Matt (perso) par defaut (positionnement, methode Clouet, croyance, 4 piliers, 8 regles facecam, grille hebdo, roadmap, repris de strategie-instagram-perso/) et Monica (MOA) (strategie de contenu de l'agente MOA Monica Ayala : positionnement "la locale de confiance", croyance, filtre anti-dispersion, 5 piliers, hooks/CTA, tunnel, semaine type validee 2 YouTube + 5 shorts, scoreboard, source /root/workspace/MOA-Agent-Pack/). La vue Monica a un bouton de telechargement du PDF complet (strategie + plan d'action).
/root/workspace/apps/content-dashboardpublic/index.html).content-dashboard.service (/etc/systemd/system/, EnvironmentFile .env, Restart=always).127.0.0.1)./etc/nginx/sites-enabled/contenu.panelbay.com.conf (HTTPS Let's Encrypt).contenu.panelbay.com via Cloudflare (DNS-only), cree par le skill expose-webapp.systemctl restart content-dashboardjournalctl -u content-dashboard -n 50/login, mot de passe = CONTENT_DASH_PASSWORD (.env), cookie de session HttpOnly signe HMAC (CONTENT_DASH_SECRET), valable 30 jours. Deconnexion : /logout.CONTENT_DASH_PASSWORD dans .env puis systemctl restart content-dashboard.data/content.db (WAL). Tables principales :competitors (name, handle, channel_id) + competitor_videos (video_id, competitor_id, titre, url, date, duree, vues, likes, thumbnail). Concurrents seedes dans db.py (SEED_COMPETITORS, Nomad Capitalist = UC3k3floOm_HtKOv0l6JU-xQ). Ajouter un concurrent : ligne dans SEED_COMPETITORS puis python3 db.py.ideas : le board (titre, format facecam/value_asset/reel, pilier, categorie, niveau, preuve, structure, mot-cle, statut, notes, lien vers une video publiee, archived).videos : les videos YouTube publiees (video_id, titre, url, date, duree, vues, likes, commentaires, thumbnail, pilier/categorie tagues a la main, source inventory/scrape, enriched).stats_history : un snapshot (video, date, vues, likes) par jour et par video, pour suivre la croissance.video_analytics : vraies stats YouTube Studio par video (via l'API YouTube Analytics, pas yt-dlp) : views, minutes_watched, avg_view_duration_s, avg_view_pct (retention moyenne), subs_gained/lost, likes, comments, shares. Cle = video_id, independant de videos (couvre les 200 videos les plus vues de la chaine, pas seulement les 48 suivies en yt-dlp). Rempli par import_analytics.py.channel_daily : serie temporelle au niveau chaine (1 ligne/jour, ~400 jours) : views, minutes_watched, avg_view_duration_s, subs_gained/lost, likes. Pour les fenetres glissantes (28j/90j) et futurs graphes de tendance.meta : cle/valeur (last_scrape, last_scrape_status, last_analytics_import, last_analytics_status).seed.py) : 40 idees importees de strategie-instagram-perso/idees-facecam-quotidienne.md (30 idees facecam) + plan-videos.md (Value Assets + reels), et les 43 videos de l'inventaire /root/aios/data/paraguay_videos_validated.json (channel_handle=mattparaguay). seed.py est idempotent (idees seedees seulement si table vide ; videos upsertees sans ecraser les vues scrapees).scrape_youtube.py. Channel id UCjmtRl_IpqjHkqrk67WICvQ (@mattparaguay).lib/media_ingest.py). Trois passes : 1. flat : yt-dlp -J --flat-playlist sur la page /videos. Recupere TOUTES les videos avec id, titre, vues, duree. C'est la colonne vertebrale : les vues sont la metrique fiable qui repond a "qu'est-ce qui marche".
2. rss : le flux feeds/videos.xml (non gate) donne la date de publication + titre des ~15 videos les plus recentes (la passe flat n'a pas la date).
3. ryd : API Return YouTube Dislike (returnyoutubedislikeapi.com/votes?videoId=, sans cle, sans auth) pour le vrai nombre de likes de chaque video. On ignore volontairement ses champs dislikes/rating : YouTube a cache les dislikes publics en dec 2021, donc ce sont des ESTIMATIONS extrapolees, pas des vrais chiffres (Matt a demande likes seulement, 2026-09-10).
4. enrich : yt-dlp -J --no-playlist par video, best-effort pour les commentaires. Souvent bloque par YouTube ("Sign in to confirm you're not a bot") sans cookies : le nombre de commentaires reste donc souvent vide, c'est attendu et gere proprement (pas d'erreur, champ -). Pour les commentaires de facon fiable il faudrait une cle YouTube Data API (gratuite) ou des cookies YouTube.
COALESCE(title,?)), pour garder les titres FR de l'inventaire (YouTube sert parfois des titres traduits EN).content-dashboard-scrape.timer lance scrape_youtube.py tous les jours a 06:30 (heure systeme = Asuncion). Bouton "Rafraichir depuis YouTube" dans l'UI (POST /api/refresh, lance le scrape en thread de fond, l'UI poll /api/bootstrap toutes les 4s).cd /root/workspace/apps/content-dashboard && python3 scrape_youtube.py (options --flat-only, --enrich-limit N).scrape_competitors.py reutilise les memes helpers (flat_pass/fetch_rss_map/ryd_likes de scrape_youtube.py) mais ecrit dans competitor_videos. Plafonne a 80 videos recentes par chaine (--limit) car ces chaines sont enormes, et ne prend les likes que pour le top 40 par vues (--like-top) pour rester rapide. Lance aussi par le timer quotidien (2e ExecStart de content-dashboard-scrape.service). Bouton "Rafraichir les concurrents" dans l'UI (POST /api/competitors/refresh). Dates : flat_pass(approximate_date=True) (extractor arg youtubetab:approximate_date) donne une date APPROXIMATIVE (basee sur "il y a X semaines") pour TOUTES leurs videos, et le RSS fournit la date EXACTE des ~15 plus recentes ; on garde l'exacte quand dispo, sinon l'approximative. Tri par defaut sur les VUES (leurs cartons).import_analytics.py. Deux pulls via l'API YouTube Analytics (youtubeanalytics.googleapis.com/v2/reports, ids=channel==MINE) : (1) metriques par video (dimension=video, rapport "top videos" plafonne aux 200 plus vues) -> video_analytics ; (2) serie journaliere (dimension=day, ~400 j) -> channel_daily.lib.google_workspace.load_credentials() (memes creds que Gmail/Drive/Calendar). Scopes ajoutes le 2026-09-12 dans /root/aios/lib/google_workspace.py : youtube.readonly, yt-analytics.readonly, youtube.force-ssl. Pas de cle YouTube dediee. Chaine = "Matt Atma" (@mattparaguay), id UCjmtRl_IpqjHkqrk67WICvQ.cd /root/workspace/apps/content-dashboard && python3 import_analytics.py (--days N pour la fenetre channel_daily).content-dashboard-scrape.service (timer quotidien 06:30), apres yt-dlp + concurrents./api/bootstrap : chaque video recoit avg_view_pct, minutes_watched, avg_view_duration_s, subs_net (join sur video_analytics) ; bloc channel_analytics (lifetime + fenetres 28j/90j). Colonnes UI "Retention / Watch / Abonnes" dans l'onglet Resultats + tuiles analytics.GET / (protege, sert public/index.html), GET /login, POST /api/login, GET /logout, GET /healthz.GET /downloads/strategie-monica.pdf (protege par le meme cookie de session) sert /root/workspace/MOA-Agent-Pack/strategie-monica.pdf (constante MONICA_PDF dans server.py). Le PDF est rebuild par bash /root/workspace/MOA-Agent-Pack/build-strategie-pdf.sh (pandoc -> HTML -> wkhtmltopdf, concatene strategie-contenu-monica.md + plan-action-monica.md) ; a relancer apres toute modif de ces deux fichiers.GET /api/bootstrap (tout ce que le SPA affiche : idees, videos, stats, enums, meta), POST /api/ideas, PATCH /api/ideas/{id}, POST /api/ideas/{id}/archive.PATCH /api/videos/{video_id} (tague pilier/categorie/notes a la main), POST /api/refresh.GET /api/competitors (concurrents + leurs videos + stats, charge a l'ouverture de l'onglet), POST /api/competitors/refresh.GET /api/thumbnails?q=<thematique>&n=<24|36|48> (recherche live YouTube via scrape_youtube.youtube_search, yt-dlp ytsearch). Renvoie id, titre, chaine, vues, thumbnail, url. Rien n'est stocke en base (recherche a la demande).ryd, cf §5). Commentaires souvent vides : bot-gating YouTube. Ne pas "reparer" en boucle : c'est une limite d'API, pas un bug. La solution durable pour les commentaires (et une garantie sur les likes) = cle YouTube Data API dans .env + adapter scrape_youtube.py.DELETE FROM en Bash : pour reset la base, supprimer le fichier data/content.db (+ -wal/-shm) puis python3 db.py && python3 seed.py.impressions/impressionClickThroughRate rejetees, Studio-only). Seule metrique de qualite dispo = averageViewPercentage (retention). Ne pas promettre le CTR miniature en automatique ; il faut aller le lire dans Studio a la main, ou passer par l'export CSV de Studio.videos en suit 48 : les deux tables sont independantes (join sur video_id). Les videos hors top-200 par vues (queue tres faible) n'ont pas d'analytics ; c'est le plafond de l'API, pas un bug.a trier -> retenu -> tourne -> publie, bouton reanalyser, retirer). Filtres statut + categorie + type, tri (recents/vues/categorie/type), recherche.▶ sur la vignette charge un <iframe> d'embed a la demande (helper shEmbed() dans index.html : /embed/ YouTube, /embed/v2/ TikTok, /p/<code>/embed Instagram ; TikTok/Instagram = format haut .embed.tall). Best-effort : un shortlink vm.tiktok.com sans id video n'a pas d'apercu, fallback lien. Pas de cle, pas de token.pillar (categorie de CONTENU/sujet : Immobilier, Residence, Passeport, Fiscalite, Make money (general), Plan B, Vie sur place), ctype (TYPE de video : facecam, react, sketch, POV, visite, interview, vlog, autre) et account (COMPTE cible : Matt FR, Matt ES, MOA, RP). Les trois listes sont extensibles depuis l'UI (+ Nouvelle… -> prompt -> POST /api/shorts/categories {kind,value}, kind ∈ pillar/ctype/account -> CATEGORY_KEYS), stockees en JSON dans meta (cles short_pillars / short_types / short_accounts), seedees par db.init_db() depuis db.SHORT_PILLARS / SHORT_TYPES / SHORT_ACCOUNTS. Affichees en chips colores (.b-acc or = compte, .b-pill bleu = contenu, .b-type vert-lime = type). Barre : filtres statut + categorie + type + compte, tri par chacun.shorts (url, platform youtube/tiktok/instagram/autre/idee, title, channel, views, likes, thumbnail, note, status, pillar, ctype, account, source shortcut/manuel, enriched, archived). Colonnes pillar/ctype/account ajoutees par migration idempotente dans init_db() (ALTER TABLE si absentes)..shpick sur chaque carte -> barre #shBulk (apparait des qu'1 selection) avec selects Compte/Categorie/Type/Statut + Appliquer -> POST /api/shorts/bulk {ids,fields} (met a jour uniquement les champs remplis, sur tous les ids). Bouton "Tout selectionner" (bascule sur les shorts affiches/filtres). Selection = Set shSel cote client.classify_short(text) (server.py, CLASSIFY_PILLARS/CLASSIFY_TYPES, FR/ES/EN, 1er hit gagne, plus specifique d'abord) devine pillar+ctype. Applique a l'arrivee : dans _enrich_short (URL shorts, sur title+channel+note+url) et _insert_short (idees sans lien, sur title+note). Ne remplit QUE les champs vides (COALESCE(NULLIF(col,''),?)), n'ecrase jamais un tag manuel. Bouton "Auto-categoriser" -> POST /api/shorts/autocat (repasse sur tous les shorts non tagues). Limite : les reels Instagram n'ont ni titre ni texte -> non classifies (a taguer via bulk edit). Pas d'IA/CLI, pur keyword.POST /api/shorts/ingest avec le jeton SHORTS_INGEST_TOKEN (dans le .env de l'app) en ?token= ou en-tete Bearer. Accepte JSON {url} / {text} / {note}, un formulaire, ou un corps texte brut : la 1re URL trouvee = le short, le reste = la note ; sans URL = idee texte. Meme URL renvoyee = pas de doublon, la note est ajoutee. Fiche utilisateur : RACCOURCI-SHORTS.md (raccourci de partage iOS "Short AIOS", meme mecanique que voice-inbox).scrape_youtube.probe_short, thread de fond) : YouTube = oEmbed (titre, chaine, miniature) + RYD (vues, likes) ; TikTok = oEmbed (titre, auteur, miniature ; pas de vues, yt-dlp bloque par IP) ; Instagram = lien seul (connexion requise) ; autre = yt-dlp best-effort. Jamais bloquant.GET /api/shorts, POST /api/shorts (ajout manuel), PATCH /api/shorts/{id} (note/statut/titre), POST /api/shorts/{id}/archive, POST /api/shorts/{id}/reprobe.pillar='Recette' (bulk edit + filtre). L'AIOS les traite hors-app (lit content.db WHERE pillar='Recette', extrait via yt-dlp, ecrit dans /root/workspace/Recettes/recipes.json, PDF pour l'assistante). Voir workspace/Recettes/README.md.analyze_short.py. Par short non encore analyse (hook vide, analysis_tries < 3) : telecharge l'audio du reel (yt-dlp -x mp3), le transcrit (lib/stt_client.transcribe, cle STT lue dans /root/aios/.env), extrait le hook (1re phrase dite), le classe (classify_hook : Contradiction/Question/Chiffre/Secret/Histoire/Resultat/Probleme/Opinion/Affirmation...), et calcule 2 notes.shorts (migration idempotente init_db) : hook, transcript, hook_type, perf_score (0-100), hook_score (0-100), analysis_tries, analyzed_at.score_perf) : auto-suffisant faute de baseline par createur short par short = magnitude de vues (log) + taux de likes + taux de commentaires (plafonds 55/35/10, reglables). NULL si pas de vues : les reels Instagram n'exposent pas les vues (login wall), donc perf reste vide pour eux (le hook + hook_score, eux, marchent partout). Pour des vues IG il faudrait passer par Apify (voie B shortform-engine).score_hook) : deterministe, base = famille d'accroche (curiosite), + bonus chiffre/mots a tension/adresse au spectateur/brievete, - malus amorce molle ("en gros", "du coup") / phrase trop longue. Reglable ; upgradable vers un juge LLM plus tard._enrich_short appelle analyze_short.analyze_one dans le meme thread de fond (apres avoir les vues/likes) ; (2) bouton "🎯 Analyser + noter" dans la barre Shorts -> POST /api/shorts/analyze (worker detache --limit 25, le front re-poll 10x/15s) ; (3) cron quotidien 06:17 analyze_short.py --limit 40 (log data/analyze_short.log), avant le scrape de 06:30.public/index.html) : sur chaque carte short, une ligne hook en italique + deux pastilles de note colorees (rouge->vert par valeur, helper scoreBadge) Perf et Hook (pastille "—" grisee si pas encore note). Tri Note perf / Note hook ajoute a #shSort. Pas de vues IG = pastille Perf "—", c'est attendu.shorts n'a pas de colonne comments (ne pas la SELECT) ; score_perf recoit 0 pour les commentaires. Reels IG geles/supprimes = download qui echoue, analysis_tries bumpe, abandon apres 3 passes (comme classify_ai)./root/workspace/analyse-reels-marvin/ (carte des performances interactive + hooks-idees.md + build_tools.py reutilisable) genere a partir du corpus data/mason-ndiaye-coach/ ; meme grille de classification de hook.analyze_short.py (transcription yt-dlp+whisper -> hook -> classification -> perf_score + hook_score), 7 colonnes ajoutees a shorts (migration idempotente), declenche a l'ingest (_enrich_short) + bouton "🎯 Analyser + noter" (POST /api/shorts/analyze) + cron quotidien 06:17. UI : hook en italique + pastilles de note Perf/Hook colorees sur chaque carte, tri par note perf/hook. Limite : reels Instagram sans vues -> perf reste vide (hook + note hook OK partout). Fichiers : db.py (colonnes), server.py (trigger + endpoint), analyze_short.py (nouveau), public/index.html (badges + sort + bouton), crontab. Restart content-dashboard. Teste : 5/5 reels IG transcrits, hooks classes + notes stockees, service actif, JS OK.#shExclBtn) qui ouvre un panneau #shExclPanel : 4 rangées de chips toggle (Catégorie/pillar, Type/ctype, Compte/account, Statut), une par valeur existante. Cliquer une valeur la met en rouge barré = les shorts qui la portent sont CACHÉS. Cumulatif et combinable avec les filtres inclusifs existants (statut/catégorie/type/compte/recherche). Usage type : exclure "Recette" de la vue contenu. État shExcl={pillar,ctype,account,status} (Sets), renderExclPanel(), ajout dans shPass (masque si une valeur du short est exclue), nettoyage des exclusions obsolètes + refresh dans loadShorts, bouton "Réinitialiser les exclusions". Frontend only (index.html lu à la demande, pas de restart). Fichiers : public/index.html (CSS .exclpanel/.chip-ex, HTML panneau, JS)..sh-btn.recipe + handler + endpoint POST /api/shorts/{id}/recipe + constantes RECIPES_DIR/QUEUE + file _queue.jsonl). A la place : valeur Recette ajoutee a la dimension de contenu pillar (defaut db.SHORT_PILLARS + injectee dans le meta short_pillars existant). Matt tague les recettes via le select pillar ou le bulk edit ; le tag persiste et se filtre. L'AIOS traite hors-app en lisant content.db (shorts WHERE pillar='Recette'), dedoublonnage via le short_id stocke dans recipes.json. La colonne shorts.recipe reste en base (inutilisee, inoffensive). Short 40 (recette de test) reclasse pillar='Recette', archived=0. Restart content-dashboard.shorts.recipe (migration idempotente init_db). Endpoint POST /api/shorts/{id}/recipe : marque le short + append une ligne JSON dans /root/workspace/Recettes/_queue.jsonl ({short_id,url,title,channel,platform,note,added_at,status}). UI : 3e bouton rond orange 🍳 dans .sh-actions (.sh-btn.recipe, confirm avant), a cote de ✍/✓/✕. Le traitement (extraction recette depuis description+transcript+metadata via yt-dlp, ecriture de la fiche, PDF pour l'assistante) vit hors de l'app dans /root/workspace/Recettes/ (source unique recipes.json -> scripts/build.py regenere INDEX.md + Recettes.pdf ; scripts/fetch_recipe.py = helper yt-dlp). Objectif : ne PAS stocker les videos, juste en extraire les recettes puis purger. Fichiers : db.py (colonne recipe), server.py (endpoint + RECIPES_DIR/QUEUE), public/index.html (bouton + CSS + handler). Restart content-dashboard. Teste : migration OK, service actif, build PDF OK.scripts (title, body, status brouillon/a tourner/tourne/publie, origin idea|short|vierge, idea_id, short_id, snapshot source_url/source_title/source_thumbnail/source_platform, position, archived). Endpoints : GET /api/scripts (liste non archivee, tri publie en bas puis updated_at desc), POST /api/scripts (cree OU renvoie le script existant deja lie a la meme idee/short = pas de doublon ; si origin=idea, passe l'idee en "scripte" quand elle est en idee/valide), PATCH /api/scripts/{id} (title/body/status), POST /api/scripts/{id}/archive. UI : onglet 2 colonnes (liste gauche + editeur plein largeur droite avec sauvegarde auto debounce 700ms), volet source (embed video via shEmbed reutilise, ou notes/structure/pilier de l'idee live depuis STATE.ideas). Boutons d'entree : "✍️ Ecrire le script" dans la fiche idee (openIdea) et ✍ dans .sh-actions des cartes Shorts, tous deux via createScriptFrom() -> POST -> selectTab('scripts') -> openScript(). Refactor : selectTab(name) extrait du handler d'onglet (ajout de 'scripts' a la liste hidden). Fichiers : db.py (SCRIPT_STATUSES + table), server.py (4 endpoints + SCRIPT_FIELDS), public/index.html (tab, section, CSS .sc-*, module JS scripts, boutons). python3 db.py (cree la table) + restart content-dashboard. Teste : create/patch/list/archive OK (SQL direct), service actif, endpoints proteges (401 sans cookie). Limite : editeur en texte brut (pas de markdown rendu), autosave silencieux (indicateur "Enregistre").classify_ai.py : pour chaque short dont le TYPE (ctype) est vide, il recupere UNE image (miniature si dispo = YouTube/TikTok ; sinon frame yt-dlp+ffmpeg ; sinon, pour Instagram, screenshot Playwright de la page /embed) et l'envoie a Gemini vision (gemini-flash-latest, cle GEMINI_TTS_API_KEY lue directement dans /root/aios/.env) qui renvoie type + pilier choisis STRICTEMENT dans les listes editables de Matt (ou null si pas sur). Remplit uniquement les champs vides (jamais par-dessus un tag manuel). Colonne shorts.ai_tries (migration idempotente) : compte les echecs, on arrete de reessayer un short apres 3 passes (reels IG geles/supprimes). Deux declencheurs : (1) **cron */15** */15 * * * * cd .../content-dashboard && PATH=... python3 classify_ai.py --limit 25 (la biblio se remplit seule, log data/classify_ai.log) ; (2) bouton "✨ Detecter par IA" dans la barre Shorts -> POST /api/shorts/ai-classify (lance le worker detache via subprocess, le front re-poll 6x/15s pour voir les tags arriver). Pieges resolus : gemini-flash-latest = modele "thinking" (gemini-3.x) qui consomme tout le budget en pensees et renvoie un contenu vide (finishReason MAX_TOKENS) -> mettre generationConfig.thinkingConfig.thinkingBudget=0 + maxOutputTokens=512 ; le modele ecrit parfois la cle pilier/categorie au lieu de pillar (fallback dans le parsing). Test 2026-09-23 : 10/10 reels IG tagues (facecam/react/autre + pilier). Fichiers : classify_ai.py (nouveau), db.py (colonne ai_tries), server.py (endpoint + imports subprocess/sys), public/index.html (bouton). Restart content-dashboard (endpoint + import). Limite connue : type des shorts = du best-effort visuel, a corriger a la main via le bulk edit quand l'image manque ou que l'IA se trompe. MAJ meme jour : la selection du worker (et le compteur pending de l'endpoint) reprend un short des qu'il manque le type OU la categorie (pas seulement le type), pour que les DEUX dimensions se remplissent dans le temps ; l'IA laisse la categorie vide quand elle n'est pas sure du sujet (a taguer a la main).<div class="foot"> : select statut a trier/retenu/tourne/publie + boutons reprobe ↻ et archive ✕). Remplace par deux boutons ronds .sh-actions visibles au survol de la carte (.shortcard:hover, opacite .55 en tactile) : ✓ vert = Valider (toggle statut retenu ↔ à trier, la carte prend la classe .is-valid = bord vert quand validee) et ✕ rouge = Supprimer (archive avec confirm). La fonction reprobe (recharger titre/vues) n'est plus accessible depuis la carte. Frontend only (index.html), pas de restart..shpick, barre #shBulk, POST /api/shorts/bulk {ids,fields} (account/pillar/ctype/status), bouton Tout selectionner. Auto-cat : classify_short() keyword FR/ES/EN, applique dans _enrich_short + _insert_short (remplit uniquement les champs vides), bouton + POST /api/shorts/autocat. Reels IG sans texte non classifies (attendu). server.py + index.html. Restart content-dashboard./media/?size=l = 404). Solution : garder l'iframe /embed mais le recadrer en CSS (.embed.ig aspect-ratio 1/1 + iframe position absolue top:-54px height calc(100%+320px)) pour ne montrer que la zone media, en masquant l'en-tete profil et le bandeau likes/commentaires. shEmbed renvoie kind (yt/tt/ig), classe via embedCls(). Valeurs de crop best-effort (a ajuster si l'en-tete depasse ou si le bas est coupe). Frontend only.<iframe> d'embed (une frame visible au lieu du rectangle vide). Les cartes avec miniature la gardent (leger) + ▶ click-to-play. Idee sans lien = placeholder. Frontend only..chip-tg, barre #shChanBar, etat shChan, couleurs CHAN_COLORS + fallback) au-dessus de la grille Shorts, a la place du menu deroulant compte (le select compte reste SUR la carte pour l'assignation). Filtre par clic. HTML relu a la demande, pas de restart. Demande Matt (maquette pilules).#shCount) de la barre Shorts (demande Matt, cosmetique). HTML relu a la demande, pas de restart.account (Matt FR / Matt ES / MOA / RP) sur les shorts, meme mecanique editable que pillar/ctype (colonne migree, meta short_accounts, CATEGORY_KEYS, filtre + tri + chip or .b-acc). SHORT_FIELDS etendu, /api/shorts renvoie accounts, endpoint categories accepte kind=account.shEmbed(), overlay ▶ sur la vignette, plus de vignette vide inerte) ; (2) deux categories editables par short : pillar (categorie de contenu) + ctype (type de video), listes extensibles depuis l'UI (POST /api/shorts/categories, stockees dans meta short_pillars/short_types, defauts db.SHORT_PILLARS/SHORT_TYPES), chips colores b-pill/b-type ; (3) filtres categorie + type dans la barre + tri par categorie/type. Migration idempotente (ALTER TABLE shorts ADD pillar/ctype dans init_db). Fichiers : db.py, server.py (SHORT_FIELDS + endpoint categories + pillars/ctypes dans /api/shorts), public/index.html. Restart content-dashboard.scrape_youtube.py : (1) flat_pass(lang=...) + enrich_one(lang="fr") ajoutent l'extractor-arg youtube:lang=fr / youtubetab:lang=fr (force le titre d'origine ; laisse None pour les concurrents), run() appelle flat_pass(lang="fr") ; (2) rss_pass rend le titre RSS/Atom AUTORITAIRE (title=COALESCE(?,title)) car le flux Atom porte toujours le titre original, jamais la traduction. Correction ponctuelle des 6 titres deja stockes en anglais. Note : le flat lang=fr et le RSS peuvent donner deux libelles FR legerement differents (A/B test de titre YouTube) ; le RSS gagne au prochain scrape. Aucun restart (service lit content.db a la demande).Matt (perso) / Monica (MOA) (STRAT_VIEW, fonctions stratMattHTML() / stratMonicaHTML() dans index.html, Matt reste la vue par defaut). Nouvelle route protegee GET /downloads/strategie-monica.pdf (FileResponse, MONICA_PDF) + bouton PDF dans la vue Monica. Le PDF (10 pages) vit dans /root/workspace/MOA-Agent-Pack/strategie-monica.pdf, genere par build-strategie-pdf.sh du meme dossier. Demande Matt : garder la strat de Monica dans son dashboard + en fichier telechargeable.const PILLAR_COLORS={} vide (stub laisse en tete de script, ligne 205) entrait en collision avec le vrai const PILLAR_COLORS={...} plus bas -> erreur JS fatale "Identifier 'PILLAR_COLORS' has already been declared" qui empechait tout le SPA de demarrer (back-end OK, bootstrap 200, mais front mort). Stub supprime. Lecon : pas de double const de meme nom dans le meme scope ; index.html est servi a la demande, pas besoin de restart.PILLAR_COLORS / CAT_COLORS en tete de script. Logique : PILIERS = sujet (Plan B bleu, Fiscalite vert, Immobilier terracotta, Vie sur place violet, N/A gris) ; CATEGORIES = intention (Credibilite or, Education cyan, Valeur vert-lime, Transformationnel rose). Style .tag-txt (texte gras colore), .tag-empty (italique "a classer"). Le crayon d'edition reste. Anciennes classes chip .b-cat2/.b-todo supprimees.db.CATEGORIES : credibilite/education/valeur/transformationnel) d'apres titres + transcripts (/root/aios/data/transcripts/, data/paraguay_kb/) pour les cas ambigus. Reference des definitions : strategie-instagram-perso/STRATEGIE.md (section "Les 4 categories"). Distribution : education 18, valeur 14, transformationnel 9, credibilite 7. UI onglet Resultats : les colonnes Pilier et Categorie n'affichent plus un <select> mais un tag fixe (chip bleu = pilier, chip vert = categorie, chip pointille "a classer" si vide) ; un crayon en fin de ligne bascule la ligne en mode edition (les 2 dropdowns reapparaissent), une coche verte referme. Etat d'edition = Set editRows cote client, PATCH /api/videos/{id} inchange. Une nouvelle video scrapee sans categorie s'affiche "a classer" (a reclasser a la main via le crayon, ou a la prochaine passe de classement).db.PILLARS passe a 4 piliers + N/A ; 7 videos et 5 idees reclassees en Immobilier ; onglet Strategie mis a jour (4 piliers).db.PILLARS) pour les videos anterieures a la strategie qui ne rentrent dans aucun pilier (BOLIVIA VLOG, Michel Barnier, apprendre une langue) ; "N/A" est exclu de l'agregat by_pillar ("quel pilier performe"). Reste modifiable a la main via le select pilier de l'onglet Resultats./root/workspace/strategie-instagram-perso/ (STRATEGIE.md + ROADMAP.md). Nouvelle modale openVideoAnalysis(video_id) (vues, retention, watch time, abonnes nets, likes/comments + lien Studio) ouvrable depuis chaque ligne de l'onglet Resultats ("Analyse complete") ET depuis une carte du board liee a une video publiee (published_video_id). Le board (idees->...->tourne->publie) reste la liste de videos + suivi de tournage ; lier la video publiee dans la modale d'idee fait apparaitre le lien d'analyse sur la carte.import_analytics.py (OAuth Google AIOS, scopes youtube ajoutes), tables video_analytics (200 videos : retention, watch time, abonnes nets, likes/comments/shares API) + channel_daily (serie ~400 j). Expose dans /api/bootstrap (par video + bloc channel_analytics lifetime/28j/90j), colonnes UI Retention/Watch/Abonnes + tuiles analytics dans l'onglet Resultats. Ajoute au timer quotidien (3e ExecStart). Limite connue : impressions/CTR miniature non exposes par l'API (Studio-only).yt-dlp ytsearch via /api/thumbnails, tri par vues, lightbox maxres. + dates concurrents en approximate_date (toutes datees). + toutes les dates UI au format europeen JJ/MM/AAAA.stats_history (views_deltas() : latest - plus ancien snapshot dans la fenetre). Tant que 30 jours d'historique ne sont pas accumules, montre le gain depuis le debut du tracking + note explicite (tracked_days). Grosses videos = delta grossier (vues arrondies au millier par YouTube), videos recentes = exact.scrape_competitors.py (tables competitors/competitor_videos), Nomad Capitalist seede (80 videos recentes + likes top 40). Ajoute au timer quotidien.ryd (Return YouTube Dislike API) pour le vrai nombre de likes de chaque video, sans cle. Dislikes volontairement exclus (estimation non fiable depuis dec 2021).