openvibers/openvibe.live Commits
Content and Moments: VODs, clips and pastes are one human-made feed at /content (filters All, VODs, Clips, Pastes; New, or Top by views over a week, a month or all time), and everything the AI makes is a separate feed at /moments (auto-clips, AI moment pastes, from VODs and caught live, and AI-written after-show recaps), every card badged AI. /vods, /clips and /pastes are /content with that filter (the address becomes /content?type=…; /pastes?edit=<slug> still opens the editor). The navbar shows Content and Moments instead of VODs, Clips and Pastes; the home page's AI Moments header links to /moments, its Recent VODs/Clips/Pastes "View All" to the filtered feed, and its Recent Clips and Pastes rails list people's only
Changes
@@@ -33,7 +33,7 @@ The SPA is plain JavaScript with no build step. What loads is decided per route:public/index.html shell: navbar, home page, empty <section id="page-*"> shells, core scriptspublic/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetchroutes: URL pattern → features--public/fragments/*.html page markup for channel, VOD/clip players, dashboard, broadcast, chat, documentation++public/fragments/*.html page markup for channel, VOD/clip players, dashboard, broadcast, chat, documentation, content, momentspublic/css/features/*.css rules used only by one feature (split out of style.css / broadcast.css / i18n-star.css)public/js/ov-loader.js ov.load(feature), ov.route(path), ov.prefetch(), route generations, scopes, stubspublic/js/app.js core: router, auth, API helper, shared renderers, connection pill@@@ -72,6 +72,19 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c- **Featured live stream.** `public/js/home-featured.js` (feature `featured`) loads only when someone islive: `GET /api/home/featured` picks and rotates the stream; playback is FLV (RTMP) or the JSMPEG relay,live frames for WebRTC. Off switch remembered in `localStorage` (`ov_home_featured_off`).++- **Content and Moments.** `/content` is what people made (VODs, clips people cut, people's pastes);++ `/moments` is what the AI made (auto-clips, AI moment pastes, AI-written after-show recaps), every++ card marked AI. `/vods`, `/clips` and `/pastes` are `/content` with that filter (the address becomes++ `/content?type=…`). One lazy feature (`public/js/content-feed.js`, fragments `content`/`moments`):++ a masonry of cards (one column on phones, two or three wider), infinite scroll by cursor, clips++ that play muted one at a time when mostly in view. The server merges the sources++ (`server/content/feed.js`, `GET /api/content/feed` and `/api/content/moments`): who made an item is++ the store's answer, never a guess here (Media's clip `auto_generated`, Community's paste `origin`,++ `stream_recaps.ai`), each source filters server-side, public items only. The cursor carries each++ source's offset (and a Top window's start); source pages are cached 30 s (Top 2 min) and a page++ waits at most 4 s for Media and Community, leaving a slow one out and asking again on the next++ page. Auto-clips made before Media kept the flag are marked from Live's `auto_clip_log`++ (`syncAutoClipFlags` in `server/ai/auto-clip-job.js`).- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-meshWebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existingmembers answer; the server pushes the channel list to every chat socket (`voice-channels` message);nnew file mode 100644@@@ -0,0 +1,458 @@++/* The Content and Moments feeds (public/js/content-feed.js, fragments content.html and moments.html).++ One column of large cards on a phone, two or three masonry columns wider up. Theme tokens only. */++++/* `hidden` must win over the display of .btn, .cf-seg and the pills. */++.cf-wrap [hidden] { display: none !important; }++.cf-wrap {++ --cf-gap: 16px;++ --cf-radius: 14px;++ --cf-ai: #a78bfa; /* AI text on dark surfaces */++ --cf-ai-2: #22d3ee;++ --cf-ai-fill: linear-gradient(135deg, #6d28d9, #0e7490); /* behind white text: 4.5:1 or better */++ max-width: 1240px;++ margin: 0 auto;++ padding: 20px 16px 48px;++}++++/* ── header ─────────────────────────────────────────────────────────────── */++.cf-head {++ display: flex;++ align-items: flex-end;++ justify-content: space-between;++ gap: 12px 20px;++ flex-wrap: wrap;++ margin-bottom: 14px;++}++.cf-head-text { min-width: 0; flex: 1 1 320px; }++.cf-h1 {++ margin: 0;++ font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem);++ font-weight: 800;++ letter-spacing: -0.02em;++ line-height: 1.15;++ display: flex;++ align-items: center;++ gap: 10px;++}++.cf-sub { margin: 6px 0 0; color: var(--text-secondary); font-size: 0.92rem; max-width: 60ch; }++.cf-head-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }++.cf-crosslink {++ display: inline-flex;++ align-items: center;++ gap: 7px;++ padding: 7px 12px;++ border-radius: var(--radius-pill, 999px);++ border: 1px solid var(--border-light);++ color: var(--text-secondary);++ font-size: 0.85rem;++ font-weight: 600;++ text-decoration: none;++ transition: color var(--motion-fast, 120ms), border-color var(--motion-fast, 120ms), background var(--motion-fast, 120ms);++}++.cf-crosslink:hover { color: var(--text-primary); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }++.cf-wrap--ai .cf-crosslink:hover { border-color: var(--cf-ai); background: color-mix(in srgb, var(--cf-ai) 10%, transparent); }++.cf-ai-mark {++ display: inline-grid;++ place-items: center;++ width: 1.5em;++ height: 1.5em;++ border-radius: 10px;++ font-size: 0.75em;++ color: #fff;++ background: linear-gradient(135deg, var(--cf-ai), var(--cf-ai-2));++ box-shadow: 0 4px 16px color-mix(in srgb, var(--cf-ai) 35%, transparent);++}++++/* ── filter bar ─────────────────────────────────────────────────────────── */++.cf-bar {++ position: sticky;++ top: var(--navbar-h, 56px);++ z-index: 5;++ display: flex;++ align-items: center;++ justify-content: space-between;++ flex-wrap: wrap;++ gap: 10px 16px;++ padding: 10px 0;++ margin-bottom: 8px;++ background: color-mix(in srgb, var(--bg-primary) 88%, transparent);++ backdrop-filter: blur(10px);++ -webkit-backdrop-filter: blur(10px);++}++.cf-chips { display: flex; flex-wrap: wrap; gap: 8px; }++.cf-chip {++ display: inline-flex;++ align-items: center;++ gap: 7px;++ min-height: 36px;++ padding: 0 14px;++ border-radius: var(--radius-pill, 999px);++ border: 1px solid var(--border-light);++ background: var(--bg-card);++ color: var(--text-secondary);++ font: inherit;++ font-size: 0.88rem;++ font-weight: 600;++ cursor: pointer;++ transition: background var(--motion-fast, 120ms), color var(--motion-fast, 120ms), border-color var(--motion-fast, 120ms);++}++.cf-chip:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 60%, var(--border-light)); }++.cf-chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }++.cf-wrap--ai .cf-chip[aria-pressed="true"] { background: var(--cf-ai-fill); border-color: transparent; color: #fff; }++.cf-chip:focus-visible, .cf-seg button:focus-visible { outline: none; box-shadow: var(--focus-ring); }++++.cf-sorts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }++.cf-seg {++ display: inline-flex;++ padding: 3px;++ border-radius: var(--radius-pill, 999px);++ background: var(--bg-secondary);++ border: 1px solid var(--border);++}++.cf-seg button {++ min-height: 30px;++ padding: 0 12px;++ border: 0;++ border-radius: var(--radius-pill, 999px);++ background: transparent;++ color: var(--text-secondary);++ font: inherit;++ font-size: 0.82rem;++ font-weight: 600;++ cursor: pointer;++}++.cf-seg button:hover { color: var(--text-primary); }++.cf-seg button[aria-pressed="true"] { background: var(--bg-hover); color: var(--text-primary); box-shadow: var(--elev-1); }++++/* ── masonry ────────────────────────────────────────────────────────────── */++.cf-feed { display: flex; align-items: flex-start; gap: var(--cf-gap); }++.cf-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: var(--cf-gap); }++.cf-feed[data-cols="1"] { max-width: 640px; margin: 0 auto; }++++/* ── cards ──────────────────────────────────────────────────────────────── */++.cf-card {++ position: relative;++ background: var(--bg-card);++ border: 1px solid var(--border);++ border-radius: var(--cf-radius);++ overflow: hidden;++ box-shadow: var(--elev-1);++ transition: border-color var(--motion-normal, 200ms), box-shadow var(--motion-normal, 200ms), transform var(--motion-normal, 200ms) var(--ease-standard, ease);++ animation: cfIn .32s var(--ease-standard, ease) both;++}++.cf-card:hover { border-color: var(--border-light); box-shadow: var(--elev-2); }++.cf-card--ai { border-color: color-mix(in srgb, var(--cf-ai) 30%, var(--border)); }++.cf-card--ai:hover { border-color: color-mix(in srgb, var(--cf-ai) 55%, var(--border)); }++@keyframes cfIn { from { opacity: 0; transform: translateY(8px); } }++++.cf-media {++ position: relative;++ display: block;++ aspect-ratio: 16 / 9;++ background: var(--bg-secondary);++ overflow: hidden;++ color: inherit;++}++.cf-media--image { aspect-ratio: 4 / 3; }++.cf-thumb { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }++.cf-thumb--ph {++ display: grid;++ place-items: center;++ font-size: 2rem;++ color: var(--text-muted);++ background:++ radial-gradient(120% 90% at 20% 10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),++ var(--bg-secondary);++}++.cf-card--ai .cf-thumb--ph {++ background:++ radial-gradient(120% 90% at 15% 10%, color-mix(in srgb, var(--cf-ai) 30%, transparent), transparent 60%),++ radial-gradient(90% 80% at 90% 100%, color-mix(in srgb, var(--cf-ai-2) 20%, transparent), transparent 60%),++ var(--bg-secondary);++}++.cf-media img.cf-thumb { transition: transform .5s var(--ease-standard, ease); }++a.cf-media:hover img.cf-thumb { transform: scale(1.03); }++.cf-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #000; display: block; cursor: pointer; }++++.cf-kind, .cf-ai {++ position: absolute;++ top: 10px;++ left: 10px;++ z-index: 2;++ display: inline-flex;++ align-items: center;++ gap: 6px;++ padding: 4px 9px;++ border-radius: var(--radius-pill, 999px);++ font-size: 0.72rem;++ font-weight: 700;++ letter-spacing: 0.02em;++ line-height: 1.2;++ color: #fff;++ background: rgba(8, 12, 22, 0.66);++ backdrop-filter: blur(6px);++ -webkit-backdrop-filter: blur(6px);++ text-transform: uppercase;++ pointer-events: none;++}++.cf-ai { background: var(--cf-ai-fill); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }++.cf-code .cf-ai { left: auto; right: 10px; }++++.cf-pill {++ position: absolute;++ z-index: 2;++ padding: 3px 8px;++ border-radius: 7px;++ font-size: 0.74rem;++ font-weight: 700;++ font-variant-numeric: tabular-nums;++ color: #fff;++ background: rgba(8, 12, 22, 0.72);++ pointer-events: none;++}++.cf-dur { right: 10px; bottom: 10px; }++.cf-parts { left: 10px; bottom: 10px; }++++.cf-play {++ position: absolute;++ inset: 0;++ margin: auto;++ width: 54px;++ height: 54px;++ display: grid;++ place-items: center;++ border-radius: 50%;++ font-size: 1.15rem;++ color: #fff;++ background: rgba(8, 12, 22, 0.55);++ backdrop-filter: blur(4px);++ -webkit-backdrop-filter: blur(4px);++ opacity: 0;++ transform: scale(.9);++ transition: opacity var(--motion-normal, 200ms), transform var(--motion-normal, 200ms);++ pointer-events: none;++}++.cf-play i { margin-left: 3px; }++a.cf-media:hover .cf-play, .cf-media--clip:not(.is-playing) .cf-play { opacity: 1; transform: none; }++++.cf-sound, .cf-open {++ position: absolute;++ z-index: 3;++ width: 36px;++ height: 36px;++ display: grid;++ place-items: center;++ border: 0;++ border-radius: 50%;++ font-size: 0.9rem;++ color: #fff;++ background: rgba(8, 12, 22, 0.62);++ backdrop-filter: blur(6px);++ -webkit-backdrop-filter: blur(6px);++ cursor: pointer;++ text-decoration: none;++ transition: background var(--motion-fast, 120ms), transform var(--motion-fast, 120ms);++}++.cf-sound { left: 10px; bottom: 10px; }++.cf-open { right: 10px; top: 10px; }++.cf-sound:hover, .cf-open:hover { background: rgba(8, 12, 22, 0.85); transform: scale(1.06); }++.cf-sound:focus-visible, .cf-open:focus-visible { outline: none; box-shadow: var(--focus-ring); }++++.cf-nsfw img.cf-thumb { filter: blur(22px) saturate(.6); transform: scale(1.15); }++.cf-nsfw-label {++ position: absolute;++ inset: 0;++ margin: auto;++ width: max-content;++ height: max-content;++ padding: 6px 12px;++ border-radius: 8px;++ font-weight: 800;++ font-size: 0.8rem;++ letter-spacing: 0.08em;++ color: #fff;++ background: rgba(239, 68, 68, 0.85);++}++++/* text pastes: the code itself is the picture */++.cf-code {++ position: relative;++ display: block;++ color: var(--text-primary);++ text-decoration: none;++ background:++ linear-gradient(180deg, transparent 60%, var(--bg-card) 100%),++ color-mix(in srgb, var(--bg-primary) 70%, var(--bg-secondary));++ border-bottom: 1px solid var(--border);++ padding: 44px 16px 18px;++ max-height: 260px;++ overflow: hidden;++}++.cf-code pre { margin: 0; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; }++.cf-code code {++ font-family: var(--font-mono);++ font-size: 0.8rem;++ line-height: 1.55;++ color: var(--text-secondary);++ display: -webkit-box;++ -webkit-line-clamp: 9;++ -webkit-box-orient: vertical;++ overflow: hidden;++}++.cf-code .cf-kind { text-transform: none; background: color-mix(in srgb, var(--accent) 22%, rgba(8, 12, 22, 0.7)); }++.cf-code:hover code { color: var(--text-primary); }++++.cf-body { padding: 12px 14px 14px; display: grid; gap: 8px; min-width: 0; }++.cf-title {++ color: var(--text-primary);++ font-weight: 700;++ font-size: 1rem;++ line-height: 1.3;++ text-decoration: none;++ display: -webkit-box;++ -webkit-line-clamp: 2;++ -webkit-box-orient: vertical;++ overflow: hidden;++ overflow-wrap: anywhere;++}++.cf-title:hover { color: var(--accent-light); }++.cf-streamtitle { color: var(--text-muted); font-size: 0.82rem; margin-top: -4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++++.cf-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; font-size: 0.82rem; color: var(--text-muted); min-width: 0; }++.cf-chan { display: inline-flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%; color: var(--text-secondary); font-weight: 600; text-decoration: none; }++a.cf-chan:hover .cf-name { color: var(--text-primary); text-decoration: underline; }++.cf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++.cf-av {++ flex: none;++ width: 26px;++ height: 26px;++ border-radius: 50%;++ display: grid;++ place-items: center;++ overflow: hidden;++ font-size: 0.72rem;++ font-weight: 800;++ color: #fff;++ background: var(--accent-dark);++}++.cf-av img { width: 100%; height: 100%; object-fit: cover; display: block; }++.cf-facts { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px 0; min-width: 0; }++.cf-facts > * + *::before { content: '·'; margin: 0 6px; opacity: .6; }++.cf-ailabel { color: var(--cf-ai); font-weight: 700; }++.cf-likes i { color: #f472b6; font-size: 0.75em; }++.cf-by { font-size: 0.8rem; color: var(--text-muted); }++.cf-by a { color: var(--text-secondary); font-weight: 600; text-decoration: none; }++.cf-by a:hover { text-decoration: underline; }++.cf-excerpt {++ margin: 0;++ color: var(--text-secondary);++ font-size: 0.88rem;++ line-height: 1.5;++ display: -webkit-box;++ -webkit-line-clamp: 3;++ -webkit-box-orient: vertical;++ overflow: hidden;++ overflow-wrap: anywhere;++}++.cf-moment-link {++ justify-self: start;++ display: inline-flex;++ align-items: center;++ gap: 6px;++ font-size: 0.84rem;++ font-weight: 700;++ color: var(--cf-ai);++ text-decoration: none;++}++.cf-moment-link:hover { text-decoration: underline; }++++.cf-grade {++ position: absolute;++ right: 12px;++ top: 10px;++ z-index: 2;++ width: 42px;++ height: 42px;++ display: grid;++ place-items: center;++ border-radius: 12px;++ font-weight: 900;++ font-size: 1.3rem;++ color: #fff;++ background: rgba(8, 12, 22, 0.7);++ border: 2px solid currentColor;++}++.cf-grade--s { color: #fbbf24; }++.cf-grade--a { color: #4ade80; }++.cf-grade--b { color: #60a5fa; }++.cf-grade--c { color: #94a3b8; }++++/* staff bulk-select wrapper (app.js _selWrap) around a feed card */++.cf-col > .sel-card-wrap { position: relative; }++.cf-col > .sel-card-wrap > .sel-card-check { z-index: 4; }++++/* ── skeletons, footer ──────────────────────────────────────────────────── */++.cf-skel { animation: none; box-shadow: none; }++.cf-skel-media { aspect-ratio: 16 / 9; background: color-mix(in srgb, var(--text-muted) 10%, transparent); }++.cf-skel-body { padding: 14px; display: grid; gap: 10px; }++.cf-skel-line { height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--text-muted) 14%, transparent); }++.cf-skel-line--short { width: 55%; }++.cf-skel-media, .cf-skel-line { position: relative; overflow: hidden; }++.cf-skel-media::after, .cf-skel-line::after {++ content: '';++ position: absolute;++ inset: 0;++ background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--text-primary) 7%, transparent) 50%, transparent 80%);++ transform: translateX(-100%);++ animation: cfSweep 1.5s ease-in-out infinite;++}++@keyframes cfSweep { to { transform: translateX(100%); } }++++.cf-foot { display: flex; justify-content: center; padding: 20px 0 4px; }++.cf-foot:empty { padding: 0; }++.cf-empty { text-align: center; padding: 48px 16px; max-width: 440px; }++.cf-empty-icon { font-size: 2.4rem; color: var(--text-muted); opacity: .6; }++.cf-empty-title { margin: 14px 0 4px; font-weight: 700; font-size: 1.05rem; color: var(--text-primary); }++.cf-empty-sub { margin: 0; color: var(--text-muted); font-size: 0.9rem; }++.cf-note {++ display: flex;++ align-items: center;++ gap: 12px;++ flex-wrap: wrap;++ justify-content: center;++ padding: 12px 16px;++ border-radius: 12px;++ border: 1px solid var(--border);++ background: var(--bg-secondary);++ color: var(--text-secondary);++ font-size: 0.88rem;++ text-align: center;++}++.cf-note--error { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }++.cf-end { margin: 0; color: var(--text-muted); font-size: 0.85rem; }++.cf-sentinel { height: 1px; }++++@media (max-width: 679px) {++ /* A phone gets to the first card quickly: a tighter header and one scrolling row of chips. */++ .cf-wrap { padding: 12px 12px 40px; --cf-gap: 14px; }++ .cf-head { margin-bottom: 6px; gap: 8px 12px; align-items: center; }++ .cf-h1 { font-size: 1.45rem; }++ .cf-sub { font-size: 0.84rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }++ .cf-crosslink { padding: 5px 10px; font-size: 0.8rem; }++ .cf-bar { gap: 8px; padding: 8px 0; }++ .cf-chips {++ flex-wrap: nowrap;++ overflow-x: auto;++ scrollbar-width: none;++ width: calc(100% + 24px);++ margin: 0 -12px;++ padding: 0 12px;++ }++ .cf-chips::-webkit-scrollbar { display: none; }++ .cf-chip { flex: none; min-height: 34px; padding: 0 12px; font-size: 0.84rem; }++}++@media (prefers-reduced-motion: reduce) {++ .cf-card { animation: none; }++ .cf-skel-media::after, .cf-skel-line::after { animation: none; }++ .cf-media img.cf-thumb, .cf-play { transition: none; }++}@@@ -29,12 +29,24 @@"/js/app-media.js"]},-- "lists": {-- "js": [-- "/js/ov-density.js"++ "feeds": {++ "css": [++ "/css/features/content.css"],++ "js": [++ "/js/content-feed.js"++ ]++ },++ "content": {++ "fragment": "content","deps": [-- "appMedia"++ "feeds"++ ]++ },++ "moments": {++ "fragment": "moments",++ "deps": [++ "feeds"]},"player": {@@@ -280,9 +292,15 @@]},{-- "path": "^/(vods|clips)/?$",++ "path": "^/(content|vods|clips|pastes)/?$",++ "features": [++ "content"++ ]++ },++ {++ "path": "^/moments/?$","features": [-- "lists"++ "moments"]},{@@@ -316,7 +334,7 @@]},{-- "path": "^/(pastes/?$|p/)",++ "path": "^/p/","features": ["pastes"]nnew file mode 100644@@@ -0,0 +1,34 @@++<div class="cf-wrap" data-feed="content">++ <header class="cf-head">++ <div class="cf-head-text">++ <h1 class="cf-h1">Content</h1>++ <p class="cf-sub">Streams, clips and pastes made by people on OpenVibe.</p>++ </div>++ <div class="cf-head-actions">++ <a class="cf-crosslink" href="/moments" data-cf-link><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> AI Moments</a>++ <button type="button" class="btn btn-small btn-outline cf-new-paste" data-cf-new-paste hidden><i class="fa-solid fa-plus" aria-hidden="true"></i> New paste</button>++ </div>++ </header>++ <div class="cf-bar">++ <div class="cf-chips" role="group" aria-label="Show">++ <button type="button" class="cf-chip" data-cf-type="all" aria-pressed="true">All</button>++ <button type="button" class="cf-chip" data-cf-type="vods" aria-pressed="false"><i class="fa-solid fa-video" aria-hidden="true"></i> VODs</button>++ <button type="button" class="cf-chip" data-cf-type="clips" aria-pressed="false"><i class="fa-solid fa-scissors" aria-hidden="true"></i> Clips</button>++ <button type="button" class="cf-chip" data-cf-type="pastes" aria-pressed="false"><i class="fa-solid fa-paste" aria-hidden="true"></i> Pastes</button>++ </div>++ <div class="cf-sorts">++ <div class="cf-seg" role="group" aria-label="Sort">++ <button type="button" data-cf-sort="new" aria-pressed="true">New</button>++ <button type="button" data-cf-sort="top" aria-pressed="false">Top</button>++ </div>++ <div class="cf-seg cf-window" role="group" aria-label="Top of" hidden>++ <button type="button" data-cf-window="week" aria-pressed="false">Week</button>++ <button type="button" data-cf-window="month" aria-pressed="true">Month</button>++ <button type="button" data-cf-window="all" aria-pressed="false">All time</button>++ </div>++ </div>++ </div>++ <div class="cf-feed" aria-busy="true"></div>++ <div class="cf-foot" aria-live="polite"></div>++ <div class="cf-sentinel" aria-hidden="true"></div>++</div>nnew file mode 100644@@@ -0,0 +1,33 @@++<div class="cf-wrap cf-wrap--ai" data-feed="moments">++ <header class="cf-head">++ <div class="cf-head-text">++ <h1 class="cf-h1"><span class="cf-ai-mark" aria-hidden="true"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI Moments</h1>++ <p class="cf-sub">Auto-clips, standout moments and after-show recaps the AI made from OpenVibe streams. Everything here is AI-generated.</p>++ </div>++ <div class="cf-head-actions">++ <a class="cf-crosslink" href="/content" data-cf-link><i class="fa-solid fa-user-pen" aria-hidden="true"></i> Made by people</a>++ </div>++ </header>++ <div class="cf-bar">++ <div class="cf-chips" role="group" aria-label="Show">++ <button type="button" class="cf-chip" data-cf-type="all" aria-pressed="true">All</button>++ <button type="button" class="cf-chip" data-cf-type="clips" aria-pressed="false"><i class="fa-solid fa-scissors" aria-hidden="true"></i> Auto-clips</button>++ <button type="button" class="cf-chip" data-cf-type="shots" aria-pressed="false"><i class="fa-solid fa-image" aria-hidden="true"></i> Moments</button>++ <button type="button" class="cf-chip" data-cf-type="recaps" aria-pressed="false"><i class="fa-solid fa-clipboard-list" aria-hidden="true"></i> Recaps</button>++ </div>++ <div class="cf-sorts">++ <div class="cf-seg" role="group" aria-label="Sort">++ <button type="button" data-cf-sort="new" aria-pressed="true">New</button>++ <button type="button" data-cf-sort="top" aria-pressed="false">Top</button>++ </div>++ <div class="cf-seg cf-window" role="group" aria-label="Top of" hidden>++ <button type="button" data-cf-window="week" aria-pressed="false">Week</button>++ <button type="button" data-cf-window="month" aria-pressed="true">Month</button>++ <button type="button" data-cf-window="all" aria-pressed="false">All time</button>++ </div>++ </div>++ </div>++ <div class="cf-feed" aria-busy="true"></div>++ <div class="cf-foot" aria-live="polite"></div>++ <div class="cf-sentinel" aria-hidden="true"></div>++</div>@@@ -310,7 +310,7 @@<div class="home-section-lazy" id="home-moments-section" style="display:none"><div class="section-header" style="--sc:#22d3ee"><h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2>-- <span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>++ <a href="/moments" onclick="return handleLinkClick(event, '/moments')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">See all</a></div><div class="moments-row" id="home-moments-row"></div></div>@@@ -473,7 +473,7 @@<!-- Recent VODs --><div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header"><h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>-- <a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>++ <a href="/content?type=vods" onclick="return handleLinkClick(event, '/content?type=vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a></div><div class="stream-grid" id="home-recent-vods-grid"></div><div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>@@@ -481,7 +481,7 @@<!-- Recent Clips --><div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header"><h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>-- <a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>++ <a href="/content?type=clips" onclick="return handleLinkClick(event, '/content?type=clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a></div><div class="stream-grid home-clips-grid" id="home-clips-grid"></div><div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>@@@ -489,7 +489,7 @@<!-- Recent Pastes --><div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header"><h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>-- <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>++ <a href="/content?type=pastes" onclick="return handleLinkClick(event, '/content?type=pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a></div><div class="home-pastes-list" id="home-pastes-list"></div><div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>@@@ -679,29 +679,9 @@</div></section>-- <!-- ── VIDEOS PAGE ───────────────────────────────────── -->-- <section id="page-vods" class="page">-- <div class="container">-- <div class="section-header">-- <h2><i class="fa-solid fa-video"></i> Videos</h2>-- </div>-- <div id="vods-streamer-filters" class="media-filter-bar" style="display:none"></div>-- <div class="stream-grid" id="vods-grid-page"></div>-- <div id="vods-pagination-page" class="pastes-pagination" style="display:none"></div>-- </div>-- </section>---- <!-- ── CLIPS PAGE ─────────────────────────────────────── -->-- <section id="page-clips" class="page">-- <div class="container">-- <div class="section-header">-- <h2><i class="fa-solid fa-scissors"></i> Clips</h2>-- </div>-- <div id="clips-streamer-filters" class="media-filter-bar" style="display:none"></div>-- <div class="stream-grid" id="clips-grid-page"></div>-- <div id="clips-pagination-page" class="pastes-pagination" style="display:none"></div>-- </div>-- </section>++ <!-- ── CONTENT (/content; /vods /clips /pastes are it with a filter) and AI MOMENTS (/moments) ── -->++ <section id="page-content" class="page" data-fragment="content"><!--ov:fragment:content--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>++ <section id="page-moments" class="page" data-fragment="moments"><!--ov:fragment:moments--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section><!-- ── CHANNEL PAGE (/:username) ─────────────────────── --><section id="page-channel" class="page" data-fragment="channel"><!--ov:fragment:channel--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>@@@ -755,50 +735,13 @@</div></section>-- <!-- ═══════════════════════════════════════════════════════════ -->-- <!-- PASTES INDEX -->-- <!-- ═══════════════════════════════════════════════════════════ -->-- <section id="page-pastes" class="page">-- <div class="content-container">-- <div class="page-header" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;">-- <div>-- <h1><i class="fa-solid fa-paste"></i> Pastes & Images</h1>-- <p class="page-subtitle">Share code, notes, prompts, and images with the community. <span id="pastes-count"></span></p>-- </div>-- <div style="display:flex;gap:8px;">-- <button class="btn btn-primary" onclick="openNewPasteModal()"><i class="fa-solid fa-plus"></i> New Paste</button>-- <button class="btn btn-outline" onclick="openImageUpload()"><i class="fa-solid fa-cloud-arrow-up"></i> Upload Image</button>-- </div>-- </div>---- <div class="pastes-toolbar">-- <div class="pastes-filters">-- <button class="btn btn-sm pastes-filter-btn active" data-filter="all" onclick="filterPastes('all')">All</button>-- <button class="btn btn-sm pastes-filter-btn" data-filter="paste" onclick="filterPastes('paste')"><i class="fa-solid fa-code"></i> Pastes</button>-- <button class="btn btn-sm pastes-filter-btn" data-filter="screenshot" onclick="filterPastes('screenshot')"><i class="fa-solid fa-image"></i> Images</button>-- </div>-- <div class="pastes-search">-- <input type="text" id="pastes-search-input" placeholder="Search pastes…" onkeydown="if(event.key==='Enter')searchPastes()">-- <button class="btn btn-sm btn-outline" onclick="searchPastes()"><i class="fa-solid fa-search"></i></button>-- </div>-- </div>---- <div id="pastes-streamer-filters" class="media-filter-bar" style="display:none"></div>---- <div class="pastes-grid" id="pastes-grid">-- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>-- </div>-- <div id="pastes-pagination" class="pastes-pagination"></div>-- </div>-- </section>--<!-- ═══════════════════════════════════════════════════════════ --><!-- PASTE VIEWER --><!-- ═══════════════════════════════════════════════════════════ --><section id="page-paste-viewer" class="page"><div class="content-container"><div class="paste-viewer-nav">-- <button class="btn btn-outline btn-sm" onclick="navigate('/pastes')"><i class="fa-solid fa-arrow-left"></i> All Pastes</button>++ <button class="btn btn-outline btn-sm" onclick="navigate('/content?type=pastes')"><i class="fa-solid fa-arrow-left"></i> All Pastes</button></div><div id="paste-viewer-content"><div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>@@@ -1481,7 +1481,8 @@ async function loadHomeClips(opts) {homeRailLoaded(rail, offset, clips.length, data.total);renderHomePagination('home-clips-pagination', rail);};-- await apiSWR(`/clips?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });++ // People's clips; the AI's auto-clips have their own place (/moments, the AI Moments rail).++ await apiSWR(`/clips?limit=${limit}&offset=${offset}&auto_generated=0`, render, { ttl: 180000 });} catch { rail.filling = false; }}@@@ -1521,7 +1522,7 @@ async function loadHomePastes(opts) {homeRailLoaded(rail, offset, pastes.length, data.total);renderHomePagination('home-pastes-pagination', rail);};-- await apiSWR(`/pastes?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });++ await apiSWR(`/pastes?limit=${limit}&offset=${offset}&origin=user`, render, { ttl: 180000 });} catch { rail.filling = false; }}@@@ -102,81 +102,7 @@ function downloadMediaTranscript(btn) {document.body.appendChild(a); a.click();setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);} // streamer's user id — the stable chat room key--const VODS_PAGE_SIZE = 24; // now = grouped SESSIONS (cards) per page--const VODS_FETCH_WINDOW = 600; // raw VODs fetched up-front to group + paginate client-side--let _vodsGroupCache = { key: null, groups: [], totalVideos: 0, streamers: [], truncated: false };--const CLIPS_PAGE_SIZE = 24;--let currentClipsPage = 1;--let currentClipsStreamerFilter = 'all';--let currentVodsSort = 'newest';--let currentClipsSort = 'newest';----function setVodsPage(page) {-- const safePage = Math.max(1, page | 0);-- if (safePage === currentVodsPage) return;-- currentVodsPage = safePage;-- loadVodsPage();-- const top = document.getElementById('page-vods');-- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });--}----function setClipsPage(page) {-- const safePage = Math.max(1, page | 0);-- if (safePage === currentClipsPage) return;-- currentClipsPage = safePage;-- loadClipsPage();-- const top = document.getElementById('page-clips');-- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });--}----function setVodsSort(sort) {-- const s = sort === 'oldest' ? 'oldest' : 'newest';-- if (s === currentVodsSort) return;-- currentVodsSort = s;-- currentVodsPage = 1;-- loadVodsPage();--}----function setClipsSort(sort) {-- const s = sort === 'oldest' ? 'oldest' : 'newest';-- if (s === currentClipsSort) return;-- currentClipsSort = s;-- currentClipsPage = 1;-- loadClipsPage();--}----function renderVodsStreamerFilters(streamers = [], activeFilter = 'all') {-- renderMediaStreamerFilters({-- barId: 'vods-streamer-filters',-- streamers,-- activeFilter,-- onSelect: 'setVodsStreamerFilter',-- countKey: 'vod_count',-- allLabel: 'All streamers',-- });--}----function renderClipsStreamerFilters(streamers = [], activeFilter = 'all') {-- renderMediaStreamerFilters({-- barId: 'clips-streamer-filters',-- streamers,-- activeFilter,-- onSelect: 'setClipsStreamerFilter',-- countKey: 'clip_count',-- allLabel: 'All streamers',-- });--}----function setClipsStreamerFilter(username = 'all') {-- const nextFilter = String(username || 'all').trim() || 'all';-- if (nextFilter === currentClipsStreamerFilter) return;-- currentClipsStreamerFilter = nextFilter;-- currentClipsPage = 1;-- loadClipsPage();-- const top = document.getElementById('page-clips');-- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });--}--++// The site-wide VOD and clip lists are the Content feed now (public/js/content-feed.js).// AI "memory" timeline on the VOD player — clickable timestamps that seek the video.async function loadVodAiTimeline(vodId) {const el = document.getElementById('vp-ai-timeline');@@@ -258,212 +184,6 @@ function _attachVodProgressTracking(video, vodId) {if (t > 3 && !(dur > 0 && t > dur - 10)) _saveVodProgress(vodId, t);}, { once: true });}--// Back-compat alias for the global VOD/clip page call sites.--function _adminCardWrap(type, id, cardHtml, ownerIsOwner) { return _selWrap(type, id, cardHtml, ownerIsOwner); }--function _updateAdminBulkBar() { _selRenderBar(); _selSyncAllBtns(); }----// A single VOD card's inner anchor (shared by the flat list + expanded session groups).--function _vodCardInner(v, myId) {-- return `<a class="stream-card" href="/vod/${v.id}" onclick="return handleLinkClick(event, '/vod/${v.id}')">-- <div class="stream-card-thumb">-- ${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}-- ${!v.is_public && v.user_id === myId ? '<span class="stream-card-nsfw" style="background:var(--text-muted)">PRIVATE</span>' : ''}-- ${v.stream_protocol ? protocolBadge(v.stream_protocol) : ''}-- <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || v.duration)}</span>-- </div>-- <div class="stream-card-info">-- <div class="stream-card-title">${esc(v.title || 'VOD')}</div>-- <div class="stream-card-streamer">-- ${_avatarSpan(v.avatar_url, v.username, v.profile_color)}-- ${esc(v.username || 'Unknown')}-- <span class="stream-card-date">${timeAgo(v.created_at)}</span>-- </div>-- ${_cardAiHTML(v.ai_overview_short, v.ai_overview)}-- </div>-- </a>`;--}--function _vodDayKey(ts) {-- try { const d = new Date(String(ts).replace(' ', 'T') + (/[TZ]/.test(String(ts)) ? '' : 'Z')); return isNaN(d) ? String(ts).slice(0, 10) : d.toISOString().slice(0, 10); }-- catch { return String(ts).slice(0, 10); }--}--// Group CONSECUTIVE VODs from the same streamer with the same title on the same day — these are--// almost always one session split by stream restarts / server restarts, so we condense them.--function _groupVods(vods) {-- const groups = [];-- let cur = null;-- for (const v of vods) {-- const key = `${v.user_id}|${String(v.title || '').trim().toLowerCase()}|${_vodDayKey(v.created_at)}`;-- if (cur && cur.key === key) cur.items.push(v);-- else { cur = { key, items: [v] }; groups.push(cur); }-- }-- return groups;--}--// Renders an array of ALREADY-grouped VOD groups ({ items: [...] }). Pagination happens by--// group (session), so a 30-part session counts as one card instead of blanking the page.--function _renderVodGroups(groups, myId) {-- return groups.map((g, gi) => {-- if (g.items.length < 2) {-- const v = g.items[0];-- return _adminCardWrap('vod', v.id, _vodCardInner(v, myId), !!v.owner_is_owner);-- }-- const rep = g.items.reduce((a, b) => ((b.duration_seconds || 0) > (a.duration_seconds || 0) ? b : a), g.items[0]);-- const totalDur = g.items.reduce((s, v) => s + (v.duration_seconds || v.duration || 0), 0);-- const gid = 'vg' + gi;-- const n = g.items.length;-- const groupCard = `<div class="stream-card vod-group-card" id="group-${gid}" onclick="toggleVodGroup('${gid}')" title="${n} videos from this session — click to expand">-- <div class="stream-card-thumb">-- ${thumbImg(rep.thumbnail_url, 'fa-video', rep.title, `/api/thumbnails/generate/vod/${rep.id}`)}-- <span class="vod-group-count"><i class="fa-solid fa-layer-group"></i> ${n}</span>-- <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(totalDur)}</span>-- </div>-- <div class="stream-card-info">-- <div class="stream-card-title">${esc(rep.title || 'VOD')}</div>-- <div class="stream-card-streamer">-- ${_avatarSpan(rep.avatar_url, rep.username, rep.profile_color)}-- ${esc(rep.username || 'Unknown')}-- <span class="stream-card-date">${timeAgo(rep.created_at)}</span>-- </div>-- <button class="vod-group-hint" type="button"><i class="fa-solid fa-chevron-down vod-group-chev"></i> <span>${n} parts — <b>expand</b></span></button>-- </div>-- </div>`;-- const parts = g.items.map(v => _adminCardWrap('vod', v.id, _vodCardInner(v, myId), !!v.owner_is_owner)).join('');-- return groupCard + `<div class="vod-group-parts" id="parts-${gid}" style="display:none">${parts}</div>`;-- }).join('');--}--function toggleVodGroup(gid) {-- const parts = document.getElementById('parts-' + gid);-- const card = document.getElementById('group-' + gid);-- if (!parts) return;-- const opening = parts.style.display === 'none';-- parts.style.display = opening ? 'grid' : 'none';-- if (card) card.classList.toggle('expanded', opening);-- if (typeof _selSyncAllBtns === 'function') _selSyncAllBtns();--}----async function loadVodsPage() {-- const grid = document.getElementById('vods-grid-page');-- const pager = document.getElementById('vods-pagination-page');-- const filterBar = document.getElementById('vods-streamer-filters');-- if (!grid) return console.error('[VODs] grid element #vods-grid-page not found');-- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-spinner fa-spin fa-2x"></i><p>Loading videos...</p></div>';-- if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }-- if (filterBar) {-- filterBar.style.display = 'none';-- filterBar.innerHTML = '';-- }-- // Mutations (admin delete/bulk) refresh via this callback — bust the cache so they re-fetch;-- // plain pagination (setVodsPage) keeps the cache and is instant.-- _selSetContext(_isContentAdmin(), () => { _vodsGroupCache.key = null; return loadVodsPage(); });---- try {-- const perPage = VODS_PAGE_SIZE; // grouped sessions per page-- const key = `${currentVodsStreamerFilter || 'all'}|${currentVodsSort || 'newest'}`;-- // Fetch the whole window once per filter/sort, then paginate the GROUPED sessions in-memory-- // so a multi-part session counts as one card (raw-VOD paging showed near-empty pages).-- if (_vodsGroupCache.key !== key) {-- const params = new URLSearchParams({ limit: String(VODS_FETCH_WINDOW), offset: '0' });-- if (currentVodsStreamerFilter && currentVodsStreamerFilter !== 'all') params.set('username', currentVodsStreamerFilter);-- if (currentVodsSort === 'oldest') params.set('sort', 'oldest');-- const data = await api(`/vods?${params.toString()}`);-- const vods = data.vods || [];-- const totalVideos = data.total ?? vods.length;-- _vodsGroupCache = {-- key, groups: _groupVods(vods), totalVideos,-- streamers: data.streamers || [],-- truncated: totalVideos > vods.length,-- };-- }-- const { groups, streamers, truncated, totalVideos } = _vodsGroupCache;---- renderVodsStreamerFilters(streamers, currentVodsStreamerFilter);---- const totalGroups = groups.length;-- const totalPages = Math.max(1, Math.ceil(totalGroups / perPage));-- if (currentVodsPage > totalPages) currentVodsPage = totalPages;---- if (!totalGroups) {-- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-video fa-3x"></i><p>No videos yet</p><p class="muted">Videos are recorded automatically when streamers go live</p></div>';-- return;-- }---- const startG = (currentVodsPage - 1) * perPage;-- const pageGroups = groups.slice(startG, startG + perPage);-- const myId = currentUser ? currentUser.id : null;-- grid.innerHTML = _renderVodGroups(pageGroups, myId)-- + (truncated && currentVodsPage === totalPages ? `<div class="vods-truncated-note muted">Showing the ${totalVideos > VODS_FETCH_WINDOW ? 'most recent ' + VODS_FETCH_WINDOW : totalVideos} videos.</div>` : '');-- _updateAdminBulkBar('vod');---- renderVodsPagination('vods-pagination-page', currentVodsPage, totalGroups, perPage, 'setVodsPage', 'sessions', { sort: currentVodsSort, setter: 'setVodsSort' });-- } catch (e) {-- console.error('Failed to load videos', e);-- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-triangle-exclamation fa-3x"></i><p>Failed to load videos</p><p class="muted">' + esc(e.message || String(e)) + '</p></div>';-- }--}----/* ── Clips Page ───────────────────────────────────────────────── */--async function loadClipsPage() {-- const grid = document.getElementById('clips-grid-page');-- const pager = document.getElementById('clips-pagination-page');-- const filterBar = document.getElementById('clips-streamer-filters');-- if (!grid) return console.error('[Clips] grid element not found');-- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-spinner fa-spin fa-2x"></i><p>Loading clips...</p></div>';-- if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }-- if (filterBar) {-- filterBar.style.display = 'none';-- filterBar.innerHTML = '';-- }-- _selSetContext(_isContentAdmin(), loadClipsPage);-- try {-- const limit = CLIPS_PAGE_SIZE;-- const offset = (currentClipsPage - 1) * limit;-- const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });-- if (currentClipsStreamerFilter && currentClipsStreamerFilter !== 'all') {-- params.set('username', currentClipsStreamerFilter);-- }-- if (currentClipsSort === 'oldest') params.set('sort', 'oldest');-- const data = await api(`/clips?${params.toString()}`);-- const clips = data.clips || [];-- const total = data.total ?? clips.length;-- const totalPages = Math.max(1, Math.ceil(total / limit));---- renderClipsStreamerFilters(data.streamers || [], currentClipsStreamerFilter);---- if (currentClipsPage > totalPages) {-- currentClipsPage = totalPages;-- return loadClipsPage();-- }---- if (!clips.length) {-- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-scissors fa-3x"></i><p>No public clips yet</p><p class="muted">Viewers can create clips during live streams using the clip button</p></div>';-- return;-- }-- grid.innerHTML = clips.map(cl => _adminCardWrap('clip', cl.id, `-- <a class="stream-card" href="/clip/${cl.id}" onclick="return handleLinkClick(event, '/clip/${cl.id}')">-- <div class="stream-card-thumb">-- ${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}-- ${cl.stream_protocol ? protocolBadge(cl.stream_protocol) : ''}-- <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(cl.duration_seconds)}</span>-- </div>-- <div class="stream-card-info">-- <div class="stream-card-title">${esc(cl.title || 'Clip')}</div>-- <div class="stream-card-streamer">-- ${_avatarSpan(cl.avatar_url, cl.username, cl.profile_color)}-- Clipped by ${esc(cl.display_name || cl.username || 'Unknown')}-- <span class="stream-card-date">${timeAgo(cl.created_at)}</span>-- </div>-- ${_cardAiHTML(cl.ai_overview_short, cl.ai_overview)}-- </div>-- </a>-- `, !!(cl.owner_is_owner || cl.streamer_is_owner))).join('');-- _updateAdminBulkBar('clip');---- renderVodsPagination('clips-pagination-page', currentClipsPage, total, limit, 'setClipsPage', 'clips', { sort: currentClipsSort, setter: 'setClipsSort' });-- } catch (e) {-- console.error('Failed to load clips', e);-- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-triangle-exclamation fa-3x"></i><p>Failed to load clips</p><p class="muted">' + esc(e.message || String(e)) + '</p></div>';-- }--}--/* ── VOD Player ───────────────────────────────────────────────── */async function loadVodPlayer(vodId, seekTo) {try {@@@ -55,7 +55,7 @@ function isStaffUser(user = currentUser) {}// Reserved paths (not usernames)--const RESERVED = new Set(['vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);++const RESERVED = new Set(['content', 'moments', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;function normalizeChannelUsername(username) {@@@ -1064,12 +1064,15 @@ function routeFromURL() {// Home: /showPage('home');whenRouteReady('home', () => loadHome());-- } else if (segments[0] === 'vods') {-- showPage('vods');-- whenRouteReady('vods', () => loadVodsPage());-- } else if (segments[0] === 'clips') {-- showPage('clips');-- whenRouteReady('clips', () => loadClipsPage());++ } else if (segments[0] === 'content' || segments[0] === 'vods' || segments[0] === 'clips') {++ // What people made (public/js/content-feed.js). /vods and /clips are it with a filter.++ showPage('content');++ const preset = segments[0] === 'content' ? null : segments[0];++ whenRouteReady('content', () => loadContentPage({ type: preset }));++ } else if (segments[0] === 'moments') {++ // What the AI made: auto-clips, AI moments, AI recaps.++ showPage('moments');++ whenRouteReady('moments', () => loadMomentsPage());} else if (segments[0] === 'vod' && segments[1]) {// VOD player: /vod/:id (optional ?t=<seconds> to auto-seek, e.g. from a clip link)showPage('vod-player');@@@ -1107,12 +1110,10 @@ function routeFromURL() {window.location.href = `${getScraplandiaUrl()}/canvas`;return;} else if (segments[0] === 'pastes') {-- showPage('pastes');++ // The Content feed's Pastes filter; ?edit=<slug> (from a paste's Edit button) opens the editor over it.++ showPage('content');const editSlug = new URLSearchParams(window.location.search).get('edit');-- whenRouteReady('pastes', () => {-- if (typeof loadPastesPage === 'function') loadPastesPage();-- });-- // Handle ?edit=slug++ whenRouteReady('content', () => loadContentPage({ type: 'pastes' }));if (editSlug) {Promise.all([api(`/pastes/${editSlug}`), ov.load('pastes')]).then(([data]) => {if (data.paste && typeof openNewPasteModal === 'function') openNewPasteModal({@@@ -1143,9 +1144,6 @@ function routeFromURL() {} else if (segments[0] === 'privacy') {window.location.replace('/privacy');return;-- } else if (segments[0] === 'pastes' && !segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {-- _pasteHandOver(`/pastes${location.search}`);-- return;} else if (segments[0] === 'p' && segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {// Pastes live on openvibe.community now — hand the browser over (same slug, same URL shape)._pasteHandOver(`/p/${encodeURIComponent(segments[1])}${location.search}`);@@@ -1193,7 +1191,7 @@ function showNotFound() {p.textContent = 'Nothing lives at this address. The link may be mistyped, or what it pointed to was removed or made private.';const links = document.createElement('p');links.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:20px';-- for (const [href, label, cls] of [['/', 'Home', 'btn btn-primary'], ['/vods', 'VODs', 'btn btn-outline'], ['/clips', 'Clips', 'btn btn-outline'], ['/chat', 'Chat', 'btn btn-outline']]) {++ for (const [href, label, cls] of [['/', 'Home', 'btn btn-primary'], ['/content', 'Content', 'btn btn-outline'], ['/moments', 'AI Moments', 'btn btn-outline'], ['/chat', 'Chat', 'btn btn-outline']]) {const a = document.createElement('a');a.href = href; a.className = cls; a.textContent = label;a.addEventListener('click', (e) => handleLinkClick(e, href));@@@ -1350,7 +1348,7 @@ function showPage(page) {}// Highlight nav link-- const pageToNav = { home: 'home', vods: 'vods', clips: 'clips', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', pastes: 'pastes', 'paste-viewer': 'pastes', arena: 'arena' };++ const pageToNav = { home: 'home', content: 'content', moments: 'moments', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', 'paste-viewer': 'content', arena: 'arena' };const navPage = pageToNav[page];if (navPage) {const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);@@@ -1379,8 +1377,6 @@ window.addEventListener('resize', updateNavHeroTransparency, { passive: true });/* ── Channel Page (/:username) ────────────────────────────────── */let currentChannelUsername = null; // is the current channel's user owner-rank?let _activeChannelUserId = null;--let currentVodsPage = 1;--let currentVodsStreamerFilter = 'all';// Small Newest/Oldest segmented control (shared markup). `setter` is a global fn name// taking 'newest'|'oldest'.@@@ -1436,65 +1432,6 @@ function _capTag(s) {}).join(' ');}--function renderMediaStreamerFilters({-- barId,-- streamers = [],-- activeFilter = 'all',-- onSelect = 'setVodsStreamerFilter',-- countKey = 'vod_count',-- allLabel = 'All streamers',--} = {}) {-- const bar = document.getElementById(barId);-- if (!bar) return;---- const normalizedActive = (activeFilter || 'all').toLowerCase();-- const unique = [];-- const seen = new Set();-- for (const streamer of (streamers || [])) {-- const username = String(streamer?.username || '').trim();-- if (!username) continue;-- const key = username.toLowerCase();-- if (seen.has(key)) continue;-- seen.add(key);-- unique.push(streamer);-- }---- if (!unique.length) {-- bar.style.display = 'none';-- bar.innerHTML = '';-- return;-- }---- bar.style.display = 'flex';-- bar.innerHTML = `-- <button class="media-filter-chip ${normalizedActive === 'all' ? 'active' : ''}" onclick="${onSelect}('all')">-- <i class="fa-solid fa-layer-group"></i>-- <span>${esc(allLabel)}</span>-- </button>-- ${unique.map(streamer => {-- const username = String(streamer.username || '').trim();-- const label = streamer.display_name || username;-- const count = Number(streamer[countKey] || 0);-- return `-- <button class="media-filter-chip ${normalizedActive === username.toLowerCase() ? 'active' : ''}" onclick="${onSelect}('${esc(username)}')">-- <span>${esc(label)}</span>-- ${count > 0 ? `<span class="media-filter-chip-count">${count}</span>` : ''}-- </button>-- `;-- }).join('')}-- `;--}----function setVodsStreamerFilter(username = 'all') {-- const nextFilter = String(username || 'all').trim() || 'all';-- if (nextFilter === currentVodsStreamerFilter) return;-- currentVodsStreamerFilter = nextFilter;-- currentVodsPage = 1;-- loadVodsPage();-- const top = document.getElementById('page-vods');-- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });--}--// Turn a UTC SQL timestamp ("YYYY-MM-DD HH:MM:SS") or ISO string into "x ago".function _aiTimeAgo(ts) {if (!ts) return '';nnew file mode 100644@@@ -0,0 +1,527 @@++/* OpenVibe.Live — the Content and Moments feeds.++ *++ * /content what people made: VODs, clips people cut, pastes loadContentPage({ type })++ * /moments what the AI made: auto-clips, AI moments, AI recaps loadMomentsPage()++ * /vods /clips /pastes are /content with that filter (the address becomes /content?type=…).++ *++ * The server merges the sources and pages them with a cursor (server/content/feed.js); this file++ * only renders. Cards are built as DOM nodes, never from HTML strings, so nothing a title or a++ * paste contains can become markup.++ *++ * Layout: one column of large cards on a phone; two or three columns on wider screens, each new++ * card going to the shortest column (a masonry that never reflows what is already on screen).++ * Scrolling near the end loads the next page. Clips play muted, one at a time, when mostly in view;++ * a tap turns the sound on, the title or the corner button opens the clip.++ * Everything the page starts is released through the route scope (ov.scope()) when it is left.++ */++(function () {++ 'use strict';++++ const FEEDS = {++ content: {++ page: 'page-content', path: '/content', api: '/api/content/feed',++ types: ['all', 'vods', 'clips', 'pastes'],++ titles: { all: 'Content', vods: 'VODs', clips: 'Clips', pastes: 'Pastes' },++ empty: {++ all: ['fa-photo-film', 'Nothing here yet', 'Streams, clips and pastes people share show up here.'],++ vods: ['fa-video', 'No videos yet', 'Streams are recorded automatically when streamers go live.'],++ clips: ['fa-scissors', 'No clips yet', 'Viewers clip the best moments of a stream with the clip button.'],++ pastes: ['fa-paste', 'No pastes yet', 'Share code, notes or a screenshot and it shows up here.'],++ },++ },++ moments: {++ page: 'page-moments', path: '/moments', api: '/api/content/moments',++ types: ['all', 'clips', 'shots', 'recaps'],++ titles: { all: 'AI Moments', clips: 'AI auto-clips', shots: 'AI moments', recaps: 'AI recaps' },++ empty: {++ all: ['fa-wand-magic-sparkles', 'No AI moments yet', 'The AI clips standout moments and writes recaps after streams.'],++ clips: ['fa-scissors', 'No auto-clips yet', 'When chat erupts during a stream, the AI cuts a clip of it.'],++ shots: ['fa-image', 'No AI moments yet', 'The AI picks standout frames from recent streams.'],++ recaps: ['fa-clipboard-list', 'No AI recaps yet', 'After a long enough stream the AI writes a recap.'],++ },++ },++ };++ const WINDOWS = ['week', 'month', 'all'];++ const PAGE = 12;++++ let S = null; // the feed on screen++ let soundOn = false; // a person turned the sound on: later clips play with sound too++++ // ── small DOM helpers ──────────────────────────────────────────────────────────────────++ function el(tag, attrs, ...kids) {++ const n = document.createElement(tag);++ if (attrs) for (const [k, v] of Object.entries(attrs)) {++ if (v == null || v === false) continue;++ if (k === 'class') n.className = v;++ else if (k === 'text') n.textContent = v;++ else if (k === 'dataset') Object.assign(n.dataset, v);++ else n.setAttribute(k, v === true ? '' : String(v));++ }++ for (const kid of kids) if (kid != null && kid !== false) n.append(kid);++ return n;++ }++ const icon = (name, cls) => el('i', { class: `fa-solid ${name}${cls ? ` ${cls}` : ''}`, 'aria-hidden': 'true' });++ const spa = (href, attrs, ...kids) => el('a', { href, 'data-spa': '1', ...attrs }, ...kids);++++ function compact(n) {++ const v = Math.max(0, Number(n) || 0);++ if (v >= 999500) return `${(v / 1e6).toFixed(v >= 9999500 ? 0 : 1).replace(/\.0$/, '')}M`;++ if (v >= 999.5) return `${(v / 1e3).toFixed(v >= 9999.5 ? 0 : 1).replace(/\.0$/, '')}K`;++ return String(v);++ }++ function duration(sec) {++ sec = Math.max(0, Math.round(Number(sec) || 0));++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;++ return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;++ }++ const ago = (iso) => (typeof timeAgo === 'function' ? timeAgo(iso) : '');++ function avatar(p) {++ const letter = (String((p && (p.display_name || p.username)) || '?')[0] || '?').toUpperCase();++ const box = el('span', { class: 'cf-av', 'aria-hidden': 'true' });++ if (p && p.profile_color && /^#[0-9a-f]{3,8}$/i.test(p.profile_color)) box.style.background = p.profile_color;++ if (p && p.avatar_url) {++ const img = el('img', { src: p.avatar_url, alt: '', loading: 'lazy', decoding: 'async' });++ img.addEventListener('error', () => { img.remove(); box.textContent = letter; }, { once: true });++ box.append(img);++ } else box.textContent = letter;++ return box;++ }++ function thumb(url, fallbackIcon) {++ if (!url) return el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon));++ const img = el('img', { class: 'cf-thumb', src: url, alt: '', loading: 'lazy', decoding: 'async' });++ img.addEventListener('error', () => img.replaceWith(el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon))), { once: true });++ return img;++ }++ const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-wand-magic-sparkles'), 'AI');++++ // ── cards ──────────────────────────────────────────────────────────────────────────────++ function meta(it, extra) {++ const row = el('div', { class: 'cf-meta' });++ if (it.channel) {++ const who = [avatar(it.channel), el('span', { class: 'cf-name', text: it.channel.display_name || it.channel.username })];++ row.append(it.channel.href ? spa(it.channel.href, { class: 'cf-chan' }, ...who) : el('span', { class: 'cf-chan' }, ...who));++ }++ const bits = el('span', { class: 'cf-facts' });++ if (it.ai_label) bits.append(el('span', { class: 'cf-ailabel', text: it.ai_label }));++ if (it.created_at) bits.append(el('time', { datetime: it.created_at, title: new Date(it.created_at).toLocaleString(), text: ago(it.created_at) }));++ if (it.kind === 'recap') { if (it.views) bits.append(el('span', { text: `peak ${compact(it.views)} watching` })); }++ else bits.append(el('span', { text: `${compact(it.views)} view${it.views === 1 ? '' : 's'}` }));++ if (it.kind === 'paste' && it.likes) bits.append(el('span', { class: 'cf-likes' }, icon('fa-heart'), ` ${compact(it.likes)}`));++ if (extra) bits.append(extra);++ row.append(bits);++ return row;++ }++++ function vodCard(it) {++ const media = spa(it.href, { class: 'cf-media', 'aria-label': `Watch ${it.title}` },++ thumb(it.thumbnail_url, 'fa-video'),++ el('span', { class: 'cf-kind' }, icon('fa-video'), 'VOD'),++ el('span', { class: 'cf-pill cf-dur', text: duration(it.duration_seconds) }),++ el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));++ const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }), meta(it));++ if (it.excerpt) body.append(el('p', { class: 'cf-excerpt', text: it.excerpt }));++ return el('article', { class: 'cf-card cf-card--vod' }, media, body);++ }++++ function clipCard(it) {++ const playable = !!(it.thumbnail_url && it.preview_url);++ const badge = it.ai ? aiBadge() : el('span', { class: 'cf-kind' }, icon('fa-scissors'), 'Clip');++ const dur = el('span', { class: 'cf-pill cf-dur', text: duration(it.duration_seconds) });++ let box, video = null;++ if (playable) {++ // Plays in place: a tap is for sound, the corner button and the title open the clip.++ video = el('video', { class: 'cf-video', muted: true, playsinline: true, loop: true, preload: 'none', poster: it.thumbnail_url, 'aria-label': it.title });++ video.muted = true;++ video.dataset.src = it.preview_url;++ box = el('div', { class: 'cf-media cf-media--clip' }, video, badge, dur,++ spa(it.href, { class: 'cf-open', 'aria-label': 'Open clip', title: 'Open clip' }, icon('fa-up-right-and-down-left-from-center')),++ el('button', { type: 'button', class: 'cf-sound', 'aria-label': 'Turn sound on', title: 'Sound' }, icon('fa-volume-xmark')),++ el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));++ } else {++ box = spa(it.href, { class: 'cf-media', 'aria-label': `Watch ${it.title}` }, thumb(it.thumbnail_url, 'fa-scissors'), badge, dur,++ el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));++ }++ const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }), meta(it));++ if (it.by) body.append(el('div', { class: 'cf-by' }, icon('fa-scissors'), ' Clipped by ', it.by.href ? spa(it.by.href, { text: it.by.display_name || it.by.username }) : it.by.display_name));++ if (it.excerpt) body.append(el('p', { class: 'cf-excerpt', text: it.excerpt }));++ const card = el('article', { class: `cf-card cf-card--clip${it.ai ? ' cf-card--ai' : ''}` }, box, body);++ if (video && S) S.clips.observe(video);++ return card;++ }++++ function pasteCard(it) {++ let media;++ if (it.paste_type === 'image') {++ media = spa(it.href, { class: `cf-media cf-media--image${it.nsfw ? ' cf-nsfw' : ''}`, 'aria-label': `Open ${it.title}` },++ thumb(it.image_url, 'fa-image'),++ it.ai ? aiBadge() : el('span', { class: 'cf-kind' }, icon('fa-image'), 'Image'));++ if (it.nsfw) media.append(el('span', { class: 'cf-nsfw-label', text: 'NSFW' }));++ } else {++ media = spa(it.href, { class: 'cf-code', 'aria-label': `Open ${it.title}` },++ el('pre', null, el('code', { text: it.excerpt || '' })),++ el('span', { class: 'cf-kind' }, icon('fa-code'), it.language && it.language !== 'text' ? it.language : 'Paste'));++ if (it.ai) media.append(aiBadge());++ }++ const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }), meta(it));++ if (it.paste_type === 'image' && it.excerpt) body.append(el('p', { class: 'cf-excerpt', text: it.excerpt }));++ if (it.moment_href) body.append(spa(it.moment_href, { class: 'cf-moment-link' }, icon('fa-circle-play'), ' Watch this moment'));++ return el('article', { class: `cf-card cf-card--paste${it.ai ? ' cf-card--ai' : ''}` }, media, body);++ }++++ function recapCard(it) {++ const media = spa(it.href, { class: 'cf-media cf-media--recap', 'aria-label': `Read the recap: ${it.title}` },++ it.thumbnail_url ? thumb(it.thumbnail_url, 'fa-clipboard-list') : el('span', { class: 'cf-thumb cf-thumb--ph' }, icon('fa-clipboard-list')),++ aiBadge());++ if (it.grade) media.append(el('span', { class: `cf-grade cf-grade--${it.grade.toLowerCase()}`, title: `Grade ${it.grade}`, text: it.grade }));++ if (it.duration_seconds) media.append(el('span', { class: 'cf-pill cf-dur', text: duration(it.duration_seconds) }));++ const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }));++ if (it.stream_title && it.stream_title !== it.title) body.append(el('div', { class: 'cf-streamtitle', text: it.stream_title }));++ body.append(meta(it));++ if (it.excerpt) body.append(el('p', { class: 'cf-excerpt', text: it.excerpt }));++ return el('article', { class: 'cf-card cf-card--recap cf-card--ai' }, media, body);++ }++++ const BUILD = { vod: vodCard, clip: clipCard, paste: pasteCard, recap: recapCard };++++ /** Staff see the same bulk-select checkboxes as on the channel pages (app.js _selWrap). */++ function selectable(it, card) {++ if (!S.sel || !['vod', 'clip', 'paste'].includes(it.kind)) return card;++ const id = String(it.id);++ const input = el('input', { type: 'checkbox', 'aria-label': `Select ${it.title}` });++ input.checked = !!(window._sel && window._sel[it.kind] && window._sel[it.kind].has(id));++ input.addEventListener('change', () => { if (typeof _selToggle === 'function') _selToggle(it.kind, id, input.checked); });++ const label = el('label', { class: 'sel-card-check', title: 'Select' }, input);++ label.addEventListener('click', (e) => e.stopPropagation());++ return el('div', { class: 'sel-card-wrap', dataset: { selType: it.kind, selId: id } }, label, card);++ }++++ // ── masonry columns ────────────────────────────────────────────────────────────────────++ function columnCount() {++ const w = S.feedEl.clientWidth || window.innerWidth;++ return w >= 1080 ? 3 : w >= 680 ? 2 : 1;++ }++ function buildColumns(n) {++ S.feedEl.replaceChildren();++ S.cols = [];++ for (let i = 0; i < n; i++) { const c = el('div', { class: 'cf-col' }); S.cols.push(c); S.feedEl.append(c); }++ S.feedEl.dataset.cols = String(n);++ }++ function shortest() {++ let best = S.cols[0], h = Infinity;++ for (const c of S.cols) { const ch = c.offsetHeight; if (ch < h - 1) { h = ch; best = c; } }++ return best;++ }++ function place(node) { shortest().append(node); }++ function relayout() {++ if (!S) return;++ const n = columnCount();++ if (n === S.cols.length) return;++ const nodes = S.cards.slice();++ buildColumns(n);++ for (const node of nodes) place(node);++ }++++ // ── skeletons and footer states ────────────────────────────────────────────────────────++ function skeletons(count) {++ const out = [];++ for (let i = 0; i < count; i++) {++ const sk = el('div', { class: 'cf-card cf-skel', 'data-skeleton': '1', 'aria-hidden': 'true' },++ el('div', { class: 'cf-skel-media' }),++ el('div', { class: 'cf-skel-body' }, el('span', { class: 'cf-skel-line' }), el('span', { class: 'cf-skel-line cf-skel-line--short' })));++ out.push(sk);++ place(sk);++ }++ S.skels = out;++ }++ function clearSkeletons() { for (const sk of S.skels || []) sk.remove(); S.skels = []; }++++ function foot(kind, detail) {++ const f = S.footEl;++ f.replaceChildren();++ f.dataset.state = kind || '';++ if (kind === 'empty') {++ const [ic, title, sub] = S.cfg.empty[S.type] || S.cfg.empty.all;++ f.append(el('div', { class: 'cf-empty' }, icon(ic, 'cf-empty-icon'), el('p', { class: 'cf-empty-title', text: title }), el('p', { class: 'cf-empty-sub', text: sub })));++ } else if (kind === 'error' || kind === 'partial') {++ const retry = el('button', { type: 'button', class: 'btn btn-small btn-outline' }, icon('fa-rotate-right'), ' Try again');++ retry.addEventListener('click', () => { foot(null); loadMore(true); });++ f.append(el('div', { class: `cf-note${kind === 'error' ? ' cf-note--error' : ''}` },++ el('span', { text: kind === 'error' ? (detail || 'This feed could not load.') : 'Some of this feed did not load. What loaded is shown.' }), retry));++ } else if (kind === 'end') {++ f.append(el('p', { class: 'cf-end', text: S.feed === 'moments' ? 'That is every AI moment for now.' : "You're all caught up." }));++ } else if (kind === 'more') {++ const more = el('button', { type: 'button', class: 'btn btn-small btn-outline cf-more' }, 'Load more');++ more.addEventListener('click', () => loadMore(true));++ f.append(more);++ }++ }++++ // ── loading ────────────────────────────────────────────────────────────────────────────++ function queryString(withCursor) {++ const p = new URLSearchParams();++ if (S.type !== 'all') p.set('type', S.type);++ if (S.sort === 'top') { p.set('sort', 'top'); p.set('window', S.win); }++ if (withCursor) { p.set('limit', String(PAGE)); if (S.next) p.set('cursor', S.next); }++ return p.toString();++ }++++ async function loadMore(retry) {++ if (!S || S.loading || ((S.done || S.halted) && !retry)) return;++ const st = S, gen = st.gen;++ st.loading = true;++ st.halted = false;++ const first = !st.cards.length;++ if (first) { if (!st.skels.length) skeletons(st.cols.length > 1 ? st.cols.length * 2 : 2); }++ else skeletons(st.cols.length);++ st.feedEl.setAttribute('aria-busy', 'true');++ let data = null, failure = null;++ try {++ const res = await fetch(`${st.cfg.api}?${queryString(true)}`, { signal: st.abort.signal, headers: { Accept: 'application/json' } });++ data = await res.json().catch(() => null);++ if (!res.ok) failure = (data && data.error) || 'This feed could not load.';++ } catch (err) {++ if (err && err.name === 'AbortError') return;++ failure = navigator.onLine === false ? "You're offline." : 'This feed could not load.';++ }++ if (S !== st || st.gen !== gen) return;++ st.loading = false;++ clearSkeletons();++ st.feedEl.setAttribute('aria-busy', 'false');++ // Stop here until someone asks again: an observer that keeps seeing the end must not++ // turn a failing upstream into a request loop.++ if (failure || !data) { st.halted = true; foot('error', failure); return; }++++ render(data.items || []);++ st.next = data.next || null;++ st.done = !st.next;++ if (data.sources) st.sources = data.sources;++ if (!st.cards.length) {++ if (data.partial) { st.halted = true; foot('error', 'This feed could not load right now.'); }++ else foot('empty');++ return;++ }++ if (data.partial) { foot('partial'); if (!(data.items || []).length) { st.halted = true; return; } }++ else if (!(data.items || []).length && !st.done) { st.halted = true; foot('more'); return; } // never spin on empty pages++ else foot(st.done ? 'end' : ('IntersectionObserver' in window ? null : 'more'));++ // A short page (or a tall screen) can leave the end in view: keep going.++ if (!st.done && !data.partial) requestAnimationFrame(() => { if (S === st) st.watchEnd(); });++ }++++ function render(items) {++ const vodGroups = S.sort === 'new';++ for (const it of items) {++ if (!it || !it.key || S.seen.has(it.key) || !BUILD[it.kind]) continue;++ S.seen.add(it.key);++ // A stream that restarted is several VODs in a row with the same title on the same day:++ // one card that says how many parts, instead of a column of copies.++ if (vodGroups && it.kind === 'vod' && S.lastVod && S.lastVod.group === vodGroup(it)) {++ S.lastVod.parts++;++ S.lastVod.seconds += Number(it.duration_seconds) || 0;++ S.lastVod.partsEl.textContent = `${S.lastVod.parts} parts`;++ S.lastVod.partsEl.hidden = false;++ S.lastVod.durEl.textContent = duration(S.lastVod.seconds);++ continue;++ }++ let card;++ try { card = BUILD[it.kind](it); } catch (err) { console.warn('[Feed] card failed', it.key, err); continue; }++ card.dataset.key = it.key;++ const node = selectable(it, card);++ S.cards.push(node);++ place(node);++ if (it.kind === 'vod') {++ const partsEl = el('span', { class: 'cf-pill cf-parts', hidden: true }, '');++ card.querySelector('.cf-media').append(partsEl);++ S.lastVod = { group: vodGroup(it), parts: 1, seconds: Number(it.duration_seconds) || 0, partsEl, durEl: card.querySelector('.cf-dur') };++ } else S.lastVod = null;++ }++ }++ const vodGroup = (it) => `${it.channel ? it.channel.username : ''}|${String(it.title || '').trim().toLowerCase()}|${String(it.created_at || '').slice(0, 10)}`;++++ // ── clips: one plays at a time ─────────────────────────────────────────────────────────++ function clipController(scope) {++ const ratios = new Map();++ let active = null;++ const still = (() => {++ try { if (matchMedia('(prefers-reduced-motion: reduce)').matches) return true; } catch { /* */ }++ try { if (navigator.connection && navigator.connection.saveData) return true; } catch { /* */ }++ return false;++ })();++ const media = (v) => v.closest('.cf-media');++ function setSoundIcon(v) {++ const b = media(v) && media(v).querySelector('.cf-sound');++ if (!b) return;++ b.replaceChildren(icon(v.muted ? 'fa-volume-xmark' : 'fa-volume-high'));++ b.setAttribute('aria-label', v.muted ? 'Turn sound on' : 'Turn sound off');++ }++ function start(v, withSound) {++ if (!v.dataset.src) return;++ if (active && active !== v) stop(active);++ active = v;++ if (!v.getAttribute('src')) v.src = v.dataset.src;++ v.muted = !withSound;++ media(v).classList.add('is-playing');++ setSoundIcon(v);++ const p = v.play();++ if (p && p.catch) p.catch(() => {++ // Sound without a fresh tap can be refused: fall back to silent rather than stopped.++ if (!v.muted) { v.muted = true; setSoundIcon(v); v.play().catch(() => media(v).classList.remove('is-playing')); }++ else media(v).classList.remove('is-playing');++ });++ }++ function stop(v) {++ try { v.pause(); } catch { /* */ }++ if (media(v)) media(v).classList.remove('is-playing');++ if (active === v) active = null;++ }++ function unload(v) {++ if (!v.getAttribute('src')) return;++ stop(v);++ v.removeAttribute('src');++ try { v.load(); } catch { /* */ } // gives the decoder and its buffer back++ }++ function pick() {++ if (still) return;++ // The most visible clip; among clips equally in view, the one nearest the middle of the screen.++ let best = null, bestRatio = 0.6, bestDist = Infinity;++ const mid = window.innerHeight / 2;++ for (const [v, r] of ratios) {++ if (r < bestRatio - 0.01 || !v.isConnected) continue;++ const box = v.getBoundingClientRect();++ const dist = Math.abs(box.top + box.height / 2 - mid);++ if (r > bestRatio + 0.01 || dist < bestDist) { best = v; bestRatio = Math.max(r, 0.6); bestDist = dist; }++ }++ if (best && best !== active) start(best, soundOn);++ else if (!best && active && (ratios.get(active) || 0) < 0.35) stop(active);++ for (const [v, r] of ratios) if (r === 0 && v !== active) unload(v);++ }++ const io = new IntersectionObserver((entries) => {++ for (const e of entries) ratios.set(e.target, e.isIntersecting ? e.intersectionRatio : 0);++ pick();++ }, { threshold: [0, 0.35, 0.6, 0.8, 1] });++ scope.observe(io);++ scope.onDispose(() => { for (const v of ratios.keys()) unload(v); ratios.clear(); });++ return {++ observe(v) { ratios.set(v, 0); io.observe(v); },++ /** A tap on a clip: sound on (or off), and this clip is the one playing. */++ tap(v) {++ if (active !== v || v.paused) { soundOn = true; start(v, true); return; }++ v.muted = !v.muted;++ soundOn = !v.muted;++ setSoundIcon(v);++ },++ forget() { for (const v of ratios.keys()) { io.unobserve(v); unload(v); } ratios.clear(); },++ };++ }++++ // ── controls and the address ───────────────────────────────────────────────────────────++ function syncControls() {++ const w = S.wrap;++ w.querySelectorAll('[data-cf-type]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.cfType === S.type)));++ w.querySelectorAll('[data-cf-sort]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.cfSort === S.sort)));++ w.querySelectorAll('[data-cf-window]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.cfWindow === S.win)));++ const win = w.querySelector('.cf-window');++ if (win) win.hidden = S.sort !== 'top';++ const np = w.querySelector('[data-cf-new-paste]');++ if (np) np.hidden = !(S.type === 'pastes');++ if (typeof setPageTitle === 'function') setPageTitle(S.cfg.titles[S.type] || S.cfg.titles.all);++ }++ function syncUrl() {++ const qs = queryString(false);++ const url = `${S.cfg.path}${qs ? `?${qs}` : ''}`;++ if (location.pathname + location.search !== url) history.replaceState(history.state, '', url);++ }++++ function restart() {++ S.gen++;++ try { S.abort.abort(); } catch { /* */ }++ S.abort = new AbortController();++ S.clips.forget();++ S.next = null; S.done = false; S.loading = false; S.halted = false;++ S.seen = new Set(); S.cards = []; S.skels = []; S.lastVod = null;++ buildColumns(columnCount());++ foot(null);++ syncControls();++ syncUrl();++ if (S.sel && typeof _selSetContext === 'function') _selSetContext(true, () => S && restart());++ loadMore();++ }++++ /** A new filter starts a new list: from its top, if the reader had scrolled past it. */++ function refilter() {++ restart();++ const top = S.wrap.querySelector('.cf-bar').getBoundingClientRect().top;++ if (top < 0 || S.wrap.getBoundingClientRect().top < 0) window.scrollTo({ top: Math.max(0, S.wrap.getBoundingClientRect().top + window.scrollY) });++ }++++ function onControl(e) {++ const b = e.target.closest('button');++ if (!b || !S || !S.wrap.contains(b)) return;++ if (b.dataset.cfType && b.dataset.cfType !== S.type && S.cfg.types.includes(b.dataset.cfType)) { S.type = b.dataset.cfType; refilter(); }++ else if (b.dataset.cfSort && b.dataset.cfSort !== S.sort) { S.sort = b.dataset.cfSort === 'top' ? 'top' : 'new'; refilter(); }++ else if (b.dataset.cfWindow && b.dataset.cfWindow !== S.win && WINDOWS.includes(b.dataset.cfWindow)) { S.win = b.dataset.cfWindow; refilter(); }++ else if (b.hasAttribute('data-cf-new-paste')) {++ const go = () => { if (typeof openNewPasteModal === 'function') openNewPasteModal(); };++ if (window.ov) ov.load('pastes').then(go, () => {}); else go();++ }++ }++++ function onFeedClick(e) {++ const sound = e.target.closest('.cf-sound');++ const clipBox = e.target.closest('.cf-media--clip');++ if (clipBox && (sound || !e.target.closest('a'))) {++ const v = clipBox.querySelector('video');++ if (v) { e.preventDefault(); S.clips.tap(v); }++ return;++ }++ const a = e.target.closest('a[data-spa]');++ if (a && typeof handleLinkClick === 'function') handleLinkClick(e, a.getAttribute('href'));++ }++++ // ── entry ──────────────────────────────────────────────────────────────────────────────++ function open(feed, opts) {++ const cfg = FEEDS[feed];++ const section = document.getElementById(cfg.page);++ const wrap = section && section.querySelector('.cf-wrap');++ if (!wrap) return;++ const scope = window.ov ? ov.scope() : { observe: (o) => o, onDispose() {}, listen(t, n, f, o) { t.addEventListener(n, f, o); } };++ const params = new URLSearchParams(location.search);++ const want = (opts && opts.type) || params.get('type');++ S = {++ feed, cfg, wrap, scope,++ feedEl: wrap.querySelector('.cf-feed'), footEl: wrap.querySelector('.cf-foot'), sentinel: wrap.querySelector('.cf-sentinel'),++ type: cfg.types.includes(want) ? want : 'all',++ sort: params.get('sort') === 'top' ? 'top' : 'new',++ win: WINDOWS.includes(params.get('window')) ? params.get('window') : 'month',++ gen: 0, abort: new AbortController(), cols: [], cards: [], skels: [], seen: new Set(), next: null, done: false, loading: false, halted: false, lastVod: null,++ sel: typeof _isContentAdmin === 'function' && _isContentAdmin(),++ };++ const st = S;++ st.clips = clipController(scope);++ // Leaving the route: stop the fetch, the players and the observers; this state is dead.++ scope.onDispose(() => {++ try { st.abort.abort(); } catch { /* */ }++ if (st.sel && typeof _selSetContext === 'function') _selSetContext(false);++ if (S === st) S = null;++ });++ scope.listen(wrap.querySelector('.cf-bar'), 'click', onControl);++ scope.listen(st.feedEl, 'click', onFeedClick);++ scope.listen(wrap.querySelector('.cf-head'), 'click', (e) => {++ const a = e.target.closest('a[data-cf-link]');++ if (a && typeof handleLinkClick === 'function') handleLinkClick(e, a.getAttribute('href'));++ });++ let resizeRaf = 0;++ scope.listen(window, 'resize', () => { cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(relayout); }, { passive: true });++ if ('IntersectionObserver' in window) {++ const io = scope.observe(new IntersectionObserver((entries) => {++ if (entries.some((e) => e.isIntersecting)) loadMore();++ }, { rootMargin: '900px 0px' }));++ io.observe(st.sentinel);++ // Re-observing makes the observer report the sentinel's current state once more.++ st.watchEnd = () => { io.unobserve(st.sentinel); io.observe(st.sentinel); };++ } else st.watchEnd = () => {};++ restart();++ }++++ window.loadContentPage = (opts) => open('content', opts || {});++ window.loadMomentsPage = (opts) => open('moments', opts || {});++})();@@@ -17,8 +17,8 @@heading: 'Watch',items: [{ name: 'Live streams', url: '/', onclick: "return handleLinkClick(event, '/')" },-- { name: 'Stream VODs', url: '/vods', onclick: "return handleLinkClick(event, '/vods')" },-- { name: 'Clips', url: '/clips', onclick: "return handleLinkClick(event, '/clips')" },++ { name: 'VODs, clips & pastes', url: '/content', onclick: "return handleLinkClick(event, '/content')" },++ { name: 'AI Moments', url: '/moments', onclick: "return handleLinkClick(event, '/moments')" },{ name: 'The Arena', url: '/arena', onclick: "return handleLinkClick(event, '/arena')" },{ name: 'Global chat', url: '/chat', onclick: "return handleLinkClick(event, '/chat')" },],@@@ -20,9 +20,9 @@const staff = !!(user && ((user.capabilities && user.capabilities.admin_panel) || (typeof hasCapability === 'function' && hasCapability('can_access_staff_console'))));return [{ id: 'home', page: 'home', label: 'Home', href: '/', icon: 'fa-house' },-- { id: 'vods', page: 'vods', label: 'VODs', href: '/vods', icon: 'fa-video' },-- { id: 'clips', page: 'clips', label: 'Clips', href: '/clips', icon: 'fa-scissors' },-- { id: 'pastes', page: 'pastes', label: 'Pastes', href: '/pastes', icon: 'fa-paste' },++ // VODs, clips and pastes people made are one feed; what the AI made is the other.++ { id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },++ { id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },{ id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },{ id: 'game', page: 'game', label: 'Game', href: 'https://openvibe.games', icon: 'fa-gamepad' },@@@ -5,17 +5,11 @@═══════════════════════════════════════════════════════════════ */// ── State ───────────────────────────────────────────────────--let _pastesLoaded = false;--let _pastesOffset = 0;--let _pastesFilter = 'all'; // 'all' | 'paste' | 'screenshot'--let _pastesUser = 'all'; // 'all' | <username>--let _pastesSearch = '';--let _pastesSort = 'newest'; // 'newest' | 'oldest'--let _pastesTotal = 0;++// The paste list is the Content feed's Pastes filter now (public/js/content-feed.js); this file++// keeps the viewer, the editor, screenshots and the card the dashboard uses.let _pastesCooldownUntil = 0; // timestamp (ms) until next paste allowedlet _pasteLimitCache = null; // cached config from /pastes/configlet _pasteLimitCacheTime = 0;--const PASTES_PER_PAGE = 30;const PASTE_LIMIT_CACHE_TTL = 60000; // 1 minute// ── Syntax highlighting (lightweight, no lib needed) ────────@@@ -51,85 +45,6 @@ function highlightSyntax(code, lang) {}// ── Load pastes index page ──────────────────────────────────--function loadPastesPage() {-- _pastesOffset = 0;-- _pastesFilter = 'all';-- _pastesUser = 'all';-- _pastesSearch = '';-- _pastesSort = 'newest';-- _pastesLoaded = true;---- const grid = document.getElementById('pastes-grid');-- if (grid) grid.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';---- fetchPastes();--}----async function fetchPastes() {-- const grid = document.getElementById('pastes-grid');-- if (!grid) return;---- try {-- let url = `/pastes?limit=${PASTES_PER_PAGE}&offset=${_pastesOffset}`;-- if (_pastesFilter !== 'all') url += `&type=${_pastesFilter}`;-- if (_pastesUser !== 'all') url += `&username=${encodeURIComponent(_pastesUser)}`;-- if (_pastesSearch) url += `&search=${encodeURIComponent(_pastesSearch)}`;-- if (_pastesSort === 'oldest') url += `&sort=oldest`;---- const data = await api(url);-- _pastesTotal = data.total || 0;---- // Per-user filter bar (reuses the shared VOD/clip filter renderer).-- if (typeof renderMediaStreamerFilters === 'function') {-- renderMediaStreamerFilters({-- barId: 'pastes-streamer-filters',-- streamers: data.users || [],-- activeFilter: _pastesUser,-- onSelect: 'setPastesUserFilter',-- countKey: 'paste_count',-- allLabel: 'All users',-- });-- }---- document.getElementById('pastes-count').textContent = `${_pastesTotal} item${_pastesTotal !== 1 ? 's' : ''}`;---- if (!data.pastes?.length && _pastesOffset === 0) {-- grid.innerHTML = `-- <div class="empty-state" style="grid-column: 1/-1; text-align:center; padding:48px 20px;">-- <i class="fa-solid fa-paste" style="font-size:2.5rem; opacity:0.3; margin-bottom:12px;"></i>-- <p style="opacity:0.5;">No pastes yet. Share something!</p>-- </div>`;-- return;-- }---- grid.innerHTML = data.pastes.map(p => renderPasteCard(p)).join('');---- // Admin bulk-select (ctrl/shift multi-select + bulk delete) on the pastes index,-- // mirroring the global VODs/Clips pages. No-op for non-admins (cards stay plain links).-- if (typeof _selSetContext === 'function' && typeof _isContentAdmin === 'function') {-- _selSetContext(_isContentAdmin(), fetchPastes);-- }---- // Pagination-- const pagEl = document.getElementById('pastes-pagination');-- if (pagEl) {-- const totalPages = Math.ceil(_pastesTotal / PASTES_PER_PAGE);-- const currentPage = Math.floor(_pastesOffset / PASTES_PER_PAGE) + 1;-- const sortHtml = (typeof sortToggleHTML === 'function') ? sortToggleHTML(_pastesSort, 'pastesSort') : '';-- let pageHtml = '';-- if (totalPages > 1) {-- if (currentPage > 1) pageHtml += `<button class="btn btn-outline btn-sm" onclick="pastesPaginate(${_pastesOffset - PASTES_PER_PAGE})"><i class="fa-solid fa-chevron-left"></i> Prev</button>`;-- pageHtml += `<span class="pastes-page-info">Page ${currentPage} of ${totalPages}</span>`;-- if (currentPage < totalPages) pageHtml += `<button class="btn btn-outline btn-sm" onclick="pastesPaginate(${_pastesOffset + PASTES_PER_PAGE})">Next <i class="fa-solid fa-chevron-right"></i></button>`;-- }-- pagEl.innerHTML = sortHtml + pageHtml;-- pagEl.style.display = (sortHtml || pageHtml) ? '' : 'none';-- }-- } catch (err) {-- grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1;text-align:center;padding:48px;"><p style="color:var(--danger);">Failed to load pastes</p></div>`;-- }--}--function renderPasteCard(p) {const isScreenshot = p.type === 'screenshot';const timeAgo = formatTimeAgo(p.created_at);@@@ -175,45 +90,6 @@ function renderPasteCard(p) {return (typeof _selWrap === 'function') ? _selWrap('paste', p.slug, _cardInner, !!p.owner_is_owner) : _cardInner;}--function filterPastes(type) {-- _pastesFilter = type;-- _pastesOffset = 0;-- document.querySelectorAll('.pastes-filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter === type));-- fetchPastes();--}----function setPastesUserFilter(username = 'all') {-- const next = String(username || 'all').trim() || 'all';-- if (next === _pastesUser) return;-- _pastesUser = next;-- _pastesOffset = 0;-- fetchPastes();-- const top = document.getElementById('page-pastes');-- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });--}----function searchPastes() {-- const input = document.getElementById('pastes-search-input');-- _pastesSearch = input?.value?.trim() || '';-- _pastesOffset = 0;-- fetchPastes();--}----function pastesPaginate(offset) {-- _pastesOffset = Math.max(0, offset);-- fetchPastes();-- document.getElementById('page-pastes')?.scrollTo(0, 0);--}----function pastesSort(sort) {-- const s = sort === 'oldest' ? 'oldest' : 'newest';-- if (s === _pastesSort) return;-- _pastesSort = s;-- _pastesOffset = 0;-- fetchPastes();--}----// ── Markdown rendering ──────────────────────────────────────────────────────────// A small self-contained renderer. Paste content is arbitrary user input, so the// order here is not negotiable: escape EVERYTHING first, then add markup. That way@@@ -153,7 +153,9 @@ async function _checkLiveStream(stream) {stream_id: streamId, auto_generated: true, description: verdict.desc || '',}).catch((e) => { console.warn(`[AutoClip] Media createClip failed for stream ${streamId}:`, e.message); return null; });if (clip) {-- _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: start, title, clip_id: clip.id, sig: _sceneSig(verdict.desc || title) });++ // deduplicated: Media handed back a clip someone already cut of this window. It stays in++ // the log for the caps, but it is theirs, not the AI's (syncAutoClipFlags skips it).++ _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: start, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, sig: _sceneSig(verdict.desc || title) });console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs`);}} catch (e) {@@@ -297,12 +299,50 @@ async function clipVodMoment(o) {title: title || 'Standout Moment', user_id: vod.user_id,stream_id: vod.stream_id || undefined, auto_generated: true, description: desc || '',});-- if (clip) _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, sig: _sceneSig(desc || title) });++ if (clip) _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, dedup: !!clip.deduplicated || undefined, sig: _sceneSig(desc || title) });return clip || null;} catch { return null; }}++// ── Tell Media which clips the AI cut ────────────────────────────────────────────────────++// Media dropped the auto_generated flag Live sent with every auto-clip until it learned to keep it,++// so those clips were listed as people's clips (the Content feed shows people's work only; the++// AI's goes to Moments). This log names every auto-clip Live made; each one is marked in Media++// once (PUT /clips/:id { auto_generated }, app authority) and remembered, so the next run only++// asks about new ones. A Media that answers without the flag has not been upgraded yet: stop and++// try again on the next run. A clip Media no longer has is done.++const FLAG_SYNC_SETTING = 'auto_clip_flags_synced';++const FLAG_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;++async function syncAutoClipFlags({ max = 400 } = {}) {++ let done = new Set();++ try { const d = JSON.parse(db.getState(FLAG_SYNC_SETTING) || '[]'); if (Array.isArray(d)) done = new Set(d.map(String)); } catch { /* */ }++ const ids = [...new Set(_clipLog().filter((e) => e && !e.dedup).map((e) => e.clip_id).filter((id) => id != null && /^\d+$/.test(String(id))).map(String))]++ .filter((id) => !done.has(id)).slice(0, max);++ let marked = 0, gone = 0, pending = 0, skipped = 0;++ for (const id of ids) {++ try {++ // The job cuts in the streamer's name. A clip under anyone else is one Media returned as a++ // duplicate of a person's clip before the log said so: never relabel a person's work.++ const cur = await media.getClip(id, { timeoutMs: 10_000 });++ if (cur && cur.auto_generated === true) { done.add(id); marked++; continue; }++ if (!cur || (cur.channel_user_id != null && String(cur.user_id) !== String(cur.channel_user_id))) { done.add(id); skipped++; continue; }++ const out = await media.updateClip(id, { auto_generated: true }, { timeoutMs: 10_000 });++ const clip = (out && out.clip) || out;++ if (!clip || clip.auto_generated !== true) { pending = ids.length - marked - gone - skipped; break; } // Media not upgraded yet++ done.add(id); marked++;++ } catch (e) {++ if (e && (e.status === 404 || e.status === 410)) { done.add(id); gone++; continue; }++ pending = ids.length - marked - gone - skipped;++ break; // Media down or refusing: the rest wait for the next run++ }++ }++ try { db.setState(FLAG_SYNC_SETTING, JSON.stringify([...done].slice(-2000))); } catch { /* */ }++ if (marked || gone || skipped) console.log(`[AutoClip] Marked ${marked} auto-clip(s) as AI-made in Media${gone ? `, ${gone} no longer there` : ''}${skipped ? `, ${skipped} left as a person's clip` : ''}${pending ? `, ${pending} left for the next run` : ''}`);++ return { marked, gone, skipped, pending };++}++let _backfillTimer = null;++let _flagTimer = null;function start() {if (_timer) return;_timer = setInterval(() => { _tick().catch(() => {}); }, CHECK_INTERVAL_MS);@@@ -313,11 +353,16 @@ function start() {// re-check used to be joined by a second one over the same VODs (double AI + ffmpeg, duplicate// clips). Jittered so it does not start in lockstep with every other boot-time job._backfillTimer = require('../utils/jobs').every('auto-clip-vod-backfill', 5 * 60 * 1000, () => backfillVodClips(), { initialDelayMs: 30 * 1000, jitterMs: 30 * 1000 });++ _flagTimer = require('../utils/jobs').every('auto-clip-flag-sync', FLAG_SYNC_INTERVAL_MS, () => syncAutoClipFlags(), { initialDelayMs: 90 * 1000, jitterMs: 60 * 1000 });console.log('[AutoClip] Live auto-clip job started (selective chat-spike + AI agreement, cuts via OpenVibe.Media) + persistent VOD backfill');}--function stop() { if (_timer) { clearInterval(_timer); _timer = null; } if (_backfillTimer) { _backfillTimer(); _backfillTimer = null; } }++function stop() {++ if (_timer) { clearInterval(_timer); _timer = null; }++ if (_backfillTimer) { _backfillTimer(); _backfillTimer = null; }++ if (_flagTimer) { _flagTimer(); _flagTimer = null; }++}--module.exports = { start, stop, clipVodMoment, backfillVodClips, _tick };++module.exports = { start, stop, clipVodMoment, backfillVodClips, syncAutoClipFlags, _tick };// CLI: force a historical backfill batch, e.g. `node server/ai/auto-clip-job.js --backfill --limit=4`if (require.main === module) {nnew file mode 100644@@@ -0,0 +1,443 @@++'use strict';++/**++ * The Content and Moments feeds: one scrolling list merged from several services.++ *++ * Content (/content, GET /api/content/feed) what people made: VODs, clips people cut, pastes++ * people wrote++ * Moments (/moments, GET /api/content/moments) what the AI made: auto-clips, AI moment pastes++ * (from VODs and caught live), AI after-show recaps++ *++ * Who made it is decided by the service that stores it, never by guessing here:++ * - clips: OpenVibe.Media's `auto_generated` (set when Live's auto-clip job cuts one; Media's++ * ?auto_generated=1|0 filters by it). Clips made before Media kept the flag are corrected from++ * Live's own auto-clip log (auto-clip-job.js syncAutoClipFlags).++ * - pastes: OpenVibe.Community's `origin` ('ai' for Live's AI jobs, which post with X-OV-Origin: ai;++ * ?origin=user|ai filters by it).++ * - VODs are recordings of people streaming: always Content.++ * - recaps: Live's own stream_recaps rows whose write-up the AI produced (ai = 1); template++ * recaps are stats, not generated content, and are listed nowhere.++ * Every source filters server-side, so a page is never a full page with the other kind taken out.++ *++ * Privacy: public items only. Media lists without include_private (is_public = 1), Community lists++ * for an anonymous service call (public, not burn-after-read), and each row is checked again here++ * (lookups.isPublic) before it is shown. Items of banned accounts are left out.++ *++ * Paging: `cursor` carries each source's offset (and, for Top, the window's start, so a window++ * does not slide while someone scrolls). A page fetches the next `limit + SLACK` rows of every++ * source, merges them by the sort (newest first, or score: views, a paste like worth five views, a++ * recap's peak viewers), and stops when a source that has more upstream runs out of fetched rows,++ * so the order across pages is exact. A new item arriving between pages can repeat one card (the++ * client drops repeated keys); none is skipped.++ *++ * Cost: every source page is cached (30 s for New, 2 min for Top) and shared by concurrent++ * requests. A page waits at most DEADLINE_MS for Media and Community; a source that has not++ * answered by then is left out of this page (its offset stays, so the next page asks again) and++ * the answer says which (`sources`, `partial`). Its fetch keeps running and fills the cache.++ */++const db = require('../db/database');++const media = require('../media-client');++const pastesClient = require('../pastes-client');++const lookups = require('../media-proxy/lookups');++const { publicFeedItem } = require('../web/serializers');++++const DEADLINE_MS = 4000;++const SOURCE_TIMEOUT_MS = 3500;++const DEFAULT_LIMIT = 12;++const MAX_LIMIT = 30;++const SLACK = 4; // rows fetched beyond the page, so a skipped row rarely shortens it++const MAX_OFFSET = 10_000; // per source; deeper than anyone scrolls, and bounds crafted cursors++const TTL_NEW_MS = 30_000;++const TTL_TOP_MS = 120_000;++const CACHE_MAX = 400;++++const WINDOWS = { week: 7, month: 30, all: null };++const DEFAULT_WINDOW = 'month';++++/** Which sources each feed and each of its filters reads. */++const FEEDS = {++ content: { types: { all: ['vods', 'clips', 'pastes'], vods: ['vods'], clips: ['clips'], pastes: ['pastes'] } },++ moments: { types: { all: ['aiclips', 'aipastes', 'recaps'], clips: ['aiclips'], shots: ['aipastes'], recaps: ['recaps'] } },++};++++// ── helpers ──────────────────────────────────────────────────────────────────────────────────++++/** SQLite 'YYYY-MM-DD HH:MM:SS' (UTC) or ISO → ms (0 when unreadable). */++function ts(s) {++ if (!s) return 0;++ const str = String(s);++ const n = Date.parse(/[TZ]|[+-]\d\d:?\d\d$/.test(str) ? str : `${str.replace(' ', 'T')}Z`);++ return Number.isFinite(n) ? n : 0;++}++const iso = (s) => { const n = ts(s); return n ? new Date(n).toISOString() : null; };++const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);++const clean = (s, max) => {++ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();++ return max && t.length > max ? `${t.slice(0, max - 1).replace(/\s+\S*$/, '')}…` : t;++};++const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);++function parseJson(v) {++ if (!v) return null;++ if (typeof v === 'object') return v;++ try { const o = JSON.parse(v); return o && typeof o === 'object' ? o : null; } catch { return null; }++}++++const _users = new Map(); // per page build: id/username → row (or null)++function userById(id) {++ if (id == null || id === '') return null;++ const key = `i:${id}`;++ if (!_users.has(key)) { let u = null; try { u = db.getUserById(Number(id)) || null; } catch { /* */ } _users.set(key, u); }++ return _users.get(key);++}++function userByName(name) {++ if (!name) return null;++ const key = `n:${String(name).toLowerCase()}`;++ if (!_users.has(key)) { let u = null; try { u = db.getUserByUsername(String(name)) || null; } catch { /* */ } _users.set(key, u); }++ return _users.get(key);++}++const banned = (u) => !!(u && (u.is_banned === 1 || u.is_banned === true));++/** The public face of a Live account, with its channel link. */++function person(u) {++ if (!u || !u.username) return null;++ return { username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null, href: `/@${u.username}` };++}++++// ── sources ──────────────────────────────────────────────────────────────────────────────────++// Each: load(offset, n, { sort, since }) → { rows, total|null }, and item(row) → feed item or null++// (null = not shown, still consumed). `score` must follow the upstream order for Top.++++const mediaRows = (r, key) => (r && Array.isArray(r[key]) ? r[key] : []);++const mediaTotal = (r) => (r && Number.isFinite(Number(r.total)) ? Number(r.total) : null);++++/** A VOD row that is not worth a card: failed, still recording, or an empty shell nothing will fill. */++function unplayableVod(v) {++ if (!v || v.is_recording || v.clips_only || v.status === 'failed' || v.status === 'recording') return true;++ if (v.status !== 'pending') return false;++ if (num(v.duration_seconds ?? v.duration) > 0 || num(v.file_size) > 0 || v.thumbnail_url) return false;++ return Date.now() - ts(v.created_at) > 10 * 60 * 1000;++}++++function vodItem(v) {++ if (!lookups.isPublic(v) || unplayableVod(v)) return null;++ const owner = userById(v.user_id);++ if (banned(owner)) return null;++ // Content is what people made: the card carries no AI overview (the VOD page still has it).++ return {++ kind: 'vod', id: Number(v.id), href: `/vod/${Number(v.id)}`,++ title: clean(v.title, 140) || 'Stream recording',++ created_at: iso(v.created_at), views: num(v.view_count),++ duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),++ thumbnail_url: media.publicUrl(v.thumbnail_url) || null,++ excerpt: v.description ? clean(v.description, 220) : null,++ channel: person(owner), ai: false,++ };++}++++function clipItem(c, { ai }) {++ if (!lookups.isPublic(c)) return null;++ if (c.status && c.status !== 'ready') return null;++ if (!!c.auto_generated !== ai) return null; // an upstream that ignored the filter never mixes the feeds++ const channelOwner = userById(c.channel_user_id != null ? c.channel_user_id : c.user_id);++ const creator = userById(c.user_id);++ if (banned(channelOwner) || banned(creator)) return null;++ // An AI clip's card says what the AI saw; a person's clip carries only what they wrote.++ let summary = null;++ if (ai) { try { const s = db.getClipAiState(c.id); summary = s && (s.ai_overview_short || s.ai_overview); } catch { /* */ } }++ const by = !ai && creator && channelOwner && creator.id !== channelOwner.id ? person(creator) : null;++ return {++ kind: 'clip', id: Number(c.id), href: `/clip/${Number(c.id)}`,++ title: clean(c.title, 140) || 'Clip',++ created_at: iso(c.created_at), views: num(c.view_count),++ duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),++ thumbnail_url: media.publicUrl(c.thumbnail_url) || null,++ preview_url: media.publicUrl(c.playback_url) || media.clipUrl(c.id),++ excerpt: summary ? clean(summary, 220) : (c.description ? clean(c.description, 220) : null),++ channel: person(channelOwner || creator), by, ai,++ ai_label: ai ? 'Auto-clip' : null,++ };++}++++const VOD_LINK_RE = /^\/vod\/\d+(\?t=\d+(\.\d+)?)?$/;++function pasteItem(p, { ai }) {++ if (!p || p.visibility !== 'public' || p.burn_after_read) return null;++ if ((p.origin === 'ai') !== ai) return null;++ const meta = parseJson(p.metadata) || {};++ let channel = null;++ if (ai) {++ // AI pastes have no author; they belong to the stream they came from.++ let stream = null;++ try { stream = p.stream_id ? db.getStreamById(Number(p.stream_id)) : null; } catch { /* */ }++ const owner = stream ? userById(stream.user_id) : userByName(meta.username);++ if (banned(owner)) return null;++ channel = person(owner);++ } else {++ // The author is a network account: their Live channel when they have one, else just the name.++ // Community's avatar is used only as an absolute https URL (it may be relative to Community).++ const local = userByName(p.username);++ if (banned(local)) return null;++ const theirAvatar = typeof p.avatar_url === 'string' && /^https:\/\//i.test(p.avatar_url) ? p.avatar_url : null;++ channel = local ? person(local)++ : (p.username ? { username: p.username, display_name: p.display_name || p.username, avatar_url: theirAvatar, profile_color: p.profile_color || null, href: null } : null);++ if (channel && local && !channel.avatar_url) channel.avatar_url = theirAvatar;++ }++ const shot = p.type === 'screenshot';++ // People's pastes show their own words; the AI summary is shown on AI pastes only.++ const text = ai ? (p.ai_summary || p.content || null) : (shot ? null : (p.content || null));++ return {++ kind: 'paste', id: String(p.slug), href: `/p/${encodeURIComponent(p.slug)}`,++ title: clean(p.title, 140) || (shot ? 'Image' : 'Paste'),++ created_at: iso(p.created_at), views: num(p.views), likes: num(p.likes),++ paste_type: shot ? 'image' : 'text',++ language: shot ? null : (p.language || 'text'),++ image_url: shot ? (media.publicUrl(p.screenshot_url) || null) : null,++ excerpt: text ? (shot || ai ? clean(text, 260) : String(text).slice(0, 600)) : null,++ nsfw: !!p.is_nsfw,++ channel, ai,++ ai_label: ai ? (meta.live ? 'Caught live' : 'AI moment') : null,++ moment_href: ai && typeof meta.vod_link === 'string' && VOD_LINK_RE.test(meta.vod_link) ? meta.vod_link : null,++ };++}++++function recapItem(r) {++ const j = parseJson(r.json);++ if (!j || !j.write || !j.stream) return null;++ const owner = userById(r.user_id);++ if (!owner || banned(owner)) return null;++ return {++ kind: 'recap', id: Number(r.stream_id), href: `/recap/${Number(r.stream_id)}`,++ title: clean(j.write.headline || j.stream.title, 140) || 'After-show report',++ created_at: iso(r.created_at), views: num(j.stream.peak_viewers),++ duration_seconds: Math.round(num(j.stream.duration_seconds)),++ thumbnail_url: j.vod && j.vod.thumbnail_url ? media.publicUrl(j.vod.thumbnail_url) : null,++ excerpt: j.write.summary ? clean(j.write.summary, 260) : null,++ grade: ['S', 'A', 'B', 'C'].includes(j.write.grade) ? j.write.grade : null,++ stream_title: clean(j.stream.title, 120) || null,++ channel: person(owner), ai: true, ai_label: 'AI recap',++ };++}++++const SOURCES = {++ vods: {++ async load(offset, n, { sort, since }) {++ const r = await media.listVods({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined }, { timeoutMs: SOURCE_TIMEOUT_MS });++ return { rows: mediaRows(r, 'vods'), total: mediaTotal(r) };++ },++ item: vodItem,++ score: (it) => it.views,++ },++ clips: {++ async load(offset, n, { sort, since }) {++ const r = await media.listClips({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined, auto_generated: 0, status: 'ready' }, { timeoutMs: SOURCE_TIMEOUT_MS });++ return { rows: mediaRows(r, 'clips'), total: mediaTotal(r) };++ },++ item: (c) => clipItem(c, { ai: false }),++ score: (it) => it.views,++ },++ aiclips: {++ async load(offset, n, { sort, since }) {++ const r = await media.listClips({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined, auto_generated: 1, status: 'ready' }, { timeoutMs: SOURCE_TIMEOUT_MS });++ return { rows: mediaRows(r, 'clips'), total: mediaTotal(r) };++ },++ item: (c) => clipItem(c, { ai: true }),++ score: (it) => it.views,++ },++ pastes: {++ load: (offset, n, opts) => communityPastes('user', offset, n, opts),++ item: (p) => pasteItem(p, { ai: false }),++ score: (it) => it.views + 5 * it.likes,++ },++ aipastes: {++ load: (offset, n, opts) => communityPastes('ai', offset, n, opts),++ item: (p) => pasteItem(p, { ai: true }),++ score: (it) => it.views + 5 * it.likes,++ },++ recaps: {++ async load(offset, n, { sort, since }) {++ try { require('../recap/recap').ensureTable(); } catch { /* */ }++ const where = ['r.ai = 1', 'COALESCE(u.is_banned, 0) = 0'];++ const params = [];++ if (since) { where.push('datetime(r.created_at) >= datetime(?)'); params.push(since); }++ const order = sort === 'top'++ ? "COALESCE(json_extract(r.json, '$.stream.peak_viewers'), 0) DESC, r.created_at DESC, r.stream_id DESC"++ : 'r.created_at DESC, r.stream_id DESC';++ const from = `FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE ${where.join(' AND ')}`;++ const rows = db.all(`SELECT r.stream_id, r.user_id, r.json, r.created_at ${from} ORDER BY ${order} LIMIT ? OFFSET ?`, [...params, n, offset]) || [];++ const total = (db.get(`SELECT COUNT(*) AS n ${from}`, params) || {}).n || 0;++ return { rows, total };++ },++ item: recapItem,++ score: (it) => it.views,++ },++};++++/** People's or AI pastes from OpenVibe.Community. Pastes are only listed once Community owns them. */++async function communityPastes(origin, offset, n, { sort, since }) {++ if (!pastesClient.onCommunity()) { const e = new Error('pastes are listed from OpenVibe.Community only'); e.unavailable = true; throw e; }++ const r = await pastesClient.request('GET', '', {++ query: { origin, limit: n, offset, sort: sort === 'top' ? 'top' : 'newest', since: since || undefined, pinned_first: 0 },++ timeoutMs: SOURCE_TIMEOUT_MS,++ });++ return { rows: mediaRows(r, 'pastes'), total: mediaTotal(r) };++}++++// ── cache ────────────────────────────────────────────────────────────────────────────────────++++const _pages = new Map(); // key → { at, ttl, value, pending }++/** A source page, fetched at most once at a time and kept for `ttl`. Failures are not kept. */++function cachedPage(key, ttl, load) {++ const hit = _pages.get(key);++ if (hit && hit.value && Date.now() - hit.at < ttl) return Promise.resolve(hit.value);++ if (hit && hit.pending) return hit.pending;++ const entry = { at: 0, ttl, value: null, pending: null };++ entry.pending = Promise.resolve().then(load).then(++ (value) => { entry.value = value; entry.at = Date.now(); entry.pending = null; return value; },++ (err) => { if (_pages.get(key) === entry) _pages.delete(key); throw err; },++ );++ _pages.delete(key);++ _pages.set(key, entry);++ if (_pages.size > CACHE_MAX) _pages.delete(_pages.keys().next().value);++ return entry.pending;++}++++// ── cursor ───────────────────────────────────────────────────────────────────────────────────++++function encodeCursor(c) { return Buffer.from(JSON.stringify(c)).toString('base64url'); }++function decodeCursor(s) {++ if (!s) return null;++ try {++ const c = JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));++ return c && typeof c === 'object' && c.o && typeof c.o === 'object' ? c : null;++ } catch { return null; }++}++++class FeedError extends Error {++ constructor(status, message) { super(message); this.status = status; }++}++++/** The request's feed options, validated. Throws FeedError(400). */++function parseQuery(feed, q = {}) {++ const def = FEEDS[feed];++ if (!def) throw new FeedError(404, 'No such feed');++ const type = String(q.type || 'all').toLowerCase();++ if (!def.types[type]) throw new FeedError(400, `type must be one of: ${Object.keys(def.types).join(', ')}`);++ const sort = String(q.sort || 'new').toLowerCase();++ if (sort !== 'new' && sort !== 'top') throw new FeedError(400, 'sort must be new or top');++ let windowName = null;++ if (sort === 'top') {++ windowName = String(q.window || DEFAULT_WINDOW).toLowerCase();++ if (!(windowName in WINDOWS)) throw new FeedError(400, 'window must be week, month or all');++ }++ const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(q.limit, 10) || DEFAULT_LIMIT));++ const key = `${feed}|${type}|${sort}|${windowName || ''}`;++ const cursor = q.cursor ? decodeCursor(q.cursor) : null;++ if (q.cursor && (!cursor || cursor.k !== key)) throw new FeedError(400, 'This cursor belongs to another feed or is damaged; start from the first page');++ // The window starts on the hour (so pages share cache entries) and stays put across pages.++ let since = null;++ if (windowName && WINDOWS[windowName]) {++ since = cursor && typeof cursor.s === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(cursor.s) ? cursor.s++ : sqlTime(Math.floor((Date.now() - WINDOWS[windowName] * 86400_000) / 3600_000) * 3600_000);++ }++ const offsets = {};++ for (const s of def.types[type]) {++ const o = cursor ? parseInt(cursor.o[s], 10) : 0;++ offsets[s] = Number.isFinite(o) && o > 0 ? Math.min(o, MAX_OFFSET) : 0;++ }++ return { feed, type, sort, window: windowName, limit, key, since, offsets, sources: def.types[type] };++}++++/** Newest first; Top: higher score first, then newest. Ties by kind and id so the order is total. */++function comparator(sort) {++ const kindRank = { vod: 0, clip: 1, paste: 2, recap: 3 };++ return (a, b) => {++ if (sort === 'top' && b.score !== a.score) return b.score - a.score;++ const ta = ts(a.item.created_at), tb = ts(b.item.created_at);++ if (tb !== ta) return tb - ta;++ if (a.item.kind !== b.item.kind) return kindRank[a.item.kind] - kindRank[b.item.kind];++ return String(b.item.id).localeCompare(String(a.item.id), undefined, { numeric: true });++ };++}++++/**++ * One page of a feed. → { feed, type, sort, window, items, next, sources, partial }++ * Never rejects for an upstream failure; throws FeedError for a bad request.++ */++async function page(feed, query = {}) {++ const q = parseQuery(feed, query);++ const n = q.limit + SLACK;++ const ttl = q.sort === 'top' ? TTL_TOP_MS : TTL_NEW_MS;++++ // Every source, raced against one deadline.++ let timer;++ const deadline = new Promise((resolve) => { timer = setTimeout(resolve, DEADLINE_MS, { timedOut: true }); });++ const results = await Promise.all(q.sources.map((name) => {++ const offset = q.offsets[name];++ const key = `${name}|${q.sort}|${q.since || ''}|${offset}|${n}`;++ const loaded = cachedPage(key, ttl, () => SOURCES[name].load(offset, n, { sort: q.sort, since: q.since }))++ .then((value) => ({ name, value }), (err) => ({ name, error: err }));++ return Promise.race([loaded, deadline.then(() => ({ name, error: { timedOut: true } }))]);++ })).finally(() => clearTimeout(timer));++++ _users.clear();++ const status = {};++ const heads = [];++ for (const r of results) {++ if (r.error) {++ status[r.name] = r.error.unavailable ? 'unavailable' : (r.error.timedOut ? 'timeout' : 'error');++ if (!r.error.unavailable && !r.error.timedOut) console.warn(`[Feed] ${q.feed}/${r.name}:`, r.error.message || r.error);++ continue;++ }++ status[r.name] = 'ok';++ const src = SOURCES[r.name];++ const rows = r.value.rows || [];++ const offset = q.offsets[r.name];++ const total = r.value.total;++ // An empty answer ends the source whatever its total says: paging could not move past it.++ const more = rows.length > 0 && (total != null ? offset + rows.length < total : rows.length >= n);++ heads.push({++ name: r.name, i: 0, more,++ entries: rows.map((row) => {++ let item = null;++ try { item = src.item(row); } catch (err) { console.warn(`[Feed] ${r.name} row skipped:`, err.message); }++ return item ? { item, score: src.score(item) } : null;++ }),++ });++ }++++ // Merge. Stop as soon as a source with more upstream has no fetched row left: its next row++ // might belong before anything else still waiting, so this page ends here.++ const cmp = comparator(q.sort);++ const items = [];++ while (items.length < q.limit) {++ for (const h of heads) while (h.i < h.entries.length && !h.entries[h.i]) h.i++;++ if (heads.some((h) => h.i >= h.entries.length && h.more)) break;++ let best = null;++ for (const h of heads) if (h.i < h.entries.length && (!best || cmp(h.entries[h.i], best.entries[best.i]) < 0)) best = h;++ if (!best) break;++ items.push(best.entries[best.i].item);++ best.i++;++ }++ // Skipped rows right behind the last shown one are consumed too.++ for (const h of heads) while (h.i < h.entries.length && !h.entries[h.i]) h.i++;++++ const offsets = { ...q.offsets };++ let more = false;++ const failed = [];++ for (const h of heads) {++ offsets[h.name] = q.offsets[h.name] + h.i;++ if (h.more || h.i < h.entries.length) more = true;++ }++ for (const name of q.sources) {++ if (status[name] !== 'ok' && status[name] !== 'unavailable') { failed.push(name); more = true; }++ }++ const next = more ? encodeCursor({ k: q.key, o: offsets, s: q.since || undefined, f: failed.length ? failed : undefined }) : null;++ return {++ feed: q.feed, type: q.type, sort: q.sort, window: q.window,++ items: items.map(publicFeedItem),++ next,++ sources: status,++ partial: failed.length > 0,++ };++}++++function _reset() { _pages.clear(); _users.clear(); }++++module.exports = { page, parseQuery, FeedError, FEEDS, WINDOWS, SOURCES, DEADLINE_MS, _reset, _items: { vodItem, clipItem, pasteItem, recapItem } };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++/**++ * /api/content — the Content and Moments feeds (server/content/feed.js).++ *++ * GET /feed?type=all|vods|clips|pastes&sort=new|top&window=week|month|all&limit=&cursor=++ * what people made (VODs, clips people cut, people's pastes)++ * GET /moments?type=all|clips|shots|recaps&sort=new|top&window=…&limit=&cursor=++ * what the AI made (auto-clips, AI moment pastes, AI after-show recaps)++ *++ * → { feed, type, sort, window, items: [publicFeedItem…], next: cursor|null, sources: {name: status}, partial }++ *++ * Public items only and the same answer for everyone, so it is cacheable by browsers and CDNs for++ * a short while. A bad parameter is 400; an upstream that is down or slow is never an error here++ * (the page leaves that source out and says so in `sources`).++ */++const express = require('express');++const feed = require('./feed');++++const router = express.Router();++++function handler(name) {++ return async (req, res) => {++ try {++ const out = await feed.page(name, req.query || {});++ // A partial page must not be kept: the next request should ask the missing source again.++ res.set('Cache-Control', out.partial ? 'no-store' : `public, max-age=${out.sort === 'top' ? 60 : 15}`);++ res.json(out);++ } catch (err) {++ if (err instanceof feed.FeedError) return res.status(err.status).json({ error: err.message });++ console.error(`[Feed] ${name}:`, err && err.message);++ res.status(500).json({ error: 'Failed to load the feed' });++ }++ };++}++++router.get('/feed', handler('content'));++router.get('/moments', handler('moments'));++++module.exports = router;@@@ -604,8 +604,9 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));});--// Pastes are moving to openvibe.community (PASTES_ON_COMMUNITY=1): the old Live URLs keep++// Pastes are moving to openvibe.community (PASTES_ON_COMMUNITY=1): the old Live paste URLs keep// working as permanent redirects, so links in chat, search results and clipboards survive.++// /pastes itself stays here: it is the Content feed with the Pastes filter (public/js/content-feed.js).const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');if (process.env.PASTES_ON_COMMUNITY === '1') {// Someone signed in here should arrive signed in there: they go through Community's silent sign-in@@@ -618,7 +619,6 @@ if (process.env.PASTES_ON_COMMUNITY === '1') {res.redirect(302, `${COMMUNITY_URL}/auth/login?silent=1&next=${encodeURIComponent(target)}`);};app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));-- app.get('/pastes', (req, res) => handOver(req, res, '/pastes'));}// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)@@@ -685,6 +685,7 @@ app.get('/canvas', (req, res) => res.redirect(301, 'https://openvibe.games/canvaapp.use('/api/game', (req, res) => res.status(410).json({ error: 'Game has moved to https://openvibe.games/game' }));app.use('/api/meta', metaRoutes);app.use('/api/pastes', pasteRoutes);++app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feedsapp.use('/api/home', require('./home/routes'));app.use('/api/i18n', require('./i18n/routes')); // on-demand translation for viewers (any line → your language)app.use('/api/kiosk', require('./kiosk/routes'));@@@ -33,8 +33,19 @@ async function _cached(key, fn) {return v;}const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);--const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);--const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public' }))?.pastes || []);++// People's clips and pastes: the AI's are listed on /moments (server/content/feed.js).++const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset, auto_generated: 0 }))?.clips || []);++const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public', origin: 'user' }))?.pastes || []);++/** The first page of the Content or Moments feed, as list-page items. */++const _feedList = (feed) => _cached(`feed:${feed}`, async () => {++ const out = await require('../content/feed').page(feed, { limit: 24 });++ const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };++ return (out.items || []).map((it) => ({++ url: it.href, name: it.title, by: it.channel ? it.channel.display_name : null,++ meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),++ desc: it.excerpt,++ }));++});const { isPrivate } = require('../media-proxy/access');const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));@@@ -111,7 +122,9 @@ async function _pageMeta(routePath) {if (p === '/') return _homeMeta();// List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)if (p === '/vods') return _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));-- if (p === '/clips') return _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — highlights clipped by viewers and auto-generated by AI.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));++ if (p === '/clips') return _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));++ if (p === '/content') return _listMeta('content', 'Content', 'VODs, clips and pastes made by the people of OpenVibe.Live — recorded streams, the moments viewers clipped, and the code, notes and screenshots they shared.', () => _feedList('content'));++ if (p === '/moments') return _listMeta('moments', 'AI Moments', 'AI-made highlights from OpenVibe.Live streams: auto-clips of the moments chat erupted, standout frames the AI picked, and AI-written after-show recaps. Everything listed here is AI-generated.', () => _feedList('moments'));if (p === '/pastes') return _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: `/p/${x.slug}`, name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));// Detail pages@@@ -511,7 +524,7 @@ function _cacheSet(key, html) {_cache.set(key, { html, at: Date.now() });}--const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;++const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;async function middleware(req, res, next) {if (req.method !== 'GET') return next();@@@ -553,7 +566,7 @@ function _urlTag(loc, lastmod, changefreq, priority) {async function buildSitemap() {const urls = [];const seenChannels = new Set();-- const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];++ const statics = [['/', 'daily', '1.0'], ['/content', 'hourly', '0.9'], ['/moments', 'hourly', '0.7'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.try {for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {@@@ -13,7 +13,8 @@* fallback):** / home-- * /vods /clips /pastes /updates /documentation exact path++ * /content /moments the feeds (people's work, AI's)++ * /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)* /settings /admin /themes exact path (the client redirects them)* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves* /@user /@user/<slot> a user that exists@@@ -39,7 +40,7 @@ const OK = 200;const NOT_FOUND = 404;// Pages that exist only at their exact path.--const EXACT = new Set(['vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes']);++const EXACT = new Set(['content', 'moments', 'vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes']);// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);@@@ -57,6 +57,44 @@ function publicUserProfile(u) {'openvibe_coins_balance', 'last_seen', 'is_owner']);}++// ── Feed items (server/content/feed.js) ───────────────────────────────────────────────────────++// Built from Media, Community and Live rows that carry far more than a card needs (file paths,++// storage keys, owner subjects, AI state). Copied through an allow-list, so a field a source adds++// later never reaches the public feed by accident.++const FEED_ITEM_FIELDS = {++ kind: 'string', id: 'id', href: 'string', title: 'string', created_at: 'string',++ views: 'number', likes: 'number', duration_seconds: 'number',++ thumbnail_url: 'string', preview_url: 'string', image_url: 'string',++ excerpt: 'string', paste_type: 'string', language: 'string', nsfw: 'boolean',++ ai: 'boolean', ai_label: 'string', moment_href: 'string', grade: 'string', stream_title: 'string',++};++const FEED_PERSON_FIELDS = ['username', 'display_name', 'avatar_url', 'profile_color', 'href'];++++function _feedPerson(p) {++ if (!p || typeof p !== 'object' || !p.username) return null;++ const out = {};++ for (const k of FEED_PERSON_FIELDS) out[k] = p[k] == null ? null : String(p[k]);++ return out;++}++++/** One card of the Content or Moments feed as anyone may see it. */++function publicFeedItem(item) {++ if (!item || typeof item !== 'object') return null;++ const out = { key: `${item.kind}:${item.id}` };++ for (const [k, type] of Object.entries(FEED_ITEM_FIELDS)) {++ const v = item[k];++ if (v === undefined) continue;++ if (v === null) { out[k] = null; continue; }++ if (type === 'id') out[k] = typeof v === 'number' ? v : String(v);++ else if (type === 'number') out[k] = Number.isFinite(Number(v)) ? Number(v) : 0;++ else if (type === 'boolean') out[k] = !!v;++ else out[k] = String(v);++ }++ out.channel = _feedPerson(item.channel);++ if (item.by !== undefined) out.by = _feedPerson(item.by);++ return out;++}++/*** Defence in depth for JSON that is about to be sent to an unprivileged caller: walks the value and* reports any ALWAYS_SECRET key with a non-empty value. Used by tests over real route responses.@@@ -72,4 +110,4 @@ function findSecrets(value, path = '$', out = []) {return out;}--module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, findSecrets };++module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, publicFeedItem, findSecrets };@@@ -24,7 +24,9 @@ const ROUTES_ONLY = process.argv.includes('--routes-only');const SETTLE = Number(process.env.SETTLE || 3500);const ROUTES = [-- ['/', 'page-home'], ['/vods', 'page-vods'], ['/clips', 'page-clips'], ['/pastes', 'page-pastes'],++ ['/', 'page-home'], ['/content', 'page-content'], ['/moments', 'page-moments'],++ // The old list pages are the Content feed with a filter.++ ['/vods', 'page-content'], ['/clips', 'page-content'], ['/pastes', 'page-content'],['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],['/updates', 'page-updates'], ['/dashboard', null], [process.env.CHANNEL || '/@admin', 'page-channel'],// Answered 404 by the server (server/web/page-status.js) with the same shell.@@@ -145,14 +147,14 @@ const pageState = `(() => ({await cdp.send('Page.navigate', { url: BASE + '/' });await sleep(SETTLE);const go = async (p) => { await cdp.evaluate(`navigate(${JSON.stringify(p)})`); await sleep(1500); return cdp.evaluate(pageState); };-- const a = await go('/vods');++ const a = await go('/content');const b = await go('/chat');const c = await go('/broadcast');await cdp.evaluate('history.back()'); await sleep(1500);const back = await cdp.evaluate(pageState);await cdp.evaluate('history.forward()'); await sleep(1500);const fwd = await cdp.evaluate(pageState);-- const ok = a.active === 'page-vods' && b.active === 'page-chat' && c.active === 'page-broadcast' && back.active === 'page-chat' && fwd.active === 'page-broadcast';++ const ok = a.active === 'page-content' && b.active === 'page-chat' && c.active === 'page-broadcast' && back.active === 'page-chat' && fwd.active === 'page-broadcast';if (ok) pass('SPA navigate + back/forward land on the right page'); else fail(`navigation: ${[a, b, c, back, fwd].map(s => s.active).join(' → ')}`);const seen = new Map();for (const u of scripts) { const k = u.split('?')[0]; seen.set(k, (seen.get(k) || 0) + 1); }@@@ -199,7 +201,7 @@ const pageState = `(() => ({await cdp.send('Page.navigate', { url: BASE + '/' });await sleep(SETTLE);const ch = process.env.CHANNEL || '/@admin';-- const lap = [ch, '/chat', '/dashboard', '/', '/broadcast', ch, '/', '/vods', '/'];++ const lap = [ch, '/chat', '/dashboard', '/', '/broadcast', ch, '/', '/content', '/moments', '/'];const probes = [];for (let i = 0; i < 3; i++) {for (const p of lap) { await cdp.evaluate(`navigate(${JSON.stringify(p)})`); await sleep(1200); }nnew file mode 100644@@@ -0,0 +1,375 @@++/**++ * The Content (people's work) and Moments (AI's) feeds: GET /api/content/feed and /api/content/moments++ * (server/content/feed.js), against in-process stand-ins for OpenVibe.Media and OpenVibe.Community.++ *++ * Checks: who made what is decided by the stores (Media's auto_generated, Community's origin; AI++ * recaps are Live's own), and each feed shows only its kind even when an upstream ignores the++ * filter; private, unlisted, burn-after-read and banned accounts' items never appear, and nothing++ * asks for hidden items; cursor paging walks the merged order exactly (New and Top, with a fixed++ * window); a slow upstream is left out within the deadline and asked again on the next page; pages++ * are cached; the answer's shape is the allow-listed one; and the auto-clip flag sync marks only++ * the AI's own clips in Media.++ *++ * node test/content-feed.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++++const tmp = path.join(os.tmpdir(), `ov-content-feed-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++process.env.PASTES_AUTHORITY = 'community';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++const addUser = (id, username, banned = 0) => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_banned, avatar_url, created_at)++ VALUES (?, ?, ?, ?, 'x', 'streamer', ?, ?, '2025-01-01 00:00:00')`).run(id, username, username.toUpperCase(), `${username}@x`, banned, `https://media.test/a/${username}.png`);++addUser(3, 'alice');++addUser(4, 'bob');++addUser(9, 'banned', 1);++db.ensureChannel(3);++const chan = db.getChannelByUserId(3);++const streamId = Number(db.createStream({ user_id: 3, channel_id: chan.id, title: 'Night stream', protocol: 'rtmp' }).lastInsertRowid);++db.endStream(streamId);++++const MIN = 60_000;++const at = (minsAgo) => new Date(Date.now() - minsAgo * MIN).toISOString().replace('T', ' ').slice(0, 19);++++// ── OpenVibe.Media stand-in ──++const media = require('../server/media-client');++const asked = [];++let mediaMode = 'ok'; // 'ok' | 'stall' | 'down' | 'ignore-filters'++let VODS = [], CLIPS = [];++const vod = (id, mins, extra = {}) => ({ id, user_id: 3, title: `VOD ${id}`, visibility: 'public', is_public: true, status: 'ready', duration_seconds: 600, view_count: 0, created_at: at(mins), thumbnail_url: `/t/vod-${id}.jpg`, file_path: `vod-${id}.webm`, storage_key: 'secret/key', ...extra });++const clip = (id, mins, extra = {}) => ({ id, user_id: 4, channel_user_id: 3, vod_id: 1, title: `Clip ${id}`, visibility: 'public', is_public: true, status: 'ready', auto_generated: false, duration_seconds: 20, view_count: 0, created_at: at(mins), thumbnail_url: `/t/clip-${id}.jpg`, playback_url: `https://media.test/c/${id}`, file_path: `clip-${id}.webm`, ...extra });++VODS = [++ vod(1, 10, { view_count: 50 }), vod(2, 30, { view_count: 5 }), vod(3, 50, { view_count: 500 }), vod(4, 70),++ vod(5, 90, { visibility: 'private', is_public: false, title: 'SECRET VOD' }),++ vod(6, 95, { visibility: 'unlisted', is_public: false, title: 'UNLISTED VOD' }),++ vod(7, 100, { user_id: 9, title: 'Banned streamer VOD' }),++ vod(8, 60 * 24 * 20, { view_count: 9000, title: 'Old popular VOD' }), // 20 days ago++];++CLIPS = [++ clip(11, 5, { view_count: 30 }), clip(12, 25), clip(13, 45, { view_count: 300 }),++ clip(14, 15, { auto_generated: true, user_id: 3, title: 'AI clip A', view_count: 7 }),++ clip(15, 35, { auto_generated: true, user_id: 3, title: 'AI clip B' }),++ clip(16, 40, { visibility: 'private', is_public: false, title: 'SECRET CLIP' }),++ clip(17, 42, { status: 'failed', title: 'Failed cut' }),++ clip(18, 44, { auto_generated: true, user_id: 3, visibility: 'private', is_public: false, title: 'SECRET AI CLIP' }),++];++const since = (row, q) => !q.since || new Date(row.created_at.replace(' ', 'T') + 'Z') >= new Date(q.since.replace(' ', 'T') + 'Z');++const order = (rows, q) => rows.sort((a, b) => (q.order === 'views' ? (b.view_count - a.view_count) : 0) || (b.created_at < a.created_at ? -1 : b.created_at > a.created_at ? 1 : 0));++const gate = async () => {++ if (mediaMode === 'stall') await new Promise((r) => setTimeout(r, 6000));++ if (mediaMode === 'down') throw new media.MediaApiError('Media unreachable', 0);++};++media.listVods = async (q = {}) => {++ asked.push({ kind: 'vods', ...q });++ await gate();++ const leaky = mediaMode === 'ignore-filters';++ const rows = order(VODS.filter((v) => leaky || ((q.include_private || v.is_public) && since(v, q))), q);++ return { vods: rows.slice(q.offset || 0, (q.offset || 0) + (q.limit || 50)), total: rows.length };++};++media.listClips = async (q = {}) => {++ asked.push({ kind: 'clips', ...q });++ await gate();++ const leaky = mediaMode === 'ignore-filters';++ const rows = order(CLIPS.filter((c) => leaky || ((q.include_private || c.is_public) && since(c, q)++ && (q.auto_generated == null || Number(c.auto_generated) === Number(q.auto_generated))++ && (q.status !== 'ready' || c.status === 'ready'))), q);++ return { clips: rows.slice(q.offset || 0, (q.offset || 0) + (q.limit || 50)), total: rows.length };++};++++// ── OpenVibe.Community stand-in ──++const pastesClient = require('../server/pastes-client');++const paste = (slug, mins, extra = {}) => ({ id: slug.length, slug, origin: 'user', type: 'paste', title: `Paste ${slug}`, content: `print("${slug}")`, language: 'python', visibility: 'public', burn_after_read: false, views: 0, likes: 0, username: 'bob', display_name: 'Bob', avatar_url: 'https://media.test/a/bob.png', owner_subject: 'usr_bob', created_at: at(mins), ...extra });++const PASTES = [++ paste('p-one', 8, { likes: 20 }), paste('p-two', 28), paste('p-three', 48, { views: 10 }),++ paste('p-private', 12, { visibility: 'private', title: 'SECRET PASTE' }),++ paste('p-burn', 14, { burn_after_read: true, title: 'BURN PASTE' }),++ paste('p-banned', 16, { username: 'banned', title: 'Banned author paste' }),++ paste('ai-shot', 20, { origin: 'ai', type: 'screenshot', title: 'AI shot', content: 'long text', username: null, display_name: 'OpenVibe AI', owner_subject: null, stream_id: streamId, screenshot_url: 'https://media.test/f/s.jpg', ai_summary: 'Everyone laughed', metadata: JSON.stringify({ ai_moment: true, vod_link: '/vod/1?t=42', username: 'alice' }) }),++ paste('ai-live', 38, { origin: 'ai', type: 'screenshot', title: 'Caught live', username: null, owner_subject: null, stream_id: streamId, screenshot_url: 'https://media.test/f/l.jpg', metadata: JSON.stringify({ ai_moment: true, live: true, vod_link: 'javascript:alert(1)' }) }),++];++let communityMode = 'ok';++const communityCalls = [];++pastesClient.request = async (method, p, { query = {}, act = {} } = {}) => {++ communityCalls.push({ method, path: p, query, act });++ if (communityMode === 'down') throw Object.assign(new Error('down'), { status: 502 });++ if (communityMode === 'lying-total') return { pastes: [], total: 999 };++ const leaky = communityMode === 'ignore-filters';++ const rows = PASTES.filter((x) => leaky || (x.visibility === 'public' && !x.burn_after_read && (!query.origin || x.origin === query.origin) && since(x, query)))++ .sort((a, b) => (query.sort === 'top' ? (b.views + 5 * b.likes) - (a.views + 5 * a.likes) : 0) || (b.created_at < a.created_at ? -1 : b.created_at > a.created_at ? 1 : 0));++ const off = Number(query.offset) || 0;++ return { pastes: rows.slice(off, off + (Number(query.limit) || 50)), total: rows.length };++};++++// ── Live's own AI recaps ──++require('../server/recap/recap').ensureTable();++const recapJson = (headline, peak) => JSON.stringify({ stream: { id: streamId, title: 'Night stream', duration_seconds: 3600, peak_viewers: peak }, write: { headline, summary: 'A good night.', grade: 'A' }, vod: { id: 1, thumbnail_url: 'https://media.test/t/vod-1.jpg' } });++raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai, created_at) VALUES (?, 3, ?, 1, ?)').run(streamId, recapJson('What a night', 12), at(33));++raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai, created_at) VALUES (?, 3, ?, 0, ?)').run(streamId + 1000, recapJson('Template recap', 99), at(3));++++const feed = require('../server/content/feed');++const { findSecrets } = require('../server/web/serializers');++const express = require('express');++const app = express();++app.use('/api/content', require('../server/content/routes'));++const server = http.createServer(app).listen(0);++function get(p) {++ return new Promise((resolve, reject) => {++ http.get({ port: server.address().port, path: p }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, headers: res.headers, json }); });++ }).on('error', reject);++ });++}++async function walk(base, limit) {++ const keys = [];++ let cursor = null, pages = 0;++ do {++ const r = await get(`${base}${base.includes('?') ? '&' : '?'}limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));++ keys.push(...r.json.items.map((i) => i.key));++ cursor = r.json.next;++ assert.ok(++pages < 50, 'paging never ends');++ } while (cursor);++ return keys;++}++++let failures = 0;++async function check(name, fn) {++ feed._reset();++ asked.length = 0; communityCalls.length = 0;++ mediaMode = 'ok'; communityMode = 'ok';++ try { await fn(); quiet(` ok - ${name}`); }++ catch (e) { failures++; quiet(` FAIL - ${name}\n ${e.stack.split('\n').slice(0, 4).join('\n ')}`); }++}++++server.on('listening', async () => {++ await check('Content lists people\'s work only; the stores are asked for it by name', async () => {++ const r = await get('/api/content/feed?limit=30');++ assert.strictEqual(r.status, 200);++ const keys = r.json.items.map((i) => i.key);++ assert.deepStrictEqual(keys, ['clip:11', 'paste:p-one', 'vod:1', 'clip:12', 'paste:p-two', 'vod:2', 'clip:13', 'paste:p-three', 'vod:3', 'vod:4', 'vod:8']);++ assert.ok(r.json.items.every((i) => i.ai === false), 'nothing on Content is marked AI');++ const clipAsks = asked.filter((a) => a.kind === 'clips');++ assert.ok(clipAsks.length && clipAsks.every((a) => a.auto_generated === 0 && a.status === 'ready'), 'Media is asked for people\'s playable clips');++ assert.ok(communityCalls.every((c) => c.query.origin === 'user' && String(c.query.pinned_first) === '0'), 'Community is asked for people\'s pastes, pinned in date order');++ assert.deepStrictEqual(r.json.sources, { vods: 'ok', clips: 'ok', pastes: 'ok' });++ assert.strictEqual(r.json.partial, false);++ assert.strictEqual(r.json.next, null, 'everything fit on one page');++ });++++ await check('Moments lists the AI\'s work only: auto-clips, AI pastes and AI-written recaps, each labelled', async () => {++ const r = await get('/api/content/moments?limit=30');++ assert.strictEqual(r.status, 200);++ const byKey = Object.fromEntries(r.json.items.map((i) => [i.key, i]));++ assert.deepStrictEqual(r.json.items.map((i) => i.key), ['clip:14', 'paste:ai-shot', 'recap:' + streamId, 'clip:15', 'paste:ai-live']);++ assert.ok(r.json.items.every((i) => i.ai === true && i.ai_label), 'every Moments card says it is AI');++ assert.strictEqual(byKey['paste:ai-shot'].ai_label, 'AI moment');++ assert.strictEqual(byKey['paste:ai-shot'].moment_href, '/vod/1?t=42');++ assert.strictEqual(byKey['paste:ai-shot'].channel.username, 'alice', 'an AI paste belongs to the stream it came from');++ assert.strictEqual(byKey['paste:ai-live'].ai_label, 'Caught live');++ assert.strictEqual(byKey['paste:ai-live'].moment_href, null, 'only a /vod/<id>?t= link is passed on');++ assert.strictEqual(byKey['clip:14'].ai_label, 'Auto-clip');++ assert.strictEqual(byKey['recap:' + streamId].href, `/recap/${streamId}`);++ assert.ok(!r.json.items.some((i) => /Template recap/.test(i.title)), 'a template recap is not AI content');++ assert.ok(asked.filter((a) => a.kind === 'clips').every((a) => a.auto_generated === 1));++ assert.ok(communityCalls.every((c) => c.query.origin === 'ai'));++ assert.ok(!asked.some((a) => a.kind === 'vods'), 'VODs are never AI content');++ });++++ await check('an upstream that ignores the filters still cannot mix the feeds or leak hidden items', async () => {++ mediaMode = 'ignore-filters'; communityMode = 'ignore-filters';++ const content = await get('/api/content/feed?limit=30');++ const moments = await get('/api/content/moments?limit=30');++ const all = [...content.json.items, ...moments.json.items];++ const text = JSON.stringify(all);++ for (const secret of ['SECRET', 'UNLISTED', 'BURN PASTE', 'Banned', 'Failed cut']) assert.ok(!text.includes(secret), `${secret} leaked`);++ assert.ok(content.json.items.every((i) => !i.ai) && moments.json.items.every((i) => i.ai));++ assert.ok(!content.json.items.some((i) => i.key === 'clip:14' || i.key === 'paste:ai-shot'));++ assert.ok(!moments.json.items.some((i) => i.key === 'clip:11' || i.key === 'paste:p-one'));++ });++++ await check('privacy: nothing asks for hidden items; answers carry only the public card fields', async () => {++ await get('/api/content/feed?limit=30');++ await get('/api/content/moments?limit=30');++ assert.ok(asked.every((a) => !a.include_private && !a.include_unlisted), 'no include_private/include_unlisted upstream');++ assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && c.act.liveUserId == null), 'Community is read as nobody in particular');++ const r = await get('/api/content/feed?limit=30');++ assert.deepStrictEqual(findSecrets(r.json), []);++ const allowed = new Set(['key', 'kind', 'id', 'href', 'title', 'created_at', 'views', 'likes', 'duration_seconds', 'thumbnail_url', 'preview_url', 'image_url', 'excerpt', 'paste_type', 'language', 'nsfw', 'ai', 'ai_label', 'moment_href', 'grade', 'stream_title', 'channel', 'by']);++ for (const it of r.json.items) for (const k of Object.keys(it)) assert.ok(allowed.has(k), `unexpected field ${k} on ${it.key}`);++ const v1 = r.json.items.find((i) => i.key === 'vod:1');++ assert.strictEqual(v1.thumbnail_url, `${media.MEDIA_PUBLIC_URL}/t/vod-1.jpg`, 'Media paths become absolute');++ assert.deepStrictEqual(v1.channel, { username: 'alice', display_name: 'ALICE', avatar_url: 'https://media.test/a/alice.png', profile_color: '#8b5cf6', href: '/@alice' });++ assert.ok(!('file_path' in v1) && !('storage_key' in v1));++ const c11 = r.json.items.find((i) => i.key === 'clip:11');++ assert.strictEqual(c11.channel.username, 'alice', 'a clip belongs to the clipped channel');++ assert.strictEqual(c11.by.username, 'bob', 'and names who clipped it');++ assert.strictEqual(c11.preview_url, 'https://media.test/c/11');++ assert.match(r.headers['cache-control'], /public, max-age=\d+/);++ });++++ await check('cursor paging walks the merged New order exactly, every page size', async () => {++ const full = (await get('/api/content/feed?limit=30')).json.items.map((i) => i.key);++ for (const size of [1, 2, 3, 5]) {++ feed._reset();++ assert.deepStrictEqual(await walk('/api/content/feed', size), full, `limit=${size}`);++ }++ const moments = (await get('/api/content/moments?limit=30')).json.items.map((i) => i.key);++ assert.deepStrictEqual(await walk('/api/content/moments', 2), moments);++ });++++ await check('Top ranks by score inside a fixed window, and pages the same way', async () => {++ const r = await get('/api/content/feed?sort=top&window=week&limit=30');++ assert.strictEqual(r.status, 200);++ const keys = r.json.items.map((i) => i.key);++ assert.deepStrictEqual(keys.slice(0, 3), ['vod:3', 'clip:13', 'paste:p-one'], 'views, a paste like worth five');++ assert.ok(!keys.includes('vod:8'), 'the 20-day-old VOD is outside the week');++ const sinces = new Set([...asked.map((a) => a.since), ...communityCalls.map((c) => c.query.since)]);++ assert.strictEqual(sinces.size, 1, 'every source gets the same window');++ const s = [...sinces][0];++ assert.match(s, /^\d{4}-\d{2}-\d{2} \d{2}:00:00$/, 'the window starts on the hour');++ assert.ok(Math.abs(Date.parse(s.replace(' ', 'T') + 'Z') - (Date.now() - 7 * 86400_000)) < 3600_000 + 5000);++ feed._reset();++ assert.deepStrictEqual(await walk('/api/content/feed?sort=top&window=week', 2), keys);++ const allTime = await get('/api/content/feed?sort=top&window=all&limit=30');++ assert.strictEqual(allTime.json.items[0].key, 'vod:8', 'all time has no window');++ });++++ await check('the window stays put across pages (it travels in the cursor)', async () => {++ const first = await get('/api/content/feed?sort=top&window=month&limit=2');++ const c = JSON.parse(Buffer.from(first.json.next, 'base64url').toString());++ c.s = '2026-01-01 00:00:00';++ const forged = Buffer.from(JSON.stringify(c)).toString('base64url');++ asked.length = 0;++ await get(`/api/content/feed?sort=top&window=month&limit=2&cursor=${forged}`);++ assert.ok(asked.length && asked.every((a) => a.since === '2026-01-01 00:00:00'), 'the next page asks for the same window');++ });++++ await check('filters read only their sources', async () => {++ const vods = await get('/api/content/feed?type=vods&limit=30');++ assert.ok(vods.json.items.every((i) => i.kind === 'vod'));++ assert.deepStrictEqual(Object.keys(vods.json.sources), ['vods']);++ assert.strictEqual(communityCalls.length, 0);++ const shots = await get('/api/content/moments?type=shots');++ assert.deepStrictEqual(shots.json.items.map((i) => i.key), ['paste:ai-shot', 'paste:ai-live']);++ const recaps = await get('/api/content/moments?type=recaps');++ assert.deepStrictEqual(recaps.json.items.map((i) => i.kind), ['recap']);++ });++++ await check('bad requests are 400: unknown type/sort/window, a cursor from another feed or damaged', async () => {++ assert.strictEqual((await get('/api/content/feed?type=recaps')).status, 400);++ assert.strictEqual((await get('/api/content/feed?sort=hot')).status, 400);++ assert.strictEqual((await get('/api/content/feed?sort=top&window=decade')).status, 400);++ const other = (await get('/api/content/moments?limit=1')).json.next;++ assert.ok(other);++ assert.strictEqual((await get(`/api/content/feed?cursor=${other}`)).status, 400);++ assert.strictEqual((await get('/api/content/feed?cursor=%%%nope')).status, 400);++ });++++ await check('a slow Media is left out within the deadline and asked again on the next page', async () => {++ mediaMode = 'stall';++ const t0 = Date.now();++ const r = await get('/api/content/feed?limit=3');++ const took = Date.now() - t0;++ assert.ok(took < feed.DEADLINE_MS + 1000, `took ${took}ms`);++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(r.json.sources, { vods: 'timeout', clips: 'timeout', pastes: 'ok' });++ assert.strictEqual(r.json.partial, true);++ assert.deepStrictEqual(r.json.items.map((i) => i.key), ['paste:p-one', 'paste:p-two', 'paste:p-three']);++ assert.strictEqual(r.headers['cache-control'], 'no-store', 'a partial page is not cached downstream');++ const c = JSON.parse(Buffer.from(r.json.next, 'base64url').toString());++ assert.strictEqual(c.o.vods, 0, 'the VOD offset did not move');++ mediaMode = 'ok';++ feed._reset();++ const next = await get(`/api/content/feed?limit=3&cursor=${r.json.next}`);++ assert.ok(next.json.items.some((i) => i.kind === 'vod'), 'the next page has the VODs');++ assert.strictEqual(next.json.partial, false);++ });++++ await check('Media down: the other sources still answer; everything down: an empty partial page, not an error', async () => {++ mediaMode = 'down';++ const r = await get('/api/content/feed?limit=30');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json.sources.vods, 'error');++ assert.ok(r.json.items.length && r.json.items.every((i) => i.kind === 'paste'));++ communityMode = 'down';++ feed._reset();++ const none = await get('/api/content/feed?limit=30');++ assert.strictEqual(none.status, 200);++ assert.deepStrictEqual(none.json.items, []);++ assert.strictEqual(none.json.partial, true);++ assert.ok(none.json.next, 'the client may try again');++ });++++ await check('pastes are listed only when Community owns them', async () => {++ process.env.PASTES_AUTHORITY = '';++ try {++ const r = await get('/api/content/feed?limit=30');++ assert.strictEqual(r.json.sources.pastes, 'unavailable');++ assert.strictEqual(r.json.partial, false, 'not a failure to retry');++ assert.ok(!r.json.items.some((i) => i.kind === 'paste'));++ assert.strictEqual(communityCalls.length, 0);++ } finally { process.env.PASTES_AUTHORITY = 'community'; }++ });++++ await check('an upstream that answers no rows but claims more cannot keep the feed paging forever', async () => {++ communityMode = 'lying-total';++ const r = await get('/api/content/feed?type=pastes&limit=5');++ assert.deepStrictEqual(r.json.items, []);++ assert.strictEqual(r.json.next, null);++ });++++ await check('source pages are cached and shared by concurrent requests', async () => {++ await Promise.all([get('/api/content/feed?limit=4'), get('/api/content/feed?limit=4'), get('/api/content/feed?limit=4')]);++ const n = asked.length + communityCalls.length;++ assert.strictEqual(n, 3, `one call per source, got ${n}`);++ await get('/api/content/feed?limit=4');++ assert.strictEqual(asked.length + communityCalls.length, 3, 'a repeat within the TTL is served from the cache');++ });++++ await check('the auto-clip flag sync marks only the AI\'s own clips in Media', async () => {++ const job = require('../server/ai/auto-clip-job');++ db.setState('auto_clip_log', JSON.stringify([++ { clip_id: 101, stream_id: streamId, ts: Date.now() },++ { clip_id: 102, stream_id: streamId, ts: Date.now(), dedup: true }, // someone else's clip handed back++ { clip_id: 103, stream_id: streamId, ts: Date.now() }, // a viewer's clip under the AI's log entry++ { clip_id: 104, stream_id: streamId, ts: Date.now() }, // deleted since++ { clip_id: 105, stream_id: streamId, ts: Date.now() }, // already marked++ ]));++ const media2 = { 101: { id: 101, user_id: 3, channel_user_id: 3, auto_generated: false }, 102: { id: 102, user_id: 4, channel_user_id: 3, auto_generated: false }, 103: { id: 103, user_id: 4, channel_user_id: 3, auto_generated: false }, 105: { id: 105, user_id: 3, channel_user_id: 3, auto_generated: true } };++ const puts = [];++ media.getClip = async (id) => { if (!media2[id]) throw new media.MediaApiError('Clip not found', 404); return media2[id]; };++ let upgraded = false;++ media.updateClip = async (id, body) => { puts.push({ id: Number(id), body }); const c = media2[id]; if (upgraded) c.auto_generated = body.auto_generated; return { clip: c }; };++ let out = await job.syncAutoClipFlags();++ assert.strictEqual(out.marked, 0, 'an old Media that drops the flag is not taken as done');++ assert.ok(out.pending > 0);++ upgraded = true;++ out = await job.syncAutoClipFlags();++ assert.deepStrictEqual(puts.map((p) => p.id), [101, 101], 'only 101 is written (once per run until it sticks)');++ assert.ok(!puts.some((p) => p.id === 102 || p.id === 103), 'a person\'s clip is never relabelled');++ assert.deepStrictEqual({ marked: out.marked, gone: out.gone, skipped: out.skipped, pending: out.pending }, { marked: 2, gone: 1, skipped: 1, pending: 0 });++ puts.length = 0;++ out = await job.syncAutoClipFlags();++ assert.deepStrictEqual(puts, [], 'a second run has nothing left to do');++ });++++ server.close();++ for (const f of [tmp, `${tmp}-wal`, `${tmp}-shm`]) { try { fs.unlinkSync(f); } catch { /* */ } }++ quiet(failures ? `\n${failures} check(s) failed` : '\ncontent feed: all checks passed');++ process.exit(failures ? 1 : 0);++});@@@ -135,7 +135,7 @@ server.listen(0, '127.0.0.1', async () => {console.log('Page status (soft 404s)');await check('every real route answers 200', () => expectAll([-- '/', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes',++ '/', '/content', '/content/', '/moments', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes','/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby','/arena', '/arena/beef/4', '/arena/alice','/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',@@@ -144,7 +144,7 @@ server.listen(0, '127.0.0.1', async () => {], 200));await check('unknown paths answer 404', () => expectAll([-- '/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1',++ '/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1', '/content/vods', '/moments/1','/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/nobody', '/nobody/main', '/alice/main/extra', '/alice/bad!slot','/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1','/p/missing', '/p/bad!slug', '/p/abc123/extra',
@@ -33,7 +33,7 @@ The SPA is plain JavaScript with no build step. What loads is decided per route:
@@ -33,7 +33,7 @@ The SPA is plain JavaScript with no build step. What loads is decided per route:
public/index.html shell: navbar, home page, empty <section id="page-*"> shells, core scripts
public/index.html shell: navbar, home page, empty <section id="page-*"> shells, core scripts
public/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetch
public/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetch
routes: URL pattern → features
routes: URL pattern → features
-public/fragments/*.html page markup for channel, VOD/clip players, dashboard, broadcast, chat, documentation
+public/fragments/*.html page markup for channel, VOD/clip players, dashboard, broadcast, chat, documentation, content, moments
public/css/features/*.css rules used only by one feature (split out of style.css / broadcast.css / i18n-star.css)
public/css/features/*.css rules used only by one feature (split out of style.css / broadcast.css / i18n-star.css)
public/js/ov-loader.js ov.load(feature), ov.route(path), ov.prefetch(), route generations, scopes, stubs
public/js/ov-loader.js ov.load(feature), ov.route(path), ov.prefetch(), route generations, scopes, stubs
public/js/app.js core: router, auth, API helper, shared renderers, connection pill
public/js/app.js core: router, auth, API helper, shared renderers, connection pill
@@ -72,6 +72,19 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
@@ -72,6 +72,19 @@ public/js/app-*.js route code split out of app.js: home, channel, media, c
- **Featured live stream.** `public/js/home-featured.js` (feature `featured`) loads only when someone is
- **Featured live stream.** `public/js/home-featured.js` (feature `featured`) loads only when someone is
live: `GET /api/home/featured` picks and rotates the stream; playback is FLV (RTMP) or the JSMPEG relay,
live: `GET /api/home/featured` picks and rotates the stream; playback is FLV (RTMP) or the JSMPEG relay,
live frames for WebRTC. Off switch remembered in `localStorage` (`ov_home_featured_off`).
live frames for WebRTC. Off switch remembered in `localStorage` (`ov_home_featured_off`).
+- **Content and Moments.** `/content` is what people made (VODs, clips people cut, people's pastes);
+ `/moments` is what the AI made (auto-clips, AI moment pastes, AI-written after-show recaps), every
+ card marked AI. `/vods`, `/clips` and `/pastes` are `/content` with that filter (the address becomes
+ `/content?type=…`). One lazy feature (`public/js/content-feed.js`, fragments `content`/`moments`):
+ a masonry of cards (one column on phones, two or three wider), infinite scroll by cursor, clips
+ that play muted one at a time when mostly in view. The server merges the sources
+ (`server/content/feed.js`, `GET /api/content/feed` and `/api/content/moments`): who made an item is
+ the store's answer, never a guess here (Media's clip `auto_generated`, Community's paste `origin`,
+ `stream_recaps.ai`), each source filters server-side, public items only. The cursor carries each
+ source's offset (and a Top window's start); source pages are cached 30 s (Top 2 min) and a page
+ waits at most 4 s for Media and Community, leaving a slow one out and asking again on the next
+ page. Auto-clips made before Media kept the flag are marked from Live's `auto_clip_log`
+ (`syncAutoClipFlags` in `server/ai/auto-clip-job.js`).
- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh
- **Voice channels.** `public/js/call.js` + `voice-channels.js` (feature `voice`, chat route): full-mesh
WebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existing
WebRTC, signalling over `/ws/call` (`server/streaming/call-server.js`). The newcomer offers, existing
members answer; the server pushes the channel list to every chat socket (`voice-channels` message);
members answer; the server pushes the channel list to every chat socket (`voice-channels` message);
new file mode 100644
new file mode 100644
@@ -0,0 +1,458 @@
@@ -0,0 +1,458 @@
+/* The Content and Moments feeds (public/js/content-feed.js, fragments content.html and moments.html).
+ One column of large cards on a phone, two or three masonry columns wider up. Theme tokens only. */
+
+/* `hidden` must win over the display of .btn, .cf-seg and the pills. */
+.cf-wrap [hidden] { display: none !important; }
+.cf-wrap {
+ --cf-gap: 16px;
+ --cf-radius: 14px;
+ --cf-ai: #a78bfa; /* AI text on dark surfaces */
+ --cf-ai-2: #22d3ee;
+ --cf-ai-fill: linear-gradient(135deg, #6d28d9, #0e7490); /* behind white text: 4.5:1 or better */
+ max-width: 1240px;
+ margin: 0 auto;
+ padding: 20px 16px 48px;
+}
+
+/* ── header ─────────────────────────────────────────────────────────────── */
+.cf-head {
+ display: flex;
+ align-items: flex-end;
+ justify-content: space-between;
+ gap: 12px 20px;
+ flex-wrap: wrap;
+ margin-bottom: 14px;
+}
+.cf-head-text { min-width: 0; flex: 1 1 320px; }
+.cf-h1 {
+ margin: 0;
+ font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem);
+ font-weight: 800;
+ letter-spacing: -0.02em;
+ line-height: 1.15;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.cf-sub { margin: 6px 0 0; color: var(--text-secondary); font-size: 0.92rem; max-width: 60ch; }
+.cf-head-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.cf-crosslink {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ padding: 7px 12px;
+ border-radius: var(--radius-pill, 999px);
+ border: 1px solid var(--border-light);
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ font-weight: 600;
+ text-decoration: none;
+ transition: color var(--motion-fast, 120ms), border-color var(--motion-fast, 120ms), background var(--motion-fast, 120ms);
+}
+.cf-crosslink:hover { color: var(--text-primary); border-color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
+.cf-wrap--ai .cf-crosslink:hover { border-color: var(--cf-ai); background: color-mix(in srgb, var(--cf-ai) 10%, transparent); }
+.cf-ai-mark {
+ display: inline-grid;
+ place-items: center;
+ width: 1.5em;
+ height: 1.5em;
+ border-radius: 10px;
+ font-size: 0.75em;
+ color: #fff;
+ background: linear-gradient(135deg, var(--cf-ai), var(--cf-ai-2));
+ box-shadow: 0 4px 16px color-mix(in srgb, var(--cf-ai) 35%, transparent);
+}
+
+/* ── filter bar ─────────────────────────────────────────────────────────── */
+.cf-bar {
+ position: sticky;
+ top: var(--navbar-h, 56px);
+ z-index: 5;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 10px 16px;
+ padding: 10px 0;
+ margin-bottom: 8px;
+ background: color-mix(in srgb, var(--bg-primary) 88%, transparent);
+ backdrop-filter: blur(10px);
+ -webkit-backdrop-filter: blur(10px);
+}
+.cf-chips { display: flex; flex-wrap: wrap; gap: 8px; }
+.cf-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 36px;
+ padding: 0 14px;
+ border-radius: var(--radius-pill, 999px);
+ border: 1px solid var(--border-light);
+ background: var(--bg-card);
+ color: var(--text-secondary);
+ font: inherit;
+ font-size: 0.88rem;
+ font-weight: 600;
+ cursor: pointer;
+ transition: background var(--motion-fast, 120ms), color var(--motion-fast, 120ms), border-color var(--motion-fast, 120ms);
+}
+.cf-chip:hover { color: var(--text-primary); border-color: color-mix(in srgb, var(--accent) 60%, var(--border-light)); }
+.cf-chip[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--on-accent, #fff); }
+.cf-wrap--ai .cf-chip[aria-pressed="true"] { background: var(--cf-ai-fill); border-color: transparent; color: #fff; }
+.cf-chip:focus-visible, .cf-seg button:focus-visible { outline: none; box-shadow: var(--focus-ring); }
+
+.cf-sorts { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
+.cf-seg {
+ display: inline-flex;
+ padding: 3px;
+ border-radius: var(--radius-pill, 999px);
+ background: var(--bg-secondary);
+ border: 1px solid var(--border);
+}
+.cf-seg button {
+ min-height: 30px;
+ padding: 0 12px;
+ border: 0;
+ border-radius: var(--radius-pill, 999px);
+ background: transparent;
+ color: var(--text-secondary);
+ font: inherit;
+ font-size: 0.82rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+.cf-seg button:hover { color: var(--text-primary); }
+.cf-seg button[aria-pressed="true"] { background: var(--bg-hover); color: var(--text-primary); box-shadow: var(--elev-1); }
+
+/* ── masonry ────────────────────────────────────────────────────────────── */
+.cf-feed { display: flex; align-items: flex-start; gap: var(--cf-gap); }
+.cf-col { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: var(--cf-gap); }
+.cf-feed[data-cols="1"] { max-width: 640px; margin: 0 auto; }
+
+/* ── cards ──────────────────────────────────────────────────────────────── */
+.cf-card {
+ position: relative;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: var(--cf-radius);
+ overflow: hidden;
+ box-shadow: var(--elev-1);
+ transition: border-color var(--motion-normal, 200ms), box-shadow var(--motion-normal, 200ms), transform var(--motion-normal, 200ms) var(--ease-standard, ease);
+ animation: cfIn .32s var(--ease-standard, ease) both;
+}
+.cf-card:hover { border-color: var(--border-light); box-shadow: var(--elev-2); }
+.cf-card--ai { border-color: color-mix(in srgb, var(--cf-ai) 30%, var(--border)); }
+.cf-card--ai:hover { border-color: color-mix(in srgb, var(--cf-ai) 55%, var(--border)); }
+@keyframes cfIn { from { opacity: 0; transform: translateY(8px); } }
+
+.cf-media {
+ position: relative;
+ display: block;
+ aspect-ratio: 16 / 9;
+ background: var(--bg-secondary);
+ overflow: hidden;
+ color: inherit;
+}
+.cf-media--image { aspect-ratio: 4 / 3; }
+.cf-thumb { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: block; }
+.cf-thumb--ph {
+ display: grid;
+ place-items: center;
+ font-size: 2rem;
+ color: var(--text-muted);
+ background:
+ radial-gradient(120% 90% at 20% 10%, color-mix(in srgb, var(--accent) 18%, transparent), transparent 60%),
+ var(--bg-secondary);
+}
+.cf-card--ai .cf-thumb--ph {
+ background:
+ radial-gradient(120% 90% at 15% 10%, color-mix(in srgb, var(--cf-ai) 30%, transparent), transparent 60%),
+ radial-gradient(90% 80% at 90% 100%, color-mix(in srgb, var(--cf-ai-2) 20%, transparent), transparent 60%),
+ var(--bg-secondary);
+}
+.cf-media img.cf-thumb { transition: transform .5s var(--ease-standard, ease); }
+a.cf-media:hover img.cf-thumb { transform: scale(1.03); }
+.cf-video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; background: #000; display: block; cursor: pointer; }
+
+.cf-kind, .cf-ai {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ z-index: 2;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 4px 9px;
+ border-radius: var(--radius-pill, 999px);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ line-height: 1.2;
+ color: #fff;
+ background: rgba(8, 12, 22, 0.66);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+ text-transform: uppercase;
+ pointer-events: none;
+}
+.cf-ai { background: var(--cf-ai-fill); box-shadow: 0 2px 10px rgba(0, 0, 0, 0.35); }
+.cf-code .cf-ai { left: auto; right: 10px; }
+
+.cf-pill {
+ position: absolute;
+ z-index: 2;
+ padding: 3px 8px;
+ border-radius: 7px;
+ font-size: 0.74rem;
+ font-weight: 700;
+ font-variant-numeric: tabular-nums;
+ color: #fff;
+ background: rgba(8, 12, 22, 0.72);
+ pointer-events: none;
+}
+.cf-dur { right: 10px; bottom: 10px; }
+.cf-parts { left: 10px; bottom: 10px; }
+
+.cf-play {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 54px;
+ height: 54px;
+ display: grid;
+ place-items: center;
+ border-radius: 50%;
+ font-size: 1.15rem;
+ color: #fff;
+ background: rgba(8, 12, 22, 0.55);
+ backdrop-filter: blur(4px);
+ -webkit-backdrop-filter: blur(4px);
+ opacity: 0;
+ transform: scale(.9);
+ transition: opacity var(--motion-normal, 200ms), transform var(--motion-normal, 200ms);
+ pointer-events: none;
+}
+.cf-play i { margin-left: 3px; }
+a.cf-media:hover .cf-play, .cf-media--clip:not(.is-playing) .cf-play { opacity: 1; transform: none; }
+
+.cf-sound, .cf-open {
+ position: absolute;
+ z-index: 3;
+ width: 36px;
+ height: 36px;
+ display: grid;
+ place-items: center;
+ border: 0;
+ border-radius: 50%;
+ font-size: 0.9rem;
+ color: #fff;
+ background: rgba(8, 12, 22, 0.62);
+ backdrop-filter: blur(6px);
+ -webkit-backdrop-filter: blur(6px);
+ cursor: pointer;
+ text-decoration: none;
+ transition: background var(--motion-fast, 120ms), transform var(--motion-fast, 120ms);
+}
+.cf-sound { left: 10px; bottom: 10px; }
+.cf-open { right: 10px; top: 10px; }
+.cf-sound:hover, .cf-open:hover { background: rgba(8, 12, 22, 0.85); transform: scale(1.06); }
+.cf-sound:focus-visible, .cf-open:focus-visible { outline: none; box-shadow: var(--focus-ring); }
+
+.cf-nsfw img.cf-thumb { filter: blur(22px) saturate(.6); transform: scale(1.15); }
+.cf-nsfw-label {
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: max-content;
+ height: max-content;
+ padding: 6px 12px;
+ border-radius: 8px;
+ font-weight: 800;
+ font-size: 0.8rem;
+ letter-spacing: 0.08em;
+ color: #fff;
+ background: rgba(239, 68, 68, 0.85);
+}
+
+/* text pastes: the code itself is the picture */
+.cf-code {
+ position: relative;
+ display: block;
+ color: var(--text-primary);
+ text-decoration: none;
+ background:
+ linear-gradient(180deg, transparent 60%, var(--bg-card) 100%),
+ color-mix(in srgb, var(--bg-primary) 70%, var(--bg-secondary));
+ border-bottom: 1px solid var(--border);
+ padding: 44px 16px 18px;
+ max-height: 260px;
+ overflow: hidden;
+}
+.cf-code pre { margin: 0; white-space: pre-wrap; word-break: break-word; overflow-wrap: anywhere; }
+.cf-code code {
+ font-family: var(--font-mono);
+ font-size: 0.8rem;
+ line-height: 1.55;
+ color: var(--text-secondary);
+ display: -webkit-box;
+ -webkit-line-clamp: 9;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+}
+.cf-code .cf-kind { text-transform: none; background: color-mix(in srgb, var(--accent) 22%, rgba(8, 12, 22, 0.7)); }
+.cf-code:hover code { color: var(--text-primary); }
+
+.cf-body { padding: 12px 14px 14px; display: grid; gap: 8px; min-width: 0; }
+.cf-title {
+ color: var(--text-primary);
+ font-weight: 700;
+ font-size: 1rem;
+ line-height: 1.3;
+ text-decoration: none;
+ display: -webkit-box;
+ -webkit-line-clamp: 2;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ overflow-wrap: anywhere;
+}
+.cf-title:hover { color: var(--accent-light); }
+.cf-streamtitle { color: var(--text-muted); font-size: 0.82rem; margin-top: -4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
+.cf-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 10px; font-size: 0.82rem; color: var(--text-muted); min-width: 0; }
+.cf-chan { display: inline-flex; align-items: center; gap: 8px; min-width: 0; max-width: 100%; color: var(--text-secondary); font-weight: 600; text-decoration: none; }
+a.cf-chan:hover .cf-name { color: var(--text-primary); text-decoration: underline; }
+.cf-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.cf-av {
+ flex: none;
+ width: 26px;
+ height: 26px;
+ border-radius: 50%;
+ display: grid;
+ place-items: center;
+ overflow: hidden;
+ font-size: 0.72rem;
+ font-weight: 800;
+ color: #fff;
+ background: var(--accent-dark);
+}
+.cf-av img { width: 100%; height: 100%; object-fit: cover; display: block; }
+.cf-facts { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 4px 0; min-width: 0; }
+.cf-facts > * + *::before { content: '·'; margin: 0 6px; opacity: .6; }
+.cf-ailabel { color: var(--cf-ai); font-weight: 700; }
+.cf-likes i { color: #f472b6; font-size: 0.75em; }
+.cf-by { font-size: 0.8rem; color: var(--text-muted); }
+.cf-by a { color: var(--text-secondary); font-weight: 600; text-decoration: none; }
+.cf-by a:hover { text-decoration: underline; }
+.cf-excerpt {
+ margin: 0;
+ color: var(--text-secondary);
+ font-size: 0.88rem;
+ line-height: 1.5;
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ overflow-wrap: anywhere;
+}
+.cf-moment-link {
+ justify-self: start;
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-size: 0.84rem;
+ font-weight: 700;
+ color: var(--cf-ai);
+ text-decoration: none;
+}
+.cf-moment-link:hover { text-decoration: underline; }
+
+.cf-grade {
+ position: absolute;
+ right: 12px;
+ top: 10px;
+ z-index: 2;
+ width: 42px;
+ height: 42px;
+ display: grid;
+ place-items: center;
+ border-radius: 12px;
+ font-weight: 900;
+ font-size: 1.3rem;
+ color: #fff;
+ background: rgba(8, 12, 22, 0.7);
+ border: 2px solid currentColor;
+}
+.cf-grade--s { color: #fbbf24; }
+.cf-grade--a { color: #4ade80; }
+.cf-grade--b { color: #60a5fa; }
+.cf-grade--c { color: #94a3b8; }
+
+/* staff bulk-select wrapper (app.js _selWrap) around a feed card */
+.cf-col > .sel-card-wrap { position: relative; }
+.cf-col > .sel-card-wrap > .sel-card-check { z-index: 4; }
+
+/* ── skeletons, footer ──────────────────────────────────────────────────── */
+.cf-skel { animation: none; box-shadow: none; }
+.cf-skel-media { aspect-ratio: 16 / 9; background: color-mix(in srgb, var(--text-muted) 10%, transparent); }
+.cf-skel-body { padding: 14px; display: grid; gap: 10px; }
+.cf-skel-line { height: 12px; border-radius: 6px; background: color-mix(in srgb, var(--text-muted) 14%, transparent); }
+.cf-skel-line--short { width: 55%; }
+.cf-skel-media, .cf-skel-line { position: relative; overflow: hidden; }
+.cf-skel-media::after, .cf-skel-line::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: linear-gradient(100deg, transparent 20%, color-mix(in srgb, var(--text-primary) 7%, transparent) 50%, transparent 80%);
+ transform: translateX(-100%);
+ animation: cfSweep 1.5s ease-in-out infinite;
+}
+@keyframes cfSweep { to { transform: translateX(100%); } }
+
+.cf-foot { display: flex; justify-content: center; padding: 20px 0 4px; }
+.cf-foot:empty { padding: 0; }
+.cf-empty { text-align: center; padding: 48px 16px; max-width: 440px; }
+.cf-empty-icon { font-size: 2.4rem; color: var(--text-muted); opacity: .6; }
+.cf-empty-title { margin: 14px 0 4px; font-weight: 700; font-size: 1.05rem; color: var(--text-primary); }
+.cf-empty-sub { margin: 0; color: var(--text-muted); font-size: 0.9rem; }
+.cf-note {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ flex-wrap: wrap;
+ justify-content: center;
+ padding: 12px 16px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ font-size: 0.88rem;
+ text-align: center;
+}
+.cf-note--error { border-color: color-mix(in srgb, var(--danger) 45%, var(--border)); }
+.cf-end { margin: 0; color: var(--text-muted); font-size: 0.85rem; }
+.cf-sentinel { height: 1px; }
+
+@media (max-width: 679px) {
+ /* A phone gets to the first card quickly: a tighter header and one scrolling row of chips. */
+ .cf-wrap { padding: 12px 12px 40px; --cf-gap: 14px; }
+ .cf-head { margin-bottom: 6px; gap: 8px 12px; align-items: center; }
+ .cf-h1 { font-size: 1.45rem; }
+ .cf-sub { font-size: 0.84rem; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+ .cf-crosslink { padding: 5px 10px; font-size: 0.8rem; }
+ .cf-bar { gap: 8px; padding: 8px 0; }
+ .cf-chips {
+ flex-wrap: nowrap;
+ overflow-x: auto;
+ scrollbar-width: none;
+ width: calc(100% + 24px);
+ margin: 0 -12px;
+ padding: 0 12px;
+ }
+ .cf-chips::-webkit-scrollbar { display: none; }
+ .cf-chip { flex: none; min-height: 34px; padding: 0 12px; font-size: 0.84rem; }
+}
+@media (prefers-reduced-motion: reduce) {
+ .cf-card { animation: none; }
+ .cf-skel-media::after, .cf-skel-line::after { animation: none; }
+ .cf-media img.cf-thumb, .cf-play { transition: none; }
+}
@@ -29,12 +29,24 @@
@@ -29,12 +29,24 @@
"/js/app-media.js"
"/js/app-media.js"
]
]
},
},
- "lists": {
- "js": [
- "/js/ov-density.js"
+ "feeds": {
+ "css": [
+ "/css/features/content.css"
],
],
+ "js": [
+ "/js/content-feed.js"
+ ]
+ },
+ "content": {
+ "fragment": "content",
"deps": [
"deps": [
- "appMedia"
+ "feeds"
+ ]
+ },
+ "moments": {
+ "fragment": "moments",
+ "deps": [
+ "feeds"
]
]
},
},
"player": {
"player": {
@@ -280,9 +292,15 @@
@@ -280,9 +292,15 @@
]
]
},
},
{
{
- "path": "^/(vods|clips)/?$",
+ "path": "^/(content|vods|clips|pastes)/?$",
+ "features": [
+ "content"
+ ]
+ },
+ {
+ "path": "^/moments/?$",
"features": [
"features": [
- "lists"
+ "moments"
]
]
},
},
{
{
@@ -316,7 +334,7 @@
@@ -316,7 +334,7 @@
]
]
},
},
{
{
- "path": "^/(pastes/?$|p/)",
+ "path": "^/p/",
"features": [
"features": [
"pastes"
"pastes"
]
]
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+<div class="cf-wrap" data-feed="content">
+ <header class="cf-head">
+ <div class="cf-head-text">
+ <h1 class="cf-h1">Content</h1>
+ <p class="cf-sub">Streams, clips and pastes made by people on OpenVibe.</p>
+ </div>
+ <div class="cf-head-actions">
+ <a class="cf-crosslink" href="/moments" data-cf-link><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> AI Moments</a>
+ <button type="button" class="btn btn-small btn-outline cf-new-paste" data-cf-new-paste hidden><i class="fa-solid fa-plus" aria-hidden="true"></i> New paste</button>
+ </div>
+ </header>
+ <div class="cf-bar">
+ <div class="cf-chips" role="group" aria-label="Show">
+ <button type="button" class="cf-chip" data-cf-type="all" aria-pressed="true">All</button>
+ <button type="button" class="cf-chip" data-cf-type="vods" aria-pressed="false"><i class="fa-solid fa-video" aria-hidden="true"></i> VODs</button>
+ <button type="button" class="cf-chip" data-cf-type="clips" aria-pressed="false"><i class="fa-solid fa-scissors" aria-hidden="true"></i> Clips</button>
+ <button type="button" class="cf-chip" data-cf-type="pastes" aria-pressed="false"><i class="fa-solid fa-paste" aria-hidden="true"></i> Pastes</button>
+ </div>
+ <div class="cf-sorts">
+ <div class="cf-seg" role="group" aria-label="Sort">
+ <button type="button" data-cf-sort="new" aria-pressed="true">New</button>
+ <button type="button" data-cf-sort="top" aria-pressed="false">Top</button>
+ </div>
+ <div class="cf-seg cf-window" role="group" aria-label="Top of" hidden>
+ <button type="button" data-cf-window="week" aria-pressed="false">Week</button>
+ <button type="button" data-cf-window="month" aria-pressed="true">Month</button>
+ <button type="button" data-cf-window="all" aria-pressed="false">All time</button>
+ </div>
+ </div>
+ </div>
+ <div class="cf-feed" aria-busy="true"></div>
+ <div class="cf-foot" aria-live="polite"></div>
+ <div class="cf-sentinel" aria-hidden="true"></div>
+</div>
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+<div class="cf-wrap cf-wrap--ai" data-feed="moments">
+ <header class="cf-head">
+ <div class="cf-head-text">
+ <h1 class="cf-h1"><span class="cf-ai-mark" aria-hidden="true"><i class="fa-solid fa-wand-magic-sparkles"></i></span> AI Moments</h1>
+ <p class="cf-sub">Auto-clips, standout moments and after-show recaps the AI made from OpenVibe streams. Everything here is AI-generated.</p>
+ </div>
+ <div class="cf-head-actions">
+ <a class="cf-crosslink" href="/content" data-cf-link><i class="fa-solid fa-user-pen" aria-hidden="true"></i> Made by people</a>
+ </div>
+ </header>
+ <div class="cf-bar">
+ <div class="cf-chips" role="group" aria-label="Show">
+ <button type="button" class="cf-chip" data-cf-type="all" aria-pressed="true">All</button>
+ <button type="button" class="cf-chip" data-cf-type="clips" aria-pressed="false"><i class="fa-solid fa-scissors" aria-hidden="true"></i> Auto-clips</button>
+ <button type="button" class="cf-chip" data-cf-type="shots" aria-pressed="false"><i class="fa-solid fa-image" aria-hidden="true"></i> Moments</button>
+ <button type="button" class="cf-chip" data-cf-type="recaps" aria-pressed="false"><i class="fa-solid fa-clipboard-list" aria-hidden="true"></i> Recaps</button>
+ </div>
+ <div class="cf-sorts">
+ <div class="cf-seg" role="group" aria-label="Sort">
+ <button type="button" data-cf-sort="new" aria-pressed="true">New</button>
+ <button type="button" data-cf-sort="top" aria-pressed="false">Top</button>
+ </div>
+ <div class="cf-seg cf-window" role="group" aria-label="Top of" hidden>
+ <button type="button" data-cf-window="week" aria-pressed="false">Week</button>
+ <button type="button" data-cf-window="month" aria-pressed="true">Month</button>
+ <button type="button" data-cf-window="all" aria-pressed="false">All time</button>
+ </div>
+ </div>
+ </div>
+ <div class="cf-feed" aria-busy="true"></div>
+ <div class="cf-foot" aria-live="polite"></div>
+ <div class="cf-sentinel" aria-hidden="true"></div>
+</div>
@@ -310,7 +310,7 @@
@@ -310,7 +310,7 @@
<div class="home-section-lazy" id="home-moments-section" style="display:none">
<div class="home-section-lazy" id="home-moments-section" style="display:none">
<div class="section-header" style="--sc:#22d3ee">
<div class="section-header" style="--sc:#22d3ee">
<h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2>
<h2><i class="fa-solid fa-brain"></i> AI Moments<small class="section-kicker">picked by AI</small></h2>
- <span class="muted" style="font-size:0.8rem">wild seconds the AI clipped from recent streams</span>
+ <a href="/moments" onclick="return handleLinkClick(event, '/moments')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">See all</a>
</div>
</div>
<div class="moments-row" id="home-moments-row"></div>
<div class="moments-row" id="home-moments-row"></div>
</div>
</div>
@@ -473,7 +473,7 @@
@@ -473,7 +473,7 @@
<!-- Recent VODs -->
<!-- Recent VODs -->
<div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header">
<div class="section-header" style="--sc:#a78bfa;display:none" id="home-recent-vods-header">
<h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
<h2><i class="fa-solid fa-video"></i> Recent VODs<small class="section-kicker">full streams</small></h2>
- <a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
+ <a href="/content?type=vods" onclick="return handleLinkClick(event, '/content?type=vods')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
</div>
<div class="stream-grid" id="home-recent-vods-grid"></div>
<div class="stream-grid" id="home-recent-vods-grid"></div>
<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-recent-vods-pagination" class="pastes-pagination" style="display:none"></div>
@@ -481,7 +481,7 @@
@@ -481,7 +481,7 @@
<!-- Recent Clips -->
<!-- Recent Clips -->
<div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header">
<div class="section-header" style="--sc:#f472b6;display:none" id="home-clips-header">
<h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
<h2><i class="fa-solid fa-scissors"></i> Recent Clips<small class="section-kicker">the good bits</small></h2>
- <a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
+ <a href="/content?type=clips" onclick="return handleLinkClick(event, '/content?type=clips')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
</div>
<div class="stream-grid home-clips-grid" id="home-clips-grid"></div>
<div class="stream-grid home-clips-grid" id="home-clips-grid"></div>
<div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-clips-pagination" class="pastes-pagination" style="display:none"></div>
@@ -489,7 +489,7 @@
@@ -489,7 +489,7 @@
<!-- Recent Pastes -->
<!-- Recent Pastes -->
<div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header">
<div class="section-header" style="--sc:#2dd4bf;display:none" id="home-pastes-header">
<h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
<h2><i class="fa-solid fa-paste"></i> Recent Pastes<small class="section-kicker">shots & snippets</small></h2>
- <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
+ <a href="/content?type=pastes" onclick="return handleLinkClick(event, '/content?type=pastes')" class="badge" style="cursor:pointer;text-decoration:none;background:var(--surface);">View All</a>
</div>
</div>
<div class="home-pastes-list" id="home-pastes-list"></div>
<div class="home-pastes-list" id="home-pastes-list"></div>
<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
<div id="home-pastes-pagination" class="pastes-pagination" style="display:none"></div>
@@ -679,29 +679,9 @@
@@ -679,29 +679,9 @@
</div>
</div>
</section>
</section>
- <!-- ── VIDEOS PAGE ───────────────────────────────────── -->
- <section id="page-vods" class="page">
- <div class="container">
- <div class="section-header">
- <h2><i class="fa-solid fa-video"></i> Videos</h2>
- </div>
- <div id="vods-streamer-filters" class="media-filter-bar" style="display:none"></div>
- <div class="stream-grid" id="vods-grid-page"></div>
- <div id="vods-pagination-page" class="pastes-pagination" style="display:none"></div>
- </div>
- </section>
-
- <!-- ── CLIPS PAGE ─────────────────────────────────────── -->
- <section id="page-clips" class="page">
- <div class="container">
- <div class="section-header">
- <h2><i class="fa-solid fa-scissors"></i> Clips</h2>
- </div>
- <div id="clips-streamer-filters" class="media-filter-bar" style="display:none"></div>
- <div class="stream-grid" id="clips-grid-page"></div>
- <div id="clips-pagination-page" class="pastes-pagination" style="display:none"></div>
- </div>
- </section>
+ <!-- ── CONTENT (/content; /vods /clips /pastes are it with a filter) and AI MOMENTS (/moments) ── -->
+ <section id="page-content" class="page" data-fragment="content"><!--ov:fragment:content--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
+ <section id="page-moments" class="page" data-fragment="moments"><!--ov:fragment:moments--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<!-- ── CHANNEL PAGE (/:username) ─────────────────────── -->
<!-- ── CHANNEL PAGE (/:username) ─────────────────────── -->
<section id="page-channel" class="page" data-fragment="channel"><!--ov:fragment:channel--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<section id="page-channel" class="page" data-fragment="channel"><!--ov:fragment:channel--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
@@ -755,50 +735,13 @@
@@ -755,50 +735,13 @@
</div>
</div>
</section>
</section>
- <!-- ═══════════════════════════════════════════════════════════ -->
- <!-- PASTES INDEX -->
- <!-- ═══════════════════════════════════════════════════════════ -->
- <section id="page-pastes" class="page">
- <div class="content-container">
- <div class="page-header" style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:12px;">
- <div>
- <h1><i class="fa-solid fa-paste"></i> Pastes & Images</h1>
- <p class="page-subtitle">Share code, notes, prompts, and images with the community. <span id="pastes-count"></span></p>
- </div>
- <div style="display:flex;gap:8px;">
- <button class="btn btn-primary" onclick="openNewPasteModal()"><i class="fa-solid fa-plus"></i> New Paste</button>
- <button class="btn btn-outline" onclick="openImageUpload()"><i class="fa-solid fa-cloud-arrow-up"></i> Upload Image</button>
- </div>
- </div>
-
- <div class="pastes-toolbar">
- <div class="pastes-filters">
- <button class="btn btn-sm pastes-filter-btn active" data-filter="all" onclick="filterPastes('all')">All</button>
- <button class="btn btn-sm pastes-filter-btn" data-filter="paste" onclick="filterPastes('paste')"><i class="fa-solid fa-code"></i> Pastes</button>
- <button class="btn btn-sm pastes-filter-btn" data-filter="screenshot" onclick="filterPastes('screenshot')"><i class="fa-solid fa-image"></i> Images</button>
- </div>
- <div class="pastes-search">
- <input type="text" id="pastes-search-input" placeholder="Search pastes…" onkeydown="if(event.key==='Enter')searchPastes()">
- <button class="btn btn-sm btn-outline" onclick="searchPastes()"><i class="fa-solid fa-search"></i></button>
- </div>
- </div>
-
- <div id="pastes-streamer-filters" class="media-filter-bar" style="display:none"></div>
-
- <div class="pastes-grid" id="pastes-grid">
- <div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
- </div>
- <div id="pastes-pagination" class="pastes-pagination"></div>
- </div>
- </section>
-
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- PASTE VIEWER -->
<!-- PASTE VIEWER -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
<section id="page-paste-viewer" class="page">
<section id="page-paste-viewer" class="page">
<div class="content-container">
<div class="content-container">
<div class="paste-viewer-nav">
<div class="paste-viewer-nav">
- <button class="btn btn-outline btn-sm" onclick="navigate('/pastes')"><i class="fa-solid fa-arrow-left"></i> All Pastes</button>
+ <button class="btn btn-outline btn-sm" onclick="navigate('/content?type=pastes')"><i class="fa-solid fa-arrow-left"></i> All Pastes</button>
</div>
</div>
<div id="paste-viewer-content">
<div id="paste-viewer-content">
<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>
@@ -1481,7 +1481,8 @@ async function loadHomeClips(opts) {
@@ -1481,7 +1481,8 @@ async function loadHomeClips(opts) {
homeRailLoaded(rail, offset, clips.length, data.total);
homeRailLoaded(rail, offset, clips.length, data.total);
renderHomePagination('home-clips-pagination', rail);
renderHomePagination('home-clips-pagination', rail);
};
};
- await apiSWR(`/clips?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });
+ // People's clips; the AI's auto-clips have their own place (/moments, the AI Moments rail).
+ await apiSWR(`/clips?limit=${limit}&offset=${offset}&auto_generated=0`, render, { ttl: 180000 });
} catch { rail.filling = false; }
} catch { rail.filling = false; }
}
}
@@ -1521,7 +1522,7 @@ async function loadHomePastes(opts) {
@@ -1521,7 +1522,7 @@ async function loadHomePastes(opts) {
homeRailLoaded(rail, offset, pastes.length, data.total);
homeRailLoaded(rail, offset, pastes.length, data.total);
renderHomePagination('home-pastes-pagination', rail);
renderHomePagination('home-pastes-pagination', rail);
};
};
- await apiSWR(`/pastes?limit=${limit}&offset=${offset}`, render, { ttl: 180000 });
+ await apiSWR(`/pastes?limit=${limit}&offset=${offset}&origin=user`, render, { ttl: 180000 });
} catch { rail.filling = false; }
} catch { rail.filling = false; }
}
}
@@ -102,81 +102,7 @@ function downloadMediaTranscript(btn) {
@@ -102,81 +102,7 @@ function downloadMediaTranscript(btn) {
document.body.appendChild(a); a.click();
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
} // streamer's user id — the stable chat room key
} // streamer's user id — the stable chat room key
-const VODS_PAGE_SIZE = 24; // now = grouped SESSIONS (cards) per page
-const VODS_FETCH_WINDOW = 600; // raw VODs fetched up-front to group + paginate client-side
-let _vodsGroupCache = { key: null, groups: [], totalVideos: 0, streamers: [], truncated: false };
-const CLIPS_PAGE_SIZE = 24;
-let currentClipsPage = 1;
-let currentClipsStreamerFilter = 'all';
-let currentVodsSort = 'newest';
-let currentClipsSort = 'newest';
-
-function setVodsPage(page) {
- const safePage = Math.max(1, page | 0);
- if (safePage === currentVodsPage) return;
- currentVodsPage = safePage;
- loadVodsPage();
- const top = document.getElementById('page-vods');
- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
-}
-
-function setClipsPage(page) {
- const safePage = Math.max(1, page | 0);
- if (safePage === currentClipsPage) return;
- currentClipsPage = safePage;
- loadClipsPage();
- const top = document.getElementById('page-clips');
- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
-}
-
-function setVodsSort(sort) {
- const s = sort === 'oldest' ? 'oldest' : 'newest';
- if (s === currentVodsSort) return;
- currentVodsSort = s;
- currentVodsPage = 1;
- loadVodsPage();
-}
-
-function setClipsSort(sort) {
- const s = sort === 'oldest' ? 'oldest' : 'newest';
- if (s === currentClipsSort) return;
- currentClipsSort = s;
- currentClipsPage = 1;
- loadClipsPage();
-}
-
-function renderVodsStreamerFilters(streamers = [], activeFilter = 'all') {
- renderMediaStreamerFilters({
- barId: 'vods-streamer-filters',
- streamers,
- activeFilter,
- onSelect: 'setVodsStreamerFilter',
- countKey: 'vod_count',
- allLabel: 'All streamers',
- });
-}
-
-function renderClipsStreamerFilters(streamers = [], activeFilter = 'all') {
- renderMediaStreamerFilters({
- barId: 'clips-streamer-filters',
- streamers,
- activeFilter,
- onSelect: 'setClipsStreamerFilter',
- countKey: 'clip_count',
- allLabel: 'All streamers',
- });
-}
-
-function setClipsStreamerFilter(username = 'all') {
- const nextFilter = String(username || 'all').trim() || 'all';
- if (nextFilter === currentClipsStreamerFilter) return;
- currentClipsStreamerFilter = nextFilter;
- currentClipsPage = 1;
- loadClipsPage();
- const top = document.getElementById('page-clips');
- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
-}
-
+// The site-wide VOD and clip lists are the Content feed now (public/js/content-feed.js).
// AI "memory" timeline on the VOD player — clickable timestamps that seek the video.
// AI "memory" timeline on the VOD player — clickable timestamps that seek the video.
async function loadVodAiTimeline(vodId) {
async function loadVodAiTimeline(vodId) {
const el = document.getElementById('vp-ai-timeline');
const el = document.getElementById('vp-ai-timeline');
@@ -258,212 +184,6 @@ function _attachVodProgressTracking(video, vodId) {
@@ -258,212 +184,6 @@ function _attachVodProgressTracking(video, vodId) {
if (t > 3 && !(dur > 0 && t > dur - 10)) _saveVodProgress(vodId, t);
if (t > 3 && !(dur > 0 && t > dur - 10)) _saveVodProgress(vodId, t);
}, { once: true });
}, { once: true });
}
}
-// Back-compat alias for the global VOD/clip page call sites.
-function _adminCardWrap(type, id, cardHtml, ownerIsOwner) { return _selWrap(type, id, cardHtml, ownerIsOwner); }
-function _updateAdminBulkBar() { _selRenderBar(); _selSyncAllBtns(); }
-
-// A single VOD card's inner anchor (shared by the flat list + expanded session groups).
-function _vodCardInner(v, myId) {
- return `<a class="stream-card" href="/vod/${v.id}" onclick="return handleLinkClick(event, '/vod/${v.id}')">
- <div class="stream-card-thumb">
- ${thumbImg(v.thumbnail_url, 'fa-video', v.title, `/api/thumbnails/generate/vod/${v.id}`)}
- ${!v.is_public && v.user_id === myId ? '<span class="stream-card-nsfw" style="background:var(--text-muted)">PRIVATE</span>' : ''}
- ${v.stream_protocol ? protocolBadge(v.stream_protocol) : ''}
- <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(v.duration_seconds || v.duration)}</span>
- </div>
- <div class="stream-card-info">
- <div class="stream-card-title">${esc(v.title || 'VOD')}</div>
- <div class="stream-card-streamer">
- ${_avatarSpan(v.avatar_url, v.username, v.profile_color)}
- ${esc(v.username || 'Unknown')}
- <span class="stream-card-date">${timeAgo(v.created_at)}</span>
- </div>
- ${_cardAiHTML(v.ai_overview_short, v.ai_overview)}
- </div>
- </a>`;
-}
-function _vodDayKey(ts) {
- try { const d = new Date(String(ts).replace(' ', 'T') + (/[TZ]/.test(String(ts)) ? '' : 'Z')); return isNaN(d) ? String(ts).slice(0, 10) : d.toISOString().slice(0, 10); }
- catch { return String(ts).slice(0, 10); }
-}
-// Group CONSECUTIVE VODs from the same streamer with the same title on the same day — these are
-// almost always one session split by stream restarts / server restarts, so we condense them.
-function _groupVods(vods) {
- const groups = [];
- let cur = null;
- for (const v of vods) {
- const key = `${v.user_id}|${String(v.title || '').trim().toLowerCase()}|${_vodDayKey(v.created_at)}`;
- if (cur && cur.key === key) cur.items.push(v);
- else { cur = { key, items: [v] }; groups.push(cur); }
- }
- return groups;
-}
-// Renders an array of ALREADY-grouped VOD groups ({ items: [...] }). Pagination happens by
-// group (session), so a 30-part session counts as one card instead of blanking the page.
-function _renderVodGroups(groups, myId) {
- return groups.map((g, gi) => {
- if (g.items.length < 2) {
- const v = g.items[0];
- return _adminCardWrap('vod', v.id, _vodCardInner(v, myId), !!v.owner_is_owner);
- }
- const rep = g.items.reduce((a, b) => ((b.duration_seconds || 0) > (a.duration_seconds || 0) ? b : a), g.items[0]);
- const totalDur = g.items.reduce((s, v) => s + (v.duration_seconds || v.duration || 0), 0);
- const gid = 'vg' + gi;
- const n = g.items.length;
- const groupCard = `<div class="stream-card vod-group-card" id="group-${gid}" onclick="toggleVodGroup('${gid}')" title="${n} videos from this session — click to expand">
- <div class="stream-card-thumb">
- ${thumbImg(rep.thumbnail_url, 'fa-video', rep.title, `/api/thumbnails/generate/vod/${rep.id}`)}
- <span class="vod-group-count"><i class="fa-solid fa-layer-group"></i> ${n}</span>
- <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(totalDur)}</span>
- </div>
- <div class="stream-card-info">
- <div class="stream-card-title">${esc(rep.title || 'VOD')}</div>
- <div class="stream-card-streamer">
- ${_avatarSpan(rep.avatar_url, rep.username, rep.profile_color)}
- ${esc(rep.username || 'Unknown')}
- <span class="stream-card-date">${timeAgo(rep.created_at)}</span>
- </div>
- <button class="vod-group-hint" type="button"><i class="fa-solid fa-chevron-down vod-group-chev"></i> <span>${n} parts — <b>expand</b></span></button>
- </div>
- </div>`;
- const parts = g.items.map(v => _adminCardWrap('vod', v.id, _vodCardInner(v, myId), !!v.owner_is_owner)).join('');
- return groupCard + `<div class="vod-group-parts" id="parts-${gid}" style="display:none">${parts}</div>`;
- }).join('');
-}
-function toggleVodGroup(gid) {
- const parts = document.getElementById('parts-' + gid);
- const card = document.getElementById('group-' + gid);
- if (!parts) return;
- const opening = parts.style.display === 'none';
- parts.style.display = opening ? 'grid' : 'none';
- if (card) card.classList.toggle('expanded', opening);
- if (typeof _selSyncAllBtns === 'function') _selSyncAllBtns();
-}
-
-async function loadVodsPage() {
- const grid = document.getElementById('vods-grid-page');
- const pager = document.getElementById('vods-pagination-page');
- const filterBar = document.getElementById('vods-streamer-filters');
- if (!grid) return console.error('[VODs] grid element #vods-grid-page not found');
- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-spinner fa-spin fa-2x"></i><p>Loading videos...</p></div>';
- if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }
- if (filterBar) {
- filterBar.style.display = 'none';
- filterBar.innerHTML = '';
- }
- // Mutations (admin delete/bulk) refresh via this callback — bust the cache so they re-fetch;
- // plain pagination (setVodsPage) keeps the cache and is instant.
- _selSetContext(_isContentAdmin(), () => { _vodsGroupCache.key = null; return loadVodsPage(); });
-
- try {
- const perPage = VODS_PAGE_SIZE; // grouped sessions per page
- const key = `${currentVodsStreamerFilter || 'all'}|${currentVodsSort || 'newest'}`;
- // Fetch the whole window once per filter/sort, then paginate the GROUPED sessions in-memory
- // so a multi-part session counts as one card (raw-VOD paging showed near-empty pages).
- if (_vodsGroupCache.key !== key) {
- const params = new URLSearchParams({ limit: String(VODS_FETCH_WINDOW), offset: '0' });
- if (currentVodsStreamerFilter && currentVodsStreamerFilter !== 'all') params.set('username', currentVodsStreamerFilter);
- if (currentVodsSort === 'oldest') params.set('sort', 'oldest');
- const data = await api(`/vods?${params.toString()}`);
- const vods = data.vods || [];
- const totalVideos = data.total ?? vods.length;
- _vodsGroupCache = {
- key, groups: _groupVods(vods), totalVideos,
- streamers: data.streamers || [],
- truncated: totalVideos > vods.length,
- };
- }
- const { groups, streamers, truncated, totalVideos } = _vodsGroupCache;
-
- renderVodsStreamerFilters(streamers, currentVodsStreamerFilter);
-
- const totalGroups = groups.length;
- const totalPages = Math.max(1, Math.ceil(totalGroups / perPage));
- if (currentVodsPage > totalPages) currentVodsPage = totalPages;
-
- if (!totalGroups) {
- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-video fa-3x"></i><p>No videos yet</p><p class="muted">Videos are recorded automatically when streamers go live</p></div>';
- return;
- }
-
- const startG = (currentVodsPage - 1) * perPage;
- const pageGroups = groups.slice(startG, startG + perPage);
- const myId = currentUser ? currentUser.id : null;
- grid.innerHTML = _renderVodGroups(pageGroups, myId)
- + (truncated && currentVodsPage === totalPages ? `<div class="vods-truncated-note muted">Showing the ${totalVideos > VODS_FETCH_WINDOW ? 'most recent ' + VODS_FETCH_WINDOW : totalVideos} videos.</div>` : '');
- _updateAdminBulkBar('vod');
-
- renderVodsPagination('vods-pagination-page', currentVodsPage, totalGroups, perPage, 'setVodsPage', 'sessions', { sort: currentVodsSort, setter: 'setVodsSort' });
- } catch (e) {
- console.error('Failed to load videos', e);
- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-triangle-exclamation fa-3x"></i><p>Failed to load videos</p><p class="muted">' + esc(e.message || String(e)) + '</p></div>';
- }
-}
-
-/* ── Clips Page ───────────────────────────────────────────────── */
-async function loadClipsPage() {
- const grid = document.getElementById('clips-grid-page');
- const pager = document.getElementById('clips-pagination-page');
- const filterBar = document.getElementById('clips-streamer-filters');
- if (!grid) return console.error('[Clips] grid element not found');
- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-spinner fa-spin fa-2x"></i><p>Loading clips...</p></div>';
- if (pager) { pager.style.display = 'none'; pager.innerHTML = ''; }
- if (filterBar) {
- filterBar.style.display = 'none';
- filterBar.innerHTML = '';
- }
- _selSetContext(_isContentAdmin(), loadClipsPage);
- try {
- const limit = CLIPS_PAGE_SIZE;
- const offset = (currentClipsPage - 1) * limit;
- const params = new URLSearchParams({ limit: String(limit), offset: String(offset) });
- if (currentClipsStreamerFilter && currentClipsStreamerFilter !== 'all') {
- params.set('username', currentClipsStreamerFilter);
- }
- if (currentClipsSort === 'oldest') params.set('sort', 'oldest');
- const data = await api(`/clips?${params.toString()}`);
- const clips = data.clips || [];
- const total = data.total ?? clips.length;
- const totalPages = Math.max(1, Math.ceil(total / limit));
-
- renderClipsStreamerFilters(data.streamers || [], currentClipsStreamerFilter);
-
- if (currentClipsPage > totalPages) {
- currentClipsPage = totalPages;
- return loadClipsPage();
- }
-
- if (!clips.length) {
- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-scissors fa-3x"></i><p>No public clips yet</p><p class="muted">Viewers can create clips during live streams using the clip button</p></div>';
- return;
- }
- grid.innerHTML = clips.map(cl => _adminCardWrap('clip', cl.id, `
- <a class="stream-card" href="/clip/${cl.id}" onclick="return handleLinkClick(event, '/clip/${cl.id}')">
- <div class="stream-card-thumb">
- ${thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${cl.id}`)}
- ${cl.stream_protocol ? protocolBadge(cl.stream_protocol) : ''}
- <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(cl.duration_seconds)}</span>
- </div>
- <div class="stream-card-info">
- <div class="stream-card-title">${esc(cl.title || 'Clip')}</div>
- <div class="stream-card-streamer">
- ${_avatarSpan(cl.avatar_url, cl.username, cl.profile_color)}
- Clipped by ${esc(cl.display_name || cl.username || 'Unknown')}
- <span class="stream-card-date">${timeAgo(cl.created_at)}</span>
- </div>
- ${_cardAiHTML(cl.ai_overview_short, cl.ai_overview)}
- </div>
- </a>
- `, !!(cl.owner_is_owner || cl.streamer_is_owner))).join('');
- _updateAdminBulkBar('clip');
-
- renderVodsPagination('clips-pagination-page', currentClipsPage, total, limit, 'setClipsPage', 'clips', { sort: currentClipsSort, setter: 'setClipsSort' });
- } catch (e) {
- console.error('Failed to load clips', e);
- grid.innerHTML = '<div class="empty-state"><i class="fa-solid fa-triangle-exclamation fa-3x"></i><p>Failed to load clips</p><p class="muted">' + esc(e.message || String(e)) + '</p></div>';
- }
-}
-
/* ── VOD Player ───────────────────────────────────────────────── */
/* ── VOD Player ───────────────────────────────────────────────── */
async function loadVodPlayer(vodId, seekTo) {
async function loadVodPlayer(vodId, seekTo) {
try {
try {
@@ -55,7 +55,7 @@ function isStaffUser(user = currentUser) {
@@ -55,7 +55,7 @@ function isStaffUser(user = currentUser) {
}
}
// Reserved paths (not usernames)
// Reserved paths (not usernames)
-const RESERVED = new Set(['vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);
+const RESERVED = new Set(['content', 'moments', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
function normalizeChannelUsername(username) {
function normalizeChannelUsername(username) {
@@ -1064,12 +1064,15 @@ function routeFromURL() {
@@ -1064,12 +1064,15 @@ function routeFromURL() {
// Home: /
// Home: /
showPage('home');
showPage('home');
whenRouteReady('home', () => loadHome());
whenRouteReady('home', () => loadHome());
- } else if (segments[0] === 'vods') {
- showPage('vods');
- whenRouteReady('vods', () => loadVodsPage());
- } else if (segments[0] === 'clips') {
- showPage('clips');
- whenRouteReady('clips', () => loadClipsPage());
+ } else if (segments[0] === 'content' || segments[0] === 'vods' || segments[0] === 'clips') {
+ // What people made (public/js/content-feed.js). /vods and /clips are it with a filter.
+ showPage('content');
+ const preset = segments[0] === 'content' ? null : segments[0];
+ whenRouteReady('content', () => loadContentPage({ type: preset }));
+ } else if (segments[0] === 'moments') {
+ // What the AI made: auto-clips, AI moments, AI recaps.
+ showPage('moments');
+ whenRouteReady('moments', () => loadMomentsPage());
} else if (segments[0] === 'vod' && segments[1]) {
} else if (segments[0] === 'vod' && segments[1]) {
// VOD player: /vod/:id (optional ?t=<seconds> to auto-seek, e.g. from a clip link)
// VOD player: /vod/:id (optional ?t=<seconds> to auto-seek, e.g. from a clip link)
showPage('vod-player');
showPage('vod-player');
@@ -1107,12 +1110,10 @@ function routeFromURL() {
@@ -1107,12 +1110,10 @@ function routeFromURL() {
window.location.href = `${getScraplandiaUrl()}/canvas`;
window.location.href = `${getScraplandiaUrl()}/canvas`;
return;
return;
} else if (segments[0] === 'pastes') {
} else if (segments[0] === 'pastes') {
- showPage('pastes');
+ // The Content feed's Pastes filter; ?edit=<slug> (from a paste's Edit button) opens the editor over it.
+ showPage('content');
const editSlug = new URLSearchParams(window.location.search).get('edit');
const editSlug = new URLSearchParams(window.location.search).get('edit');
- whenRouteReady('pastes', () => {
- if (typeof loadPastesPage === 'function') loadPastesPage();
- });
- // Handle ?edit=slug
+ whenRouteReady('content', () => loadContentPage({ type: 'pastes' }));
if (editSlug) {
if (editSlug) {
Promise.all([api(`/pastes/${editSlug}`), ov.load('pastes')]).then(([data]) => {
Promise.all([api(`/pastes/${editSlug}`), ov.load('pastes')]).then(([data]) => {
if (data.paste && typeof openNewPasteModal === 'function') openNewPasteModal({
if (data.paste && typeof openNewPasteModal === 'function') openNewPasteModal({
@@ -1143,9 +1144,6 @@ function routeFromURL() {
@@ -1143,9 +1144,6 @@ function routeFromURL() {
} else if (segments[0] === 'privacy') {
} else if (segments[0] === 'privacy') {
window.location.replace('/privacy');
window.location.replace('/privacy');
return;
return;
- } else if (segments[0] === 'pastes' && !segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {
- _pasteHandOver(`/pastes${location.search}`);
- return;
} else if (segments[0] === 'p' && segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {
} else if (segments[0] === 'p' && segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {
// Pastes live on openvibe.community now — hand the browser over (same slug, same URL shape).
// Pastes live on openvibe.community now — hand the browser over (same slug, same URL shape).
_pasteHandOver(`/p/${encodeURIComponent(segments[1])}${location.search}`);
_pasteHandOver(`/p/${encodeURIComponent(segments[1])}${location.search}`);
@@ -1193,7 +1191,7 @@ function showNotFound() {
@@ -1193,7 +1191,7 @@ function showNotFound() {
p.textContent = 'Nothing lives at this address. The link may be mistyped, or what it pointed to was removed or made private.';
p.textContent = 'Nothing lives at this address. The link may be mistyped, or what it pointed to was removed or made private.';
const links = document.createElement('p');
const links = document.createElement('p');
links.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:20px';
links.style.cssText = 'display:flex;flex-wrap:wrap;gap:8px;justify-content:center;margin-top:20px';
- for (const [href, label, cls] of [['/', 'Home', 'btn btn-primary'], ['/vods', 'VODs', 'btn btn-outline'], ['/clips', 'Clips', 'btn btn-outline'], ['/chat', 'Chat', 'btn btn-outline']]) {
+ for (const [href, label, cls] of [['/', 'Home', 'btn btn-primary'], ['/content', 'Content', 'btn btn-outline'], ['/moments', 'AI Moments', 'btn btn-outline'], ['/chat', 'Chat', 'btn btn-outline']]) {
const a = document.createElement('a');
const a = document.createElement('a');
a.href = href; a.className = cls; a.textContent = label;
a.href = href; a.className = cls; a.textContent = label;
a.addEventListener('click', (e) => handleLinkClick(e, href));
a.addEventListener('click', (e) => handleLinkClick(e, href));
@@ -1350,7 +1348,7 @@ function showPage(page) {
@@ -1350,7 +1348,7 @@ function showPage(page) {
}
}
// Highlight nav link
// Highlight nav link
- const pageToNav = { home: 'home', vods: 'vods', clips: 'clips', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', pastes: 'pastes', 'paste-viewer': 'pastes', arena: 'arena' };
+ const pageToNav = { home: 'home', content: 'content', moments: 'moments', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', 'paste-viewer': 'content', arena: 'arena' };
const navPage = pageToNav[page];
const navPage = pageToNav[page];
if (navPage) {
if (navPage) {
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
@@ -1379,8 +1377,6 @@ window.addEventListener('resize', updateNavHeroTransparency, { passive: true });
@@ -1379,8 +1377,6 @@ window.addEventListener('resize', updateNavHeroTransparency, { passive: true });
/* ── Channel Page (/:username) ────────────────────────────────── */
/* ── Channel Page (/:username) ────────────────────────────────── */
let currentChannelUsername = null; // is the current channel's user owner-rank?
let currentChannelUsername = null; // is the current channel's user owner-rank?
let _activeChannelUserId = null;
let _activeChannelUserId = null;
-let currentVodsPage = 1;
-let currentVodsStreamerFilter = 'all';
// Small Newest/Oldest segmented control (shared markup). `setter` is a global fn name
// Small Newest/Oldest segmented control (shared markup). `setter` is a global fn name
// taking 'newest'|'oldest'.
// taking 'newest'|'oldest'.
@@ -1436,65 +1432,6 @@ function _capTag(s) {
@@ -1436,65 +1432,6 @@ function _capTag(s) {
}).join(' ');
}).join(' ');
}
}
-function renderMediaStreamerFilters({
- barId,
- streamers = [],
- activeFilter = 'all',
- onSelect = 'setVodsStreamerFilter',
- countKey = 'vod_count',
- allLabel = 'All streamers',
-} = {}) {
- const bar = document.getElementById(barId);
- if (!bar) return;
-
- const normalizedActive = (activeFilter || 'all').toLowerCase();
- const unique = [];
- const seen = new Set();
- for (const streamer of (streamers || [])) {
- const username = String(streamer?.username || '').trim();
- if (!username) continue;
- const key = username.toLowerCase();
- if (seen.has(key)) continue;
- seen.add(key);
- unique.push(streamer);
- }
-
- if (!unique.length) {
- bar.style.display = 'none';
- bar.innerHTML = '';
- return;
- }
-
- bar.style.display = 'flex';
- bar.innerHTML = `
- <button class="media-filter-chip ${normalizedActive === 'all' ? 'active' : ''}" onclick="${onSelect}('all')">
- <i class="fa-solid fa-layer-group"></i>
- <span>${esc(allLabel)}</span>
- </button>
- ${unique.map(streamer => {
- const username = String(streamer.username || '').trim();
- const label = streamer.display_name || username;
- const count = Number(streamer[countKey] || 0);
- return `
- <button class="media-filter-chip ${normalizedActive === username.toLowerCase() ? 'active' : ''}" onclick="${onSelect}('${esc(username)}')">
- <span>${esc(label)}</span>
- ${count > 0 ? `<span class="media-filter-chip-count">${count}</span>` : ''}
- </button>
- `;
- }).join('')}
- `;
-}
-
-function setVodsStreamerFilter(username = 'all') {
- const nextFilter = String(username || 'all').trim() || 'all';
- if (nextFilter === currentVodsStreamerFilter) return;
- currentVodsStreamerFilter = nextFilter;
- currentVodsPage = 1;
- loadVodsPage();
- const top = document.getElementById('page-vods');
- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
-}
-
// Turn a UTC SQL timestamp ("YYYY-MM-DD HH:MM:SS") or ISO string into "x ago".
// Turn a UTC SQL timestamp ("YYYY-MM-DD HH:MM:SS") or ISO string into "x ago".
function _aiTimeAgo(ts) {
function _aiTimeAgo(ts) {
if (!ts) return '';
if (!ts) return '';
new file mode 100644
new file mode 100644
@@ -0,0 +1,527 @@
@@ -0,0 +1,527 @@
+/* OpenVibe.Live — the Content and Moments feeds.
+ *
+ * /content what people made: VODs, clips people cut, pastes loadContentPage({ type })
+ * /moments what the AI made: auto-clips, AI moments, AI recaps loadMomentsPage()
+ * /vods /clips /pastes are /content with that filter (the address becomes /content?type=…).
+ *
+ * The server merges the sources and pages them with a cursor (server/content/feed.js); this file
+ * only renders. Cards are built as DOM nodes, never from HTML strings, so nothing a title or a
+ * paste contains can become markup.
+ *
+ * Layout: one column of large cards on a phone; two or three columns on wider screens, each new
+ * card going to the shortest column (a masonry that never reflows what is already on screen).
+ * Scrolling near the end loads the next page. Clips play muted, one at a time, when mostly in view;
+ * a tap turns the sound on, the title or the corner button opens the clip.
+ * Everything the page starts is released through the route scope (ov.scope()) when it is left.
+ */
+(function () {
+ 'use strict';
+
+ const FEEDS = {
+ content: {
+ page: 'page-content', path: '/content', api: '/api/content/feed',
+ types: ['all', 'vods', 'clips', 'pastes'],
+ titles: { all: 'Content', vods: 'VODs', clips: 'Clips', pastes: 'Pastes' },
+ empty: {
+ all: ['fa-photo-film', 'Nothing here yet', 'Streams, clips and pastes people share show up here.'],
+ vods: ['fa-video', 'No videos yet', 'Streams are recorded automatically when streamers go live.'],
+ clips: ['fa-scissors', 'No clips yet', 'Viewers clip the best moments of a stream with the clip button.'],
+ pastes: ['fa-paste', 'No pastes yet', 'Share code, notes or a screenshot and it shows up here.'],
+ },
+ },
+ moments: {
+ page: 'page-moments', path: '/moments', api: '/api/content/moments',
+ types: ['all', 'clips', 'shots', 'recaps'],
+ titles: { all: 'AI Moments', clips: 'AI auto-clips', shots: 'AI moments', recaps: 'AI recaps' },
+ empty: {
+ all: ['fa-wand-magic-sparkles', 'No AI moments yet', 'The AI clips standout moments and writes recaps after streams.'],
+ clips: ['fa-scissors', 'No auto-clips yet', 'When chat erupts during a stream, the AI cuts a clip of it.'],
+ shots: ['fa-image', 'No AI moments yet', 'The AI picks standout frames from recent streams.'],
+ recaps: ['fa-clipboard-list', 'No AI recaps yet', 'After a long enough stream the AI writes a recap.'],
+ },
+ },
+ };
+ const WINDOWS = ['week', 'month', 'all'];
+ const PAGE = 12;
+
+ let S = null; // the feed on screen
+ let soundOn = false; // a person turned the sound on: later clips play with sound too
+
+ // ── small DOM helpers ──────────────────────────────────────────────────────────────────
+ function el(tag, attrs, ...kids) {
+ const n = document.createElement(tag);
+ if (attrs) for (const [k, v] of Object.entries(attrs)) {
+ if (v == null || v === false) continue;
+ if (k === 'class') n.className = v;
+ else if (k === 'text') n.textContent = v;
+ else if (k === 'dataset') Object.assign(n.dataset, v);
+ else n.setAttribute(k, v === true ? '' : String(v));
+ }
+ for (const kid of kids) if (kid != null && kid !== false) n.append(kid);
+ return n;
+ }
+ const icon = (name, cls) => el('i', { class: `fa-solid ${name}${cls ? ` ${cls}` : ''}`, 'aria-hidden': 'true' });
+ const spa = (href, attrs, ...kids) => el('a', { href, 'data-spa': '1', ...attrs }, ...kids);
+
+ function compact(n) {
+ const v = Math.max(0, Number(n) || 0);
+ if (v >= 999500) return `${(v / 1e6).toFixed(v >= 9999500 ? 0 : 1).replace(/\.0$/, '')}M`;
+ if (v >= 999.5) return `${(v / 1e3).toFixed(v >= 9999.5 ? 0 : 1).replace(/\.0$/, '')}K`;
+ return String(v);
+ }
+ function duration(sec) {
+ sec = Math.max(0, Math.round(Number(sec) || 0));
+ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
+ return h ? `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${m}:${String(s).padStart(2, '0')}`;
+ }
+ const ago = (iso) => (typeof timeAgo === 'function' ? timeAgo(iso) : '');
+ function avatar(p) {
+ const letter = (String((p && (p.display_name || p.username)) || '?')[0] || '?').toUpperCase();
+ const box = el('span', { class: 'cf-av', 'aria-hidden': 'true' });
+ if (p && p.profile_color && /^#[0-9a-f]{3,8}$/i.test(p.profile_color)) box.style.background = p.profile_color;
+ if (p && p.avatar_url) {
+ const img = el('img', { src: p.avatar_url, alt: '', loading: 'lazy', decoding: 'async' });
+ img.addEventListener('error', () => { img.remove(); box.textContent = letter; }, { once: true });
+ box.append(img);
+ } else box.textContent = letter;
+ return box;
+ }
+ function thumb(url, fallbackIcon) {
+ if (!url) return el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon));
+ const img = el('img', { class: 'cf-thumb', src: url, alt: '', loading: 'lazy', decoding: 'async' });
+ img.addEventListener('error', () => img.replaceWith(el('span', { class: 'cf-thumb cf-thumb--ph' }, icon(fallbackIcon))), { once: true });
+ return img;
+ }
+ const aiBadge = () => el('span', { class: 'cf-ai', title: 'Made by AI' }, icon('fa-wand-magic-sparkles'), 'AI');
+
+ // ── cards ──────────────────────────────────────────────────────────────────────────────
+ function meta(it, extra) {
+ const row = el('div', { class: 'cf-meta' });
+ if (it.channel) {
+ const who = [avatar(it.channel), el('span', { class: 'cf-name', text: it.channel.display_name || it.channel.username })];
+ row.append(it.channel.href ? spa(it.channel.href, { class: 'cf-chan' }, ...who) : el('span', { class: 'cf-chan' }, ...who));
+ }
+ const bits = el('span', { class: 'cf-facts' });
+ if (it.ai_label) bits.append(el('span', { class: 'cf-ailabel', text: it.ai_label }));
+ if (it.created_at) bits.append(el('time', { datetime: it.created_at, title: new Date(it.created_at).toLocaleString(), text: ago(it.created_at) }));
+ if (it.kind === 'recap') { if (it.views) bits.append(el('span', { text: `peak ${compact(it.views)} watching` })); }
+ else bits.append(el('span', { text: `${compact(it.views)} view${it.views === 1 ? '' : 's'}` }));
+ if (it.kind === 'paste' && it.likes) bits.append(el('span', { class: 'cf-likes' }, icon('fa-heart'), ` ${compact(it.likes)}`));
+ if (extra) bits.append(extra);
+ row.append(bits);
+ return row;
+ }
+
+ function vodCard(it) {
+ const media = spa(it.href, { class: 'cf-media', 'aria-label': `Watch ${it.title}` },
+ thumb(it.thumbnail_url, 'fa-video'),
+ el('span', { class: 'cf-kind' }, icon('fa-video'), 'VOD'),
+ el('span', { class: 'cf-pill cf-dur', text: duration(it.duration_seconds) }),
+ el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));
+ const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }), meta(it));
+ if (it.excerpt) body.append(el('p', { class: 'cf-excerpt', text: it.excerpt }));
+ return el('article', { class: 'cf-card cf-card--vod' }, media, body);
+ }
+
+ function clipCard(it) {
+ const playable = !!(it.thumbnail_url && it.preview_url);
+ const badge = it.ai ? aiBadge() : el('span', { class: 'cf-kind' }, icon('fa-scissors'), 'Clip');
+ const dur = el('span', { class: 'cf-pill cf-dur', text: duration(it.duration_seconds) });
+ let box, video = null;
+ if (playable) {
+ // Plays in place: a tap is for sound, the corner button and the title open the clip.
+ video = el('video', { class: 'cf-video', muted: true, playsinline: true, loop: true, preload: 'none', poster: it.thumbnail_url, 'aria-label': it.title });
+ video.muted = true;
+ video.dataset.src = it.preview_url;
+ box = el('div', { class: 'cf-media cf-media--clip' }, video, badge, dur,
+ spa(it.href, { class: 'cf-open', 'aria-label': 'Open clip', title: 'Open clip' }, icon('fa-up-right-and-down-left-from-center')),
+ el('button', { type: 'button', class: 'cf-sound', 'aria-label': 'Turn sound on', title: 'Sound' }, icon('fa-volume-xmark')),
+ el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));
+ } else {
+ box = spa(it.href, { class: 'cf-media', 'aria-label': `Watch ${it.title}` }, thumb(it.thumbnail_url, 'fa-scissors'), badge, dur,
+ el('span', { class: 'cf-play', 'aria-hidden': 'true' }, icon('fa-play')));
+ }
+ const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }), meta(it));
+ if (it.by) body.append(el('div', { class: 'cf-by' }, icon('fa-scissors'), ' Clipped by ', it.by.href ? spa(it.by.href, { text: it.by.display_name || it.by.username }) : it.by.display_name));
+ if (it.excerpt) body.append(el('p', { class: 'cf-excerpt', text: it.excerpt }));
+ const card = el('article', { class: `cf-card cf-card--clip${it.ai ? ' cf-card--ai' : ''}` }, box, body);
+ if (video && S) S.clips.observe(video);
+ return card;
+ }
+
+ function pasteCard(it) {
+ let media;
+ if (it.paste_type === 'image') {
+ media = spa(it.href, { class: `cf-media cf-media--image${it.nsfw ? ' cf-nsfw' : ''}`, 'aria-label': `Open ${it.title}` },
+ thumb(it.image_url, 'fa-image'),
+ it.ai ? aiBadge() : el('span', { class: 'cf-kind' }, icon('fa-image'), 'Image'));
+ if (it.nsfw) media.append(el('span', { class: 'cf-nsfw-label', text: 'NSFW' }));
+ } else {
+ media = spa(it.href, { class: 'cf-code', 'aria-label': `Open ${it.title}` },
+ el('pre', null, el('code', { text: it.excerpt || '' })),
+ el('span', { class: 'cf-kind' }, icon('fa-code'), it.language && it.language !== 'text' ? it.language : 'Paste'));
+ if (it.ai) media.append(aiBadge());
+ }
+ const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }), meta(it));
+ if (it.paste_type === 'image' && it.excerpt) body.append(el('p', { class: 'cf-excerpt', text: it.excerpt }));
+ if (it.moment_href) body.append(spa(it.moment_href, { class: 'cf-moment-link' }, icon('fa-circle-play'), ' Watch this moment'));
+ return el('article', { class: `cf-card cf-card--paste${it.ai ? ' cf-card--ai' : ''}` }, media, body);
+ }
+
+ function recapCard(it) {
+ const media = spa(it.href, { class: 'cf-media cf-media--recap', 'aria-label': `Read the recap: ${it.title}` },
+ it.thumbnail_url ? thumb(it.thumbnail_url, 'fa-clipboard-list') : el('span', { class: 'cf-thumb cf-thumb--ph' }, icon('fa-clipboard-list')),
+ aiBadge());
+ if (it.grade) media.append(el('span', { class: `cf-grade cf-grade--${it.grade.toLowerCase()}`, title: `Grade ${it.grade}`, text: it.grade }));
+ if (it.duration_seconds) media.append(el('span', { class: 'cf-pill cf-dur', text: duration(it.duration_seconds) }));
+ const body = el('div', { class: 'cf-body' }, spa(it.href, { class: 'cf-title', text: it.title }));
+ if (it.stream_title && it.stream_title !== it.title) body.append(el('div', { class: 'cf-streamtitle', text: it.stream_title }));
+ body.append(meta(it));
+ if (it.excerpt) body.append(el('p', { class: 'cf-excerpt', text: it.excerpt }));
+ return el('article', { class: 'cf-card cf-card--recap cf-card--ai' }, media, body);
+ }
+
+ const BUILD = { vod: vodCard, clip: clipCard, paste: pasteCard, recap: recapCard };
+
+ /** Staff see the same bulk-select checkboxes as on the channel pages (app.js _selWrap). */
+ function selectable(it, card) {
+ if (!S.sel || !['vod', 'clip', 'paste'].includes(it.kind)) return card;
+ const id = String(it.id);
+ const input = el('input', { type: 'checkbox', 'aria-label': `Select ${it.title}` });
+ input.checked = !!(window._sel && window._sel[it.kind] && window._sel[it.kind].has(id));
+ input.addEventListener('change', () => { if (typeof _selToggle === 'function') _selToggle(it.kind, id, input.checked); });
+ const label = el('label', { class: 'sel-card-check', title: 'Select' }, input);
+ label.addEventListener('click', (e) => e.stopPropagation());
+ return el('div', { class: 'sel-card-wrap', dataset: { selType: it.kind, selId: id } }, label, card);
+ }
+
+ // ── masonry columns ────────────────────────────────────────────────────────────────────
+ function columnCount() {
+ const w = S.feedEl.clientWidth || window.innerWidth;
+ return w >= 1080 ? 3 : w >= 680 ? 2 : 1;
+ }
+ function buildColumns(n) {
+ S.feedEl.replaceChildren();
+ S.cols = [];
+ for (let i = 0; i < n; i++) { const c = el('div', { class: 'cf-col' }); S.cols.push(c); S.feedEl.append(c); }
+ S.feedEl.dataset.cols = String(n);
+ }
+ function shortest() {
+ let best = S.cols[0], h = Infinity;
+ for (const c of S.cols) { const ch = c.offsetHeight; if (ch < h - 1) { h = ch; best = c; } }
+ return best;
+ }
+ function place(node) { shortest().append(node); }
+ function relayout() {
+ if (!S) return;
+ const n = columnCount();
+ if (n === S.cols.length) return;
+ const nodes = S.cards.slice();
+ buildColumns(n);
+ for (const node of nodes) place(node);
+ }
+
+ // ── skeletons and footer states ────────────────────────────────────────────────────────
+ function skeletons(count) {
+ const out = [];
+ for (let i = 0; i < count; i++) {
+ const sk = el('div', { class: 'cf-card cf-skel', 'data-skeleton': '1', 'aria-hidden': 'true' },
+ el('div', { class: 'cf-skel-media' }),
+ el('div', { class: 'cf-skel-body' }, el('span', { class: 'cf-skel-line' }), el('span', { class: 'cf-skel-line cf-skel-line--short' })));
+ out.push(sk);
+ place(sk);
+ }
+ S.skels = out;
+ }
+ function clearSkeletons() { for (const sk of S.skels || []) sk.remove(); S.skels = []; }
+
+ function foot(kind, detail) {
+ const f = S.footEl;
+ f.replaceChildren();
+ f.dataset.state = kind || '';
+ if (kind === 'empty') {
+ const [ic, title, sub] = S.cfg.empty[S.type] || S.cfg.empty.all;
+ f.append(el('div', { class: 'cf-empty' }, icon(ic, 'cf-empty-icon'), el('p', { class: 'cf-empty-title', text: title }), el('p', { class: 'cf-empty-sub', text: sub })));
+ } else if (kind === 'error' || kind === 'partial') {
+ const retry = el('button', { type: 'button', class: 'btn btn-small btn-outline' }, icon('fa-rotate-right'), ' Try again');
+ retry.addEventListener('click', () => { foot(null); loadMore(true); });
+ f.append(el('div', { class: `cf-note${kind === 'error' ? ' cf-note--error' : ''}` },
+ el('span', { text: kind === 'error' ? (detail || 'This feed could not load.') : 'Some of this feed did not load. What loaded is shown.' }), retry));
+ } else if (kind === 'end') {
+ f.append(el('p', { class: 'cf-end', text: S.feed === 'moments' ? 'That is every AI moment for now.' : "You're all caught up." }));
+ } else if (kind === 'more') {
+ const more = el('button', { type: 'button', class: 'btn btn-small btn-outline cf-more' }, 'Load more');
+ more.addEventListener('click', () => loadMore(true));
+ f.append(more);
+ }
+ }
+
+ // ── loading ────────────────────────────────────────────────────────────────────────────
+ function queryString(withCursor) {
+ const p = new URLSearchParams();
+ if (S.type !== 'all') p.set('type', S.type);
+ if (S.sort === 'top') { p.set('sort', 'top'); p.set('window', S.win); }
+ if (withCursor) { p.set('limit', String(PAGE)); if (S.next) p.set('cursor', S.next); }
+ return p.toString();
+ }
+
+ async function loadMore(retry) {
+ if (!S || S.loading || ((S.done || S.halted) && !retry)) return;
+ const st = S, gen = st.gen;
+ st.loading = true;
+ st.halted = false;
+ const first = !st.cards.length;
+ if (first) { if (!st.skels.length) skeletons(st.cols.length > 1 ? st.cols.length * 2 : 2); }
+ else skeletons(st.cols.length);
+ st.feedEl.setAttribute('aria-busy', 'true');
+ let data = null, failure = null;
+ try {
+ const res = await fetch(`${st.cfg.api}?${queryString(true)}`, { signal: st.abort.signal, headers: { Accept: 'application/json' } });
+ data = await res.json().catch(() => null);
+ if (!res.ok) failure = (data && data.error) || 'This feed could not load.';
+ } catch (err) {
+ if (err && err.name === 'AbortError') return;
+ failure = navigator.onLine === false ? "You're offline." : 'This feed could not load.';
+ }
+ if (S !== st || st.gen !== gen) return;
+ st.loading = false;
+ clearSkeletons();
+ st.feedEl.setAttribute('aria-busy', 'false');
+ // Stop here until someone asks again: an observer that keeps seeing the end must not
+ // turn a failing upstream into a request loop.
+ if (failure || !data) { st.halted = true; foot('error', failure); return; }
+
+ render(data.items || []);
+ st.next = data.next || null;
+ st.done = !st.next;
+ if (data.sources) st.sources = data.sources;
+ if (!st.cards.length) {
+ if (data.partial) { st.halted = true; foot('error', 'This feed could not load right now.'); }
+ else foot('empty');
+ return;
+ }
+ if (data.partial) { foot('partial'); if (!(data.items || []).length) { st.halted = true; return; } }
+ else if (!(data.items || []).length && !st.done) { st.halted = true; foot('more'); return; } // never spin on empty pages
+ else foot(st.done ? 'end' : ('IntersectionObserver' in window ? null : 'more'));
+ // A short page (or a tall screen) can leave the end in view: keep going.
+ if (!st.done && !data.partial) requestAnimationFrame(() => { if (S === st) st.watchEnd(); });
+ }
+
+ function render(items) {
+ const vodGroups = S.sort === 'new';
+ for (const it of items) {
+ if (!it || !it.key || S.seen.has(it.key) || !BUILD[it.kind]) continue;
+ S.seen.add(it.key);
+ // A stream that restarted is several VODs in a row with the same title on the same day:
+ // one card that says how many parts, instead of a column of copies.
+ if (vodGroups && it.kind === 'vod' && S.lastVod && S.lastVod.group === vodGroup(it)) {
+ S.lastVod.parts++;
+ S.lastVod.seconds += Number(it.duration_seconds) || 0;
+ S.lastVod.partsEl.textContent = `${S.lastVod.parts} parts`;
+ S.lastVod.partsEl.hidden = false;
+ S.lastVod.durEl.textContent = duration(S.lastVod.seconds);
+ continue;
+ }
+ let card;
+ try { card = BUILD[it.kind](it); } catch (err) { console.warn('[Feed] card failed', it.key, err); continue; }
+ card.dataset.key = it.key;
+ const node = selectable(it, card);
+ S.cards.push(node);
+ place(node);
+ if (it.kind === 'vod') {
+ const partsEl = el('span', { class: 'cf-pill cf-parts', hidden: true }, '');
+ card.querySelector('.cf-media').append(partsEl);
+ S.lastVod = { group: vodGroup(it), parts: 1, seconds: Number(it.duration_seconds) || 0, partsEl, durEl: card.querySelector('.cf-dur') };
+ } else S.lastVod = null;
+ }
+ }
+ const vodGroup = (it) => `${it.channel ? it.channel.username : ''}|${String(it.title || '').trim().toLowerCase()}|${String(it.created_at || '').slice(0, 10)}`;
+
+ // ── clips: one plays at a time ─────────────────────────────────────────────────────────
+ function clipController(scope) {
+ const ratios = new Map();
+ let active = null;
+ const still = (() => {
+ try { if (matchMedia('(prefers-reduced-motion: reduce)').matches) return true; } catch { /* */ }
+ try { if (navigator.connection && navigator.connection.saveData) return true; } catch { /* */ }
+ return false;
+ })();
+ const media = (v) => v.closest('.cf-media');
+ function setSoundIcon(v) {
+ const b = media(v) && media(v).querySelector('.cf-sound');
+ if (!b) return;
+ b.replaceChildren(icon(v.muted ? 'fa-volume-xmark' : 'fa-volume-high'));
+ b.setAttribute('aria-label', v.muted ? 'Turn sound on' : 'Turn sound off');
+ }
+ function start(v, withSound) {
+ if (!v.dataset.src) return;
+ if (active && active !== v) stop(active);
+ active = v;
+ if (!v.getAttribute('src')) v.src = v.dataset.src;
+ v.muted = !withSound;
+ media(v).classList.add('is-playing');
+ setSoundIcon(v);
+ const p = v.play();
+ if (p && p.catch) p.catch(() => {
+ // Sound without a fresh tap can be refused: fall back to silent rather than stopped.
+ if (!v.muted) { v.muted = true; setSoundIcon(v); v.play().catch(() => media(v).classList.remove('is-playing')); }
+ else media(v).classList.remove('is-playing');
+ });
+ }
+ function stop(v) {
+ try { v.pause(); } catch { /* */ }
+ if (media(v)) media(v).classList.remove('is-playing');
+ if (active === v) active = null;
+ }
+ function unload(v) {
+ if (!v.getAttribute('src')) return;
+ stop(v);
+ v.removeAttribute('src');
+ try { v.load(); } catch { /* */ } // gives the decoder and its buffer back
+ }
+ function pick() {
+ if (still) return;
+ // The most visible clip; among clips equally in view, the one nearest the middle of the screen.
+ let best = null, bestRatio = 0.6, bestDist = Infinity;
+ const mid = window.innerHeight / 2;
+ for (const [v, r] of ratios) {
+ if (r < bestRatio - 0.01 || !v.isConnected) continue;
+ const box = v.getBoundingClientRect();
+ const dist = Math.abs(box.top + box.height / 2 - mid);
+ if (r > bestRatio + 0.01 || dist < bestDist) { best = v; bestRatio = Math.max(r, 0.6); bestDist = dist; }
+ }
+ if (best && best !== active) start(best, soundOn);
+ else if (!best && active && (ratios.get(active) || 0) < 0.35) stop(active);
+ for (const [v, r] of ratios) if (r === 0 && v !== active) unload(v);
+ }
+ const io = new IntersectionObserver((entries) => {
+ for (const e of entries) ratios.set(e.target, e.isIntersecting ? e.intersectionRatio : 0);
+ pick();
+ }, { threshold: [0, 0.35, 0.6, 0.8, 1] });
+ scope.observe(io);
+ scope.onDispose(() => { for (const v of ratios.keys()) unload(v); ratios.clear(); });
+ return {
+ observe(v) { ratios.set(v, 0); io.observe(v); },
+ /** A tap on a clip: sound on (or off), and this clip is the one playing. */
+ tap(v) {
+ if (active !== v || v.paused) { soundOn = true; start(v, true); return; }
+ v.muted = !v.muted;
+ soundOn = !v.muted;
+ setSoundIcon(v);
+ },
+ forget() { for (const v of ratios.keys()) { io.unobserve(v); unload(v); } ratios.clear(); },
+ };
+ }
+
+ // ── controls and the address ───────────────────────────────────────────────────────────
+ function syncControls() {
+ const w = S.wrap;
+ w.querySelectorAll('[data-cf-type]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.cfType === S.type)));
+ w.querySelectorAll('[data-cf-sort]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.cfSort === S.sort)));
+ w.querySelectorAll('[data-cf-window]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.cfWindow === S.win)));
+ const win = w.querySelector('.cf-window');
+ if (win) win.hidden = S.sort !== 'top';
+ const np = w.querySelector('[data-cf-new-paste]');
+ if (np) np.hidden = !(S.type === 'pastes');
+ if (typeof setPageTitle === 'function') setPageTitle(S.cfg.titles[S.type] || S.cfg.titles.all);
+ }
+ function syncUrl() {
+ const qs = queryString(false);
+ const url = `${S.cfg.path}${qs ? `?${qs}` : ''}`;
+ if (location.pathname + location.search !== url) history.replaceState(history.state, '', url);
+ }
+
+ function restart() {
+ S.gen++;
+ try { S.abort.abort(); } catch { /* */ }
+ S.abort = new AbortController();
+ S.clips.forget();
+ S.next = null; S.done = false; S.loading = false; S.halted = false;
+ S.seen = new Set(); S.cards = []; S.skels = []; S.lastVod = null;
+ buildColumns(columnCount());
+ foot(null);
+ syncControls();
+ syncUrl();
+ if (S.sel && typeof _selSetContext === 'function') _selSetContext(true, () => S && restart());
+ loadMore();
+ }
+
+ /** A new filter starts a new list: from its top, if the reader had scrolled past it. */
+ function refilter() {
+ restart();
+ const top = S.wrap.querySelector('.cf-bar').getBoundingClientRect().top;
+ if (top < 0 || S.wrap.getBoundingClientRect().top < 0) window.scrollTo({ top: Math.max(0, S.wrap.getBoundingClientRect().top + window.scrollY) });
+ }
+
+ function onControl(e) {
+ const b = e.target.closest('button');
+ if (!b || !S || !S.wrap.contains(b)) return;
+ if (b.dataset.cfType && b.dataset.cfType !== S.type && S.cfg.types.includes(b.dataset.cfType)) { S.type = b.dataset.cfType; refilter(); }
+ else if (b.dataset.cfSort && b.dataset.cfSort !== S.sort) { S.sort = b.dataset.cfSort === 'top' ? 'top' : 'new'; refilter(); }
+ else if (b.dataset.cfWindow && b.dataset.cfWindow !== S.win && WINDOWS.includes(b.dataset.cfWindow)) { S.win = b.dataset.cfWindow; refilter(); }
+ else if (b.hasAttribute('data-cf-new-paste')) {
+ const go = () => { if (typeof openNewPasteModal === 'function') openNewPasteModal(); };
+ if (window.ov) ov.load('pastes').then(go, () => {}); else go();
+ }
+ }
+
+ function onFeedClick(e) {
+ const sound = e.target.closest('.cf-sound');
+ const clipBox = e.target.closest('.cf-media--clip');
+ if (clipBox && (sound || !e.target.closest('a'))) {
+ const v = clipBox.querySelector('video');
+ if (v) { e.preventDefault(); S.clips.tap(v); }
+ return;
+ }
+ const a = e.target.closest('a[data-spa]');
+ if (a && typeof handleLinkClick === 'function') handleLinkClick(e, a.getAttribute('href'));
+ }
+
+ // ── entry ──────────────────────────────────────────────────────────────────────────────
+ function open(feed, opts) {
+ const cfg = FEEDS[feed];
+ const section = document.getElementById(cfg.page);
+ const wrap = section && section.querySelector('.cf-wrap');
+ if (!wrap) return;
+ const scope = window.ov ? ov.scope() : { observe: (o) => o, onDispose() {}, listen(t, n, f, o) { t.addEventListener(n, f, o); } };
+ const params = new URLSearchParams(location.search);
+ const want = (opts && opts.type) || params.get('type');
+ S = {
+ feed, cfg, wrap, scope,
+ feedEl: wrap.querySelector('.cf-feed'), footEl: wrap.querySelector('.cf-foot'), sentinel: wrap.querySelector('.cf-sentinel'),
+ type: cfg.types.includes(want) ? want : 'all',
+ sort: params.get('sort') === 'top' ? 'top' : 'new',
+ win: WINDOWS.includes(params.get('window')) ? params.get('window') : 'month',
+ gen: 0, abort: new AbortController(), cols: [], cards: [], skels: [], seen: new Set(), next: null, done: false, loading: false, halted: false, lastVod: null,
+ sel: typeof _isContentAdmin === 'function' && _isContentAdmin(),
+ };
+ const st = S;
+ st.clips = clipController(scope);
+ // Leaving the route: stop the fetch, the players and the observers; this state is dead.
+ scope.onDispose(() => {
+ try { st.abort.abort(); } catch { /* */ }
+ if (st.sel && typeof _selSetContext === 'function') _selSetContext(false);
+ if (S === st) S = null;
+ });
+ scope.listen(wrap.querySelector('.cf-bar'), 'click', onControl);
+ scope.listen(st.feedEl, 'click', onFeedClick);
+ scope.listen(wrap.querySelector('.cf-head'), 'click', (e) => {
+ const a = e.target.closest('a[data-cf-link]');
+ if (a && typeof handleLinkClick === 'function') handleLinkClick(e, a.getAttribute('href'));
+ });
+ let resizeRaf = 0;
+ scope.listen(window, 'resize', () => { cancelAnimationFrame(resizeRaf); resizeRaf = requestAnimationFrame(relayout); }, { passive: true });
+ if ('IntersectionObserver' in window) {
+ const io = scope.observe(new IntersectionObserver((entries) => {
+ if (entries.some((e) => e.isIntersecting)) loadMore();
+ }, { rootMargin: '900px 0px' }));
+ io.observe(st.sentinel);
+ // Re-observing makes the observer report the sentinel's current state once more.
+ st.watchEnd = () => { io.unobserve(st.sentinel); io.observe(st.sentinel); };
+ } else st.watchEnd = () => {};
+ restart();
+ }
+
+ window.loadContentPage = (opts) => open('content', opts || {});
+ window.loadMomentsPage = (opts) => open('moments', opts || {});
+})();
@@ -17,8 +17,8 @@
@@ -17,8 +17,8 @@
heading: 'Watch',
heading: 'Watch',
items: [
items: [
{ name: 'Live streams', url: '/', onclick: "return handleLinkClick(event, '/')" },
{ name: 'Live streams', url: '/', onclick: "return handleLinkClick(event, '/')" },
- { name: 'Stream VODs', url: '/vods', onclick: "return handleLinkClick(event, '/vods')" },
- { name: 'Clips', url: '/clips', onclick: "return handleLinkClick(event, '/clips')" },
+ { name: 'VODs, clips & pastes', url: '/content', onclick: "return handleLinkClick(event, '/content')" },
+ { name: 'AI Moments', url: '/moments', onclick: "return handleLinkClick(event, '/moments')" },
{ name: 'The Arena', url: '/arena', onclick: "return handleLinkClick(event, '/arena')" },
{ name: 'The Arena', url: '/arena', onclick: "return handleLinkClick(event, '/arena')" },
{ name: 'Global chat', url: '/chat', onclick: "return handleLinkClick(event, '/chat')" },
{ name: 'Global chat', url: '/chat', onclick: "return handleLinkClick(event, '/chat')" },
],
],
@@ -20,9 +20,9 @@
@@ -20,9 +20,9 @@
const staff = !!(user && ((user.capabilities && user.capabilities.admin_panel) || (typeof hasCapability === 'function' && hasCapability('can_access_staff_console'))));
const staff = !!(user && ((user.capabilities && user.capabilities.admin_panel) || (typeof hasCapability === 'function' && hasCapability('can_access_staff_console'))));
return [
return [
{ id: 'home', page: 'home', label: 'Home', href: '/', icon: 'fa-house' },
{ id: 'home', page: 'home', label: 'Home', href: '/', icon: 'fa-house' },
- { id: 'vods', page: 'vods', label: 'VODs', href: '/vods', icon: 'fa-video' },
- { id: 'clips', page: 'clips', label: 'Clips', href: '/clips', icon: 'fa-scissors' },
- { id: 'pastes', page: 'pastes', label: 'Pastes', href: '/pastes', icon: 'fa-paste' },
+ // VODs, clips and pastes people made are one feed; what the AI made is the other.
+ { id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },
+ { id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },
{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
{ id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
{ id: 'game', page: 'game', label: 'Game', href: 'https://openvibe.games', icon: 'fa-gamepad' },
{ id: 'game', page: 'game', label: 'Game', href: 'https://openvibe.games', icon: 'fa-gamepad' },
@@ -5,17 +5,11 @@
@@ -5,17 +5,11 @@
═══════════════════════════════════════════════════════════════ */
═══════════════════════════════════════════════════════════════ */
// ── State ───────────────────────────────────────────────────
// ── State ───────────────────────────────────────────────────
-let _pastesLoaded = false;
-let _pastesOffset = 0;
-let _pastesFilter = 'all'; // 'all' | 'paste' | 'screenshot'
-let _pastesUser = 'all'; // 'all' | <username>
-let _pastesSearch = '';
-let _pastesSort = 'newest'; // 'newest' | 'oldest'
-let _pastesTotal = 0;
+// The paste list is the Content feed's Pastes filter now (public/js/content-feed.js); this file
+// keeps the viewer, the editor, screenshots and the card the dashboard uses.
let _pastesCooldownUntil = 0; // timestamp (ms) until next paste allowed
let _pastesCooldownUntil = 0; // timestamp (ms) until next paste allowed
let _pasteLimitCache = null; // cached config from /pastes/config
let _pasteLimitCache = null; // cached config from /pastes/config
let _pasteLimitCacheTime = 0;
let _pasteLimitCacheTime = 0;
-const PASTES_PER_PAGE = 30;
const PASTE_LIMIT_CACHE_TTL = 60000; // 1 minute
const PASTE_LIMIT_CACHE_TTL = 60000; // 1 minute
// ── Syntax highlighting (lightweight, no lib needed) ────────
// ── Syntax highlighting (lightweight, no lib needed) ────────
@@ -51,85 +45,6 @@ function highlightSyntax(code, lang) {
@@ -51,85 +45,6 @@ function highlightSyntax(code, lang) {
}
}
// ── Load pastes index page ──────────────────────────────────
// ── Load pastes index page ──────────────────────────────────
-function loadPastesPage() {
- _pastesOffset = 0;
- _pastesFilter = 'all';
- _pastesUser = 'all';
- _pastesSearch = '';
- _pastesSort = 'newest';
- _pastesLoaded = true;
-
- const grid = document.getElementById('pastes-grid');
- if (grid) grid.innerHTML = '<div class="loading-spinner"><i class="fa-solid fa-circle-notch fa-spin"></i></div>';
-
- fetchPastes();
-}
-
-async function fetchPastes() {
- const grid = document.getElementById('pastes-grid');
- if (!grid) return;
-
- try {
- let url = `/pastes?limit=${PASTES_PER_PAGE}&offset=${_pastesOffset}`;
- if (_pastesFilter !== 'all') url += `&type=${_pastesFilter}`;
- if (_pastesUser !== 'all') url += `&username=${encodeURIComponent(_pastesUser)}`;
- if (_pastesSearch) url += `&search=${encodeURIComponent(_pastesSearch)}`;
- if (_pastesSort === 'oldest') url += `&sort=oldest`;
-
- const data = await api(url);
- _pastesTotal = data.total || 0;
-
- // Per-user filter bar (reuses the shared VOD/clip filter renderer).
- if (typeof renderMediaStreamerFilters === 'function') {
- renderMediaStreamerFilters({
- barId: 'pastes-streamer-filters',
- streamers: data.users || [],
- activeFilter: _pastesUser,
- onSelect: 'setPastesUserFilter',
- countKey: 'paste_count',
- allLabel: 'All users',
- });
- }
-
- document.getElementById('pastes-count').textContent = `${_pastesTotal} item${_pastesTotal !== 1 ? 's' : ''}`;
-
- if (!data.pastes?.length && _pastesOffset === 0) {
- grid.innerHTML = `
- <div class="empty-state" style="grid-column: 1/-1; text-align:center; padding:48px 20px;">
- <i class="fa-solid fa-paste" style="font-size:2.5rem; opacity:0.3; margin-bottom:12px;"></i>
- <p style="opacity:0.5;">No pastes yet. Share something!</p>
- </div>`;
- return;
- }
-
- grid.innerHTML = data.pastes.map(p => renderPasteCard(p)).join('');
-
- // Admin bulk-select (ctrl/shift multi-select + bulk delete) on the pastes index,
- // mirroring the global VODs/Clips pages. No-op for non-admins (cards stay plain links).
- if (typeof _selSetContext === 'function' && typeof _isContentAdmin === 'function') {
- _selSetContext(_isContentAdmin(), fetchPastes);
- }
-
- // Pagination
- const pagEl = document.getElementById('pastes-pagination');
- if (pagEl) {
- const totalPages = Math.ceil(_pastesTotal / PASTES_PER_PAGE);
- const currentPage = Math.floor(_pastesOffset / PASTES_PER_PAGE) + 1;
- const sortHtml = (typeof sortToggleHTML === 'function') ? sortToggleHTML(_pastesSort, 'pastesSort') : '';
- let pageHtml = '';
- if (totalPages > 1) {
- if (currentPage > 1) pageHtml += `<button class="btn btn-outline btn-sm" onclick="pastesPaginate(${_pastesOffset - PASTES_PER_PAGE})"><i class="fa-solid fa-chevron-left"></i> Prev</button>`;
- pageHtml += `<span class="pastes-page-info">Page ${currentPage} of ${totalPages}</span>`;
- if (currentPage < totalPages) pageHtml += `<button class="btn btn-outline btn-sm" onclick="pastesPaginate(${_pastesOffset + PASTES_PER_PAGE})">Next <i class="fa-solid fa-chevron-right"></i></button>`;
- }
- pagEl.innerHTML = sortHtml + pageHtml;
- pagEl.style.display = (sortHtml || pageHtml) ? '' : 'none';
- }
- } catch (err) {
- grid.innerHTML = `<div class="empty-state" style="grid-column:1/-1;text-align:center;padding:48px;"><p style="color:var(--danger);">Failed to load pastes</p></div>`;
- }
-}
-
function renderPasteCard(p) {
function renderPasteCard(p) {
const isScreenshot = p.type === 'screenshot';
const isScreenshot = p.type === 'screenshot';
const timeAgo = formatTimeAgo(p.created_at);
const timeAgo = formatTimeAgo(p.created_at);
@@ -175,45 +90,6 @@ function renderPasteCard(p) {
@@ -175,45 +90,6 @@ function renderPasteCard(p) {
return (typeof _selWrap === 'function') ? _selWrap('paste', p.slug, _cardInner, !!p.owner_is_owner) : _cardInner;
return (typeof _selWrap === 'function') ? _selWrap('paste', p.slug, _cardInner, !!p.owner_is_owner) : _cardInner;
}
}
-function filterPastes(type) {
- _pastesFilter = type;
- _pastesOffset = 0;
- document.querySelectorAll('.pastes-filter-btn').forEach(b => b.classList.toggle('active', b.dataset.filter === type));
- fetchPastes();
-}
-
-function setPastesUserFilter(username = 'all') {
- const next = String(username || 'all').trim() || 'all';
- if (next === _pastesUser) return;
- _pastesUser = next;
- _pastesOffset = 0;
- fetchPastes();
- const top = document.getElementById('page-pastes');
- if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
-}
-
-function searchPastes() {
- const input = document.getElementById('pastes-search-input');
- _pastesSearch = input?.value?.trim() || '';
- _pastesOffset = 0;
- fetchPastes();
-}
-
-function pastesPaginate(offset) {
- _pastesOffset = Math.max(0, offset);
- fetchPastes();
- document.getElementById('page-pastes')?.scrollTo(0, 0);
-}
-
-function pastesSort(sort) {
- const s = sort === 'oldest' ? 'oldest' : 'newest';
- if (s === _pastesSort) return;
- _pastesSort = s;
- _pastesOffset = 0;
- fetchPastes();
-}
-
-
// ── Markdown rendering ──────────────────────────────────────────────────────────
// ── Markdown rendering ──────────────────────────────────────────────────────────
// A small self-contained renderer. Paste content is arbitrary user input, so the
// A small self-contained renderer. Paste content is arbitrary user input, so the
// order here is not negotiable: escape EVERYTHING first, then add markup. That way
// order here is not negotiable: escape EVERYTHING first, then add markup. That way
@@ -153,7 +153,9 @@ async function _checkLiveStream(stream) {
@@ -153,7 +153,9 @@ async function _checkLiveStream(stream) {
stream_id: streamId, auto_generated: true, description: verdict.desc || '',
stream_id: streamId, auto_generated: true, description: verdict.desc || '',
}).catch((e) => { console.warn(`[AutoClip] Media createClip failed for stream ${streamId}:`, e.message); return null; });
}).catch((e) => { console.warn(`[AutoClip] Media createClip failed for stream ${streamId}:`, e.message); return null; });
if (clip) {
if (clip) {
- _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: start, title, clip_id: clip.id, sig: _sceneSig(verdict.desc || title) });
+ // deduplicated: Media handed back a clip someone already cut of this window. It stays in
+ // the log for the caps, but it is theirs, not the AI's (syncAutoClipFlags skips it).
+ _logClip({ stream_id: streamId, vod_id: rec.vodId, start_time: start, title, clip_id: clip.id, dedup: !!clip.deduplicated || undefined, sig: _sceneSig(verdict.desc || title) });
console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs`);
console.log(`[AutoClip] LIVE clip for stream ${streamId} @${Math.round(momentOffset)}s ("${title}") — spike ${peak.count} msgs`);
}
}
} catch (e) {
} catch (e) {
@@ -297,12 +299,50 @@ async function clipVodMoment(o) {
@@ -297,12 +299,50 @@ async function clipVodMoment(o) {
title: title || 'Standout Moment', user_id: vod.user_id,
title: title || 'Standout Moment', user_id: vod.user_id,
stream_id: vod.stream_id || undefined, auto_generated: true, description: desc || '',
stream_id: vod.stream_id || undefined, auto_generated: true, description: desc || '',
});
});
- if (clip) _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, sig: _sceneSig(desc || title) });
+ if (clip) _logClip({ stream_id: vod.stream_id || null, vod_id: vodId, start_time: start, title: title || 'Standout Moment', clip_id: clip.id, dedup: !!clip.deduplicated || undefined, sig: _sceneSig(desc || title) });
return clip || null;
return clip || null;
} catch { return null; }
} catch { return null; }
}
}
+// ── Tell Media which clips the AI cut ────────────────────────────────────────────────────
+// Media dropped the auto_generated flag Live sent with every auto-clip until it learned to keep it,
+// so those clips were listed as people's clips (the Content feed shows people's work only; the
+// AI's goes to Moments). This log names every auto-clip Live made; each one is marked in Media
+// once (PUT /clips/:id { auto_generated }, app authority) and remembered, so the next run only
+// asks about new ones. A Media that answers without the flag has not been upgraded yet: stop and
+// try again on the next run. A clip Media no longer has is done.
+const FLAG_SYNC_SETTING = 'auto_clip_flags_synced';
+const FLAG_SYNC_INTERVAL_MS = 6 * 60 * 60 * 1000;
+async function syncAutoClipFlags({ max = 400 } = {}) {
+ let done = new Set();
+ try { const d = JSON.parse(db.getState(FLAG_SYNC_SETTING) || '[]'); if (Array.isArray(d)) done = new Set(d.map(String)); } catch { /* */ }
+ const ids = [...new Set(_clipLog().filter((e) => e && !e.dedup).map((e) => e.clip_id).filter((id) => id != null && /^\d+$/.test(String(id))).map(String))]
+ .filter((id) => !done.has(id)).slice(0, max);
+ let marked = 0, gone = 0, pending = 0, skipped = 0;
+ for (const id of ids) {
+ try {
+ // The job cuts in the streamer's name. A clip under anyone else is one Media returned as a
+ // duplicate of a person's clip before the log said so: never relabel a person's work.
+ const cur = await media.getClip(id, { timeoutMs: 10_000 });
+ if (cur && cur.auto_generated === true) { done.add(id); marked++; continue; }
+ if (!cur || (cur.channel_user_id != null && String(cur.user_id) !== String(cur.channel_user_id))) { done.add(id); skipped++; continue; }
+ const out = await media.updateClip(id, { auto_generated: true }, { timeoutMs: 10_000 });
+ const clip = (out && out.clip) || out;
+ if (!clip || clip.auto_generated !== true) { pending = ids.length - marked - gone - skipped; break; } // Media not upgraded yet
+ done.add(id); marked++;
+ } catch (e) {
+ if (e && (e.status === 404 || e.status === 410)) { done.add(id); gone++; continue; }
+ pending = ids.length - marked - gone - skipped;
+ break; // Media down or refusing: the rest wait for the next run
+ }
+ }
+ try { db.setState(FLAG_SYNC_SETTING, JSON.stringify([...done].slice(-2000))); } catch { /* */ }
+ if (marked || gone || skipped) console.log(`[AutoClip] Marked ${marked} auto-clip(s) as AI-made in Media${gone ? `, ${gone} no longer there` : ''}${skipped ? `, ${skipped} left as a person's clip` : ''}${pending ? `, ${pending} left for the next run` : ''}`);
+ return { marked, gone, skipped, pending };
+}
+
let _backfillTimer = null;
let _backfillTimer = null;
+let _flagTimer = null;
function start() {
function start() {
if (_timer) return;
if (_timer) return;
_timer = setInterval(() => { _tick().catch(() => {}); }, CHECK_INTERVAL_MS);
_timer = setInterval(() => { _tick().catch(() => {}); }, CHECK_INTERVAL_MS);
@@ -313,11 +353,16 @@ function start() {
@@ -313,11 +353,16 @@ function start() {
// re-check used to be joined by a second one over the same VODs (double AI + ffmpeg, duplicate
// re-check used to be joined by a second one over the same VODs (double AI + ffmpeg, duplicate
// clips). Jittered so it does not start in lockstep with every other boot-time job.
// clips). Jittered so it does not start in lockstep with every other boot-time job.
_backfillTimer = require('../utils/jobs').every('auto-clip-vod-backfill', 5 * 60 * 1000, () => backfillVodClips(), { initialDelayMs: 30 * 1000, jitterMs: 30 * 1000 });
_backfillTimer = require('../utils/jobs').every('auto-clip-vod-backfill', 5 * 60 * 1000, () => backfillVodClips(), { initialDelayMs: 30 * 1000, jitterMs: 30 * 1000 });
+ _flagTimer = require('../utils/jobs').every('auto-clip-flag-sync', FLAG_SYNC_INTERVAL_MS, () => syncAutoClipFlags(), { initialDelayMs: 90 * 1000, jitterMs: 60 * 1000 });
console.log('[AutoClip] Live auto-clip job started (selective chat-spike + AI agreement, cuts via OpenVibe.Media) + persistent VOD backfill');
console.log('[AutoClip] Live auto-clip job started (selective chat-spike + AI agreement, cuts via OpenVibe.Media) + persistent VOD backfill');
}
}
-function stop() { if (_timer) { clearInterval(_timer); _timer = null; } if (_backfillTimer) { _backfillTimer(); _backfillTimer = null; } }
+function stop() {
+ if (_timer) { clearInterval(_timer); _timer = null; }
+ if (_backfillTimer) { _backfillTimer(); _backfillTimer = null; }
+ if (_flagTimer) { _flagTimer(); _flagTimer = null; }
+}
-module.exports = { start, stop, clipVodMoment, backfillVodClips, _tick };
+module.exports = { start, stop, clipVodMoment, backfillVodClips, syncAutoClipFlags, _tick };
// CLI: force a historical backfill batch, e.g. `node server/ai/auto-clip-job.js --backfill --limit=4`
// CLI: force a historical backfill batch, e.g. `node server/ai/auto-clip-job.js --backfill --limit=4`
if (require.main === module) {
if (require.main === module) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,443 @@
@@ -0,0 +1,443 @@
+'use strict';
+/**
+ * The Content and Moments feeds: one scrolling list merged from several services.
+ *
+ * Content (/content, GET /api/content/feed) what people made: VODs, clips people cut, pastes
+ * people wrote
+ * Moments (/moments, GET /api/content/moments) what the AI made: auto-clips, AI moment pastes
+ * (from VODs and caught live), AI after-show recaps
+ *
+ * Who made it is decided by the service that stores it, never by guessing here:
+ * - clips: OpenVibe.Media's `auto_generated` (set when Live's auto-clip job cuts one; Media's
+ * ?auto_generated=1|0 filters by it). Clips made before Media kept the flag are corrected from
+ * Live's own auto-clip log (auto-clip-job.js syncAutoClipFlags).
+ * - pastes: OpenVibe.Community's `origin` ('ai' for Live's AI jobs, which post with X-OV-Origin: ai;
+ * ?origin=user|ai filters by it).
+ * - VODs are recordings of people streaming: always Content.
+ * - recaps: Live's own stream_recaps rows whose write-up the AI produced (ai = 1); template
+ * recaps are stats, not generated content, and are listed nowhere.
+ * Every source filters server-side, so a page is never a full page with the other kind taken out.
+ *
+ * Privacy: public items only. Media lists without include_private (is_public = 1), Community lists
+ * for an anonymous service call (public, not burn-after-read), and each row is checked again here
+ * (lookups.isPublic) before it is shown. Items of banned accounts are left out.
+ *
+ * Paging: `cursor` carries each source's offset (and, for Top, the window's start, so a window
+ * does not slide while someone scrolls). A page fetches the next `limit + SLACK` rows of every
+ * source, merges them by the sort (newest first, or score: views, a paste like worth five views, a
+ * recap's peak viewers), and stops when a source that has more upstream runs out of fetched rows,
+ * so the order across pages is exact. A new item arriving between pages can repeat one card (the
+ * client drops repeated keys); none is skipped.
+ *
+ * Cost: every source page is cached (30 s for New, 2 min for Top) and shared by concurrent
+ * requests. A page waits at most DEADLINE_MS for Media and Community; a source that has not
+ * answered by then is left out of this page (its offset stays, so the next page asks again) and
+ * the answer says which (`sources`, `partial`). Its fetch keeps running and fills the cache.
+ */
+const db = require('../db/database');
+const media = require('../media-client');
+const pastesClient = require('../pastes-client');
+const lookups = require('../media-proxy/lookups');
+const { publicFeedItem } = require('../web/serializers');
+
+const DEADLINE_MS = 4000;
+const SOURCE_TIMEOUT_MS = 3500;
+const DEFAULT_LIMIT = 12;
+const MAX_LIMIT = 30;
+const SLACK = 4; // rows fetched beyond the page, so a skipped row rarely shortens it
+const MAX_OFFSET = 10_000; // per source; deeper than anyone scrolls, and bounds crafted cursors
+const TTL_NEW_MS = 30_000;
+const TTL_TOP_MS = 120_000;
+const CACHE_MAX = 400;
+
+const WINDOWS = { week: 7, month: 30, all: null };
+const DEFAULT_WINDOW = 'month';
+
+/** Which sources each feed and each of its filters reads. */
+const FEEDS = {
+ content: { types: { all: ['vods', 'clips', 'pastes'], vods: ['vods'], clips: ['clips'], pastes: ['pastes'] } },
+ moments: { types: { all: ['aiclips', 'aipastes', 'recaps'], clips: ['aiclips'], shots: ['aipastes'], recaps: ['recaps'] } },
+};
+
+// ── helpers ──────────────────────────────────────────────────────────────────────────────────
+
+/** SQLite 'YYYY-MM-DD HH:MM:SS' (UTC) or ISO → ms (0 when unreadable). */
+function ts(s) {
+ if (!s) return 0;
+ const str = String(s);
+ const n = Date.parse(/[TZ]|[+-]\d\d:?\d\d$/.test(str) ? str : `${str.replace(' ', 'T')}Z`);
+ return Number.isFinite(n) ? n : 0;
+}
+const iso = (s) => { const n = ts(s); return n ? new Date(n).toISOString() : null; };
+const num = (v) => (Number.isFinite(Number(v)) ? Number(v) : 0);
+const clean = (s, max) => {
+ const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
+ return max && t.length > max ? `${t.slice(0, max - 1).replace(/\s+\S*$/, '')}…` : t;
+};
+const sqlTime = (ms) => new Date(ms).toISOString().replace('T', ' ').slice(0, 19);
+function parseJson(v) {
+ if (!v) return null;
+ if (typeof v === 'object') return v;
+ try { const o = JSON.parse(v); return o && typeof o === 'object' ? o : null; } catch { return null; }
+}
+
+const _users = new Map(); // per page build: id/username → row (or null)
+function userById(id) {
+ if (id == null || id === '') return null;
+ const key = `i:${id}`;
+ if (!_users.has(key)) { let u = null; try { u = db.getUserById(Number(id)) || null; } catch { /* */ } _users.set(key, u); }
+ return _users.get(key);
+}
+function userByName(name) {
+ if (!name) return null;
+ const key = `n:${String(name).toLowerCase()}`;
+ if (!_users.has(key)) { let u = null; try { u = db.getUserByUsername(String(name)) || null; } catch { /* */ } _users.set(key, u); }
+ return _users.get(key);
+}
+const banned = (u) => !!(u && (u.is_banned === 1 || u.is_banned === true));
+/** The public face of a Live account, with its channel link. */
+function person(u) {
+ if (!u || !u.username) return null;
+ return { username: u.username, display_name: u.display_name || u.username, avatar_url: u.avatar_url || null, profile_color: u.profile_color || null, href: `/@${u.username}` };
+}
+
+// ── sources ──────────────────────────────────────────────────────────────────────────────────
+// Each: load(offset, n, { sort, since }) → { rows, total|null }, and item(row) → feed item or null
+// (null = not shown, still consumed). `score` must follow the upstream order for Top.
+
+const mediaRows = (r, key) => (r && Array.isArray(r[key]) ? r[key] : []);
+const mediaTotal = (r) => (r && Number.isFinite(Number(r.total)) ? Number(r.total) : null);
+
+/** A VOD row that is not worth a card: failed, still recording, or an empty shell nothing will fill. */
+function unplayableVod(v) {
+ if (!v || v.is_recording || v.clips_only || v.status === 'failed' || v.status === 'recording') return true;
+ if (v.status !== 'pending') return false;
+ if (num(v.duration_seconds ?? v.duration) > 0 || num(v.file_size) > 0 || v.thumbnail_url) return false;
+ return Date.now() - ts(v.created_at) > 10 * 60 * 1000;
+}
+
+function vodItem(v) {
+ if (!lookups.isPublic(v) || unplayableVod(v)) return null;
+ const owner = userById(v.user_id);
+ if (banned(owner)) return null;
+ // Content is what people made: the card carries no AI overview (the VOD page still has it).
+ return {
+ kind: 'vod', id: Number(v.id), href: `/vod/${Number(v.id)}`,
+ title: clean(v.title, 140) || 'Stream recording',
+ created_at: iso(v.created_at), views: num(v.view_count),
+ duration_seconds: Math.round(num(v.duration_seconds ?? v.duration)),
+ thumbnail_url: media.publicUrl(v.thumbnail_url) || null,
+ excerpt: v.description ? clean(v.description, 220) : null,
+ channel: person(owner), ai: false,
+ };
+}
+
+function clipItem(c, { ai }) {
+ if (!lookups.isPublic(c)) return null;
+ if (c.status && c.status !== 'ready') return null;
+ if (!!c.auto_generated !== ai) return null; // an upstream that ignored the filter never mixes the feeds
+ const channelOwner = userById(c.channel_user_id != null ? c.channel_user_id : c.user_id);
+ const creator = userById(c.user_id);
+ if (banned(channelOwner) || banned(creator)) return null;
+ // An AI clip's card says what the AI saw; a person's clip carries only what they wrote.
+ let summary = null;
+ if (ai) { try { const s = db.getClipAiState(c.id); summary = s && (s.ai_overview_short || s.ai_overview); } catch { /* */ } }
+ const by = !ai && creator && channelOwner && creator.id !== channelOwner.id ? person(creator) : null;
+ return {
+ kind: 'clip', id: Number(c.id), href: `/clip/${Number(c.id)}`,
+ title: clean(c.title, 140) || 'Clip',
+ created_at: iso(c.created_at), views: num(c.view_count),
+ duration_seconds: Math.round(num(c.duration_seconds ?? c.duration)),
+ thumbnail_url: media.publicUrl(c.thumbnail_url) || null,
+ preview_url: media.publicUrl(c.playback_url) || media.clipUrl(c.id),
+ excerpt: summary ? clean(summary, 220) : (c.description ? clean(c.description, 220) : null),
+ channel: person(channelOwner || creator), by, ai,
+ ai_label: ai ? 'Auto-clip' : null,
+ };
+}
+
+const VOD_LINK_RE = /^\/vod\/\d+(\?t=\d+(\.\d+)?)?$/;
+function pasteItem(p, { ai }) {
+ if (!p || p.visibility !== 'public' || p.burn_after_read) return null;
+ if ((p.origin === 'ai') !== ai) return null;
+ const meta = parseJson(p.metadata) || {};
+ let channel = null;
+ if (ai) {
+ // AI pastes have no author; they belong to the stream they came from.
+ let stream = null;
+ try { stream = p.stream_id ? db.getStreamById(Number(p.stream_id)) : null; } catch { /* */ }
+ const owner = stream ? userById(stream.user_id) : userByName(meta.username);
+ if (banned(owner)) return null;
+ channel = person(owner);
+ } else {
+ // The author is a network account: their Live channel when they have one, else just the name.
+ // Community's avatar is used only as an absolute https URL (it may be relative to Community).
+ const local = userByName(p.username);
+ if (banned(local)) return null;
+ const theirAvatar = typeof p.avatar_url === 'string' && /^https:\/\//i.test(p.avatar_url) ? p.avatar_url : null;
+ channel = local ? person(local)
+ : (p.username ? { username: p.username, display_name: p.display_name || p.username, avatar_url: theirAvatar, profile_color: p.profile_color || null, href: null } : null);
+ if (channel && local && !channel.avatar_url) channel.avatar_url = theirAvatar;
+ }
+ const shot = p.type === 'screenshot';
+ // People's pastes show their own words; the AI summary is shown on AI pastes only.
+ const text = ai ? (p.ai_summary || p.content || null) : (shot ? null : (p.content || null));
+ return {
+ kind: 'paste', id: String(p.slug), href: `/p/${encodeURIComponent(p.slug)}`,
+ title: clean(p.title, 140) || (shot ? 'Image' : 'Paste'),
+ created_at: iso(p.created_at), views: num(p.views), likes: num(p.likes),
+ paste_type: shot ? 'image' : 'text',
+ language: shot ? null : (p.language || 'text'),
+ image_url: shot ? (media.publicUrl(p.screenshot_url) || null) : null,
+ excerpt: text ? (shot || ai ? clean(text, 260) : String(text).slice(0, 600)) : null,
+ nsfw: !!p.is_nsfw,
+ channel, ai,
+ ai_label: ai ? (meta.live ? 'Caught live' : 'AI moment') : null,
+ moment_href: ai && typeof meta.vod_link === 'string' && VOD_LINK_RE.test(meta.vod_link) ? meta.vod_link : null,
+ };
+}
+
+function recapItem(r) {
+ const j = parseJson(r.json);
+ if (!j || !j.write || !j.stream) return null;
+ const owner = userById(r.user_id);
+ if (!owner || banned(owner)) return null;
+ return {
+ kind: 'recap', id: Number(r.stream_id), href: `/recap/${Number(r.stream_id)}`,
+ title: clean(j.write.headline || j.stream.title, 140) || 'After-show report',
+ created_at: iso(r.created_at), views: num(j.stream.peak_viewers),
+ duration_seconds: Math.round(num(j.stream.duration_seconds)),
+ thumbnail_url: j.vod && j.vod.thumbnail_url ? media.publicUrl(j.vod.thumbnail_url) : null,
+ excerpt: j.write.summary ? clean(j.write.summary, 260) : null,
+ grade: ['S', 'A', 'B', 'C'].includes(j.write.grade) ? j.write.grade : null,
+ stream_title: clean(j.stream.title, 120) || null,
+ channel: person(owner), ai: true, ai_label: 'AI recap',
+ };
+}
+
+const SOURCES = {
+ vods: {
+ async load(offset, n, { sort, since }) {
+ const r = await media.listVods({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined }, { timeoutMs: SOURCE_TIMEOUT_MS });
+ return { rows: mediaRows(r, 'vods'), total: mediaTotal(r) };
+ },
+ item: vodItem,
+ score: (it) => it.views,
+ },
+ clips: {
+ async load(offset, n, { sort, since }) {
+ const r = await media.listClips({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined, auto_generated: 0, status: 'ready' }, { timeoutMs: SOURCE_TIMEOUT_MS });
+ return { rows: mediaRows(r, 'clips'), total: mediaTotal(r) };
+ },
+ item: (c) => clipItem(c, { ai: false }),
+ score: (it) => it.views,
+ },
+ aiclips: {
+ async load(offset, n, { sort, since }) {
+ const r = await media.listClips({ limit: n, offset, order: sort === 'top' ? 'views' : 'newest', since: since || undefined, auto_generated: 1, status: 'ready' }, { timeoutMs: SOURCE_TIMEOUT_MS });
+ return { rows: mediaRows(r, 'clips'), total: mediaTotal(r) };
+ },
+ item: (c) => clipItem(c, { ai: true }),
+ score: (it) => it.views,
+ },
+ pastes: {
+ load: (offset, n, opts) => communityPastes('user', offset, n, opts),
+ item: (p) => pasteItem(p, { ai: false }),
+ score: (it) => it.views + 5 * it.likes,
+ },
+ aipastes: {
+ load: (offset, n, opts) => communityPastes('ai', offset, n, opts),
+ item: (p) => pasteItem(p, { ai: true }),
+ score: (it) => it.views + 5 * it.likes,
+ },
+ recaps: {
+ async load(offset, n, { sort, since }) {
+ try { require('../recap/recap').ensureTable(); } catch { /* */ }
+ const where = ['r.ai = 1', 'COALESCE(u.is_banned, 0) = 0'];
+ const params = [];
+ if (since) { where.push('datetime(r.created_at) >= datetime(?)'); params.push(since); }
+ const order = sort === 'top'
+ ? "COALESCE(json_extract(r.json, '$.stream.peak_viewers'), 0) DESC, r.created_at DESC, r.stream_id DESC"
+ : 'r.created_at DESC, r.stream_id DESC';
+ const from = `FROM stream_recaps r JOIN users u ON u.id = r.user_id WHERE ${where.join(' AND ')}`;
+ const rows = db.all(`SELECT r.stream_id, r.user_id, r.json, r.created_at ${from} ORDER BY ${order} LIMIT ? OFFSET ?`, [...params, n, offset]) || [];
+ const total = (db.get(`SELECT COUNT(*) AS n ${from}`, params) || {}).n || 0;
+ return { rows, total };
+ },
+ item: recapItem,
+ score: (it) => it.views,
+ },
+};
+
+/** People's or AI pastes from OpenVibe.Community. Pastes are only listed once Community owns them. */
+async function communityPastes(origin, offset, n, { sort, since }) {
+ if (!pastesClient.onCommunity()) { const e = new Error('pastes are listed from OpenVibe.Community only'); e.unavailable = true; throw e; }
+ const r = await pastesClient.request('GET', '', {
+ query: { origin, limit: n, offset, sort: sort === 'top' ? 'top' : 'newest', since: since || undefined, pinned_first: 0 },
+ timeoutMs: SOURCE_TIMEOUT_MS,
+ });
+ return { rows: mediaRows(r, 'pastes'), total: mediaTotal(r) };
+}
+
+// ── cache ────────────────────────────────────────────────────────────────────────────────────
+
+const _pages = new Map(); // key → { at, ttl, value, pending }
+/** A source page, fetched at most once at a time and kept for `ttl`. Failures are not kept. */
+function cachedPage(key, ttl, load) {
+ const hit = _pages.get(key);
+ if (hit && hit.value && Date.now() - hit.at < ttl) return Promise.resolve(hit.value);
+ if (hit && hit.pending) return hit.pending;
+ const entry = { at: 0, ttl, value: null, pending: null };
+ entry.pending = Promise.resolve().then(load).then(
+ (value) => { entry.value = value; entry.at = Date.now(); entry.pending = null; return value; },
+ (err) => { if (_pages.get(key) === entry) _pages.delete(key); throw err; },
+ );
+ _pages.delete(key);
+ _pages.set(key, entry);
+ if (_pages.size > CACHE_MAX) _pages.delete(_pages.keys().next().value);
+ return entry.pending;
+}
+
+// ── cursor ───────────────────────────────────────────────────────────────────────────────────
+
+function encodeCursor(c) { return Buffer.from(JSON.stringify(c)).toString('base64url'); }
+function decodeCursor(s) {
+ if (!s) return null;
+ try {
+ const c = JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
+ return c && typeof c === 'object' && c.o && typeof c.o === 'object' ? c : null;
+ } catch { return null; }
+}
+
+class FeedError extends Error {
+ constructor(status, message) { super(message); this.status = status; }
+}
+
+/** The request's feed options, validated. Throws FeedError(400). */
+function parseQuery(feed, q = {}) {
+ const def = FEEDS[feed];
+ if (!def) throw new FeedError(404, 'No such feed');
+ const type = String(q.type || 'all').toLowerCase();
+ if (!def.types[type]) throw new FeedError(400, `type must be one of: ${Object.keys(def.types).join(', ')}`);
+ const sort = String(q.sort || 'new').toLowerCase();
+ if (sort !== 'new' && sort !== 'top') throw new FeedError(400, 'sort must be new or top');
+ let windowName = null;
+ if (sort === 'top') {
+ windowName = String(q.window || DEFAULT_WINDOW).toLowerCase();
+ if (!(windowName in WINDOWS)) throw new FeedError(400, 'window must be week, month or all');
+ }
+ const limit = Math.min(MAX_LIMIT, Math.max(1, parseInt(q.limit, 10) || DEFAULT_LIMIT));
+ const key = `${feed}|${type}|${sort}|${windowName || ''}`;
+ const cursor = q.cursor ? decodeCursor(q.cursor) : null;
+ if (q.cursor && (!cursor || cursor.k !== key)) throw new FeedError(400, 'This cursor belongs to another feed or is damaged; start from the first page');
+ // The window starts on the hour (so pages share cache entries) and stays put across pages.
+ let since = null;
+ if (windowName && WINDOWS[windowName]) {
+ since = cursor && typeof cursor.s === 'string' && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(cursor.s) ? cursor.s
+ : sqlTime(Math.floor((Date.now() - WINDOWS[windowName] * 86400_000) / 3600_000) * 3600_000);
+ }
+ const offsets = {};
+ for (const s of def.types[type]) {
+ const o = cursor ? parseInt(cursor.o[s], 10) : 0;
+ offsets[s] = Number.isFinite(o) && o > 0 ? Math.min(o, MAX_OFFSET) : 0;
+ }
+ return { feed, type, sort, window: windowName, limit, key, since, offsets, sources: def.types[type] };
+}
+
+/** Newest first; Top: higher score first, then newest. Ties by kind and id so the order is total. */
+function comparator(sort) {
+ const kindRank = { vod: 0, clip: 1, paste: 2, recap: 3 };
+ return (a, b) => {
+ if (sort === 'top' && b.score !== a.score) return b.score - a.score;
+ const ta = ts(a.item.created_at), tb = ts(b.item.created_at);
+ if (tb !== ta) return tb - ta;
+ if (a.item.kind !== b.item.kind) return kindRank[a.item.kind] - kindRank[b.item.kind];
+ return String(b.item.id).localeCompare(String(a.item.id), undefined, { numeric: true });
+ };
+}
+
+/**
+ * One page of a feed. → { feed, type, sort, window, items, next, sources, partial }
+ * Never rejects for an upstream failure; throws FeedError for a bad request.
+ */
+async function page(feed, query = {}) {
+ const q = parseQuery(feed, query);
+ const n = q.limit + SLACK;
+ const ttl = q.sort === 'top' ? TTL_TOP_MS : TTL_NEW_MS;
+
+ // Every source, raced against one deadline.
+ let timer;
+ const deadline = new Promise((resolve) => { timer = setTimeout(resolve, DEADLINE_MS, { timedOut: true }); });
+ const results = await Promise.all(q.sources.map((name) => {
+ const offset = q.offsets[name];
+ const key = `${name}|${q.sort}|${q.since || ''}|${offset}|${n}`;
+ const loaded = cachedPage(key, ttl, () => SOURCES[name].load(offset, n, { sort: q.sort, since: q.since }))
+ .then((value) => ({ name, value }), (err) => ({ name, error: err }));
+ return Promise.race([loaded, deadline.then(() => ({ name, error: { timedOut: true } }))]);
+ })).finally(() => clearTimeout(timer));
+
+ _users.clear();
+ const status = {};
+ const heads = [];
+ for (const r of results) {
+ if (r.error) {
+ status[r.name] = r.error.unavailable ? 'unavailable' : (r.error.timedOut ? 'timeout' : 'error');
+ if (!r.error.unavailable && !r.error.timedOut) console.warn(`[Feed] ${q.feed}/${r.name}:`, r.error.message || r.error);
+ continue;
+ }
+ status[r.name] = 'ok';
+ const src = SOURCES[r.name];
+ const rows = r.value.rows || [];
+ const offset = q.offsets[r.name];
+ const total = r.value.total;
+ // An empty answer ends the source whatever its total says: paging could not move past it.
+ const more = rows.length > 0 && (total != null ? offset + rows.length < total : rows.length >= n);
+ heads.push({
+ name: r.name, i: 0, more,
+ entries: rows.map((row) => {
+ let item = null;
+ try { item = src.item(row); } catch (err) { console.warn(`[Feed] ${r.name} row skipped:`, err.message); }
+ return item ? { item, score: src.score(item) } : null;
+ }),
+ });
+ }
+
+ // Merge. Stop as soon as a source with more upstream has no fetched row left: its next row
+ // might belong before anything else still waiting, so this page ends here.
+ const cmp = comparator(q.sort);
+ const items = [];
+ while (items.length < q.limit) {
+ for (const h of heads) while (h.i < h.entries.length && !h.entries[h.i]) h.i++;
+ if (heads.some((h) => h.i >= h.entries.length && h.more)) break;
+ let best = null;
+ for (const h of heads) if (h.i < h.entries.length && (!best || cmp(h.entries[h.i], best.entries[best.i]) < 0)) best = h;
+ if (!best) break;
+ items.push(best.entries[best.i].item);
+ best.i++;
+ }
+ // Skipped rows right behind the last shown one are consumed too.
+ for (const h of heads) while (h.i < h.entries.length && !h.entries[h.i]) h.i++;
+
+ const offsets = { ...q.offsets };
+ let more = false;
+ const failed = [];
+ for (const h of heads) {
+ offsets[h.name] = q.offsets[h.name] + h.i;
+ if (h.more || h.i < h.entries.length) more = true;
+ }
+ for (const name of q.sources) {
+ if (status[name] !== 'ok' && status[name] !== 'unavailable') { failed.push(name); more = true; }
+ }
+ const next = more ? encodeCursor({ k: q.key, o: offsets, s: q.since || undefined, f: failed.length ? failed : undefined }) : null;
+ return {
+ feed: q.feed, type: q.type, sort: q.sort, window: q.window,
+ items: items.map(publicFeedItem),
+ next,
+ sources: status,
+ partial: failed.length > 0,
+ };
+}
+
+function _reset() { _pages.clear(); _users.clear(); }
+
+module.exports = { page, parseQuery, FeedError, FEEDS, WINDOWS, SOURCES, DEADLINE_MS, _reset, _items: { vodItem, clipItem, pasteItem, recapItem } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+/**
+ * /api/content — the Content and Moments feeds (server/content/feed.js).
+ *
+ * GET /feed?type=all|vods|clips|pastes&sort=new|top&window=week|month|all&limit=&cursor=
+ * what people made (VODs, clips people cut, people's pastes)
+ * GET /moments?type=all|clips|shots|recaps&sort=new|top&window=…&limit=&cursor=
+ * what the AI made (auto-clips, AI moment pastes, AI after-show recaps)
+ *
+ * → { feed, type, sort, window, items: [publicFeedItem…], next: cursor|null, sources: {name: status}, partial }
+ *
+ * Public items only and the same answer for everyone, so it is cacheable by browsers and CDNs for
+ * a short while. A bad parameter is 400; an upstream that is down or slow is never an error here
+ * (the page leaves that source out and says so in `sources`).
+ */
+const express = require('express');
+const feed = require('./feed');
+
+const router = express.Router();
+
+function handler(name) {
+ return async (req, res) => {
+ try {
+ const out = await feed.page(name, req.query || {});
+ // A partial page must not be kept: the next request should ask the missing source again.
+ res.set('Cache-Control', out.partial ? 'no-store' : `public, max-age=${out.sort === 'top' ? 60 : 15}`);
+ res.json(out);
+ } catch (err) {
+ if (err instanceof feed.FeedError) return res.status(err.status).json({ error: err.message });
+ console.error(`[Feed] ${name}:`, err && err.message);
+ res.status(500).json({ error: 'Failed to load the feed' });
+ }
+ };
+}
+
+router.get('/feed', handler('content'));
+router.get('/moments', handler('moments'));
+
+module.exports = router;
@@ -604,8 +604,9 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
@@ -604,8 +604,9 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
});
});
-// Pastes are moving to openvibe.community (PASTES_ON_COMMUNITY=1): the old Live URLs keep
+// Pastes are moving to openvibe.community (PASTES_ON_COMMUNITY=1): the old Live paste URLs keep
// working as permanent redirects, so links in chat, search results and clipboards survive.
// working as permanent redirects, so links in chat, search results and clipboards survive.
+// /pastes itself stays here: it is the Content feed with the Pastes filter (public/js/content-feed.js).
const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
if (process.env.PASTES_ON_COMMUNITY === '1') {
if (process.env.PASTES_ON_COMMUNITY === '1') {
// Someone signed in here should arrive signed in there: they go through Community's silent sign-in
// Someone signed in here should arrive signed in there: they go through Community's silent sign-in
@@ -618,7 +619,6 @@ if (process.env.PASTES_ON_COMMUNITY === '1') {
@@ -618,7 +619,6 @@ if (process.env.PASTES_ON_COMMUNITY === '1') {
res.redirect(302, `${COMMUNITY_URL}/auth/login?silent=1&next=${encodeURIComponent(target)}`);
res.redirect(302, `${COMMUNITY_URL}/auth/login?silent=1&next=${encodeURIComponent(target)}`);
};
};
app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));
app.get('/p/:slug', (req, res) => handOver(req, res, `/p/${encodeURIComponent(req.params.slug)}`));
- app.get('/pastes', (req, res) => handOver(req, res, '/pastes'));
}
}
// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
@@ -685,6 +685,7 @@ app.get('/canvas', (req, res) => res.redirect(301, 'https://openvibe.games/canva
@@ -685,6 +685,7 @@ app.get('/canvas', (req, res) => res.redirect(301, 'https://openvibe.games/canva
app.use('/api/game', (req, res) => res.status(410).json({ error: 'Game has moved to https://openvibe.games/game' }));
app.use('/api/game', (req, res) => res.status(410).json({ error: 'Game has moved to https://openvibe.games/game' }));
app.use('/api/meta', metaRoutes);
app.use('/api/meta', metaRoutes);
app.use('/api/pastes', pasteRoutes);
app.use('/api/pastes', pasteRoutes);
+app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feeds
app.use('/api/home', require('./home/routes'));
app.use('/api/home', require('./home/routes'));
app.use('/api/i18n', require('./i18n/routes')); // on-demand translation for viewers (any line → your language)
app.use('/api/i18n', require('./i18n/routes')); // on-demand translation for viewers (any line → your language)
app.use('/api/kiosk', require('./kiosk/routes'));
app.use('/api/kiosk', require('./kiosk/routes'));
@@ -33,8 +33,19 @@ async function _cached(key, fn) {
@@ -33,8 +33,19 @@ async function _cached(key, fn) {
return v;
return v;
}
}
const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
const _vodList = (limit, offset = 0) => _cached(`vl:${limit}:${offset}`, async () => (await media.listVods({ limit, offset }))?.vods || []);
-const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset }))?.clips || []);
-const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public' }))?.pastes || []);
+// People's clips and pastes: the AI's are listed on /moments (server/content/feed.js).
+const _clipList = (limit, offset = 0) => _cached(`cl:${limit}:${offset}`, async () => (await media.listClips({ limit, offset, auto_generated: 0 }))?.clips || []);
+const _pasteList = (limit, offset = 0) => _cached(`pl:${limit}:${offset}`, async () => (await require('../pastes-client').listPastes({ limit, offset, visibility: 'public', origin: 'user' }))?.pastes || []);
+/** The first page of the Content or Moments feed, as list-page items. */
+const _feedList = (feed) => _cached(`feed:${feed}`, async () => {
+ const out = await require('../content/feed').page(feed, { limit: 24 });
+ const kindLabel = { vod: 'VOD', clip: 'clip', paste: 'paste', recap: 'recap' };
+ return (out.items || []).map((it) => ({
+ url: it.href, name: it.title, by: it.channel ? it.channel.display_name : null,
+ meta: [kindLabel[it.kind], it.duration_seconds ? _fmtDur(it.duration_seconds) : null].filter(Boolean).join(' · '),
+ desc: it.excerpt,
+ }));
+});
const { isPrivate } = require('../media-proxy/access');
const { isPrivate } = require('../media-proxy/access');
const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));
const _vodGet = (id) => _cached(`v:${id}`, () => media.getVod(id));
const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));
const _clipGet = (id) => _cached(`c:${id}`, () => media.getClip(id));
@@ -111,7 +122,9 @@ async function _pageMeta(routePath) {
@@ -111,7 +122,9 @@ async function _pageMeta(routePath) {
if (p === '/') return _homeMeta();
if (p === '/') return _homeMeta();
// List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)
// List pages (content sourced from OpenVibe.Media, canonical URLs stay on openvibe.live)
if (p === '/vods') return _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));
if (p === '/vods') return _listMeta('vods', 'VODs', 'Browse recorded live streams (VODs) on OpenVibe.Live — auto-recorded broadcasts with AI overviews and searchable transcripts.', async () => (await _vodList(30) || []).map(v => ({ url: `/vod/${v.id}`, name: v.title || 'VOD', by: v.display_name || v.username, meta: _fmtDur(v.duration_seconds || v.duration), desc: v.ai_overview_short })));
- if (p === '/clips') return _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — highlights clipped by viewers and auto-generated by AI.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));
+ if (p === '/clips') return _listMeta('clips', 'Clips', 'Watch the best clips from OpenVibe.Live live streams — the moments viewers clipped.', async () => (await _clipList(30) || []).map(c => ({ url: `/clip/${c.id}`, name: c.title || 'Clip', by: c.display_name || c.username || c.streamer_username, meta: _fmtDur(c.duration_seconds || c.duration), desc: c.ai_overview_short })));
+ if (p === '/content') return _listMeta('content', 'Content', 'VODs, clips and pastes made by the people of OpenVibe.Live — recorded streams, the moments viewers clipped, and the code, notes and screenshots they shared.', () => _feedList('content'));
+ if (p === '/moments') return _listMeta('moments', 'AI Moments', 'AI-made highlights from OpenVibe.Live streams: auto-clips of the moments chat erupted, standout frames the AI picked, and AI-written after-show recaps. Everything listed here is AI-generated.', () => _feedList('moments'));
if (p === '/pastes') return _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: `/p/${x.slug}`, name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));
if (p === '/pastes') return _listMeta('pastes', 'Pastes', 'Code, text and screenshot pastes shared on OpenVibe.Live — a Pastebin built into the streaming network, with AI summaries.', async () => (await _pasteList(30) || []).map(x => ({ url: `/p/${x.slug}`, name: x.title || 'Paste', by: x.username || 'anon', meta: x.type === 'screenshot' ? 'image' : (x.language || 'text'), desc: x.ai_summary })));
// Detail pages
// Detail pages
@@ -511,7 +524,7 @@ function _cacheSet(key, html) {
@@ -511,7 +524,7 @@ function _cacheSet(key, html) {
_cache.set(key, { html, at: Date.now() });
_cache.set(key, { html, at: Date.now() });
}
}
-const SEO_ROUTE_RE = /^\/(?:$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;
+const SEO_ROUTE_RE = /^\/(?:$|content$|moments$|vods$|clips$|pastes$|arena$|vod\/\d+$|clip\/\d+$|recap\/\d+$|p\/[A-Za-z0-9_-]+$|@[A-Za-z0-9_.-]+$)/;
async function middleware(req, res, next) {
async function middleware(req, res, next) {
if (req.method !== 'GET') return next();
if (req.method !== 'GET') return next();
@@ -553,7 +566,7 @@ function _urlTag(loc, lastmod, changefreq, priority) {
@@ -553,7 +566,7 @@ function _urlTag(loc, lastmod, changefreq, priority) {
async function buildSitemap() {
async function buildSitemap() {
const urls = [];
const urls = [];
const seenChannels = new Set();
const seenChannels = new Set();
- const statics = [['/', 'daily', '1.0'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];
+ const statics = [['/', 'daily', '1.0'], ['/content', 'hourly', '0.9'], ['/moments', 'hourly', '0.7'], ['/vods', 'hourly', '0.8'], ['/clips', 'hourly', '0.8'], ['/pastes', 'hourly', '0.7'], ['/chat', 'daily', '0.5'], ['/arena', 'hourly', '0.6']];
// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
// Rendered docs (server/docs/routes.js): /docs is the index (README.md), the rest by file name.
try {
try {
for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {
for (const f of require('fs').readdirSync(require('path').join(__dirname, '../../docs'))) {
@@ -13,7 +13,8 @@
@@ -13,7 +13,8 @@
* fallback):
* fallback):
*
*
* / home
* / home
- * /vods /clips /pastes /updates /documentation exact path
+ * /content /moments the feeds (people's work, AI's)
+ * /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)
* /settings /admin /themes exact path (the client redirects them)
* /settings /admin /themes exact path (the client redirects them)
* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
* /@user /@user/<slot> a user that exists
* /@user /@user/<slot> a user that exists
@@ -39,7 +40,7 @@ const OK = 200;
@@ -39,7 +40,7 @@ const OK = 200;
const NOT_FOUND = 404;
const NOT_FOUND = 404;
// Pages that exist only at their exact path.
// Pages that exist only at their exact path.
-const EXACT = new Set(['vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes']);
+const EXACT = new Set(['content', 'moments', 'vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes']);
// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).
// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).
const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);
const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);
@@ -57,6 +57,44 @@ function publicUserProfile(u) {
@@ -57,6 +57,44 @@ function publicUserProfile(u) {
'openvibe_coins_balance', 'last_seen', 'is_owner']);
'openvibe_coins_balance', 'last_seen', 'is_owner']);
}
}
+// ── Feed items (server/content/feed.js) ───────────────────────────────────────────────────────
+// Built from Media, Community and Live rows that carry far more than a card needs (file paths,
+// storage keys, owner subjects, AI state). Copied through an allow-list, so a field a source adds
+// later never reaches the public feed by accident.
+const FEED_ITEM_FIELDS = {
+ kind: 'string', id: 'id', href: 'string', title: 'string', created_at: 'string',
+ views: 'number', likes: 'number', duration_seconds: 'number',
+ thumbnail_url: 'string', preview_url: 'string', image_url: 'string',
+ excerpt: 'string', paste_type: 'string', language: 'string', nsfw: 'boolean',
+ ai: 'boolean', ai_label: 'string', moment_href: 'string', grade: 'string', stream_title: 'string',
+};
+const FEED_PERSON_FIELDS = ['username', 'display_name', 'avatar_url', 'profile_color', 'href'];
+
+function _feedPerson(p) {
+ if (!p || typeof p !== 'object' || !p.username) return null;
+ const out = {};
+ for (const k of FEED_PERSON_FIELDS) out[k] = p[k] == null ? null : String(p[k]);
+ return out;
+}
+
+/** One card of the Content or Moments feed as anyone may see it. */
+function publicFeedItem(item) {
+ if (!item || typeof item !== 'object') return null;
+ const out = { key: `${item.kind}:${item.id}` };
+ for (const [k, type] of Object.entries(FEED_ITEM_FIELDS)) {
+ const v = item[k];
+ if (v === undefined) continue;
+ if (v === null) { out[k] = null; continue; }
+ if (type === 'id') out[k] = typeof v === 'number' ? v : String(v);
+ else if (type === 'number') out[k] = Number.isFinite(Number(v)) ? Number(v) : 0;
+ else if (type === 'boolean') out[k] = !!v;
+ else out[k] = String(v);
+ }
+ out.channel = _feedPerson(item.channel);
+ if (item.by !== undefined) out.by = _feedPerson(item.by);
+ return out;
+}
+
/**
/**
* Defence in depth for JSON that is about to be sent to an unprivileged caller: walks the value and
* Defence in depth for JSON that is about to be sent to an unprivileged caller: walks the value and
* reports any ALWAYS_SECRET key with a non-empty value. Used by tests over real route responses.
* reports any ALWAYS_SECRET key with a non-empty value. Used by tests over real route responses.
@@ -72,4 +110,4 @@ function findSecrets(value, path = '$', out = []) {
@@ -72,4 +110,4 @@ function findSecrets(value, path = '$', out = []) {
return out;
return out;
}
}
-module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, findSecrets };
+module.exports = { ALWAYS_SECRET, omit, publicManagedStream, publicChannel, publicStream, publicUserProfile, publicFeedItem, findSecrets };
@@ -24,7 +24,9 @@ const ROUTES_ONLY = process.argv.includes('--routes-only');
@@ -24,7 +24,9 @@ const ROUTES_ONLY = process.argv.includes('--routes-only');
const SETTLE = Number(process.env.SETTLE || 3500);
const SETTLE = Number(process.env.SETTLE || 3500);
const ROUTES = [
const ROUTES = [
- ['/', 'page-home'], ['/vods', 'page-vods'], ['/clips', 'page-clips'], ['/pastes', 'page-pastes'],
+ ['/', 'page-home'], ['/content', 'page-content'], ['/moments', 'page-moments'],
+ // The old list pages are the Content feed with a filter.
+ ['/vods', 'page-content'], ['/clips', 'page-content'], ['/pastes', 'page-content'],
['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],
['/chat', 'page-chat'], ['/arena', 'page-arena'], ['/broadcast', 'page-broadcast'], ['/documentation', 'page-documentation'],
['/updates', 'page-updates'], ['/dashboard', null], [process.env.CHANNEL || '/@admin', 'page-channel'],
['/updates', 'page-updates'], ['/dashboard', null], [process.env.CHANNEL || '/@admin', 'page-channel'],
// Answered 404 by the server (server/web/page-status.js) with the same shell.
// Answered 404 by the server (server/web/page-status.js) with the same shell.
@@ -145,14 +147,14 @@ const pageState = `(() => ({
@@ -145,14 +147,14 @@ const pageState = `(() => ({
await cdp.send('Page.navigate', { url: BASE + '/' });
await cdp.send('Page.navigate', { url: BASE + '/' });
await sleep(SETTLE);
await sleep(SETTLE);
const go = async (p) => { await cdp.evaluate(`navigate(${JSON.stringify(p)})`); await sleep(1500); return cdp.evaluate(pageState); };
const go = async (p) => { await cdp.evaluate(`navigate(${JSON.stringify(p)})`); await sleep(1500); return cdp.evaluate(pageState); };
- const a = await go('/vods');
+ const a = await go('/content');
const b = await go('/chat');
const b = await go('/chat');
const c = await go('/broadcast');
const c = await go('/broadcast');
await cdp.evaluate('history.back()'); await sleep(1500);
await cdp.evaluate('history.back()'); await sleep(1500);
const back = await cdp.evaluate(pageState);
const back = await cdp.evaluate(pageState);
await cdp.evaluate('history.forward()'); await sleep(1500);
await cdp.evaluate('history.forward()'); await sleep(1500);
const fwd = await cdp.evaluate(pageState);
const fwd = await cdp.evaluate(pageState);
- const ok = a.active === 'page-vods' && b.active === 'page-chat' && c.active === 'page-broadcast' && back.active === 'page-chat' && fwd.active === 'page-broadcast';
+ const ok = a.active === 'page-content' && b.active === 'page-chat' && c.active === 'page-broadcast' && back.active === 'page-chat' && fwd.active === 'page-broadcast';
if (ok) pass('SPA navigate + back/forward land on the right page'); else fail(`navigation: ${[a, b, c, back, fwd].map(s => s.active).join(' → ')}`);
if (ok) pass('SPA navigate + back/forward land on the right page'); else fail(`navigation: ${[a, b, c, back, fwd].map(s => s.active).join(' → ')}`);
const seen = new Map();
const seen = new Map();
for (const u of scripts) { const k = u.split('?')[0]; seen.set(k, (seen.get(k) || 0) + 1); }
for (const u of scripts) { const k = u.split('?')[0]; seen.set(k, (seen.get(k) || 0) + 1); }
@@ -199,7 +201,7 @@ const pageState = `(() => ({
@@ -199,7 +201,7 @@ const pageState = `(() => ({
await cdp.send('Page.navigate', { url: BASE + '/' });
await cdp.send('Page.navigate', { url: BASE + '/' });
await sleep(SETTLE);
await sleep(SETTLE);
const ch = process.env.CHANNEL || '/@admin';
const ch = process.env.CHANNEL || '/@admin';
- const lap = [ch, '/chat', '/dashboard', '/', '/broadcast', ch, '/', '/vods', '/'];
+ const lap = [ch, '/chat', '/dashboard', '/', '/broadcast', ch, '/', '/content', '/moments', '/'];
const probes = [];
const probes = [];
for (let i = 0; i < 3; i++) {
for (let i = 0; i < 3; i++) {
for (const p of lap) { await cdp.evaluate(`navigate(${JSON.stringify(p)})`); await sleep(1200); }
for (const p of lap) { await cdp.evaluate(`navigate(${JSON.stringify(p)})`); await sleep(1200); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,375 @@
@@ -0,0 +1,375 @@
+/**
+ * The Content (people's work) and Moments (AI's) feeds: GET /api/content/feed and /api/content/moments
+ * (server/content/feed.js), against in-process stand-ins for OpenVibe.Media and OpenVibe.Community.
+ *
+ * Checks: who made what is decided by the stores (Media's auto_generated, Community's origin; AI
+ * recaps are Live's own), and each feed shows only its kind even when an upstream ignores the
+ * filter; private, unlisted, burn-after-read and banned accounts' items never appear, and nothing
+ * asks for hidden items; cursor paging walks the merged order exactly (New and Top, with a fixed
+ * window); a slow upstream is left out within the deadline and asked again on the next page; pages
+ * are cached; the answer's shape is the allow-listed one; and the auto-clip flag sync marks only
+ * the AI's own clips in Media.
+ *
+ * node test/content-feed.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+
+const tmp = path.join(os.tmpdir(), `ov-content-feed-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+process.env.PASTES_AUTHORITY = 'community';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+const addUser = (id, username, banned = 0) => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, is_banned, avatar_url, created_at)
+ VALUES (?, ?, ?, ?, 'x', 'streamer', ?, ?, '2025-01-01 00:00:00')`).run(id, username, username.toUpperCase(), `${username}@x`, banned, `https://media.test/a/${username}.png`);
+addUser(3, 'alice');
+addUser(4, 'bob');
+addUser(9, 'banned', 1);
+db.ensureChannel(3);
+const chan = db.getChannelByUserId(3);
+const streamId = Number(db.createStream({ user_id: 3, channel_id: chan.id, title: 'Night stream', protocol: 'rtmp' }).lastInsertRowid);
+db.endStream(streamId);
+
+const MIN = 60_000;
+const at = (minsAgo) => new Date(Date.now() - minsAgo * MIN).toISOString().replace('T', ' ').slice(0, 19);
+
+// ── OpenVibe.Media stand-in ──
+const media = require('../server/media-client');
+const asked = [];
+let mediaMode = 'ok'; // 'ok' | 'stall' | 'down' | 'ignore-filters'
+let VODS = [], CLIPS = [];
+const vod = (id, mins, extra = {}) => ({ id, user_id: 3, title: `VOD ${id}`, visibility: 'public', is_public: true, status: 'ready', duration_seconds: 600, view_count: 0, created_at: at(mins), thumbnail_url: `/t/vod-${id}.jpg`, file_path: `vod-${id}.webm`, storage_key: 'secret/key', ...extra });
+const clip = (id, mins, extra = {}) => ({ id, user_id: 4, channel_user_id: 3, vod_id: 1, title: `Clip ${id}`, visibility: 'public', is_public: true, status: 'ready', auto_generated: false, duration_seconds: 20, view_count: 0, created_at: at(mins), thumbnail_url: `/t/clip-${id}.jpg`, playback_url: `https://media.test/c/${id}`, file_path: `clip-${id}.webm`, ...extra });
+VODS = [
+ vod(1, 10, { view_count: 50 }), vod(2, 30, { view_count: 5 }), vod(3, 50, { view_count: 500 }), vod(4, 70),
+ vod(5, 90, { visibility: 'private', is_public: false, title: 'SECRET VOD' }),
+ vod(6, 95, { visibility: 'unlisted', is_public: false, title: 'UNLISTED VOD' }),
+ vod(7, 100, { user_id: 9, title: 'Banned streamer VOD' }),
+ vod(8, 60 * 24 * 20, { view_count: 9000, title: 'Old popular VOD' }), // 20 days ago
+];
+CLIPS = [
+ clip(11, 5, { view_count: 30 }), clip(12, 25), clip(13, 45, { view_count: 300 }),
+ clip(14, 15, { auto_generated: true, user_id: 3, title: 'AI clip A', view_count: 7 }),
+ clip(15, 35, { auto_generated: true, user_id: 3, title: 'AI clip B' }),
+ clip(16, 40, { visibility: 'private', is_public: false, title: 'SECRET CLIP' }),
+ clip(17, 42, { status: 'failed', title: 'Failed cut' }),
+ clip(18, 44, { auto_generated: true, user_id: 3, visibility: 'private', is_public: false, title: 'SECRET AI CLIP' }),
+];
+const since = (row, q) => !q.since || new Date(row.created_at.replace(' ', 'T') + 'Z') >= new Date(q.since.replace(' ', 'T') + 'Z');
+const order = (rows, q) => rows.sort((a, b) => (q.order === 'views' ? (b.view_count - a.view_count) : 0) || (b.created_at < a.created_at ? -1 : b.created_at > a.created_at ? 1 : 0));
+const gate = async () => {
+ if (mediaMode === 'stall') await new Promise((r) => setTimeout(r, 6000));
+ if (mediaMode === 'down') throw new media.MediaApiError('Media unreachable', 0);
+};
+media.listVods = async (q = {}) => {
+ asked.push({ kind: 'vods', ...q });
+ await gate();
+ const leaky = mediaMode === 'ignore-filters';
+ const rows = order(VODS.filter((v) => leaky || ((q.include_private || v.is_public) && since(v, q))), q);
+ return { vods: rows.slice(q.offset || 0, (q.offset || 0) + (q.limit || 50)), total: rows.length };
+};
+media.listClips = async (q = {}) => {
+ asked.push({ kind: 'clips', ...q });
+ await gate();
+ const leaky = mediaMode === 'ignore-filters';
+ const rows = order(CLIPS.filter((c) => leaky || ((q.include_private || c.is_public) && since(c, q)
+ && (q.auto_generated == null || Number(c.auto_generated) === Number(q.auto_generated))
+ && (q.status !== 'ready' || c.status === 'ready'))), q);
+ return { clips: rows.slice(q.offset || 0, (q.offset || 0) + (q.limit || 50)), total: rows.length };
+};
+
+// ── OpenVibe.Community stand-in ──
+const pastesClient = require('../server/pastes-client');
+const paste = (slug, mins, extra = {}) => ({ id: slug.length, slug, origin: 'user', type: 'paste', title: `Paste ${slug}`, content: `print("${slug}")`, language: 'python', visibility: 'public', burn_after_read: false, views: 0, likes: 0, username: 'bob', display_name: 'Bob', avatar_url: 'https://media.test/a/bob.png', owner_subject: 'usr_bob', created_at: at(mins), ...extra });
+const PASTES = [
+ paste('p-one', 8, { likes: 20 }), paste('p-two', 28), paste('p-three', 48, { views: 10 }),
+ paste('p-private', 12, { visibility: 'private', title: 'SECRET PASTE' }),
+ paste('p-burn', 14, { burn_after_read: true, title: 'BURN PASTE' }),
+ paste('p-banned', 16, { username: 'banned', title: 'Banned author paste' }),
+ paste('ai-shot', 20, { origin: 'ai', type: 'screenshot', title: 'AI shot', content: 'long text', username: null, display_name: 'OpenVibe AI', owner_subject: null, stream_id: streamId, screenshot_url: 'https://media.test/f/s.jpg', ai_summary: 'Everyone laughed', metadata: JSON.stringify({ ai_moment: true, vod_link: '/vod/1?t=42', username: 'alice' }) }),
+ paste('ai-live', 38, { origin: 'ai', type: 'screenshot', title: 'Caught live', username: null, owner_subject: null, stream_id: streamId, screenshot_url: 'https://media.test/f/l.jpg', metadata: JSON.stringify({ ai_moment: true, live: true, vod_link: 'javascript:alert(1)' }) }),
+];
+let communityMode = 'ok';
+const communityCalls = [];
+pastesClient.request = async (method, p, { query = {}, act = {} } = {}) => {
+ communityCalls.push({ method, path: p, query, act });
+ if (communityMode === 'down') throw Object.assign(new Error('down'), { status: 502 });
+ if (communityMode === 'lying-total') return { pastes: [], total: 999 };
+ const leaky = communityMode === 'ignore-filters';
+ const rows = PASTES.filter((x) => leaky || (x.visibility === 'public' && !x.burn_after_read && (!query.origin || x.origin === query.origin) && since(x, query)))
+ .sort((a, b) => (query.sort === 'top' ? (b.views + 5 * b.likes) - (a.views + 5 * a.likes) : 0) || (b.created_at < a.created_at ? -1 : b.created_at > a.created_at ? 1 : 0));
+ const off = Number(query.offset) || 0;
+ return { pastes: rows.slice(off, off + (Number(query.limit) || 50)), total: rows.length };
+};
+
+// ── Live's own AI recaps ──
+require('../server/recap/recap').ensureTable();
+const recapJson = (headline, peak) => JSON.stringify({ stream: { id: streamId, title: 'Night stream', duration_seconds: 3600, peak_viewers: peak }, write: { headline, summary: 'A good night.', grade: 'A' }, vod: { id: 1, thumbnail_url: 'https://media.test/t/vod-1.jpg' } });
+raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai, created_at) VALUES (?, 3, ?, 1, ?)').run(streamId, recapJson('What a night', 12), at(33));
+raw.prepare('INSERT INTO stream_recaps (stream_id, user_id, json, ai, created_at) VALUES (?, 3, ?, 0, ?)').run(streamId + 1000, recapJson('Template recap', 99), at(3));
+
+const feed = require('../server/content/feed');
+const { findSecrets } = require('../server/web/serializers');
+const express = require('express');
+const app = express();
+app.use('/api/content', require('../server/content/routes'));
+const server = http.createServer(app).listen(0);
+function get(p) {
+ return new Promise((resolve, reject) => {
+ http.get({ port: server.address().port, path: p }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, headers: res.headers, json }); });
+ }).on('error', reject);
+ });
+}
+async function walk(base, limit) {
+ const keys = [];
+ let cursor = null, pages = 0;
+ do {
+ const r = await get(`${base}${base.includes('?') ? '&' : '?'}limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
+ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
+ keys.push(...r.json.items.map((i) => i.key));
+ cursor = r.json.next;
+ assert.ok(++pages < 50, 'paging never ends');
+ } while (cursor);
+ return keys;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ feed._reset();
+ asked.length = 0; communityCalls.length = 0;
+ mediaMode = 'ok'; communityMode = 'ok';
+ try { await fn(); quiet(` ok - ${name}`); }
+ catch (e) { failures++; quiet(` FAIL - ${name}\n ${e.stack.split('\n').slice(0, 4).join('\n ')}`); }
+}
+
+server.on('listening', async () => {
+ await check('Content lists people\'s work only; the stores are asked for it by name', async () => {
+ const r = await get('/api/content/feed?limit=30');
+ assert.strictEqual(r.status, 200);
+ const keys = r.json.items.map((i) => i.key);
+ assert.deepStrictEqual(keys, ['clip:11', 'paste:p-one', 'vod:1', 'clip:12', 'paste:p-two', 'vod:2', 'clip:13', 'paste:p-three', 'vod:3', 'vod:4', 'vod:8']);
+ assert.ok(r.json.items.every((i) => i.ai === false), 'nothing on Content is marked AI');
+ const clipAsks = asked.filter((a) => a.kind === 'clips');
+ assert.ok(clipAsks.length && clipAsks.every((a) => a.auto_generated === 0 && a.status === 'ready'), 'Media is asked for people\'s playable clips');
+ assert.ok(communityCalls.every((c) => c.query.origin === 'user' && String(c.query.pinned_first) === '0'), 'Community is asked for people\'s pastes, pinned in date order');
+ assert.deepStrictEqual(r.json.sources, { vods: 'ok', clips: 'ok', pastes: 'ok' });
+ assert.strictEqual(r.json.partial, false);
+ assert.strictEqual(r.json.next, null, 'everything fit on one page');
+ });
+
+ await check('Moments lists the AI\'s work only: auto-clips, AI pastes and AI-written recaps, each labelled', async () => {
+ const r = await get('/api/content/moments?limit=30');
+ assert.strictEqual(r.status, 200);
+ const byKey = Object.fromEntries(r.json.items.map((i) => [i.key, i]));
+ assert.deepStrictEqual(r.json.items.map((i) => i.key), ['clip:14', 'paste:ai-shot', 'recap:' + streamId, 'clip:15', 'paste:ai-live']);
+ assert.ok(r.json.items.every((i) => i.ai === true && i.ai_label), 'every Moments card says it is AI');
+ assert.strictEqual(byKey['paste:ai-shot'].ai_label, 'AI moment');
+ assert.strictEqual(byKey['paste:ai-shot'].moment_href, '/vod/1?t=42');
+ assert.strictEqual(byKey['paste:ai-shot'].channel.username, 'alice', 'an AI paste belongs to the stream it came from');
+ assert.strictEqual(byKey['paste:ai-live'].ai_label, 'Caught live');
+ assert.strictEqual(byKey['paste:ai-live'].moment_href, null, 'only a /vod/<id>?t= link is passed on');
+ assert.strictEqual(byKey['clip:14'].ai_label, 'Auto-clip');
+ assert.strictEqual(byKey['recap:' + streamId].href, `/recap/${streamId}`);
+ assert.ok(!r.json.items.some((i) => /Template recap/.test(i.title)), 'a template recap is not AI content');
+ assert.ok(asked.filter((a) => a.kind === 'clips').every((a) => a.auto_generated === 1));
+ assert.ok(communityCalls.every((c) => c.query.origin === 'ai'));
+ assert.ok(!asked.some((a) => a.kind === 'vods'), 'VODs are never AI content');
+ });
+
+ await check('an upstream that ignores the filters still cannot mix the feeds or leak hidden items', async () => {
+ mediaMode = 'ignore-filters'; communityMode = 'ignore-filters';
+ const content = await get('/api/content/feed?limit=30');
+ const moments = await get('/api/content/moments?limit=30');
+ const all = [...content.json.items, ...moments.json.items];
+ const text = JSON.stringify(all);
+ for (const secret of ['SECRET', 'UNLISTED', 'BURN PASTE', 'Banned', 'Failed cut']) assert.ok(!text.includes(secret), `${secret} leaked`);
+ assert.ok(content.json.items.every((i) => !i.ai) && moments.json.items.every((i) => i.ai));
+ assert.ok(!content.json.items.some((i) => i.key === 'clip:14' || i.key === 'paste:ai-shot'));
+ assert.ok(!moments.json.items.some((i) => i.key === 'clip:11' || i.key === 'paste:p-one'));
+ });
+
+ await check('privacy: nothing asks for hidden items; answers carry only the public card fields', async () => {
+ await get('/api/content/feed?limit=30');
+ await get('/api/content/moments?limit=30');
+ assert.ok(asked.every((a) => !a.include_private && !a.include_unlisted), 'no include_private/include_unlisted upstream');
+ assert.ok(communityCalls.every((c) => !c.query.include_unlisted && !c.act.staff && c.act.liveUserId == null), 'Community is read as nobody in particular');
+ const r = await get('/api/content/feed?limit=30');
+ assert.deepStrictEqual(findSecrets(r.json), []);
+ const allowed = new Set(['key', 'kind', 'id', 'href', 'title', 'created_at', 'views', 'likes', 'duration_seconds', 'thumbnail_url', 'preview_url', 'image_url', 'excerpt', 'paste_type', 'language', 'nsfw', 'ai', 'ai_label', 'moment_href', 'grade', 'stream_title', 'channel', 'by']);
+ for (const it of r.json.items) for (const k of Object.keys(it)) assert.ok(allowed.has(k), `unexpected field ${k} on ${it.key}`);
+ const v1 = r.json.items.find((i) => i.key === 'vod:1');
+ assert.strictEqual(v1.thumbnail_url, `${media.MEDIA_PUBLIC_URL}/t/vod-1.jpg`, 'Media paths become absolute');
+ assert.deepStrictEqual(v1.channel, { username: 'alice', display_name: 'ALICE', avatar_url: 'https://media.test/a/alice.png', profile_color: '#8b5cf6', href: '/@alice' });
+ assert.ok(!('file_path' in v1) && !('storage_key' in v1));
+ const c11 = r.json.items.find((i) => i.key === 'clip:11');
+ assert.strictEqual(c11.channel.username, 'alice', 'a clip belongs to the clipped channel');
+ assert.strictEqual(c11.by.username, 'bob', 'and names who clipped it');
+ assert.strictEqual(c11.preview_url, 'https://media.test/c/11');
+ assert.match(r.headers['cache-control'], /public, max-age=\d+/);
+ });
+
+ await check('cursor paging walks the merged New order exactly, every page size', async () => {
+ const full = (await get('/api/content/feed?limit=30')).json.items.map((i) => i.key);
+ for (const size of [1, 2, 3, 5]) {
+ feed._reset();
+ assert.deepStrictEqual(await walk('/api/content/feed', size), full, `limit=${size}`);
+ }
+ const moments = (await get('/api/content/moments?limit=30')).json.items.map((i) => i.key);
+ assert.deepStrictEqual(await walk('/api/content/moments', 2), moments);
+ });
+
+ await check('Top ranks by score inside a fixed window, and pages the same way', async () => {
+ const r = await get('/api/content/feed?sort=top&window=week&limit=30');
+ assert.strictEqual(r.status, 200);
+ const keys = r.json.items.map((i) => i.key);
+ assert.deepStrictEqual(keys.slice(0, 3), ['vod:3', 'clip:13', 'paste:p-one'], 'views, a paste like worth five');
+ assert.ok(!keys.includes('vod:8'), 'the 20-day-old VOD is outside the week');
+ const sinces = new Set([...asked.map((a) => a.since), ...communityCalls.map((c) => c.query.since)]);
+ assert.strictEqual(sinces.size, 1, 'every source gets the same window');
+ const s = [...sinces][0];
+ assert.match(s, /^\d{4}-\d{2}-\d{2} \d{2}:00:00$/, 'the window starts on the hour');
+ assert.ok(Math.abs(Date.parse(s.replace(' ', 'T') + 'Z') - (Date.now() - 7 * 86400_000)) < 3600_000 + 5000);
+ feed._reset();
+ assert.deepStrictEqual(await walk('/api/content/feed?sort=top&window=week', 2), keys);
+ const allTime = await get('/api/content/feed?sort=top&window=all&limit=30');
+ assert.strictEqual(allTime.json.items[0].key, 'vod:8', 'all time has no window');
+ });
+
+ await check('the window stays put across pages (it travels in the cursor)', async () => {
+ const first = await get('/api/content/feed?sort=top&window=month&limit=2');
+ const c = JSON.parse(Buffer.from(first.json.next, 'base64url').toString());
+ c.s = '2026-01-01 00:00:00';
+ const forged = Buffer.from(JSON.stringify(c)).toString('base64url');
+ asked.length = 0;
+ await get(`/api/content/feed?sort=top&window=month&limit=2&cursor=${forged}`);
+ assert.ok(asked.length && asked.every((a) => a.since === '2026-01-01 00:00:00'), 'the next page asks for the same window');
+ });
+
+ await check('filters read only their sources', async () => {
+ const vods = await get('/api/content/feed?type=vods&limit=30');
+ assert.ok(vods.json.items.every((i) => i.kind === 'vod'));
+ assert.deepStrictEqual(Object.keys(vods.json.sources), ['vods']);
+ assert.strictEqual(communityCalls.length, 0);
+ const shots = await get('/api/content/moments?type=shots');
+ assert.deepStrictEqual(shots.json.items.map((i) => i.key), ['paste:ai-shot', 'paste:ai-live']);
+ const recaps = await get('/api/content/moments?type=recaps');
+ assert.deepStrictEqual(recaps.json.items.map((i) => i.kind), ['recap']);
+ });
+
+ await check('bad requests are 400: unknown type/sort/window, a cursor from another feed or damaged', async () => {
+ assert.strictEqual((await get('/api/content/feed?type=recaps')).status, 400);
+ assert.strictEqual((await get('/api/content/feed?sort=hot')).status, 400);
+ assert.strictEqual((await get('/api/content/feed?sort=top&window=decade')).status, 400);
+ const other = (await get('/api/content/moments?limit=1')).json.next;
+ assert.ok(other);
+ assert.strictEqual((await get(`/api/content/feed?cursor=${other}`)).status, 400);
+ assert.strictEqual((await get('/api/content/feed?cursor=%%%nope')).status, 400);
+ });
+
+ await check('a slow Media is left out within the deadline and asked again on the next page', async () => {
+ mediaMode = 'stall';
+ const t0 = Date.now();
+ const r = await get('/api/content/feed?limit=3');
+ const took = Date.now() - t0;
+ assert.ok(took < feed.DEADLINE_MS + 1000, `took ${took}ms`);
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(r.json.sources, { vods: 'timeout', clips: 'timeout', pastes: 'ok' });
+ assert.strictEqual(r.json.partial, true);
+ assert.deepStrictEqual(r.json.items.map((i) => i.key), ['paste:p-one', 'paste:p-two', 'paste:p-three']);
+ assert.strictEqual(r.headers['cache-control'], 'no-store', 'a partial page is not cached downstream');
+ const c = JSON.parse(Buffer.from(r.json.next, 'base64url').toString());
+ assert.strictEqual(c.o.vods, 0, 'the VOD offset did not move');
+ mediaMode = 'ok';
+ feed._reset();
+ const next = await get(`/api/content/feed?limit=3&cursor=${r.json.next}`);
+ assert.ok(next.json.items.some((i) => i.kind === 'vod'), 'the next page has the VODs');
+ assert.strictEqual(next.json.partial, false);
+ });
+
+ await check('Media down: the other sources still answer; everything down: an empty partial page, not an error', async () => {
+ mediaMode = 'down';
+ const r = await get('/api/content/feed?limit=30');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json.sources.vods, 'error');
+ assert.ok(r.json.items.length && r.json.items.every((i) => i.kind === 'paste'));
+ communityMode = 'down';
+ feed._reset();
+ const none = await get('/api/content/feed?limit=30');
+ assert.strictEqual(none.status, 200);
+ assert.deepStrictEqual(none.json.items, []);
+ assert.strictEqual(none.json.partial, true);
+ assert.ok(none.json.next, 'the client may try again');
+ });
+
+ await check('pastes are listed only when Community owns them', async () => {
+ process.env.PASTES_AUTHORITY = '';
+ try {
+ const r = await get('/api/content/feed?limit=30');
+ assert.strictEqual(r.json.sources.pastes, 'unavailable');
+ assert.strictEqual(r.json.partial, false, 'not a failure to retry');
+ assert.ok(!r.json.items.some((i) => i.kind === 'paste'));
+ assert.strictEqual(communityCalls.length, 0);
+ } finally { process.env.PASTES_AUTHORITY = 'community'; }
+ });
+
+ await check('an upstream that answers no rows but claims more cannot keep the feed paging forever', async () => {
+ communityMode = 'lying-total';
+ const r = await get('/api/content/feed?type=pastes&limit=5');
+ assert.deepStrictEqual(r.json.items, []);
+ assert.strictEqual(r.json.next, null);
+ });
+
+ await check('source pages are cached and shared by concurrent requests', async () => {
+ await Promise.all([get('/api/content/feed?limit=4'), get('/api/content/feed?limit=4'), get('/api/content/feed?limit=4')]);
+ const n = asked.length + communityCalls.length;
+ assert.strictEqual(n, 3, `one call per source, got ${n}`);
+ await get('/api/content/feed?limit=4');
+ assert.strictEqual(asked.length + communityCalls.length, 3, 'a repeat within the TTL is served from the cache');
+ });
+
+ await check('the auto-clip flag sync marks only the AI\'s own clips in Media', async () => {
+ const job = require('../server/ai/auto-clip-job');
+ db.setState('auto_clip_log', JSON.stringify([
+ { clip_id: 101, stream_id: streamId, ts: Date.now() },
+ { clip_id: 102, stream_id: streamId, ts: Date.now(), dedup: true }, // someone else's clip handed back
+ { clip_id: 103, stream_id: streamId, ts: Date.now() }, // a viewer's clip under the AI's log entry
+ { clip_id: 104, stream_id: streamId, ts: Date.now() }, // deleted since
+ { clip_id: 105, stream_id: streamId, ts: Date.now() }, // already marked
+ ]));
+ const media2 = { 101: { id: 101, user_id: 3, channel_user_id: 3, auto_generated: false }, 102: { id: 102, user_id: 4, channel_user_id: 3, auto_generated: false }, 103: { id: 103, user_id: 4, channel_user_id: 3, auto_generated: false }, 105: { id: 105, user_id: 3, channel_user_id: 3, auto_generated: true } };
+ const puts = [];
+ media.getClip = async (id) => { if (!media2[id]) throw new media.MediaApiError('Clip not found', 404); return media2[id]; };
+ let upgraded = false;
+ media.updateClip = async (id, body) => { puts.push({ id: Number(id), body }); const c = media2[id]; if (upgraded) c.auto_generated = body.auto_generated; return { clip: c }; };
+ let out = await job.syncAutoClipFlags();
+ assert.strictEqual(out.marked, 0, 'an old Media that drops the flag is not taken as done');
+ assert.ok(out.pending > 0);
+ upgraded = true;
+ out = await job.syncAutoClipFlags();
+ assert.deepStrictEqual(puts.map((p) => p.id), [101, 101], 'only 101 is written (once per run until it sticks)');
+ assert.ok(!puts.some((p) => p.id === 102 || p.id === 103), 'a person\'s clip is never relabelled');
+ assert.deepStrictEqual({ marked: out.marked, gone: out.gone, skipped: out.skipped, pending: out.pending }, { marked: 2, gone: 1, skipped: 1, pending: 0 });
+ puts.length = 0;
+ out = await job.syncAutoClipFlags();
+ assert.deepStrictEqual(puts, [], 'a second run has nothing left to do');
+ });
+
+ server.close();
+ for (const f of [tmp, `${tmp}-wal`, `${tmp}-shm`]) { try { fs.unlinkSync(f); } catch { /* */ } }
+ quiet(failures ? `\n${failures} check(s) failed` : '\ncontent feed: all checks passed');
+ process.exit(failures ? 1 : 0);
+});
@@ -135,7 +135,7 @@ server.listen(0, '127.0.0.1', async () => {
@@ -135,7 +135,7 @@ server.listen(0, '127.0.0.1', async () => {
console.log('Page status (soft 404s)');
console.log('Page status (soft 404s)');
await check('every real route answers 200', () => expectAll([
await check('every real route answers 200', () => expectAll([
- '/', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes',
+ '/', '/content', '/content/', '/moments', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes',
'/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby',
'/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby',
'/arena', '/arena/beef/4', '/arena/alice',
'/arena', '/arena/beef/4', '/arena/alice',
'/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',
'/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',
@@ -144,7 +144,7 @@ server.listen(0, '127.0.0.1', async () => {
@@ -144,7 +144,7 @@ server.listen(0, '127.0.0.1', async () => {
], 200));
], 200));
await check('unknown paths answer 404', () => expectAll([
await check('unknown paths answer 404', () => expectAll([
- '/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1',
+ '/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1', '/content/vods', '/moments/1',
'/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/nobody', '/nobody/main', '/alice/main/extra', '/alice/bad!slot',
'/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/nobody', '/nobody/main', '/alice/main/extra', '/alice/bad!slot',
'/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1',
'/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1',
'/p/missing', '/p/bad!slug', '/p/abc123/extra',
'/p/missing', '/p/bad!slug', '/p/abc123/extra',