openvibers/openvibe.live Commits
Tag filters for the live list, and the tags in use (#519)
Changes
@@@ -211,6 +211,21 @@ async function getLiveStreams() {`);}++/**++ * Rows for GET /api/streams/tags: every tag in use means a stream's own tags and the AI's (`tags` and++ * `ai_tags`, both JSON text) on streams live now plus streams that started in the last seven days. The++ * route parses them (a malformed row must never fail the whole list); `recent` marks the seven-day++ * window, live or ended.++ */++async function getStreamTagUsage() {++ return await all(`++ SELECT tags, ai_tags, is_live,++ CASE WHEN started_at >= datetime('now', '-7 days') THEN 1 ELSE 0 END AS recent++ FROM streams++ WHERE is_live = 1 OR started_at >= datetime('now', '-7 days')++ `);++}++// The latest ended session per streamer. The VOD fields (vod_id, vod_thumbnail_url, …) come from// OpenVibe.Media: media-proxy/lookups.js attachPublicVods.async function getRecentStreams(limit = 20) {@@@ -3646,6 +3661,7 @@ module.exports = {ensureStreamerRoleOnFeed,// Streams (sessions)getLiveStreams, getRecentStreams, getStreamById, setStreamAiCategory, effectiveCategory, getStreamByUserId, getLiveStreamsByUserId, getStreamsByUserId, getStreamHistoryByManagedStream,++ getStreamTagUsage,createStream, endStream, onStreamLifecycle, endOtherLiveStreamsForSlot, updateViewerCount,addStreamMemory, getStreamMemories, getLatestStreamMemory, updateStreamAiOverview,setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,@@@ -12,7 +12,8 @@* DELETE /api/streams/managed/:id - Delete a managed stream** Streams (sessions on a managed stream):-- * GET /api/streams - List live streams++ * GET /api/streams - List live streams (?tag=… on own+AI tags, up to 10, ?category=)++ * GET /api/streams/tags - List the filterable tags in use (live/recent counts, source)* GET /api/streams/recent - List recently ended streams* GET /api/streams/:id - Get stream details* POST /api/streams - Go live (creates session on managed stream)@@@ -35,6 +36,7 @@ const openreMirror = require('../openre/mirror');const i18n = require('../i18n/translate');const robotStreamerService = require('../integrations/robotstreamer-service');const chatRelayService = require('../integrations/chat-relay-service');++const { TAG_FILTER_RE, streamTagSources } = require('./tags');// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.const moderation = require('../chat/moderation-client');const chatReads = require('../chat/chat-reads');@@@ -175,6 +177,37 @@ function cleanTags(tags) {return cleaned;}++/**++ * Parse ?tag= for GET /api/streams: each value is lowercased, whitespace-collapsed and must match++ * TAG_FILTER_RE; up to MAX_TAGS values, all of them required to be valid. → [] when absent, null when++ * invalid (the route answers 400).++ */++function parseTagFilter(value) {++ if (value === undefined) return [];++ const values = Array.isArray(value) ? value : [value];++ if (values.length > MAX_TAGS) return null;++ const tags = new Set();++ for (const item of values) {++ if (typeof item !== 'string') return null;++ const tag = item.replace(/\s+/g, ' ').trim().toLowerCase();++ if (!TAG_FILTER_RE.test(tag)) return null;++ tags.add(tag);++ }++ return [...tags];++}++++/**++ * Parse ?category= for GET /api/streams: one category, trimmed and case-folded to match the (lowercase)++ * taxonomy the API returns. → undefined when absent, null when invalid.++ */++function parseCategoryFilter(value) {++ if (value === undefined) return undefined;++ if (typeof value !== 'string') return null;++ const category = value.replace(/\s+/g, ' ').trim().toLowerCase();++ if (!category || category.length > MAX_CATEGORY_LENGTH) return null;++ return category;++}++/** The social links an About editor sees after saving (server/social/links.js), for the response. */async function aboutSocials(channel) {if (!channel) return { social_links: [] };@@@ -1137,6 +1170,10 @@ router.get('/channel/:username/weather', async (req, res) => {// ── List Live Streams ────────────────────────────────────────router.get('/', optionalAuth, async (req, res) => {++ const wantedTags = parseTagFilter(req.query.tag);++ const wantedCategory = parseCategoryFilter(req.query.category);++ if (wantedTags === null) return res.status(400).json({ error: 'Invalid tag filter' });++ if (wantedCategory === null) return res.status(400).json({ error: 'Invalid category filter' });try {// Every open home tab polls this every 12 seconds and the response is the same for// everyone, so let the browser and the edge answer most of those hits. Kept well under@@@ -1145,7 +1182,18 @@ router.get('/', optionalAuth, async (req, res) => {const restreamManager = require('./restream-manager');// A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.const insidePip = await db.getLiveOwnPipSourceSlots();-- const streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id))));++ let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id))));++ // Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.++ // A stream matches on its own tags or the AI's, without carrying either into the response.++ if (wantedTags.length) {++ streams = streams.filter((s) => {++ const have = streamTagSources(s);++ return wantedTags.some((tag) => have.has(tag));++ });++ }++ if (wantedCategory !== undefined) {++ streams = streams.filter((s) => String(s.category || '').replace(/\s+/g, ' ').trim().toLowerCase() === wantedCategory);++ }const channelMap = await db.getChannelsByUserIds(streams.map(s => s.user_id)); // one query, not Nconst enriched = await Promise.all(streams.map(async (s) => {const channel = channelMap[s.user_id] || null;@@@ -1173,6 +1221,37 @@ router.get('/', optionalAuth, async (req, res) => {}});++// ── Tags In Use (the browse filters) ─────────────────────────++router.get('/tags', async (req, res) => {++ try {++ // The tag set changes slowly and is the same for everyone (like the stream list, with a longer window).++ res.set('Cache-Control', 'public, max-age=60');++ const rows = await db.getStreamTagUsage();++ const counts = new Map();++ for (const row of rows) {++ const live = Number(row.is_live) === 1;++ const recent = Number(row.recent) === 1;++ for (const [tag, source] of streamTagSources(row)) {++ // Every tag listed here must be accepted back as ?tag=, so values outside the grammar++ // (for example the AI's "c++") stay out instead of becoming a chip that 400s.++ if (!TAG_FILTER_RE.test(tag)) continue;++ let entry = counts.get(tag);++ if (!entry) { entry = { tag, live: 0, recent: 0, source }; counts.set(tag, entry); }++ else if (entry.source !== source) entry.source = 'both';++ if (live) entry.live += 1;++ if (recent) entry.recent += 1;++ }++ }++ const tags = [...counts.values()]++ .sort((a, b) => b.live - a.live || b.recent - a.recent || (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0))++ .slice(0, 30);++ res.json({ tags });++ } catch (err) {++ console.error('[Streams] Tags error:', err.message);++ res.status(500).json({ error: 'Failed to list tags' });++ }++});++// ── List My Streams (all streams for current user) ───────────router.get('/mine', requireAuth, async (req, res) => {try {nnew file mode 100644@@@ -0,0 +1,68 @@++'use strict';++/**++ * The tag vocabulary a viewer can filter by.++ *++ * A stream's tags are its streamer's own (`streams.tags`) plus the AI's (`streams.ai_tags`, set by++ * db.setStreamAiCategory), both JSON text. They are normalized the way cleanTags() writes a tag++ * (whitespace collapsed, trimmed, lowercased), then words that describe the medium rather than the++ * stream are dropped: a "live", "chat" or "obs" chip says nothing about what is on screen.++ *++ * GET /api/streams (the ?tag= filter) and GET /api/streams/tags both read streams through this module.++ */++++// A tag a viewer can send back as ?tag=: 1–32 letters, digits, spaces or dashes, lowercased.++const TAG_FILTER_RE = /^[\p{L}\p{N} -]{1,32}$/u;++++// Exported so the UI and tests can see exactly what is hidden; every value is already normalized.++// Mirrors the home UI's public/js/app-home.js _TOPIC_STOP (Live #520), plus the hyphen/spaced kin a++// stored tag can have: a topic the home drops must not become a chip here, and the other way round.++const TAG_STOP_WORDS = new Set([++ // The medium, not the stream.++ 'live', 'live stream', 'live-stream', 'livestream', 'livestreaming',++ 'live streaming', 'live-streaming', 'stream', 'streaming', 'streamer',++ // The set, not the content.++ 'chat', 'livechat', 'live chat', 'live-chat', 'overlay', 'stream overlay', 'ui', 'setup',++ 'stream setup', 'streaming setup', 'lobby', 'gameplay', 'commentary', 'surveillance',++ // The rig.++ 'webcam', 'web cam', 'camera', 'facecam', 'face cam', 'multicam', 'multi cam', 'multi-cam',++ 'multiframe', 'screen', 'screen share', 'screenshare',++ 'desktop', 'browser', 'pc', 'obs', 'obs studio',++]);++++/** Normalize one stored tag like cleanTags() does; '' when it is not a usable tag or is a stop word. */++function normalizeTag(value) {++ if (typeof value !== 'string') return '';++ const tag = value.replace(/\s+/g, ' ').trim().toLowerCase();++ return tag && !TAG_STOP_WORDS.has(tag) ? tag : '';++}++++/** A JSON-text tag column as unique normalized tags (a malformed value is no tags). */++function parseTagColumn(value) {++ let parsed;++ try { parsed = JSON.parse(value || '[]'); } catch { return []; }++ if (!Array.isArray(parsed)) return [];++ const tags = [];++ const seen = new Set();++ for (const item of parsed) {++ const tag = normalizeTag(item);++ if (!tag || seen.has(tag)) continue;++ seen.add(tag);++ tags.push(tag);++ }++ return tags;++}++++/**++ * One stream row's filterable tags and where each came from: Map<tag, 'streamer' | 'ai' | 'both'>.++ * Both columns are already on the row, so provenance costs nothing and /api/streams/tags can show it.++ */++function streamTagSources(row) {++ const sources = new Map();++ for (const tag of parseTagColumn(row.tags)) sources.set(tag, 'streamer');++ for (const tag of parseTagColumn(row.ai_tags)) {++ sources.set(tag, sources.has(tag) ? 'both' : 'ai');++ }++ return sources;++}++++module.exports = { TAG_FILTER_RE, TAG_STOP_WORDS, normalizeTag, parseTagColumn, streamTagSources };nnew file mode 100644@@@ -0,0 +1,184 @@++'use strict';++/**++ * The browse tag filters (market outcome 2 in research-2026-10-30/market/outcomes/live.md), backend only.++ *++ * GET /api/streams?tag=…&tag=… narrows the live list to streams carrying ANY of the tags (at most 10, each++ * lowercased/trimmed and 1–32 letters, digits, spaces or dashes; anything else is a 400), ?category= keeps one++ * category (the effective one the API returns: AI's if set), and a request without either is the unchanged list++ * in its own order. A stream's tags are its own plus the AI's (`ai_tags`), normalized and minus the stop list, so++ * an AI-only tag filters too. GET /api/streams/tags lists the tags in use with live/recent counts and where each++ * came from, most live first then most recent, capped at 30 and cached 60 s; only values that pass TAG_FILTER_RE++ * are listed, so every chip can be sent back as ?tag=.++ *++ * node --import ./test/helpers/pg-preload.mjs test/stream-tag-filters.test.js++ */++const assert = require('assert');++const http = require('http');++const express = require('express');++++process.env.NODE_ENV = 'test';++console.warn = () => {};++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++++const db = require('../server/db/database');++const { TAG_FILTER_RE, TAG_STOP_WORDS } = require('../server/streaming/tags');++++(async () => {++ await db.initDb();++ const mk = async (username) => {++ await db.createUser({ username, display_name: username, stream_key: username.padEnd(32, '0') });++ return Number((await db.get('SELECT id FROM users WHERE username = ?', [username])).id);++ };++ // UTC text, the format ov_now() writes (migrations/0002_live.sql), so the seven-day window compares as text.++ const ts = (msAgo) => new Date(Date.now() - msAgo).toISOString().slice(0, 19).replace('T', ' ');++ const DAY = 86400000;++ const stream = async (userId, { live, startedAgo, endedAgo = null, viewers = 0, tags = [], aiTags = [], category = null, aiCategory = null }) => Number((await db.run(++ `INSERT INTO streams (user_id, title, protocol, is_live, viewer_count, category, ai_category, tags, ai_tags, started_at, ended_at, created_at)++ VALUES (?, ?, 'webrtc', ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,++ [userId, `${userId} stream`, live ? 1 : 0, viewers, category, aiCategory,++ typeof tags === 'string' ? tags : JSON.stringify(tags),++ typeof aiTags === 'string' ? aiTags : JSON.stringify(aiTags),++ ts(startedAgo), live ? null : ts(endedAgo === null ? startedAgo : endedAgo), ts(startedAgo)]++ )).lastInsertRowid);++++ const alice = await mk('alice'), bob = await mk('bob'), carol = await mk('carol');++ const dave = await mk('dave'), erin = await mk('erin'), frank = await mk('frank');++ // Alice and Bob are live and share gaming; Bob's chosen category is irl but the AI's gaming is what the API++ // returns, so category=gaming includes him and category=irl does not. Carol is music+irl, Dave ended 2 days++ // ago (counts as recent), Erin ended 10 days ago (outside the window), Frank has been live 9 days (live only).++ // The AI adds its own tags: cozy/fortnite/nature/outdoors are useful, overlay/live/stream are stop words, and++ // c++ must never become a chip. Dave has gaming in both columns: one stream, one count, source both.++ await stream(alice, { live: true, startedAgo: 60000, viewers: 50, tags: ['Gaming', 'music'], aiTags: ['cozy', 'live'], category: 'gaming' });++ await stream(bob, { live: true, startedAgo: 300000, viewers: 100, tags: ['gaming'], aiTags: ['fortnite', 'overlay'], category: 'irl', aiCategory: 'gaming' });++ await stream(carol, { live: true, startedAgo: 600000, viewers: 5, tags: ['music', 'irl'], aiTags: ['nature', 'c++'], category: 'music' });++ await stream(dave, { live: false, startedAgo: 2 * DAY, endedAgo: 2 * DAY - 3600000, tags: ['gaming', 'irl'], aiTags: ['cozy', 'stream', 'gaming'], category: 'music' });++ await stream(erin, { live: false, startedAgo: 10 * DAY, endedAgo: 10 * DAY - 3600000, tags: ['retro'], aiTags: ['kitchen'], category: 'music' });++ await stream(frank, { live: true, startedAgo: 9 * DAY, viewers: 2, tags: ['marathon'], aiTags: ['outdoors'], category: 'other' });++++ const app = express();++ app.use('/api/streams', require('../server/streaming/routes'));++ const server = await new Promise((resolve) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++ const get = async (path) => {++ const res = await fetch(base + path);++ return { status: res.status, cache: res.headers.get('cache-control'), body: await res.json() };++ };++ try {++ // ── The unchanged default ───────────────────────────────++ const all = await get('/api/streams');++ assert.strictEqual(all.status, 200);++ assert.deepStrictEqual(all.body.streams.map((s) => s.username), ['bob', 'alice', 'carol', 'frank'],++ 'no filters: every live stream, in the list\'s own order (viewers, then start)');++ assert.deepStrictEqual(all.body.streams.map((s) => s.category), ['gaming', 'gaming', 'music', 'other'],++ 'the category returned is the effective one');++ assert.strictEqual(all.body.streams[0].username, 'bob', 'the biggest crowd stays first');++++ // ── Tags: one, several, and normalized query values ─────++ const one = await get('/api/streams?tag=gaming');++ assert.strictEqual(one.status, 200);++ assert.deepStrictEqual(one.body.streams.map((s) => s.username), ['bob', 'alice'], 'one tag keeps its streams in order');++ const folded = await get('/api/streams?tag=%20GAMING%20');++ assert.deepStrictEqual(folded.body.streams.map((s) => s.username), ['bob', 'alice'], 'the query is trimmed and lowercased');++ const several = await get('/api/streams?tag=gaming&tag=music');++ assert.deepStrictEqual(several.body.streams.map((s) => s.username), ['bob', 'alice', 'carol'], 'several tags keep ANY of them');++ const overlap = await get('/api/streams?tag=music&tag=irl');++ assert.deepStrictEqual(overlap.body.streams.map((s) => s.username), ['alice', 'carol'], 'a stream is listed once');++ const none = await get('/api/streams?tag=retro');++ assert.deepStrictEqual(none.body.streams, [], 'a tag only on an old stream matches no live stream');++++ // ── Tags the AI inferred count and filter too ───────────++ const aiOnly = await get('/api/streams?tag=fortnite');++ assert.strictEqual(aiOnly.status, 200);++ assert.deepStrictEqual(aiOnly.body.streams.map((s) => s.username), ['bob'], 'a tag only in ai_tags matches ?tag=');++ const aiCozy = await get('/api/streams?tag=cozy');++ assert.deepStrictEqual(aiCozy.body.streams.map((s) => s.username), ['alice'], 'an AI tag on an ended stream does not make it live');++ const aiOldLive = await get('/api/streams?tag=outdoors');++ assert.deepStrictEqual(aiOldLive.body.streams.map((s) => s.username), ['frank'], 'a live stream carries its AI tags whatever its start time');++ const stopWord = await get('/api/streams?tag=live');++ assert.deepStrictEqual(stopWord.body.streams, [], 'a stop word matches nothing even though the AI emitted it');++ const outsideWindow = await get('/api/streams?tag=kitchen');++ assert.deepStrictEqual(outsideWindow.body.streams, [], 'an AI tag only on an old stream matches no live stream');++++ // ── Category: the effective category, case-folded ───────++ const music = await get('/api/streams?category=music');++ assert.deepStrictEqual(music.body.streams.map((s) => s.username), ['carol'], 'one category');++ const gaming = await get('/api/streams?category=GAMING');++ assert.deepStrictEqual(gaming.body.streams.map((s) => s.username), ['bob', 'alice'], 'category is case-folded');++ const chosenIrl = await get('/api/streams?category=irl');++ assert.deepStrictEqual(chosenIrl.body.streams, [], 'the AI category wins over the chosen one here');++ const combined = await get('/api/streams?tag=music&category=gaming');++ assert.deepStrictEqual(combined.body.streams.map((s) => s.username), ['alice'], 'tag and category narrow together');++++ // ── Bad filters are 400s ────────────────────────────────++ for (const path of [++ '/api/streams?tag=lofi_beats',++ '/api/streams?tag=c%2B%2B',++ '/api/streams?tag=',++ '/api/streams?tag=' + 'a'.repeat(33),++ '/api/streams?tag=' + Array.from({ length: 11 }, (_, i) => `t${i}`).join('&tag='),++ '/api/streams?tag=gaming&tag=',++ '/api/streams?tag=gaming&tag=lofi_beats',++ '/api/streams?tag[bad]=1',++ '/api/streams?category=',++ '/api/streams?category[a]=b',++ ]) {++ const bad = await get(path);++ assert.strictEqual(bad.status, 400, `${path} is a 400`);++ }++ const ten = await get('/api/streams?' + Array.from({ length: 10 }, (_, i) => `tag=t${i}`).join('&'));++ assert.strictEqual(ten.status, 200, 'ten tags is the maximum, not one over');++ assert.deepStrictEqual(ten.body.streams, []);++++ // ── The tags list: counts, order, source, cache ─────────++ const tags = await get('/api/streams/tags');++ assert.strictEqual(tags.status, 200);++ assert.strictEqual(tags.cache, 'public, max-age=60', 'the list is public and cached for a minute');++ assert.deepStrictEqual(tags.body.tags, [++ { tag: 'gaming', live: 2, recent: 3, source: 'both' },++ { tag: 'music', live: 2, recent: 2, source: 'streamer' },++ { tag: 'cozy', live: 1, recent: 2, source: 'ai' },++ { tag: 'irl', live: 1, recent: 2, source: 'streamer' },++ { tag: 'fortnite', live: 1, recent: 1, source: 'ai' },++ { tag: 'nature', live: 1, recent: 1, source: 'ai' },++ { tag: 'marathon', live: 1, recent: 0, source: 'streamer' },++ { tag: 'outdoors', live: 1, recent: 0, source: 'ai' },++ ], 'own+AI tags, stop words dropped, live/recent counts, source, and the old stream left out');++ assert.ok(!tags.body.tags.some((t) => t.tag === 'c++'), 'an AI tag outside the filter grammar is not listed');++ for (const t of tags.body.tags) {++ assert.ok(TAG_FILTER_RE.test(t.tag), `${t.tag} is filterable`);++ const back = await get(`/api/streams?tag=${encodeURIComponent(t.tag)}`);++ assert.strictEqual(back.status, 200, `every listed tag round-trips as ?tag= (${t.tag})`);++ }++++ // The stop list is exported and holds the meta words that say nothing about the stream. It covers++ // the home UI's _TOPIC_STOP (Live #520) too, so the browse chips and the home topics agree.++ const meta = ['live', 'streaming', 'stream', 'livestream', 'chat', 'livechat', 'overlay', 'ui',++ 'setup', 'lobby', 'facecam', 'multicam', 'multiframe', 'gameplay', 'commentary', 'surveillance',++ 'live stream', 'stream overlay', 'streamer', 'webcam', 'camera', 'screen', 'desktop', 'browser',++ 'pc', 'obs'];++ for (const word of meta) assert.ok(TAG_STOP_WORDS.has(word), `${word} is a stop word`);++ for (const word of ['gaming', 'outdoors', 'fortnite', 'cozy', 'music', 'nature']) {++ assert.ok(!TAG_STOP_WORDS.has(word), `${word} is a useful tag`);++ }++++ // A malformed tags value is no tags, never a failed list.++ await stream(alice, { live: true, startedAgo: 3 * DAY, viewers: 1, tags: 'not json', aiTags: 'also not json', category: 'other' });++ const still = await get('/api/streams/tags');++ assert.strictEqual(still.status, 200, 'malformed tags/ai_tags JSON does not break the list');++ assert.deepStrictEqual(still.body.tags, tags.body.tags, 'and adds no tag');++++ // At most 30, however many tags are in use.++ await stream(bob, { live: true, startedAgo: 60000, viewers: 0, tags: Array.from({ length: 35 }, (_, i) => `cap-${String(i + 1).padStart(2, '0')}`), category: 'other' });++ const capped = await get('/api/streams/tags');++ assert.strictEqual(capped.body.tags.length, 30, 'at most 30 tags');++ assert.strictEqual(new Set(capped.body.tags.map((t) => t.tag)).size, 30, 'no duplicate tags');++ assert.deepStrictEqual(capped.body.tags.slice(0, 3).map((t) => t.tag), ['gaming', 'music', 'cozy'], 'the order survives the cap');++ for (let i = 1; i < capped.body.tags.length; i++) {++ const a = capped.body.tags[i - 1], b = capped.body.tags[i];++ assert.ok(a.live > b.live || (a.live === b.live && a.recent >= b.recent), `${a.tag} sorts before ${b.tag}`);++ }++ console.log('stream-tag-filters: all checks passed');++ } finally { server.close(); }++ process.exit(0);++})().catch((e) => { console.error(e); process.exit(1); });
@@ -211,6 +211,21 @@ async function getLiveStreams() {
@@ -211,6 +211,21 @@ async function getLiveStreams() {
`);
`);
}
}
+/**
+ * Rows for GET /api/streams/tags: every tag in use means a stream's own tags and the AI's (`tags` and
+ * `ai_tags`, both JSON text) on streams live now plus streams that started in the last seven days. The
+ * route parses them (a malformed row must never fail the whole list); `recent` marks the seven-day
+ * window, live or ended.
+ */
+async function getStreamTagUsage() {
+ return await all(`
+ SELECT tags, ai_tags, is_live,
+ CASE WHEN started_at >= datetime('now', '-7 days') THEN 1 ELSE 0 END AS recent
+ FROM streams
+ WHERE is_live = 1 OR started_at >= datetime('now', '-7 days')
+ `);
+}
+
// The latest ended session per streamer. The VOD fields (vod_id, vod_thumbnail_url, …) come from
// The latest ended session per streamer. The VOD fields (vod_id, vod_thumbnail_url, …) come from
// OpenVibe.Media: media-proxy/lookups.js attachPublicVods.
// OpenVibe.Media: media-proxy/lookups.js attachPublicVods.
async function getRecentStreams(limit = 20) {
async function getRecentStreams(limit = 20) {
@@ -3646,6 +3661,7 @@ module.exports = {
@@ -3646,6 +3661,7 @@ module.exports = {
ensureStreamerRoleOnFeed,
ensureStreamerRoleOnFeed,
// Streams (sessions)
// Streams (sessions)
getLiveStreams, getRecentStreams, getStreamById, setStreamAiCategory, effectiveCategory, getStreamByUserId, getLiveStreamsByUserId, getStreamsByUserId, getStreamHistoryByManagedStream,
getLiveStreams, getRecentStreams, getStreamById, setStreamAiCategory, effectiveCategory, getStreamByUserId, getLiveStreamsByUserId, getStreamsByUserId, getStreamHistoryByManagedStream,
+ getStreamTagUsage,
createStream, endStream, onStreamLifecycle, endOtherLiveStreamsForSlot, updateViewerCount,
createStream, endStream, onStreamLifecycle, endOtherLiveStreamsForSlot, updateViewerCount,
addStreamMemory, getStreamMemories, getLatestStreamMemory, updateStreamAiOverview,
addStreamMemory, getStreamMemories, getLatestStreamMemory, updateStreamAiOverview,
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
setVodAiOverview, setClipAiOverview, setVodTranscript, setClipTranscript, getStreamMemoriesInRange,
@@ -12,7 +12,8 @@
@@ -12,7 +12,8 @@
* DELETE /api/streams/managed/:id - Delete a managed stream
* DELETE /api/streams/managed/:id - Delete a managed stream
*
*
* Streams (sessions on a managed stream):
* Streams (sessions on a managed stream):
- * GET /api/streams - List live streams
+ * GET /api/streams - List live streams (?tag=… on own+AI tags, up to 10, ?category=)
+ * GET /api/streams/tags - List the filterable tags in use (live/recent counts, source)
* GET /api/streams/recent - List recently ended streams
* GET /api/streams/recent - List recently ended streams
* GET /api/streams/:id - Get stream details
* GET /api/streams/:id - Get stream details
* POST /api/streams - Go live (creates session on managed stream)
* POST /api/streams - Go live (creates session on managed stream)
@@ -35,6 +36,7 @@ const openreMirror = require('../openre/mirror');
@@ -35,6 +36,7 @@ const openreMirror = require('../openre/mirror');
const i18n = require('../i18n/translate');
const i18n = require('../i18n/translate');
const robotStreamerService = require('../integrations/robotstreamer-service');
const robotStreamerService = require('../integrations/robotstreamer-service');
const chatRelayService = require('../integrations/chat-relay-service');
const chatRelayService = require('../integrations/chat-relay-service');
+const { TAG_FILTER_RE, streamTagSources } = require('./tags');
// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.
// The six chat tables are OpenVibe.Chat's (roadmap T3); the few reads here go through its client.
const moderation = require('../chat/moderation-client');
const moderation = require('../chat/moderation-client');
const chatReads = require('../chat/chat-reads');
const chatReads = require('../chat/chat-reads');
@@ -175,6 +177,37 @@ function cleanTags(tags) {
@@ -175,6 +177,37 @@ function cleanTags(tags) {
return cleaned;
return cleaned;
}
}
+/**
+ * Parse ?tag= for GET /api/streams: each value is lowercased, whitespace-collapsed and must match
+ * TAG_FILTER_RE; up to MAX_TAGS values, all of them required to be valid. → [] when absent, null when
+ * invalid (the route answers 400).
+ */
+function parseTagFilter(value) {
+ if (value === undefined) return [];
+ const values = Array.isArray(value) ? value : [value];
+ if (values.length > MAX_TAGS) return null;
+ const tags = new Set();
+ for (const item of values) {
+ if (typeof item !== 'string') return null;
+ const tag = item.replace(/\s+/g, ' ').trim().toLowerCase();
+ if (!TAG_FILTER_RE.test(tag)) return null;
+ tags.add(tag);
+ }
+ return [...tags];
+}
+
+/**
+ * Parse ?category= for GET /api/streams: one category, trimmed and case-folded to match the (lowercase)
+ * taxonomy the API returns. → undefined when absent, null when invalid.
+ */
+function parseCategoryFilter(value) {
+ if (value === undefined) return undefined;
+ if (typeof value !== 'string') return null;
+ const category = value.replace(/\s+/g, ' ').trim().toLowerCase();
+ if (!category || category.length > MAX_CATEGORY_LENGTH) return null;
+ return category;
+}
+
/** The social links an About editor sees after saving (server/social/links.js), for the response. */
/** The social links an About editor sees after saving (server/social/links.js), for the response. */
async function aboutSocials(channel) {
async function aboutSocials(channel) {
if (!channel) return { social_links: [] };
if (!channel) return { social_links: [] };
@@ -1137,6 +1170,10 @@ router.get('/channel/:username/weather', async (req, res) => {
@@ -1137,6 +1170,10 @@ router.get('/channel/:username/weather', async (req, res) => {
// ── List Live Streams ────────────────────────────────────────
// ── List Live Streams ────────────────────────────────────────
router.get('/', optionalAuth, async (req, res) => {
router.get('/', optionalAuth, async (req, res) => {
+ const wantedTags = parseTagFilter(req.query.tag);
+ const wantedCategory = parseCategoryFilter(req.query.category);
+ if (wantedTags === null) return res.status(400).json({ error: 'Invalid tag filter' });
+ if (wantedCategory === null) return res.status(400).json({ error: 'Invalid category filter' });
try {
try {
// Every open home tab polls this every 12 seconds and the response is the same for
// Every open home tab polls this every 12 seconds and the response is the same for
// everyone, so let the browser and the edge answer most of those hits. Kept well under
// everyone, so let the browser and the edge answer most of those hits. Kept well under
@@ -1145,7 +1182,18 @@ router.get('/', optionalAuth, async (req, res) => {
@@ -1145,7 +1182,18 @@ router.get('/', optionalAuth, async (req, res) => {
const restreamManager = require('./restream-manager');
const restreamManager = require('./restream-manager');
// A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.
// A streamer's camera that is the PIP of their own live stream shows inside it, not as a stream of its own.
const insidePip = await db.getLiveOwnPipSourceSlots();
const insidePip = await db.getLiveOwnPipSourceSlots();
- const streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id))));
+ let streams = (await db.getLiveStreams()).filter((s) => !(s.managed_stream_id && insidePip.has(Number(s.managed_stream_id))));
+ // Filters narrow the list in place, so its order (viewer_count DESC, started_at DESC) is untouched.
+ // A stream matches on its own tags or the AI's, without carrying either into the response.
+ if (wantedTags.length) {
+ streams = streams.filter((s) => {
+ const have = streamTagSources(s);
+ return wantedTags.some((tag) => have.has(tag));
+ });
+ }
+ if (wantedCategory !== undefined) {
+ streams = streams.filter((s) => String(s.category || '').replace(/\s+/g, ' ').trim().toLowerCase() === wantedCategory);
+ }
const channelMap = await db.getChannelsByUserIds(streams.map(s => s.user_id)); // one query, not N
const channelMap = await db.getChannelsByUserIds(streams.map(s => s.user_id)); // one query, not N
const enriched = await Promise.all(streams.map(async (s) => {
const enriched = await Promise.all(streams.map(async (s) => {
const channel = channelMap[s.user_id] || null;
const channel = channelMap[s.user_id] || null;
@@ -1173,6 +1221,37 @@ router.get('/', optionalAuth, async (req, res) => {
@@ -1173,6 +1221,37 @@ router.get('/', optionalAuth, async (req, res) => {
}
}
});
});
+// ── Tags In Use (the browse filters) ─────────────────────────
+router.get('/tags', async (req, res) => {
+ try {
+ // The tag set changes slowly and is the same for everyone (like the stream list, with a longer window).
+ res.set('Cache-Control', 'public, max-age=60');
+ const rows = await db.getStreamTagUsage();
+ const counts = new Map();
+ for (const row of rows) {
+ const live = Number(row.is_live) === 1;
+ const recent = Number(row.recent) === 1;
+ for (const [tag, source] of streamTagSources(row)) {
+ // Every tag listed here must be accepted back as ?tag=, so values outside the grammar
+ // (for example the AI's "c++") stay out instead of becoming a chip that 400s.
+ if (!TAG_FILTER_RE.test(tag)) continue;
+ let entry = counts.get(tag);
+ if (!entry) { entry = { tag, live: 0, recent: 0, source }; counts.set(tag, entry); }
+ else if (entry.source !== source) entry.source = 'both';
+ if (live) entry.live += 1;
+ if (recent) entry.recent += 1;
+ }
+ }
+ const tags = [...counts.values()]
+ .sort((a, b) => b.live - a.live || b.recent - a.recent || (a.tag < b.tag ? -1 : a.tag > b.tag ? 1 : 0))
+ .slice(0, 30);
+ res.json({ tags });
+ } catch (err) {
+ console.error('[Streams] Tags error:', err.message);
+ res.status(500).json({ error: 'Failed to list tags' });
+ }
+});
+
// ── List My Streams (all streams for current user) ───────────
// ── List My Streams (all streams for current user) ───────────
router.get('/mine', requireAuth, async (req, res) => {
router.get('/mine', requireAuth, async (req, res) => {
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
+'use strict';
+/**
+ * The tag vocabulary a viewer can filter by.
+ *
+ * A stream's tags are its streamer's own (`streams.tags`) plus the AI's (`streams.ai_tags`, set by
+ * db.setStreamAiCategory), both JSON text. They are normalized the way cleanTags() writes a tag
+ * (whitespace collapsed, trimmed, lowercased), then words that describe the medium rather than the
+ * stream are dropped: a "live", "chat" or "obs" chip says nothing about what is on screen.
+ *
+ * GET /api/streams (the ?tag= filter) and GET /api/streams/tags both read streams through this module.
+ */
+
+// A tag a viewer can send back as ?tag=: 1–32 letters, digits, spaces or dashes, lowercased.
+const TAG_FILTER_RE = /^[\p{L}\p{N} -]{1,32}$/u;
+
+// Exported so the UI and tests can see exactly what is hidden; every value is already normalized.
+// Mirrors the home UI's public/js/app-home.js _TOPIC_STOP (Live #520), plus the hyphen/spaced kin a
+// stored tag can have: a topic the home drops must not become a chip here, and the other way round.
+const TAG_STOP_WORDS = new Set([
+ // The medium, not the stream.
+ 'live', 'live stream', 'live-stream', 'livestream', 'livestreaming',
+ 'live streaming', 'live-streaming', 'stream', 'streaming', 'streamer',
+ // The set, not the content.
+ 'chat', 'livechat', 'live chat', 'live-chat', 'overlay', 'stream overlay', 'ui', 'setup',
+ 'stream setup', 'streaming setup', 'lobby', 'gameplay', 'commentary', 'surveillance',
+ // The rig.
+ 'webcam', 'web cam', 'camera', 'facecam', 'face cam', 'multicam', 'multi cam', 'multi-cam',
+ 'multiframe', 'screen', 'screen share', 'screenshare',
+ 'desktop', 'browser', 'pc', 'obs', 'obs studio',
+]);
+
+/** Normalize one stored tag like cleanTags() does; '' when it is not a usable tag or is a stop word. */
+function normalizeTag(value) {
+ if (typeof value !== 'string') return '';
+ const tag = value.replace(/\s+/g, ' ').trim().toLowerCase();
+ return tag && !TAG_STOP_WORDS.has(tag) ? tag : '';
+}
+
+/** A JSON-text tag column as unique normalized tags (a malformed value is no tags). */
+function parseTagColumn(value) {
+ let parsed;
+ try { parsed = JSON.parse(value || '[]'); } catch { return []; }
+ if (!Array.isArray(parsed)) return [];
+ const tags = [];
+ const seen = new Set();
+ for (const item of parsed) {
+ const tag = normalizeTag(item);
+ if (!tag || seen.has(tag)) continue;
+ seen.add(tag);
+ tags.push(tag);
+ }
+ return tags;
+}
+
+/**
+ * One stream row's filterable tags and where each came from: Map<tag, 'streamer' | 'ai' | 'both'>.
+ * Both columns are already on the row, so provenance costs nothing and /api/streams/tags can show it.
+ */
+function streamTagSources(row) {
+ const sources = new Map();
+ for (const tag of parseTagColumn(row.tags)) sources.set(tag, 'streamer');
+ for (const tag of parseTagColumn(row.ai_tags)) {
+ sources.set(tag, sources.has(tag) ? 'both' : 'ai');
+ }
+ return sources;
+}
+
+module.exports = { TAG_FILTER_RE, TAG_STOP_WORDS, normalizeTag, parseTagColumn, streamTagSources };
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+'use strict';
+/**
+ * The browse tag filters (market outcome 2 in research-2026-10-30/market/outcomes/live.md), backend only.
+ *
+ * GET /api/streams?tag=…&tag=… narrows the live list to streams carrying ANY of the tags (at most 10, each
+ * lowercased/trimmed and 1–32 letters, digits, spaces or dashes; anything else is a 400), ?category= keeps one
+ * category (the effective one the API returns: AI's if set), and a request without either is the unchanged list
+ * in its own order. A stream's tags are its own plus the AI's (`ai_tags`), normalized and minus the stop list, so
+ * an AI-only tag filters too. GET /api/streams/tags lists the tags in use with live/recent counts and where each
+ * came from, most live first then most recent, capped at 30 and cached 60 s; only values that pass TAG_FILTER_RE
+ * are listed, so every chip can be sent back as ?tag=.
+ *
+ * node --import ./test/helpers/pg-preload.mjs test/stream-tag-filters.test.js
+ */
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+
+process.env.NODE_ENV = 'test';
+console.warn = () => {};
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+
+const db = require('../server/db/database');
+const { TAG_FILTER_RE, TAG_STOP_WORDS } = require('../server/streaming/tags');
+
+(async () => {
+ await db.initDb();
+ const mk = async (username) => {
+ await db.createUser({ username, display_name: username, stream_key: username.padEnd(32, '0') });
+ return Number((await db.get('SELECT id FROM users WHERE username = ?', [username])).id);
+ };
+ // UTC text, the format ov_now() writes (migrations/0002_live.sql), so the seven-day window compares as text.
+ const ts = (msAgo) => new Date(Date.now() - msAgo).toISOString().slice(0, 19).replace('T', ' ');
+ const DAY = 86400000;
+ const stream = async (userId, { live, startedAgo, endedAgo = null, viewers = 0, tags = [], aiTags = [], category = null, aiCategory = null }) => Number((await db.run(
+ `INSERT INTO streams (user_id, title, protocol, is_live, viewer_count, category, ai_category, tags, ai_tags, started_at, ended_at, created_at)
+ VALUES (?, ?, 'webrtc', ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
+ [userId, `${userId} stream`, live ? 1 : 0, viewers, category, aiCategory,
+ typeof tags === 'string' ? tags : JSON.stringify(tags),
+ typeof aiTags === 'string' ? aiTags : JSON.stringify(aiTags),
+ ts(startedAgo), live ? null : ts(endedAgo === null ? startedAgo : endedAgo), ts(startedAgo)]
+ )).lastInsertRowid);
+
+ const alice = await mk('alice'), bob = await mk('bob'), carol = await mk('carol');
+ const dave = await mk('dave'), erin = await mk('erin'), frank = await mk('frank');
+ // Alice and Bob are live and share gaming; Bob's chosen category is irl but the AI's gaming is what the API
+ // returns, so category=gaming includes him and category=irl does not. Carol is music+irl, Dave ended 2 days
+ // ago (counts as recent), Erin ended 10 days ago (outside the window), Frank has been live 9 days (live only).
+ // The AI adds its own tags: cozy/fortnite/nature/outdoors are useful, overlay/live/stream are stop words, and
+ // c++ must never become a chip. Dave has gaming in both columns: one stream, one count, source both.
+ await stream(alice, { live: true, startedAgo: 60000, viewers: 50, tags: ['Gaming', 'music'], aiTags: ['cozy', 'live'], category: 'gaming' });
+ await stream(bob, { live: true, startedAgo: 300000, viewers: 100, tags: ['gaming'], aiTags: ['fortnite', 'overlay'], category: 'irl', aiCategory: 'gaming' });
+ await stream(carol, { live: true, startedAgo: 600000, viewers: 5, tags: ['music', 'irl'], aiTags: ['nature', 'c++'], category: 'music' });
+ await stream(dave, { live: false, startedAgo: 2 * DAY, endedAgo: 2 * DAY - 3600000, tags: ['gaming', 'irl'], aiTags: ['cozy', 'stream', 'gaming'], category: 'music' });
+ await stream(erin, { live: false, startedAgo: 10 * DAY, endedAgo: 10 * DAY - 3600000, tags: ['retro'], aiTags: ['kitchen'], category: 'music' });
+ await stream(frank, { live: true, startedAgo: 9 * DAY, viewers: 2, tags: ['marathon'], aiTags: ['outdoors'], category: 'other' });
+
+ const app = express();
+ app.use('/api/streams', require('../server/streaming/routes'));
+ const server = await new Promise((resolve) => { const s = http.createServer(app).listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const get = async (path) => {
+ const res = await fetch(base + path);
+ return { status: res.status, cache: res.headers.get('cache-control'), body: await res.json() };
+ };
+ try {
+ // ── The unchanged default ───────────────────────────────
+ const all = await get('/api/streams');
+ assert.strictEqual(all.status, 200);
+ assert.deepStrictEqual(all.body.streams.map((s) => s.username), ['bob', 'alice', 'carol', 'frank'],
+ 'no filters: every live stream, in the list\'s own order (viewers, then start)');
+ assert.deepStrictEqual(all.body.streams.map((s) => s.category), ['gaming', 'gaming', 'music', 'other'],
+ 'the category returned is the effective one');
+ assert.strictEqual(all.body.streams[0].username, 'bob', 'the biggest crowd stays first');
+
+ // ── Tags: one, several, and normalized query values ─────
+ const one = await get('/api/streams?tag=gaming');
+ assert.strictEqual(one.status, 200);
+ assert.deepStrictEqual(one.body.streams.map((s) => s.username), ['bob', 'alice'], 'one tag keeps its streams in order');
+ const folded = await get('/api/streams?tag=%20GAMING%20');
+ assert.deepStrictEqual(folded.body.streams.map((s) => s.username), ['bob', 'alice'], 'the query is trimmed and lowercased');
+ const several = await get('/api/streams?tag=gaming&tag=music');
+ assert.deepStrictEqual(several.body.streams.map((s) => s.username), ['bob', 'alice', 'carol'], 'several tags keep ANY of them');
+ const overlap = await get('/api/streams?tag=music&tag=irl');
+ assert.deepStrictEqual(overlap.body.streams.map((s) => s.username), ['alice', 'carol'], 'a stream is listed once');
+ const none = await get('/api/streams?tag=retro');
+ assert.deepStrictEqual(none.body.streams, [], 'a tag only on an old stream matches no live stream');
+
+ // ── Tags the AI inferred count and filter too ───────────
+ const aiOnly = await get('/api/streams?tag=fortnite');
+ assert.strictEqual(aiOnly.status, 200);
+ assert.deepStrictEqual(aiOnly.body.streams.map((s) => s.username), ['bob'], 'a tag only in ai_tags matches ?tag=');
+ const aiCozy = await get('/api/streams?tag=cozy');
+ assert.deepStrictEqual(aiCozy.body.streams.map((s) => s.username), ['alice'], 'an AI tag on an ended stream does not make it live');
+ const aiOldLive = await get('/api/streams?tag=outdoors');
+ assert.deepStrictEqual(aiOldLive.body.streams.map((s) => s.username), ['frank'], 'a live stream carries its AI tags whatever its start time');
+ const stopWord = await get('/api/streams?tag=live');
+ assert.deepStrictEqual(stopWord.body.streams, [], 'a stop word matches nothing even though the AI emitted it');
+ const outsideWindow = await get('/api/streams?tag=kitchen');
+ assert.deepStrictEqual(outsideWindow.body.streams, [], 'an AI tag only on an old stream matches no live stream');
+
+ // ── Category: the effective category, case-folded ───────
+ const music = await get('/api/streams?category=music');
+ assert.deepStrictEqual(music.body.streams.map((s) => s.username), ['carol'], 'one category');
+ const gaming = await get('/api/streams?category=GAMING');
+ assert.deepStrictEqual(gaming.body.streams.map((s) => s.username), ['bob', 'alice'], 'category is case-folded');
+ const chosenIrl = await get('/api/streams?category=irl');
+ assert.deepStrictEqual(chosenIrl.body.streams, [], 'the AI category wins over the chosen one here');
+ const combined = await get('/api/streams?tag=music&category=gaming');
+ assert.deepStrictEqual(combined.body.streams.map((s) => s.username), ['alice'], 'tag and category narrow together');
+
+ // ── Bad filters are 400s ────────────────────────────────
+ for (const path of [
+ '/api/streams?tag=lofi_beats',
+ '/api/streams?tag=c%2B%2B',
+ '/api/streams?tag=',
+ '/api/streams?tag=' + 'a'.repeat(33),
+ '/api/streams?tag=' + Array.from({ length: 11 }, (_, i) => `t${i}`).join('&tag='),
+ '/api/streams?tag=gaming&tag=',
+ '/api/streams?tag=gaming&tag=lofi_beats',
+ '/api/streams?tag[bad]=1',
+ '/api/streams?category=',
+ '/api/streams?category[a]=b',
+ ]) {
+ const bad = await get(path);
+ assert.strictEqual(bad.status, 400, `${path} is a 400`);
+ }
+ const ten = await get('/api/streams?' + Array.from({ length: 10 }, (_, i) => `tag=t${i}`).join('&'));
+ assert.strictEqual(ten.status, 200, 'ten tags is the maximum, not one over');
+ assert.deepStrictEqual(ten.body.streams, []);
+
+ // ── The tags list: counts, order, source, cache ─────────
+ const tags = await get('/api/streams/tags');
+ assert.strictEqual(tags.status, 200);
+ assert.strictEqual(tags.cache, 'public, max-age=60', 'the list is public and cached for a minute');
+ assert.deepStrictEqual(tags.body.tags, [
+ { tag: 'gaming', live: 2, recent: 3, source: 'both' },
+ { tag: 'music', live: 2, recent: 2, source: 'streamer' },
+ { tag: 'cozy', live: 1, recent: 2, source: 'ai' },
+ { tag: 'irl', live: 1, recent: 2, source: 'streamer' },
+ { tag: 'fortnite', live: 1, recent: 1, source: 'ai' },
+ { tag: 'nature', live: 1, recent: 1, source: 'ai' },
+ { tag: 'marathon', live: 1, recent: 0, source: 'streamer' },
+ { tag: 'outdoors', live: 1, recent: 0, source: 'ai' },
+ ], 'own+AI tags, stop words dropped, live/recent counts, source, and the old stream left out');
+ assert.ok(!tags.body.tags.some((t) => t.tag === 'c++'), 'an AI tag outside the filter grammar is not listed');
+ for (const t of tags.body.tags) {
+ assert.ok(TAG_FILTER_RE.test(t.tag), `${t.tag} is filterable`);
+ const back = await get(`/api/streams?tag=${encodeURIComponent(t.tag)}`);
+ assert.strictEqual(back.status, 200, `every listed tag round-trips as ?tag= (${t.tag})`);
+ }
+
+ // The stop list is exported and holds the meta words that say nothing about the stream. It covers
+ // the home UI's _TOPIC_STOP (Live #520) too, so the browse chips and the home topics agree.
+ const meta = ['live', 'streaming', 'stream', 'livestream', 'chat', 'livechat', 'overlay', 'ui',
+ 'setup', 'lobby', 'facecam', 'multicam', 'multiframe', 'gameplay', 'commentary', 'surveillance',
+ 'live stream', 'stream overlay', 'streamer', 'webcam', 'camera', 'screen', 'desktop', 'browser',
+ 'pc', 'obs'];
+ for (const word of meta) assert.ok(TAG_STOP_WORDS.has(word), `${word} is a stop word`);
+ for (const word of ['gaming', 'outdoors', 'fortnite', 'cozy', 'music', 'nature']) {
+ assert.ok(!TAG_STOP_WORDS.has(word), `${word} is a useful tag`);
+ }
+
+ // A malformed tags value is no tags, never a failed list.
+ await stream(alice, { live: true, startedAgo: 3 * DAY, viewers: 1, tags: 'not json', aiTags: 'also not json', category: 'other' });
+ const still = await get('/api/streams/tags');
+ assert.strictEqual(still.status, 200, 'malformed tags/ai_tags JSON does not break the list');
+ assert.deepStrictEqual(still.body.tags, tags.body.tags, 'and adds no tag');
+
+ // At most 30, however many tags are in use.
+ await stream(bob, { live: true, startedAgo: 60000, viewers: 0, tags: Array.from({ length: 35 }, (_, i) => `cap-${String(i + 1).padStart(2, '0')}`), category: 'other' });
+ const capped = await get('/api/streams/tags');
+ assert.strictEqual(capped.body.tags.length, 30, 'at most 30 tags');
+ assert.strictEqual(new Set(capped.body.tags.map((t) => t.tag)).size, 30, 'no duplicate tags');
+ assert.deepStrictEqual(capped.body.tags.slice(0, 3).map((t) => t.tag), ['gaming', 'music', 'cozy'], 'the order survives the cap');
+ for (let i = 1; i < capped.body.tags.length; i++) {
+ const a = capped.body.tags[i - 1], b = capped.body.tags[i];
+ assert.ok(a.live > b.live || (a.live === b.live && a.recent >= b.recent), `${a.tag} sorts before ${b.tag}`);
+ }
+ console.log('stream-tag-filters: all checks passed');
+ } finally { server.close(); }
+ process.exit(0);
+})().catch((e) => { console.error(e); process.exit(1); });