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

OP OpenVibers committed 23 Sept 2026 · 88f7b97fd1 · 23 files, +2102 −585

Who made what is the store's answer, never a guess in Live: Media's clip auto_generated (OpenVibe.Media ee39be1 keeps the flag Live always sent and filters by it; auto-clips made before that are marked from Live's auto_clip_log by syncAutoClipFlags, only clips cut under the streamer and never a clip Media handed back as a duplicate of a person's), Community's paste origin (user|ai; OpenVibe.Community 6d534d8 adds sort=top, since and pinned_first=0), and stream_recaps.ai (template recaps are stats and are listed nowhere). VODs are always people's. Content cards carry no AI text; AI cards say what kind (Auto-clip, AI moment, Caught live, AI recap)

GET /api/content/feed and /api/content/moments (server/content/feed.js, routes.js): each source filters server-side (Media auto_generated=0|1, status=ready, since; Community origin, sort, since, pinned_first=0; recaps from SQLite), the pages are merged by the sort and paged by a cursor holding each source's offset and a Top window's start, so the order across pages is exact and no page is a full one with the other kind taken out. Public items only: nothing asks for hidden items, each row is checked again (lookups.isPublic, burn-after-read, failed cuts), banned accounts are left out. Source pages are cached 30 s (Top 2 min) and shared by concurrent requests; a page waits at most 4 s for Media and Community, leaves a slow or failed one out (sources, partial; its offset stays so the next page asks again) and is then no-store. Items go through serializers.publicFeedItem, an allow-list. Pastes are listed only when Community owns them (PASTES_AUTHORITY=community). An upstream that answers no rows ends that source

Client (feature content/moments, deps feeds: public/js/content-feed.js, public/css/features/content.css, fragments content and moments): cards built as DOM nodes; one column on phones, two or three masonry columns wider (new cards go to the shortest column, nothing on screen reflows); infinite scroll by cursor with skeletons, Load more fallback and a retry that never loops; clips play muted inline one at a time when mostly in view, a tap turns the sound on, the corner button or title opens the clip, off-screen videos are unloaded, nothing autoplays under reduced motion or Save-Data; restarted streams' same-titled VODs in a row collapse into one card with a parts count; staff keep bulk select. The old list pages' markup left index.html, their code left app.js, app-media.js and pastes.js (renderPasteCard stays for the dashboard); the server no longer hands /pastes to Community (/p/:slug still goes there under PASTES_ON_COMMUNITY)

page-status knows /content and /moments; SEO renders both list pages (the first feed page) and adds them to the sitemap, and the /clips and /pastes list pages list people's items. Budgets went down: HTML 85.7 to 82.9 KB raw, eager JS 1111.9 to 1110.1 KB raw (252.1 to 251.9 brotli)

test/content-feed.test.js (in-process Media and Community stand-ins): the human/AI split including upstreams that ignore the filters, privacy (hidden items never asked for or shown, allow-listed fields, no secrets), cursor paging at several page sizes for New and Top with a fixed window, the deadline and retry on the next page, Media or everything down, pastes without Community, caching, bad parameters, the empty-answer guard, and the flag sync. test/page-status.test.js and test/browser/smoke.js cover the new routes (the smoke lap now includes /content and /moments)

Changes

