Monorepo for Aesthetic.Computer aesthetic.computer
at main 397 lines 15 kB view raw
1#!/usr/bin/env node 2 3/** 4 * Silo-side feed updater — manages all channels and dynamic playlists. 5 * Runs on silo.aesthetic.computer via systemd timer. 6 * 7 * Channels: KidLisp, Paintings, Mugs, Clocks, Moods, Chats, Instruments, Tapes 8 * 9 * Usage: 10 * node silo-update-feed.mjs # Update all channels 11 * node silo-update-feed.mjs kidlisp # Update one channel 12 * node silo-update-feed.mjs paintings clocks # Update specific channels 13 */ 14 15import { MongoClient } from 'mongodb'; 16 17const FEED_URL = process.env.FEED_URL || 'https://feed.aesthetic.computer/api/v1'; 18const API_SECRET = process.env.FEED_API_SECRET; 19const MONGO_URI = process.env.MONGODB_CONNECTION_STRING; 20const MONGO_DB = process.env.MONGODB_NAME || 'aesthetic'; 21 22if (!API_SECRET) { console.error('FEED_API_SECRET required'); process.exit(1); } 23if (!MONGO_URI) { console.error('MONGODB_CONNECTION_STRING required'); process.exit(1); } 24 25const log = (msg) => console.log(`${new Date().toISOString()} ${msg}`); 26 27// --------------------------------------------------------------------------- 28// Channel definitions 29// --------------------------------------------------------------------------- 30 31const dateStr = () => new Date().toLocaleDateString('en-US', { 32 weekday: 'long', year: 'numeric', month: 'long', day: 'numeric', 33}); 34 35const CHANNELS = { 36 kidlisp: { 37 title: 'KidLisp', 38 curator: 'prompt.ac', 39 summary: 'KidLisp is a TV friendly programming language for kids of all ages! Developed by @jeffrey of Aesthetic Computer.', 40 dynamicPlaylists: [ 41 { slug: 'top-100', title: () => `Top 100 as of ${dateStr()}`, summary: 'The 100 most popular KidLisp pieces by total hits', handle: null, limit: 100 }, 42 { slug: 'top-jeffrey', title: () => `Top @jeffrey as of ${dateStr()}`, summary: 'The most popular KidLisp pieces by @jeffrey', handle: 'jeffrey', limit: 100 }, 43 { slug: 'top-fifi', title: () => `Top @fifi as of ${dateStr()}`, summary: 'The most popular KidLisp pieces by @fifi', handle: 'fifi', limit: 100 }, 44 ], 45 // Colors playlist is static — managed separately, preserved by ID 46 query: async (db, handle, limit) => queryCollection(db, 'kidlisp', handle, limit, { hits: -1 }), 47 buildItem: (doc, i, total) => ({ 48 title: `$${doc.code}`, 49 source: `https://device.kidlisp.com/$${doc.code}?playlist=true&duration=24&index=${i}&total=${total}`, 50 duration: 24, 51 license: 'open', 52 provenance: { type: 'offChainURI', uri: `https://kidlisp.com/$${doc.code}` }, 53 }), 54 }, 55 56 paintings: { 57 title: 'Paintings', 58 curator: 'prompt.ac', 59 summary: 'User-created art from Aesthetic Computer drawing tools.', 60 dynamicPlaylists: [ 61 { slug: 'recent', title: () => `Recent Paintings as of ${dateStr()}`, summary: 'The latest paintings from Aesthetic Computer', handle: null, limit: 100 }, 62 { slug: 'top-jeffrey', title: () => `Top @jeffrey Paintings`, summary: 'Paintings by @jeffrey', handle: 'jeffrey', limit: 100 }, 63 { slug: 'top-fifi', title: () => `Top @fifi Paintings`, summary: 'Paintings by @fifi', handle: 'fifi', limit: 100 }, 64 ], 65 query: async (db, handle, limit) => queryCollection(db, 'paintings', handle, limit, { when: -1 }, { nuked: { $ne: true }, user: { $exists: true } }), 66 buildItem: (doc, i, total) => ({ 67 title: `#${doc.code}`, 68 source: `https://aesthetic.computer/painting~${doc.code}`, 69 duration: 10, 70 license: 'open', 71 provenance: { type: 'offChainURI', uri: `https://aesthetic.computer/painting~${doc.code}` }, 72 }), 73 }, 74 75 mugs: { 76 title: 'Mugs', 77 curator: 'prompt.ac', 78 summary: 'Print-on-demand ceramic mugs made from paintings and KidLisp art.', 79 dynamicPlaylists: [ 80 { slug: 'recent', title: () => `Recent Mugs as of ${dateStr()}`, summary: 'The latest mugs from Aesthetic Computer', handle: null, limit: 50 }, 81 ], 82 query: async (db, handle, limit) => { 83 const pipeline = []; 84 pipeline.push({ $sort: { createdAt: -1 } }); 85 pipeline.push({ $limit: limit }); 86 pipeline.push({ $project: { _id: 0, code: 1, preview: 1, variant: 1, source: 1, createdAt: 1 } }); 87 return db.collection('products').aggregate(pipeline).toArray(); 88 }, 89 buildItem: (doc, i, total) => ({ 90 title: `+${doc.code} (${doc.variant || 'white'})`, 91 source: `https://aesthetic.computer/mug~+${doc.code}`, 92 duration: 12, 93 license: 'open', 94 }), 95 }, 96 97 clocks: { 98 title: 'Clocks', 99 curator: 'prompt.ac', 100 summary: 'User-created musical clocks — time displays with melody.', 101 dynamicPlaylists: [ 102 { slug: 'top', title: () => `Top Clocks as of ${dateStr()}`, summary: 'The most popular clocks by total hits', handle: null, limit: 100 }, 103 { slug: 'recent', title: () => `Recent Clocks as of ${dateStr()}`, summary: 'Newly created clock melodies', handle: null, limit: 100, sort: { when: -1 } }, 104 ], 105 query: async (db, handle, limit, extraSort) => { 106 const pipeline = []; 107 pipeline.push({ $match: { source: { $exists: true, $ne: '' } } }); 108 pipeline.push({ $sort: extraSort || { hits: -1 } }); 109 pipeline.push({ $limit: limit }); 110 pipeline.push({ $project: { _id: 0, code: 1, source: 1, hits: 1, when: 1 } }); 111 return db.collection('clocks').aggregate(pipeline).toArray(); 112 }, 113 buildItem: (doc, i, total) => ({ 114 title: `*${doc.code}`, 115 source: `https://aesthetic.computer/clock~*${doc.code}`, 116 duration: 30, 117 license: 'open', 118 }), 119 }, 120 121 moods: { 122 title: 'Moods', 123 curator: 'prompt.ac', 124 summary: 'Text status updates from Aesthetic Computer users.', 125 dynamicPlaylists: [ 126 { slug: 'recent', title: () => `Recent Moods as of ${dateStr()}`, summary: 'The latest moods from the community', handle: null, limit: 100 }, 127 { slug: 'jeffrey', title: () => `@jeffrey Moods`, summary: 'Thoughts from @jeffrey', handle: 'jeffrey', limit: 50 }, 128 ], 129 query: async (db, handle, limit) => queryCollection(db, 'moods', handle, limit, { when: -1 }, { deleted: { $ne: true }, mood: { $exists: true, $ne: '' } }), 130 buildItem: (doc, i, total) => ({ 131 title: doc.mood?.substring(0, 60) || '...', 132 source: `https://aesthetic.computer/mood`, 133 duration: 8, 134 license: 'open', 135 }), 136 }, 137 138 chats: { 139 title: 'Chats', 140 curator: 'prompt.ac', 141 summary: 'Live chat rooms — community conversation in real-time.', 142 dynamicPlaylists: [], // No dynamic playlists — all static 143 staticItems: [ 144 { title: 'chat', source: 'https://aesthetic.computer/chat', duration: 60, license: 'open' }, 145 { title: 'laer-klokken', source: 'https://aesthetic.computer/laer-klokken', duration: 60, license: 'open' }, 146 ], 147 }, 148 149 instruments: { 150 title: 'Instruments', 151 curator: 'prompt.ac', 152 summary: 'Curated sequences using parameterized pieces for ambient display.', 153 dynamicPlaylists: [], // Chords playlist is static — migrated from KidLisp channel 154 // Chords playlist ID: ea5e26c9-e755-4f88-807e-c68a91cff49a (seeded via feed-state.json) 155 }, 156 157 tapes: { 158 title: 'Tapes', 159 curator: 'prompt.ac', 160 summary: 'Session recordings and replays from Aesthetic Computer.', 161 dynamicPlaylists: [ 162 { slug: 'recent', title: () => `Recent Tapes as of ${dateStr()}`, summary: 'The latest session recordings', handle: null, limit: 50 }, 163 ], 164 query: async (db, handle, limit) => queryCollection(db, 'tapes', handle, limit, { when: -1 }, { nuked: { $ne: true }, code: { $exists: true } }), 165 buildItem: (doc, i, total) => ({ 166 title: `!${doc.code}`, 167 source: `https://aesthetic.computer/replay~${doc.slug || doc.code}`, 168 duration: 30, 169 license: 'open', 170 }), 171 }, 172}; 173 174// --------------------------------------------------------------------------- 175// Shared query helper 176// --------------------------------------------------------------------------- 177 178async function queryCollection(db, collection, handle, limit, sort, baseMatch = {}) { 179 const pipeline = []; 180 181 const match = { ...baseMatch }; 182 if (handle) { 183 const handleDoc = await db.collection('@handles').findOne({ handle }); 184 if (!handleDoc) { 185 log(` warning: handle @${handle} not found`); 186 return []; 187 } 188 match.user = handleDoc._id; 189 } 190 if (Object.keys(match).length > 0) pipeline.push({ $match: match }); 191 192 pipeline.push( 193 { $lookup: { from: '@handles', localField: 'user', foreignField: '_id', as: '_h' } }, 194 { $sort: sort }, 195 { $limit: limit }, 196 ); 197 198 return db.collection(collection).aggregate(pipeline).toArray(); 199} 200 201// --------------------------------------------------------------------------- 202// Feed API helpers 203// --------------------------------------------------------------------------- 204 205async function api(method, path, body) { 206 const opts = { 207 method, 208 headers: { 'Authorization': `Bearer ${API_SECRET}`, 'Content-Type': 'application/json' }, 209 }; 210 if (body) opts.body = JSON.stringify(body); 211 const res = await fetch(`${FEED_URL}${path}`, opts); 212 if (!res.ok && res.status !== 404) { 213 throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`); 214 } 215 if (res.status === 204 || res.status === 404) return null; 216 return res.json(); 217} 218 219// --------------------------------------------------------------------------- 220// Channel state store (JSON file on disk to track channel/playlist IDs) 221// --------------------------------------------------------------------------- 222 223import { readFileSync, writeFileSync, existsSync } from 'fs'; 224 225const STATE_FILE = new URL('./feed-state.json', import.meta.url).pathname; 226 227function loadState() { 228 if (existsSync(STATE_FILE)) { 229 return JSON.parse(readFileSync(STATE_FILE, 'utf8')); 230 } 231 return { channels: {} }; 232} 233 234function saveState(state) { 235 writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); 236} 237 238// --------------------------------------------------------------------------- 239// Update one channel 240// --------------------------------------------------------------------------- 241 242async function updateChannel(slug, db) { 243 const config = CHANNELS[slug]; 244 if (!config) { log(`unknown channel: ${slug}`); return; } 245 246 log(`--- ${slug} ---`); 247 248 const state = loadState(); 249 let channelId = state.channels[slug]?.id; 250 let staticPlaylistUrls = state.channels[slug]?.staticPlaylistUrls || []; 251 252 // Create channel if it doesn't exist 253 if (channelId) { 254 const existing = await api('GET', `/channels/${channelId}`); 255 if (!existing) channelId = null; 256 else staticPlaylistUrls = state.channels[slug]?.staticPlaylistUrls || []; 257 } 258 259 // Handle static-only channels (chats, instruments) 260 if (config.dynamicPlaylists.length === 0) { 261 if (config.staticItems && staticPlaylistUrls.length === 0) { 262 const playlist = { 263 dpVersion: '1.1.0', 264 title: config.title, 265 summary: config.summary, 266 items: config.staticItems, 267 defaults: { display: { scaling: 'fit', background: '#000000' }, license: 'open', duration: 60 }, 268 }; 269 const created = await api('POST', '/playlists', playlist); 270 staticPlaylistUrls = [`${FEED_URL}/playlists/${created.id}`]; 271 log(` created static playlist: ${created.id} (${config.staticItems.length} items)`); 272 } 273 274 if (staticPlaylistUrls.length === 0) { 275 log(` ${slug}: no playlists yet, skipping channel creation`); 276 return; 277 } 278 279 if (!channelId) { 280 const created = await api('POST', '/channels', { 281 title: config.title, curator: config.curator, summary: config.summary, 282 playlists: staticPlaylistUrls, 283 }); 284 channelId = created.id; 285 log(` created channel: ${channelId}`); 286 } else { 287 await api('PUT', `/channels/${channelId}`, { 288 title: config.title, curator: config.curator, summary: config.summary, 289 playlists: staticPlaylistUrls, 290 }); 291 } 292 293 state.channels[slug] = { id: channelId, staticPlaylistUrls }; 294 saveState(state); 295 log(` ${slug}: done (static)`); 296 return; 297 } 298 299 // Track old dynamic playlist IDs for cleanup 300 const oldDynamicUrls = state.channels[slug]?.dynamicPlaylistUrls || []; 301 const newDynamicUrls = []; 302 303 for (const plConfig of config.dynamicPlaylists) { 304 const sortOverride = plConfig.sort; 305 let docs; 306 307 if (config.query) { 308 docs = await config.query(db, plConfig.handle, plConfig.limit, sortOverride); 309 } else { 310 docs = []; 311 } 312 313 log(` ${plConfig.slug}: ${docs.length} items`); 314 315 if (docs.length === 0) { 316 const idx = config.dynamicPlaylists.indexOf(plConfig); 317 if (oldDynamicUrls[idx]) newDynamicUrls.push(oldDynamicUrls[idx]); 318 continue; 319 } 320 321 const playlist = { 322 dpVersion: '1.1.0', 323 title: plConfig.title(), 324 summary: plConfig.summary, 325 items: docs.map((d, i) => config.buildItem(d, i, docs.length)), 326 defaults: { display: { scaling: 'fit', background: '#000000' }, license: 'open', duration: 24 }, 327 }; 328 329 const created = await api('POST', '/playlists', playlist); 330 newDynamicUrls.push(`${FEED_URL}/playlists/${created.id}`); 331 log(` ${plConfig.slug}: created ${created.id}`); 332 } 333 334 // Build final playlist URL list: [dynamic..., static...] 335 const allUrls = [...newDynamicUrls, ...staticPlaylistUrls]; 336 337 if (!channelId) { 338 const created = await api('POST', '/channels', { 339 title: config.title, curator: config.curator, summary: config.summary, 340 playlists: allUrls, 341 }); 342 channelId = created.id; 343 log(` created channel: ${channelId} (${allUrls.length} playlists)`); 344 } else { 345 await api('PUT', `/channels/${channelId}`, { 346 title: config.title, curator: config.curator, summary: config.summary, 347 playlists: allUrls, 348 }); 349 log(` channel updated (${allUrls.length} playlists)`); 350 } 351 352 // Delete old dynamic playlists 353 for (const oldUrl of oldDynamicUrls) { 354 const oldId = oldUrl.split('/').pop(); 355 if (!newDynamicUrls.some(u => u.includes(oldId))) { 356 await api('DELETE', `/playlists/${oldId}`); 357 log(` deleted old: ${oldId}`); 358 } 359 } 360 361 // Save state 362 state.channels[slug] = { id: channelId, dynamicPlaylistUrls: newDynamicUrls, staticPlaylistUrls }; 363 saveState(state); 364} 365 366// --------------------------------------------------------------------------- 367// Main 368// --------------------------------------------------------------------------- 369 370async function main() { 371 const requestedSlugs = process.argv.slice(2); 372 const slugs = requestedSlugs.length > 0 373 ? requestedSlugs 374 : Object.keys(CHANNELS); 375 376 log(`updating channels: ${slugs.join(', ')}`); 377 378 const client = new MongoClient(MONGO_URI); 379 await client.connect(); 380 const db = client.db(MONGO_DB); 381 382 for (const slug of slugs) { 383 try { 384 await updateChannel(slug, db); 385 } catch (e) { 386 log(` ERROR in ${slug}: ${e.message}`); 387 } 388 } 389 390 await client.close(); 391 log('all done.'); 392} 393 394main().catch(e => { 395 console.error('FATAL:', e.message); 396 process.exit(1); 397});