Files
indee-demo/scripts/seed-activity.ts
Dorian 0a7543cf32 Add Nostr relay + seed data to Docker deployment
- Add nostr-rs-relay service to docker-compose for persistent
  comments, reactions, and profiles on the dev server
- Add one-shot seeder container that auto-populates the relay
  with test personas, reactions, and comments on first deploy
- Proxy WebSocket connections through nginx at /relay so the
  frontend connects to the relay on the same host (no CORS)
- Make relay URL dynamic: reads from VITE_NOSTR_RELAYS in dev,
  auto-detects /relay proxy path in production Docker builds
- Make seed scripts configurable via RELAY_URL and ORIGIN env vars
- Add wait-for-relay script for reliable container orchestration
- Add "Resume last played" hero banner on My List tab

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 12:33:22 +00:00

341 lines
12 KiB
TypeScript

/**
* Seeds the local relay with reactions (kind 17) and comments (kind 1111)
* for all IndeeHub content, so the UI has real data to display.
*
* Run after seed-profiles.ts and with the relay already running.
*/
import { Relay } from 'applesauce-relay'
import { PrivateKeySigner } from 'applesauce-signers/signers/private-key-signer'
import {
TEST_PERSONAS,
TASTEMAKER_PERSONAS,
} from '../src/data/testPersonas.js'
const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777'
const ORIGIN = process.env.ORIGIN || 'http://localhost:5174'
// ── Content catalog (matching src/data/indeeHubFilms.ts) ──────────
const CONTENT = [
{ id: 'god-bless-bitcoin', title: 'God Bless Bitcoin' },
{ id: 'thethingswecarry', title: 'The Things We Carry' },
{ id: 'duel', title: 'Duel' },
{ id: '2b0d7349-c010-47a0-b584-49e1bf86ab2f', title: 'Hard Money' },
{ id: '665a4095-73b9-480d-a0a4-b2aafaf2bce4', title: 'Bitcoiners' },
{ id: '3c113b66-3bb5-4cac-90eb-965ecedc4aa2', title: 'Lekker Feeling' },
{ id: 'stranded', title: 'STRANDED' },
{ id: 'bbdb0178-0b96-4ab5-addf-ba1f029c1cb3', title: 'The Housing Bubble' },
{ id: '584f310b-2269-4b05-a09d-261a0a3c1f78', title: 'Menger' },
{ id: 'ef92cd99-7188-4c48-b4bf-0b31fdd8934e', title: 'Everybody Does It' },
{ id: 'e1bd64d6-63c9-4c91-8d91-c69f5376286e', title: 'Gods of Their Own Religion' },
{ id: 'forgingacountry', title: 'Forging a Country' },
{ id: 'home', title: 'HOME' },
{ id: 'e1f58162-9288-418e-803d-196dcde00782', title: 'Kismet' },
{ id: 'identity-theft', title: 'Identity Theft' },
{ id: 'comingto', title: 'Coming To' },
{ id: 'down-the-pch', title: 'Down the P.C.H.' },
{ id: '0cb9de15-566d-4130-b80c-d42e952bb803', title: 'Breaking Up Is Hard to Do' },
{ id: '24b6f7c6-8f56-40f2-831a-54f40b03c427', title: 'The Florist' },
{ id: '311f772f-6559-4982-8918-d0f4be9e1b76', title: 'Plastic Money' },
{ id: '5bd753b7-9ff1-4966-a1c4-b3b93c62ed5d', title: 'Time Traveling Thieves' },
{ id: '34f042bd-23d6-40f4-9707-4b3bb62fdd58', title: 'Little Billy' },
]
// ── helpers ──────────────────────────────────────────────────────
type Persona = { name: string; nsec: string; pubkey: string }
function contentUrl(contentId: string): string {
return `${ORIGIN}/content/${contentId}`
}
function pick<T>(arr: T[], n: number): T[] {
const shuffled = [...arr].sort(() => Math.random() - 0.5)
return shuffled.slice(0, n)
}
function randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min + 1)) + min
}
// ── personas ────────────────────────────────────────────────────
const allPersonas: Persona[] = [
...(TEST_PERSONAS as unknown as Persona[]),
...(TASTEMAKER_PERSONAS as unknown as Persona[]),
]
const tastemakers: Persona[] = TASTEMAKER_PERSONAS as unknown as Persona[]
const now = Math.floor(Date.now() / 1000)
const ONE_DAY = 86400
const ONE_WEEK = 7 * ONE_DAY
// Content subsets for different activity patterns
const topContent = CONTENT.slice(0, 8)
const midContent = CONTENT.slice(8, 16)
const trendingContent = pick(CONTENT.slice(0, 12), 5)
const tastemakerFaves = pick(CONTENT.slice(0, 10), 6)
// ── sample comments ─────────────────────────────────────────────
const POSITIVE_COMMENTS = [
'Absolutely incredible film. A masterpiece in every sense.',
'This movie changed my perspective on cinema. Must watch!',
'The cinematography alone is worth the price of admission.',
'One of the greatest performances I\'ve ever seen on screen.',
'Every frame is a painting. Stunning work.',
'I\'ve seen this at least 5 times and it gets better every watch.',
'The screenplay is tight, the pacing is perfect.',
'A landmark achievement in filmmaking.',
'This deserves every award it got and more.',
'Rewatched it last night — still holds up beautifully.',
'Such an important documentary. Everyone should see this.',
'The storytelling here is on another level.',
]
const MIXED_COMMENTS = [
'Good but I think it\'s a bit overrated honestly.',
'Solid film, though the third act drags a little.',
'Worth watching once for sure, but I wouldn\'t rewatch.',
'Technically impressive but emotionally I felt nothing.',
'The hype is a bit much, but it\'s still a decent movie.',
'Some great moments, but also some really slow stretches.',
'I can see why people love it, just not my cup of tea.',
'Better than I expected, worse than the reviews suggest.',
]
const NEGATIVE_COMMENTS = [
'I really don\'t understand the hype around this one.',
'Couldn\'t finish it. Way too slow for my taste.',
'Overrated. There are much better films in this genre.',
]
// ── publishing helper ───────────────────────────────────────────
async function publishEvent(
relay: Relay,
signer: PrivateKeySigner,
event: { kind: number; content: string; tags: string[][]; created_at: number },
label: string,
): Promise<boolean> {
const signed = await signer.signEvent(event)
try {
const res = await relay.publish(signed, { timeout: 5000 })
if (!res.ok) console.warn(`${label}: ${res.message}`)
return true
} catch (err) {
console.error(`${label}:`, err instanceof Error ? err.message : err)
return false
}
}
// ── seed reactions (kind 17) ────────────────────────────────────
async function seedReactions(relay: Relay) {
console.log('\n📊 Seeding reactions (kind 17)...')
let count = 0
// Top content: lots of positive reactions
for (const item of topContent) {
const voters = pick(allPersonas, randomInt(5, 10))
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const emoji = Math.random() < 0.9 ? '+' : '-'
const age = randomInt(1 * ONE_DAY, 30 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 17,
content: emoji,
tags: [
['i', contentUrl(item.id)],
['k', 'web'],
],
created_at: now - age,
}, `reaction ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Mid content: moderate reactions, more mixed
for (const item of pick(midContent, 5)) {
const voters = pick(allPersonas, randomInt(2, 5))
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const emoji = Math.random() < 0.6 ? '+' : '-'
const age = randomInt(2 * ONE_DAY, 60 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 17,
content: emoji,
tags: [
['i', contentUrl(item.id)],
['k', 'web'],
],
created_at: now - age,
}, `reaction ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Trending content: recent reactions
for (const item of trendingContent) {
const voters = pick(allPersonas, randomInt(4, 8))
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const age = randomInt(0, ONE_WEEK)
const ok = await publishEvent(relay, signer, {
kind: 17,
content: '+',
tags: [
['i', contentUrl(item.id)],
['k', 'web'],
],
created_at: now - age,
}, `trending-reaction ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Tastemaker-specific reactions
for (const item of tastemakerFaves) {
const voters = pick(tastemakers, randomInt(2, 5))
for (const persona of voters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const age = randomInt(0, 14 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 17,
content: '+',
tags: [
['i', contentUrl(item.id)],
['k', 'web'],
],
created_at: now - age,
}, `tastemaker-reaction ${persona.name}->${item.title}`)
if (ok) count++
}
}
console.log(`${count} reactions seeded`)
}
// ── seed comments (kind 1111) ───────────────────────────────────
async function seedComments(relay: Relay) {
console.log('\n💬 Seeding comments (kind 1111)...')
let count = 0
// Top content: several comments
for (const item of topContent) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(2, 5))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const comments =
Math.random() < 0.7 ? POSITIVE_COMMENTS : MIXED_COMMENTS
const content = comments[randomInt(0, comments.length - 1)]
const age = randomInt(1 * ONE_DAY, 30 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 1111,
content,
tags: [
['I', url],
['K', 'web'],
['i', url],
['k', 'web'],
],
created_at: now - age,
}, `comment ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Mid content: occasional comments
for (const item of pick(midContent, 4)) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(1, 2))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const pool = [...MIXED_COMMENTS, ...NEGATIVE_COMMENTS]
const content = pool[randomInt(0, pool.length - 1)]
const age = randomInt(3 * ONE_DAY, 45 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 1111,
content,
tags: [
['I', url],
['K', 'web'],
['i', url],
['k', 'web'],
],
created_at: now - age,
}, `comment ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Tastemaker reviews on their faves
for (const item of tastemakerFaves.slice(0, 4)) {
const url = contentUrl(item.id)
const reviewers = pick(tastemakers, randomInt(1, 3))
for (const persona of reviewers) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const content = POSITIVE_COMMENTS[randomInt(0, POSITIVE_COMMENTS.length - 1)]
const age = randomInt(0, 10 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 1111,
content,
tags: [
['I', url],
['K', 'web'],
['i', url],
['k', 'web'],
],
created_at: now - age,
}, `tastemaker-comment ${persona.name}->${item.title}`)
if (ok) count++
}
}
// Trending content: recent comments
for (const item of trendingContent.slice(0, 3)) {
const url = contentUrl(item.id)
const commenters = pick(allPersonas, randomInt(2, 4))
for (const persona of commenters) {
const signer = PrivateKeySigner.fromKey(persona.nsec)
const content = POSITIVE_COMMENTS[randomInt(0, POSITIVE_COMMENTS.length - 1)]
const age = randomInt(0, 3 * ONE_DAY)
const ok = await publishEvent(relay, signer, {
kind: 1111,
content,
tags: [
['I', url],
['K', 'web'],
['i', url],
['k', 'web'],
],
created_at: now - age,
}, `trending-comment ${persona.name}->${item.title}`)
if (ok) count++
}
}
console.log(`${count} comments seeded`)
}
// ── main ────────────────────────────────────────────────────────
async function main() {
console.log('🎬 Seeding activity data into relay at', RELAY_URL)
const relay = new Relay(RELAY_URL)
await seedReactions(relay)
await seedComments(relay)
console.log('\n✅ Done! Activity seeded successfully.')
setTimeout(() => process.exit(0), 1000)
}
main().catch((err) => {
console.error('Fatal:', err)
process.exit(1)
})