ddiff --git a/docs/architecture.md b/docs/architecture.md
iindex ff9b0105..61b1db45 100644
---- a/docs/architecture.md
++++ b/docs/architecture.md
@@@ -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/features.json registry: feature → fragment, stylesheets, scripts, dependencies, stubs, idle prefetch
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/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
@@@ -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
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`).
++- **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
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);
ddiff --git a/public/css/features/content.css b/public/css/features/content.css
nnew file mode 100644
iindex 00000000..83ca54dc
---- /dev/null
++++ b/public/css/features/content.css
@@@ -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; }
++}
ddiff --git a/public/features.json b/public/features.json
iindex 8ad1203e..5d80bdd4 100644
---- a/public/features.json
++++ b/public/features.json
@@@ -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"
]
ddiff --git a/public/fragments/content.html b/public/fragments/content.html
nnew file mode 100644
iindex 00000000..2945047c
---- /dev/null
++++ b/public/fragments/content.html
@@@ -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>
ddiff --git a/public/fragments/moments.html b/public/fragments/moments.html
nnew file mode 100644
iindex 00000000..bb0a6e8f
---- /dev/null
++++ b/public/fragments/moments.html
@@@ -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>
ddiff --git a/public/index.html b/public/index.html
iindex e8d45151..9a06d9da 100644
---- a/public/index.html
++++ b/public/index.html
@@@ -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 &amp; 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>
ddiff --git a/public/js/app-home.js b/public/js/app-home.js
iindex 513054a7..781b4646 100644
---- a/public/js/app-home.js
++++ b/public/js/app-home.js
@@@ -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; }
}
ddiff --git a/public/js/app-media.js b/public/js/app-media.js
iindex ae1a908c..18999944 100644
---- a/public/js/app-media.js
++++ b/public/js/app-media.js
@@@ -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 {
ddiff --git a/public/js/app.js b/public/js/app.js
iindex a489c4fd..86a5f39b 100644
---- a/public/js/app.js
++++ b/public/js/app.js
@@@ -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 '';
ddiff --git a/public/js/content-feed.js b/public/js/content-feed.js
nnew file mode 100644
iindex 00000000..e3e27430
---- /dev/null
++++ b/public/js/content-feed.js
@@@ -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 || {});
++})();
ddiff --git a/public/js/footer-live.js b/public/js/footer-live.js
iindex 0e749712..c4aa4eb0 100644
---- a/public/js/footer-live.js
++++ b/public/js/footer-live.js
@@@ -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')" },
],
ddiff --git a/public/js/ov-navbar-live.js b/public/js/ov-navbar-live.js
iindex 3ed3a87c..bf5dea85 100644
---- a/public/js/ov-navbar-live.js
++++ b/public/js/ov-navbar-live.js
@@@ -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' },
ddiff --git a/public/js/pastes.js b/public/js/pastes.js
iindex c1c7463e..b2a89016 100644
---- a/public/js/pastes.js
++++ b/public/js/pastes.js
@@@ -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 allowed
let _pasteLimitCache = null; // cached config from /pastes/config
let _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
ddiff --git a/server/ai/auto-clip-job.js b/server/ai/auto-clip-job.js
iindex bbf612b4..cad2c646 100644
---- a/server/ai/auto-clip-job.js
++++ b/server/ai/auto-clip-job.js
@@@ -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) {
ddiff --git a/server/content/feed.js b/server/content/feed.js
nnew file mode 100644
iindex 00000000..e8e47164
---- /dev/null
++++ b/server/content/feed.js
@@@ -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 } };
ddiff --git a/server/content/routes.js b/server/content/routes.js
nnew file mode 100644
iindex 00000000..2ad24980
---- /dev/null
++++ b/server/content/routes.js
@@@ -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;
ddiff --git a/server/index.js b/server/index.js
iindex fde65872..91fe7ac7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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/canva
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/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/i18n', require('./i18n/routes')); // on-demand translation for viewers (any line → your language)
app.use('/api/kiosk', require('./kiosk/routes'));
ddiff --git a/server/seo/seo.js b/server/seo/seo.js
iindex b141671a..5765973c 100644
---- a/server/seo/seo.js
++++ b/server/seo/seo.js
@@@ -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'))) {
ddiff --git a/server/web/page-status.js b/server/web/page-status.js
iindex a9a24ef6..4f2e5e18 100644
---- a/server/web/page-status.js
++++ b/server/web/page-status.js
@@@ -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']);
ddiff --git a/server/web/serializers.js b/server/web/serializers.js
iindex 4941a06e..b3622b53 100644
---- a/server/web/serializers.js
++++ b/server/web/serializers.js
@@@ -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 };
ddiff --git a/test/browser/smoke.js b/test/browser/smoke.js
iindex f07c321b..95bb6a15 100644
---- a/test/browser/smoke.js
++++ b/test/browser/smoke.js
@@@ -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); }
ddiff --git a/test/content-feed.test.js b/test/content-feed.test.js
nnew file mode 100644
iindex 00000000..82ff4643
---- /dev/null
++++ b/test/content-feed.test.js
@@@ -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);
++});
ddiff --git a/test/page-status.test.js b/test/page-status.test.js
iindex be7e5e48..61d4059f 100644
---- a/test/page-status.test.js
++++ b/test/page-status.test.js
@@@ -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',