Monorepo for Aesthetic.Computer
aesthetic.computer
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});