[object Object]

← back to Marketing Command Center

Add Sounds & Assets panel: curated free/royalty-free music + TikTok assets, brand-safety badged (Grateful Dead archive listen-only)

8bb0bc5d21b4ead8b07548cd7062db57e4d74b1b · 2026-07-14 12:49:58 -0700 · Steve

Files touched

Diff

commit 8bb0bc5d21b4ead8b07548cd7062db57e4d74b1b
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 14 12:49:58 2026 -0700

    Add Sounds & Assets panel: curated free/royalty-free music + TikTok assets, brand-safety badged (Grateful Dead archive listen-only)
---
 modules/channels/index.js | 64 ++++++++++++++++++++++++++++++++---
 modules/registry.js       |  1 +
 modules/sounds/index.js   | 68 +++++++++++++++++++++++++++++++++++++
 public/app.js             |  2 +-
 public/panels/channels.js | 13 ++++++--
 public/panels/sounds.html | 39 ++++++++++++++++++++++
 public/panels/sounds.js   | 85 +++++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 264 insertions(+), 8 deletions(-)

diff --git a/modules/channels/index.js b/modules/channels/index.js
index 20ca3b8..c69a918 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -271,6 +271,59 @@ function platformStatus() {
   return base;
 }
 
+// ── Meta token health (expiry-aware status) ───────────────────────────────────
+// platformStatus() is presence-only: a PRESENT-but-EXPIRED Meta token reads as
+// "connected" while every real post silently fails AND the reconnect button stays
+// hidden (connectUrl only shows when !connected). This adds a cached debug_token
+// check so an expired/invalid Meta token flips to needsReconnect → surfaces the
+// Connect button. FAIL-SAFE: a network blip or missing app creds returns ok:null
+// (unknown) and NEVER flips a possibly-good token to broken.
+let _metaHealth = null;                 // { token, at, v }
+const META_HEALTH_TTL = 10 * 60 * 1000; // 10 min — debug_token is free but rate-limited
+async function metaTokenHealth() {
+  const token = env('META_ACCESS_TOKEN');
+  if (!token) return { present: false, ok: false, reason: 'no token' };
+  const now = Date.now();
+  if (_metaHealth && _metaHealth.token === token && (now - _metaHealth.at) < META_HEALTH_TTL) return _metaHealth.v;
+  const appId = env('META_APP_ID'), secret = env('META_APP_SECRET');
+  if (!appId || !secret) {            // can't verify without app creds → unknown, don't flip
+    const v = { present: true, ok: null, reason: 'no app id/secret to verify token' };
+    _metaHealth = { token, at: now, v }; return v;
+  }
+  try {
+    const r = await fetch(`${GRAPH}/debug_token?input_token=${encodeURIComponent(token)}&access_token=${encodeURIComponent(appId + '|' + secret)}`);
+    const j = await r.json().catch(() => ({}));
+    const d = j.data || {};
+    const v = {
+      present: true,
+      ok: d.is_valid === true,
+      expiresAt: d.expires_at ? d.expires_at * 1000 : null,
+      reason: d.error?.message || (d.is_valid === false ? 'token invalid/expired' : null),
+    };
+    _metaHealth = { token, at: now, v }; return v;
+  } catch (e) {                       // network failure → unknown, keep prior belief
+    const v = { present: true, ok: null, reason: 'validity check failed: ' + e.message };
+    _metaHealth = { token, at: now, v }; return v;
+  }
+}
+
+// Mutate the presence-based status with Meta token validity. Only acts when the
+// token is DEFINITIVELY invalid (ok === false); ok:null (unknown) is left alone.
+function augmentMetaHealth(st, health) {
+  for (const p of ['facebook', 'instagram']) {
+    if (!st[p] || !st[p].connected || !health || !health.present) continue;
+    if (health.ok === false) {
+      st[p].connected = false;
+      st[p].needsReconnect = true;
+      st[p].tokenError = health.reason || 'token expired — reconnect required';
+      st[p].connectUrl = st[p].configured ? `/api/channels/connect/${p}` : st[p].connectUrl;
+    } else if (health.ok === true && health.expiresAt) {
+      st[p].tokenExpiresAt = health.expiresAt; // lets the UI show "expires in N days"
+    }
+  }
+  return st;
+}
+
 // ── Real adapters (fire only when connected) ──────────────────────────────────
 async function postFacebook(content, opts = {}) {
   // Two modes: (a) selected-pages — post to a chosen subset using each Page's OWN
@@ -400,8 +453,8 @@ module.exports = {
   title: 'Channels & Publish',
   icon: '📡',
   mount(router) {
-    router.get('/status', (_req, res) => {
-      const st = platformStatus();
+    router.get('/status', async (_req, res) => {
+      const st = augmentMetaHealth(platformStatus(), await metaTokenHealth());
       const connected = Object.values(st).filter(p => p.connected).length;
       res.json({ platforms: st, connectedCount: connected, total: Object.keys(st).length, outbox: readOutbox().length });
     });
@@ -421,8 +474,8 @@ module.exports = {
 
     // Activate page data: provider cards + which keys are already set (booleans only,
     // never the values) + the redirect URIs to register in each portal.
-    router.get('/setup', (_req, res) => {
-      const st = platformStatus();
+    router.get('/setup', async (_req, res) => {
+      const st = augmentMetaHealth(platformStatus(), await metaTokenHealth());
       const providers = SETUP_PROVIDERS.map(p => ({
         id: p.id, title: p.title, icon: p.icon, blurb: p.blurb,
         portal: p.portal, portalLabel: p.portalLabel, permissions: p.permissions,
@@ -432,6 +485,9 @@ module.exports = {
           id: pl, label: st[pl].label, connected: st[pl].connected,
           configured: st[pl].configured, connectUrl: st[pl].connectUrl,
           caveat: st[pl].caveat || null,
+          needsReconnect: st[pl].needsReconnect || false,
+          tokenError: st[pl].tokenError || null,
+          tokenExpiresAt: st[pl].tokenExpiresAt || null,
         })),
       }));
       res.json({ redirectBase: redirectBase(), providers });
diff --git a/modules/registry.js b/modules/registry.js
index 40bf333..2581245 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -14,6 +14,7 @@ module.exports = [
   'copy',               // suggested copy: subject lines, email/social body (LLM-backed)
   'layouts',            // on-demand HTML/CSS email + social layout mockups
   'assets',             // image asset library: upload / URL / live DW-catalog pull
+  'sounds',             // Sounds & Assets: curated free/royalty-free music + TikTok creative sources, badged by brand-safety (incl. Grateful Dead live archive as listen-only)
   'performance',        // performance dashboard: email + GA4 KPIs, charts, top campaigns
   'social',             // social scheduler: IG/TikTok queue board, gated publish
   'follow-counts',      // Followers/Following: per-DW-IG-account followers_count + follows_count (COUNTS ONLY, official Graph API via Norma :9810) + daily-snapshot growth chart
diff --git a/modules/sounds/index.js b/modules/sounds/index.js
new file mode 100644
index 0000000..425afc4
--- /dev/null
+++ b/modules/sounds/index.js
@@ -0,0 +1,68 @@
+// Sounds & Assets — a curated, always-live directory of FREE music + TikTok
+// creative assets for making DW social content. Static link catalog (no scraping,
+// no keys) served to the panel; edit the CATALOG below to add/curate sources.
+//
+// Brand-safety is the whole point of the two buckets:
+//   brandsafe → cleared/royalty-free to put UNDER a published DW post
+//   listen    → free to enjoy / inspiration only — NOT licensed for brand use
+// DW's socials are BUSINESS accounts, so a Grateful Dead live tape (free to hear)
+// still can't legally back a DW TikTok — only the platform's Commercial Music
+// Library or true royalty-free libraries can.
+
+const CATALOG = [
+  {
+    id: 'tiktok',
+    title: 'TikTok assets',
+    blurb: 'Trends, sounds, and the business-safe music library — start here when making a DW TikTok.',
+    items: [
+      { name: 'TikTok Creative Center', url: 'https://ads.tiktok.com/business/creativecenter/inspiration/popular/hashtag/pc/en', badge: 'tiktok', note: 'Top sounds, trending hashtags, trending videos by region/industry. Free, no ad account needed.' },
+      { name: 'TikTok Commercial Music Library (CML)', url: 'https://commercial-music.tiktok.com/', badge: 'brandsafe', note: 'The ONLY music cleared for business accounts on TikTok. Browse + preview; pick from here for DW posts.' },
+      { name: 'TikTok for Business — Creative Hub', url: 'https://www.tiktok.com/business/en/creative-center', badge: 'tiktok', note: 'Ad specs, creative best-practices, templates.' },
+      { name: 'CapCut (free editor + templates)', url: 'https://www.capcut.com/templates', badge: 'tiktok', note: 'TikTok-native editor; trend templates you drop DW photos/clips into.' },
+    ],
+  },
+  {
+    id: 'brandsafe',
+    title: 'Brand-safe music libraries',
+    blurb: 'Royalty-free / cleared for commercial social — safe to put behind a published DW post.',
+    items: [
+      { name: 'YouTube Audio Library', url: 'https://studio.youtube.com', badge: 'brandsafe', note: 'Free tracks + SFX, most usable commercially (check the per-track license). In Studio → Audio Library. Login required.' },
+      { name: 'Meta Sound Collection', url: 'https://business.facebook.com/creatorstudio/', badge: 'brandsafe', note: 'Free music/SFX cleared for Facebook + Instagram (incl. Reels). In Creator Studio → Sound Collection.' },
+      { name: 'Pixabay Music', url: 'https://pixabay.com/music/', badge: 'brandsafe', note: 'Free for commercial use, no attribution required. Big, clean catalog.' },
+      { name: 'Uppbeat', url: 'https://uppbeat.io/', badge: 'brandsafe', note: 'Free tier with a per-track credit code; built for creators/social.' },
+      { name: 'Chosic — Free Music', url: 'https://www.chosic.com/free-music/all/', badge: 'cc', note: 'Aggregates CC + royalty-free tracks with clear per-track license labels.' },
+      { name: 'Bensound', url: 'https://www.bensound.com/', badge: 'cc', note: 'Free with attribution (or license to drop it). Reliable background beds.' },
+      { name: 'Incompetech (Kevin MacLeod)', url: 'https://incompetech.com/music/royalty-free/music.html', badge: 'cc', note: 'Huge royalty-free library, CC-BY (credit Kevin MacLeod).' },
+      { name: 'Free Music Archive', url: 'https://freemusicarchive.org/', badge: 'cc', note: 'CC-licensed — filter to commercial-OK licenses before using on a DW post.' },
+    ],
+  },
+  {
+    id: 'listen',
+    title: 'Grateful Dead & live archives',
+    blurb: '🎧 Free to LISTEN — studio session fuel + inspiration. Not licensed to publish under a brand post.',
+    items: [
+      { name: 'Grateful Dead — Internet Archive', url: 'https://archive.org/details/GratefulDead', badge: 'listen', note: 'Thousands of full concerts the band let fans tape + share. Free to stream.' },
+      { name: 'Relisten (web + free app)', url: 'https://relisten.net/', badge: 'listen', note: 'Gapless player over the Archive.org Dead + jam-band tapes. Best listening experience.' },
+      { name: 'Live Music Archive (etree)', url: 'https://archive.org/details/etree', badge: 'listen', note: 'The broader taper-friendly collection — bands that allow free live sharing.' },
+      { name: 'SomaFM', url: 'https://somafm.com/', badge: 'listen', note: 'Free, ad-free curated internet radio (Groove Salad, Deep Space One) — great studio background.' },
+      { name: 'Musopen (public domain classical)', url: 'https://musopen.org/', badge: 'listen', note: 'Public-domain classical recordings — fully free to use, no license worries.' },
+    ],
+  },
+];
+
+module.exports = {
+  id: 'sounds',
+  title: 'Sounds & Assets',
+  icon: '🎵',
+  mount(router) {
+    router.get('/catalog', (_req, res) => res.json({
+      catalog: CATALOG,
+      badges: {
+        brandsafe: { label: '✅ Brand-safe', desc: 'Cleared / royalty-free for commercial social — OK to publish under a DW post.' },
+        cc: { label: '© CC / attribution', desc: 'Creative Commons — usually OK commercially WITH credit; check the per-track license.' },
+        listen: { label: '🎧 Listen / inspo', desc: 'Free to hear, NOT licensed for brand posts. Studio fuel + inspiration only.' },
+        tiktok: { label: '▶ TikTok', desc: 'TikTok-native creative resource.' },
+      },
+    }));
+  },
+};
diff --git a/public/app.js b/public/app.js
index 5a30805..9fd1826 100644
--- a/public/app.js
+++ b/public/app.js
@@ -10,7 +10,7 @@ let PANELS = [];
 // Curated nav groups. Social Media leads. Any panel not listed falls into "More".
 const GROUPS = [
   { name: 'Social Media', ids: ['accounts', 'board', 'social', 'channels', 'linkedin'] },
-  { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'templates'] },
+  { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'sounds', 'templates'] },
   { name: 'Email', ids: ['constant-contact', 'send-times'] },
   { name: 'Audience', ids: ['segments', 'profiles', 'segment-perf'] },
   { name: 'Automation', ids: ['journeys', 'browse-abandon', 'playbook'] },
diff --git a/public/panels/channels.js b/public/panels/channels.js
index af3201e..0a31d4e 100644
--- a/public/panels/channels.js
+++ b/public/panels/channels.js
@@ -92,8 +92,15 @@ window.MCC_PANELS['channels'] = {
       } catch { $('#ch-hosthint').innerHTML = ''; }
 
       $('#ch-setup').innerHTML = d.providers.map(p => {
-        const statusPills = p.platforms.map(pl =>
-          `<span class="pill" style="background:${pl.connected ? '#e3efe0' : pl.configured ? '#eef3f8' : '#f3e9e9'};color:${pl.connected ? '#3a6b3a' : pl.configured ? '#3a5a8a' : '#9a5a5a'}">${esc(pl.label)}: ${pl.connected ? 'connected' : pl.configured ? 'ready' : 'needs keys'}</span>`).join(' ');
+        const statusPills = p.platforms.map(pl => {
+          // needsReconnect (token present but expired/invalid) takes precedence over
+          // the presence-based states so a dead token reads amber, not a false "ready".
+          const s = pl.needsReconnect ? { bg: '#fff8ec', fg: '#7a5a2a', txt: 'reconnect needed' }
+            : pl.connected ? { bg: '#e3efe0', fg: '#3a6b3a', txt: 'connected' }
+            : pl.configured ? { bg: '#eef3f8', fg: '#3a5a8a', txt: 'ready' }
+            : { bg: '#f3e9e9', fg: '#9a5a5a', txt: 'needs keys' };
+          return `<span class="pill" style="background:${s.bg};color:${s.fg}"${pl.tokenError ? ` title="${esc(pl.tokenError)}"` : ''}>${esc(pl.label)}: ${s.txt}</span>`;
+        }).join(' ');
         const redirects = p.redirectUris.map(r =>
           `<div class="copyrow"><code>${esc(r.uri)}</code><button class="btn ghost xs" data-copy="${esc(r.uri)}">copy</button></div>`).join('');
         const fields = p.fields.map(f =>
@@ -102,7 +109,7 @@ window.MCC_PANELS['channels'] = {
               placeholder="${f.set ? '•••••• saved — leave blank to keep' : esc(f.ph || '')}"></div>`).join('');
         const connects = p.platforms.map(pl =>
           pl.connected ? `<span class="pill" style="background:#e3efe0;color:#3a6b3a">✓ ${esc(pl.label)}</span>`
-            : (pl.connectUrl ? `<a class="btn gated" style="text-decoration:none" href="${ORIGIN}${esc(pl.connectUrl)}">Connect ${esc(pl.label)} →</a>` : '')
+            : (pl.connectUrl ? `<a class="btn gated" style="text-decoration:none" href="${ORIGIN}${esc(pl.connectUrl)}">${pl.needsReconnect ? 'Reconnect' : 'Connect'} ${esc(pl.label)} →</a>` : '')
         ).join(' ');
         // Meta has a known gotcha: Ads-scoped apps can't grant posting permissions.
         // Bake the exact 4-step fix into the card so it's waiting whenever Steve returns.
diff --git a/public/panels/sounds.html b/public/panels/sounds.html
new file mode 100644
index 0000000..9f046f7
--- /dev/null
+++ b/public/panels/sounds.html
@@ -0,0 +1,39 @@
+<div class="muted-banner" id="snd-banner">Loading sounds & assets…</div>
+
+<div class="card" style="margin-bottom:14px">
+  <div class="row" style="gap:10px;align-items:center;flex-wrap:wrap">
+    <input type="search" id="snd-search" placeholder="Filter sources…" style="flex:1;min-width:200px" autocomplete="off">
+    <div id="snd-badgefilter" class="snd-badgefilter"></div>
+  </div>
+  <div class="muted" id="snd-legend" style="font-size:12px;margin-top:10px;line-height:1.6"></div>
+</div>
+
+<div id="snd-sections"><div class="muted">Loading…</div></div>
+
+<style>
+.snd-badgefilter{display:flex;gap:6px;flex-wrap:wrap}
+.snd-bf{background:transparent;border:1px solid var(--line);border-radius:999px;padding:5px 11px;cursor:pointer;
+ font:600 12px/1 Inter;color:var(--ink)}
+.snd-bf:hover{background:var(--cream)}
+.snd-bf.on{background:var(--accent);color:#fff;border-color:var(--accent)}
+.snd-legend b{color:var(--ink)}
+.snd-sec{margin-bottom:20px}
+.snd-sechead{display:flex;align-items:baseline;gap:10px;margin:0 0 4px}
+.snd-sechead h2{margin:0;font:600 20px/1.1 "Cormorant Garamond",Georgia,serif}
+.snd-sechead .ct{font:700 11px/1 Inter;background:var(--cream);color:var(--accent);border-radius:999px;padding:3px 9px}
+.snd-secblurb{font-size:12.5px;color:var(--mut);margin:0 0 12px}
+.snd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px}
+.snd-card{border:1px solid var(--line);border-radius:12px;padding:14px;background:#fff;display:flex;flex-direction:column;gap:6px;
+ text-decoration:none;color:inherit;transition:border-color .12s,transform .12s}
+.snd-card:hover{border-color:var(--accent);transform:translateY(-1px)}
+.snd-card .st{display:flex;align-items:center;gap:8px}
+.snd-card .nm{font:600 14.5px/1.2 Inter;flex:1;min-width:0}
+.snd-card .arr{color:var(--mut);font-size:13px}
+.snd-card .bdg{font:700 9.5px/1 Inter;border-radius:999px;padding:3px 8px;white-space:nowrap}
+.bdg-brandsafe{background:#e3efe0;color:#2f6b34}
+.bdg-cc{background:#eaf0f7;color:#33578a}
+.bdg-listen{background:#fdf3dc;color:#8a6a1e}
+.bdg-tiktok{background:#f1e8f3;color:#7a3a8a}
+.snd-card .nt{font-size:12px;line-height:1.45;color:#5a544b}
+.snd-empty{color:var(--mut);font-size:12.5px;padding:8px 0}
+</style>
diff --git a/public/panels/sounds.js b/public/panels/sounds.js
new file mode 100644
index 0000000..c04b9d2
--- /dev/null
+++ b/public/panels/sounds.js
@@ -0,0 +1,85 @@
+// Sounds & Assets panel — free music + TikTok creative sources, badged by
+// brand-safety. Search + badge facets. Pure link directory (data from
+// /api/sounds/catalog); every card opens the source in a new tab.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['sounds'] = {
+  init(root) {
+    const ORIGIN = location.origin;
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const $ = sel => root.querySelector(sel);
+
+    const state = { catalog: [], badges: {}, q: '', badge: 'all' };
+
+    async function load() {
+      let d;
+      try { d = await fetch(ORIGIN + '/api/sounds/catalog', { credentials: 'same-origin' }).then(r => r.json()); }
+      catch (_) { $('#snd-banner').textContent = 'Could not load the catalog.'; return; }
+      state.catalog = d.catalog || [];
+      state.badges = d.badges || {};
+      const total = state.catalog.reduce((n, s) => n + s.items.length, 0);
+      $('#snd-banner').innerHTML = `<b>${total}</b> free / brand-safe sources across ${state.catalog.length} groups. ` +
+        `Filter by type, then click through — sources open in a new tab.`;
+      renderLegend();
+      renderBadgeFilter();
+      renderSections();
+    }
+
+    function renderLegend() {
+      $('#snd-legend').innerHTML = Object.values(state.badges)
+        .map(b => `<span style="margin-right:16px"><b>${esc(b.label)}</b> — ${esc(b.desc)}</span>`).join('');
+    }
+
+    function renderBadgeFilter() {
+      const box = $('#snd-badgefilter');
+      const keys = ['all', ...Object.keys(state.badges)];
+      box.innerHTML = keys.map(k => {
+        const label = k === 'all' ? 'All' : (state.badges[k] ? state.badges[k].label : k);
+        return `<button type="button" class="snd-bf${state.badge === k ? ' on' : ''}" data-b="${esc(k)}">${esc(label)}</button>`;
+      }).join('');
+      box.querySelectorAll('.snd-bf').forEach(b => b.onclick = () => {
+        state.badge = b.getAttribute('data-b'); renderBadgeFilter(); renderSections();
+      });
+    }
+
+    function match(item) {
+      if (state.badge !== 'all' && item.badge !== state.badge) return false;
+      const q = state.q.trim().toLowerCase();
+      if (q && !((item.name || '').toLowerCase().includes(q) || (item.note || '').toLowerCase().includes(q))) return false;
+      return true;
+    }
+
+    function renderSections() {
+      const box = $('#snd-sections');
+      let html = '';
+      let shown = 0;
+      for (const sec of state.catalog) {
+        const items = sec.items.filter(match);
+        if (!items.length) continue;
+        shown += items.length;
+        html += `<div class="snd-sec">
+          <div class="snd-sechead"><h2>${esc(sec.title)}</h2><span class="ct">${items.length}</span></div>
+          <div class="snd-secblurb">${esc(sec.blurb || '')}</div>
+          <div class="snd-grid">${items.map(cardHTML).join('')}</div>
+        </div>`;
+      }
+      box.innerHTML = shown ? html : '<div class="snd-empty">No sources match that filter.</div>';
+    }
+
+    function cardHTML(it) {
+      const b = state.badges[it.badge] || { label: it.badge };
+      return `<a class="snd-card" href="${esc(it.url)}" target="_blank" rel="noopener noreferrer">
+        <div class="st">
+          <span class="nm">${esc(it.name)}</span>
+          <span class="bdg bdg-${esc(it.badge)}">${esc(b.label)}</span>
+        </div>
+        <div class="nt">${esc(it.note || '')}</div>
+        <div class="arr">Open ↗</div>
+      </a>`;
+    }
+
+    $('#snd-search').addEventListener('input', e => { state.q = e.target.value; renderSections(); });
+
+    load();
+  },
+};

← fa0d1c1 deploy: ship Accounts panel to Kamatera; fix .deploy.conf HE  ·  back to Marketing Command Center  ·  Expand Sounds catalog: +6 royalty-free libraries & SFX, live c60d8ff →