@homie-rocks/studio 0.2.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,12 +5,18 @@
5
5
  * The person's only step is Wrangler's sign-in (`npx wrangler login`, one
6
6
  * approval in their browser); Wrangler keeps that login, this never sees a key.
7
7
  *
8
- * It creates what the studio needs, by the names in studio.json:
9
- * the Worker (pages + rooms), a D1 database, an R2 bucket (when the account
10
- * has R2), the Table/Lobby Durable Objects (declared by the Worker)
11
- * and it REFUSES to touch a Worker, database or bucket of that name that this
8
+ * NO CREDIT CARD. It creates only what Cloudflare's free Workers plan gives a
9
+ * brand-new account with no payment method, by the names in studio.json:
10
+ * the Worker (pages + rooms), a D1 database, and the Table/Lobby Durable
11
+ * Objects (SQLite-backed, declared by the Worker).
12
+ * It never creates or binds R2: Cloudflare asks for a payment method before R2
13
+ * works, even inside its free tier, so storage for large media is its own later
14
+ * step (`homie-studio storage add`) that the person agrees to.
15
+ *
16
+ * It REFUSES to touch a Worker, database or bucket of that name that this
12
17
  * studio did not create (studio.json `cloudflare.created` is the record), so it
13
- * can never overwrite someone's existing site.
18
+ * can never overwrite someone's existing site. `deploy --plan` says all of this
19
+ * before anything happens and calls nothing.
14
20
  */
15
21
  import { spawnSync } from 'node:child_process';
16
22
  import { existsSync, writeFileSync } from 'node:fs';
@@ -53,19 +59,76 @@ function parseJson(text) {
53
59
  try { return JSON.parse(text.slice(start)); } catch { return null; }
54
60
  }
55
61
 
62
+ /*
63
+ * WHAT A NEW ACCOUNT TRIPS ON, said as the next step instead of a Wrangler stack.
64
+ * Measured names: 10034 is Cloudflare's "verify your email address" (a new
65
+ * account cannot deploy a Worker until it does); a missing workers.dev
66
+ * subdomain is registered by Wrangler itself when an agent runs it (Claude Code
67
+ * and Codex are detected), and otherwise needs one visit to the onboarding page.
68
+ */
69
+ export function explainCloudflare(out, accountId = null) {
70
+ const text = String(out ?? '');
71
+ const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
72
+ if (/\b10034\b|verify your email/i.test(text)) {
73
+ return { needs: 'cloudflare-verify-email', why: `Cloudflare needs this account's email address verified before it runs a Worker. Ask the person to open the verification email from Cloudflare (or ${dash}/profile and "Send verification email"), click it, then run \`npm run deploy\` again. No payment method is needed.` };
74
+ }
75
+ if (/register a workers\.dev subdomain|workers\/onboarding/i.test(text)) {
76
+ return { needs: 'workers-dev-subdomain', why: `This Cloudflare account has no workers.dev address yet. Ask the person to open ${dash}/workers/onboarding once and pick one (it is free), then run \`npm run deploy\` again.` };
77
+ }
78
+ if (/maximum number of (D1 )?databases|database limit|too many databases/i.test(text)) {
79
+ return { needs: 'd1-limit', why: 'This Cloudflare account has used all the D1 databases its plan allows (10 on the free plan). Nothing was deployed. Ask the person which unused database of theirs to remove, or use another account; never delete one yourself.' };
80
+ }
81
+ if (/\b1027\b|exceeded .*daily request limit/i.test(text)) {
82
+ return { needs: 'free-plan-daily-limit', why: 'This account used its free plan\'s 100,000 Worker requests for today (it resets at 00:00 UTC). Nothing is broken; try again after the reset.' };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ /** What `deploy` will create and what it costs, from studio.json alone. It calls nothing. */
88
+ export function deployPlan(root) {
89
+ const studio = readStudio(root);
90
+ const cf = { created: [], ...studio.cloudflare };
91
+ const created = new Set(cf.created ?? []);
92
+ const storage = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
93
+ const mark = (key) => (created.has(key) ? 'exists (this studio made it)' : 'will be created');
94
+ return {
95
+ ok: true, command: 'deploy plan', studio: studio.name, account: cf.accountId ?? null,
96
+ cloudflare: [
97
+ { kind: 'Worker', name: cf.worker, what: 'the studio\'s pages, each game\'s page and play shell, and /.well-known/homie-studio.json for the directory', state: mark(`worker:${cf.worker}`), plan: 'Workers Free' },
98
+ { kind: 'D1 database', name: cf.d1, what: 'the directory claim and every finished round', state: mark(`d1:${cf.d1}`), plan: 'Workers Free (500 MB per database, 5 GB per account)' },
99
+ { kind: 'Durable Object', name: 'Table', what: 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
100
+ { kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker', plan: 'Workers Free (SQLite-backed)' },
101
+ storage
102
+ ? { kind: 'R2 bucket', name: storage, what: 'large media for /media/<key> (added with storage add)', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
103
+ : { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
104
+ ],
105
+ cost: storage
106
+ ? 'Free on Cloudflare\'s Workers Free plan. R2 storage is free up to 10 GB-month; beyond that Cloudflare bills the account directly.'
107
+ : 'Free: everything above is on Cloudflare\'s Workers Free plan, which needs no payment method. Its daily limits (100,000 Worker requests; D1 5 million rows read and 100,000 written) reset at 00:00 UTC; past them requests fail until the reset, nothing is charged.',
108
+ login: 'One approval: `npx wrangler login` opens Cloudflare in the person\'s browser (a free account works; a new one verifies its email address first).',
109
+ address: `https://${cf.worker}.<the account's workers.dev subdomain>.workers.dev`,
110
+ directory: {
111
+ site: studio.homie?.directory || 'https://homie.rocks',
112
+ stores: 'the site\'s address and a claim token, the studio\'s name and slug, the @homie-rocks/studio version, and each game\'s id, name, blurb and Play/page links. Never code, media, keys or accounts.',
113
+ },
114
+ never: 'It never touches a Worker, database or bucket this studio did not create, and it never adds a payment method or buys anything.',
115
+ };
116
+ }
117
+
56
118
  export async function deploy(root, { log = () => {}, homie } = {}) {
57
119
  const studio = readStudio(root);
58
- const cf = { r2: `${studio.slug}-media`, created: [], ...studio.cloudflare };
120
+ const cf = { r2: null, created: [], ...studio.cloudflare };
59
121
  const created = new Set(cf.created ?? []);
60
122
  const who = whoami(root);
61
123
  if (!who) {
62
- return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` in the studio folder: it opens Cloudflare in the browser and the person approves once. Then run `npm run deploy` again.' };
124
+ return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` in the studio folder: it opens Cloudflare in the browser and the person approves once (a free account works, no payment method). Then run `npm run deploy` again.' };
63
125
  }
64
126
  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
65
127
  if (!accountId) {
66
128
  return { ok: false, command: 'deploy', needs: 'cloudflare-account', why: `This Cloudflare login can reach ${who.accounts.length} accounts; ask the person which one this studio uses and put its id in studio.json (cloudflare.accountId).`, accounts: who.accounts };
67
129
  }
68
130
  const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
131
+ const refuse = (why, out) => ({ ok: false, command: 'deploy', ...(explainCloudflare(out, accountId) ?? { why }) });
69
132
  // Record every resource the moment it exists, so a deploy cut short (an expired login, a network drop) resumes
70
133
  // instead of refusing its own database as "someone else's" next time.
71
134
  const remember = (key) => {
@@ -75,9 +138,22 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
75
138
  const steps = [];
76
139
  const step = (what, extra = {}) => { steps.push({ what, ...extra }); log(what); };
77
140
 
141
+ // The first deploy says what it is about to create, and what it costs, before it creates anything: the person
142
+ // reads it in the transcript whether or not the AI repeated it.
143
+ const announced = [];
144
+ if (!created.has(`worker:${cf.worker}`)) {
145
+ const account = who.accounts.find((a) => a.id === accountId)?.name ?? 'the signed-in account';
146
+ announced.push(
147
+ `First deploy of ${studio.name} to the Cloudflare account "${account}". It creates: the Worker ${cf.worker} (the site and its rooms), the D1 database ${cf.d1}, and the Durable Objects Table and Lobby (SQLite-backed).`,
148
+ 'Cost: free, on the Workers Free plan; no payment method, no R2 (storage for large media is `homie-studio storage add`, later, only if wanted).',
149
+ `The directory (${homie || studio.homie?.directory || 'https://homie.rocks'}) will store the site's address and claim, the studio's name, and each game's name, blurb and Play link.`,
150
+ );
151
+ for (const line of announced) log(line);
152
+ }
153
+
78
154
  const b = await build(root, { log });
79
- if (!b.catalogue.length) return { ok: false, command: 'deploy', why: 'no games built yet: make one with `npx --no-install homie-studio game new <id> --from gem-rush`' };
80
- step(`built ${b.catalogue.length} game(s)`);
155
+ if (!b.catalogue.length && !b.songs.length && !b.videos.length) return { ok: false, command: 'deploy', why: 'nothing to put online yet: make a game (`npx --no-install homie-studio game new <id> --from gem-rush`) or publish a song or video in music/ or videos/ (media/MEDIA.md)' };
156
+ step(`built ${b.catalogue.length} game(s), ${b.songs.length} song(s), ${b.videos.length} video(s)`);
81
157
 
82
158
  // The Worker: never one this studio did not make.
83
159
  if (!created.has(`worker:${cf.worker}`)) {
@@ -85,7 +161,7 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
85
161
  if (exists.code === 0) {
86
162
  return { ok: false, command: 'deploy', why: `A Worker named "${cf.worker}" already exists on this Cloudflare account and this studio did not create it, so nothing was deployed. Pick another name in studio.json (cloudflare.worker) and site/wrangler.jsonc (name).` };
87
163
  }
88
- if (!/10007|does not exist/i.test(exists.out)) return { ok: false, command: 'deploy', why: `could not check the Worker name: ${exists.out.trim().split('\n').slice(-3).join(' ')}` };
164
+ if (!/10007|does not exist/i.test(exists.out)) return refuse(`could not check the Worker name: ${exists.out.trim().split('\n').slice(-3).join(' ')}`, exists.out);
89
165
  }
90
166
 
91
167
  // D1.
@@ -98,34 +174,25 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
98
174
  if (!db) {
99
175
  const made = w(['d1', 'create', cf.d1]);
100
176
  const id = /"?database_id"?\s*[:=]\s*"([0-9a-f-]{36})"/.exec(made.out)?.[1];
101
- if (made.code !== 0 || !id) return { ok: false, command: 'deploy', why: `could not create the D1 database "${cf.d1}": ${made.out.trim().split('\n').slice(-4).join(' ')}` };
177
+ if (made.code !== 0 || !id) return refuse(`could not create the D1 database "${cf.d1}": ${made.out.trim().split('\n').slice(-4).join(' ')}`, made.out);
102
178
  db = { name: cf.d1, uuid: id };
103
179
  remember(`d1:${cf.d1}`);
104
180
  step(`created D1 ${cf.d1}`);
105
181
  }
106
182
 
107
- // R2, when the account has it (R2 needs a payment method on the account; without it the site runs without MEDIA).
108
- let r2 = cf.r2 || null;
109
- if (r2 && !created.has(`r2:${r2}`)) {
110
- const buckets = w(['r2', 'bucket', 'list']);
111
- const names = [...buckets.out.matchAll(/^name:\s+(\S+)/gm)].map((m) => m[1]);
112
- if (names.includes(r2)) {
113
- return { ok: false, command: 'deploy', why: `An R2 bucket named "${r2}" already exists on this account and this studio did not create it; nothing was deployed. Pick another name in studio.json (cloudflare.r2).` };
114
- }
115
- const made = w(['r2', 'bucket', 'create', r2]);
116
- if (made.code === 0) { remember(`r2:${r2}`); step(`created R2 ${r2}`); }
117
- else if (/enable R2|R2 is not enabled|10042|purchase|subscription/i.test(made.out)) { step('R2 is not enabled on this account: the site deploys without media storage for now'); r2 = null; }
118
- else return { ok: false, command: 'deploy', why: `could not create the R2 bucket "${r2}": ${made.out.trim().split('\n').slice(-4).join(' ')}` };
119
- }
183
+ // Storage (R2) is bound only when `storage add` made the bucket. Deploy itself never creates or lists R2, so a
184
+ // free account with no payment method deploys the whole studio.
185
+ const r2 = cf.r2 && created.has(`r2:${cf.r2}`) ? cf.r2 : null;
186
+ if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
120
187
 
121
188
  writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2 }));
122
189
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
123
- if (migrate.code !== 0) return { ok: false, command: 'deploy', why: `D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}` };
190
+ if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
124
191
  step('D1 migrations applied');
125
192
 
126
193
  const started = Date.now();
127
194
  const dep = w(['deploy']);
128
- if (dep.code !== 0) return { ok: false, command: 'deploy', why: `wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}` };
195
+ if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
129
196
  remember(`worker:${cf.worker}`);
130
197
  const url = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? cf.url ?? null;
131
198
  step(`deployed ${cf.worker} in ${Math.round((Date.now() - started) / 1000)} s`, { url });
@@ -145,11 +212,62 @@ export async function deploy(root, { log = () => {}, homie } = {}) {
145
212
  }
146
213
  }
147
214
 
148
- const next = { ...cf, accountId, url, d1Id: db.uuid, r2, created: [...created].sort(), deployedAt: new Date().toISOString() };
215
+ const next = { ...cf, accountId, url, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort(), deployedAt: new Date().toISOString() };
149
216
  writeStudio(root, { ...studio, cloudflare: next });
150
217
  return {
151
- ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, steps,
218
+ ok: true, command: 'deploy', url, worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
152
219
  games: b.catalogue.map((id) => ({ id, page: url ? `${url}/${id}/` : null, play: url ? `${url}/${id}/play` : null })),
220
+ songs: b.songs.map((slug) => ({ slug, page: url ? `${url}/music/${slug}/` : null })),
221
+ videos: b.videos.map((slug) => ({ slug, page: url ? `${url}/videos/${slug}/` : null })),
153
222
  claim: Boolean(claim), directory,
154
223
  };
155
224
  }
225
+
226
+ /*
227
+ * `homie-studio storage add` — the studio's own storage for large media (songs,
228
+ * videos, big art), as an R2 bucket on its account, bound as MEDIA and served at
229
+ * /media/<key>. It is separate from deploy on purpose: Cloudflare asks for a
230
+ * payment method on the account before R2 works, even inside R2's free tier
231
+ * (10 GB-month), so this is a step the person agrees to, and a studio that only
232
+ * makes games never needs it. It changes nothing on Cloudflare but the one bucket.
233
+ */
234
+ export async function storageAdd(root, { log = () => {} } = {}) {
235
+ const studio = readStudio(root);
236
+ const cf = { created: [], ...studio.cloudflare };
237
+ const created = new Set(cf.created ?? []);
238
+ const bucket = cf.r2 || `${studio.slug}-media`;
239
+ if (created.has(`r2:${bucket}`)) {
240
+ return { ok: true, command: 'storage add', bucket, already: true, next: ['npm run deploy (binds it as MEDIA if the live site does not have it yet)'] };
241
+ }
242
+ const who = whoami(root);
243
+ if (!who) return { ok: false, command: 'storage add', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` first (one approval in the browser).' };
244
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
245
+ if (!accountId) return { ok: false, command: 'storage add', needs: 'cloudflare-account', why: 'This login reaches several Cloudflare accounts; put the studio\'s in studio.json (cloudflare.accountId) first.', accounts: who.accounts };
246
+ const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
247
+ const enable = {
248
+ ok: false, command: 'storage add', needs: 'r2-payment-method', bucket,
249
+ why: `R2 is not turned on for this Cloudflare account. Cloudflare asks for a payment method before R2 works, even inside its free tier (10 GB-month of storage, 1 million writes and 10 million reads a month free). Only the person can decide that: if they want storage, they open https://dash.cloudflare.com/${accountId}/r2/overview, add R2 there, and you run \`npx --no-install homie-studio storage add\` again. Nothing was created; the studio keeps working without storage.`,
250
+ };
251
+ const buckets = w(['r2', 'bucket', 'list']);
252
+ if (buckets.code !== 0) {
253
+ if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(buckets.out)) return enable;
254
+ return { ok: false, command: 'storage add', ...(explainCloudflare(buckets.out, accountId) ?? { why: `could not list R2 buckets: ${buckets.out.trim().split('\n').slice(-3).join(' ')}` }) };
255
+ }
256
+ const names = [...buckets.out.matchAll(/^name:\s+(\S+)/gm)].map((m) => m[1]);
257
+ if (names.includes(bucket)) {
258
+ return { ok: false, command: 'storage add', why: `An R2 bucket named "${bucket}" already exists on this account and this studio did not create it; nothing was created. Put another name in studio.json (cloudflare.r2) and run storage add again.` };
259
+ }
260
+ const made = w(['r2', 'bucket', 'create', bucket]);
261
+ if (made.code !== 0) {
262
+ if (/enable R2|R2 is not enabled|10042|purchase|subscription|payment/i.test(made.out)) return enable;
263
+ return { ok: false, command: 'storage add', why: `could not create the R2 bucket "${bucket}": ${made.out.trim().split('\n').slice(-4).join(' ')}` };
264
+ }
265
+ created.add(`r2:${bucket}`);
266
+ const next = { ...readStudio(root), cloudflare: { ...readStudio(root).cloudflare, accountId, r2: bucket, created: [...created].sort() } };
267
+ writeStudio(root, next);
268
+ if (next.cloudflare.d1Id) {
269
+ writeFileSync(join(root, 'site', 'wrangler.jsonc'), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket }));
270
+ }
271
+ log(`created R2 ${bucket}`);
272
+ return { ok: true, command: 'storage add', bucket, account: accountId, next: ['npm run deploy (binds the bucket as MEDIA; the site serves it at /media/<key>)', 'npx --no-install homie-studio media put <file>'] };
273
+ }
package/lib/media.mjs ADDED
@@ -0,0 +1,126 @@
1
+ /**
2
+ * A studio's music/ and videos/ manifests (media/MEDIA.md): what the site shows,
3
+ * and where each file's bytes come from (R2 key, a file in the repository copied
4
+ * into the site, or an absolute https address).
5
+ */
6
+ import { cpSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, normalize, relative, resolve, sep } from 'node:path';
8
+
9
+ export const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
10
+ export const KINDS = {
11
+ music: ['song', 'score', 'loop', 'stem', 'sfx'],
12
+ videos: ['trailer', 'music-video', 'cutscene', 'clip'],
13
+ };
14
+ /** Cloudflare Workers static assets: one file may be at most 25 MiB. */
15
+ export const MAX_ASSET_BYTES = 25 * 1024 * 1024;
16
+ const TYPES = {
17
+ '.mp3': 'audio/mpeg', '.m4a': 'audio/mp4', '.aac': 'audio/aac', '.ogg': 'audio/ogg', '.opus': 'audio/ogg', '.wav': 'audio/wav', '.flac': 'audio/flac',
18
+ '.mp4': 'video/mp4', '.m4v': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime',
19
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.vtt': 'text/vtt', '.json': 'application/json',
20
+ };
21
+ export const typeOf = (path) => TYPES[(/\.[a-z0-9]+$/i.exec(String(path)) ?? [''])[0].toLowerCase()] ?? 'application/octet-stream';
22
+
23
+ export function manifestPath(root, kind) { return join(root, kind, 'manifest.json'); }
24
+
25
+ export function readManifest(root, kind) {
26
+ const file = manifestPath(root, kind);
27
+ if (!existsSync(file)) return { v: 1, items: [] };
28
+ const m = JSON.parse(readFileSync(file, 'utf8'));
29
+ return { v: 1, ...m, items: Array.isArray(m.items) ? m.items : [] };
30
+ }
31
+
32
+ export function writeManifest(root, kind, manifest) {
33
+ mkdirSync(join(root, kind), { recursive: true });
34
+ writeFileSync(manifestPath(root, kind), `${JSON.stringify({ v: 1, ...manifest }, null, 2)}\n`);
35
+ }
36
+
37
+ /** A path inside the studio, or null (no absolute paths, no `..` out of the repository). */
38
+ function inside(root, rel) {
39
+ if (typeof rel !== 'string' || !rel || rel.startsWith('/') || /^[a-z]+:/i.test(rel)) return null;
40
+ const abs = resolve(root, normalize(rel));
41
+ return abs.startsWith(`${resolve(root)}${sep}`) ? abs : null;
42
+ }
43
+
44
+ /**
45
+ * Every entry of one manifest with its files resolved, and why anything is left out.
46
+ * `r2`: the studio has its own bucket (so a `key` is served at /media/<key>).
47
+ */
48
+ export function resolveMedia(root, kind, { r2 = false } = {}) {
49
+ const manifest = readManifest(root, kind);
50
+ const entries = [];
51
+ const skipped = [];
52
+ const seen = new Set();
53
+ for (const item of manifest.items) {
54
+ if (!item || typeof item !== 'object') continue;
55
+ const label = item.slug ?? item.key ?? item.file ?? '(unnamed)';
56
+ if (!SLUG.test(String(item.slug ?? '')) || !item.title) { if (item.published !== false) skipped.push({ kind, item: label, why: 'no slug and title: kept in the manifest, no page' }); continue; }
57
+ if (seen.has(item.slug)) { skipped.push({ kind, item: label, why: 'a second entry with the same slug' }); continue; }
58
+ seen.add(item.slug);
59
+ if (item.published !== true) { skipped.push({ kind, item: label, why: 'not published yet ("published": true shows it)' }); continue; }
60
+ const files = [];
61
+ for (const f of Array.isArray(item.files) ? item.files : []) {
62
+ if (!f || f.public === false) continue;
63
+ let src = null;
64
+ if (typeof f.key === 'string' && f.key && r2) src = { from: 'r2', url: `/media/${f.key.split('/').map(encodeURIComponent).join('/')}` };
65
+ if (!src && f.path) {
66
+ const abs = inside(root, f.path);
67
+ if (!abs) { skipped.push({ kind, item: item.slug, file: f.path, why: 'the path is outside the studio' }); continue; }
68
+ if (existsSync(abs) && statSync(abs).isFile()) {
69
+ const bytes = statSync(abs).size;
70
+ if (bytes <= MAX_ASSET_BYTES) src = { from: 'site', url: `/${relative(root, abs).split(sep).map(encodeURIComponent).join('/')}`, abs, rel: relative(root, abs), bytes };
71
+ else skipped.push({ kind, item: item.slug, file: f.path, why: `${Math.round(bytes / 1048576)} MiB is over the 25 MiB the site serves itself: make it smaller, or give the studio storage (homie-studio storage add; Cloudflare asks for a payment method first, so ask the person) and upload it (homie-studio media put ${f.path})` });
72
+ } else if (f.key && !r2) skipped.push({ kind, item: item.slug, file: f.path, why: `the file is not on this computer and its storage key "${f.key}" needs the studio's storage (homie-studio storage add)` });
73
+ else if (!f.key && !f.url) skipped.push({ kind, item: item.slug, file: f.path, why: 'the file is not on this computer and has no R2 key or url' });
74
+ }
75
+ if (!src && typeof f.url === 'string' && /^https:\/\//.test(f.url)) src = { from: 'url', url: f.url };
76
+ if (!src) continue;
77
+ files.push({ role: String(f.role ?? 'file'), name: f.name ?? null, type: f.type ?? typeOf(f.path ?? f.key ?? f.url), bytes: f.bytes ?? src.bytes ?? null, bars: f.bars ?? null, url: src.url, from: src.from, ...(src.abs ? { abs: src.abs, rel: src.rel } : {}) });
78
+ }
79
+ const main = kind === 'music' ? 'audio' : 'video';
80
+ if (!files.some((f) => f.role === main)) { skipped.push({ kind, item: item.slug, why: `no playable "${main}" file reachable (see the file lines above)` }); continue; }
81
+ entries.push({
82
+ slug: item.slug, kind: KINDS[kind].includes(item.kind) ? item.kind : KINDS[kind][0], title: String(item.title).slice(0, 120), blurb: String(item.blurb ?? '').slice(0, 600),
83
+ duration: Number.isFinite(item.duration) ? item.duration : null, bpm: Number.isFinite(item.bpm) ? item.bpm : null, key: item.key ?? null,
84
+ lyrics: typeof item.lyrics === 'string' ? item.lyrics.slice(0, 8000) : null, credits: typeof item.credits === 'string' ? item.credits.slice(0, 600) : null,
85
+ rights: item.rights && typeof item.rights === 'object' ? item.rights : null, for: item.for && typeof item.for === 'object' ? item.for : null,
86
+ honesty: typeof item.honesty === 'string' ? item.honesty.slice(0, 600) : null, made: item.made?.at ? { at: item.made.at, provider: item.made.provider ?? null } : null,
87
+ files,
88
+ });
89
+ }
90
+ return { entries, skipped };
91
+ }
92
+
93
+ /** Copy the site-served files into site/dist and return the catalogue rows (no local paths in them). */
94
+ export function buildMedia(root, dist, { r2 = false, log = () => {} } = {}) {
95
+ const out = {};
96
+ const skipped = [];
97
+ for (const kind of ['music', 'videos']) {
98
+ const r = resolveMedia(root, kind, { r2 });
99
+ skipped.push(...r.skipped);
100
+ out[kind] = r.entries.map((e) => ({
101
+ ...e,
102
+ files: e.files.map(({ abs, rel, from, ...f }) => {
103
+ if (from === 'site') { const dest = join(dist, rel); mkdirSync(dirname(dest), { recursive: true }); cpSync(abs, dest); }
104
+ return f;
105
+ }),
106
+ }));
107
+ if (out[kind].length) log(`${kind}: ${out[kind].map((e) => e.slug).join(', ')}`);
108
+ }
109
+ for (const s of skipped) log(`${s.kind}/${s.item}${s.file ? ` ${s.file}` : ''}: ${s.why}`);
110
+ return { songs: out.music, videos: out.videos, skipped };
111
+ }
112
+
113
+ /** `homie-studio media put`: record an uploaded file's R2 key on the entry that names it (or a bare entry). */
114
+ export function recordUpload(root, rel, key, bytes, siteUrl) {
115
+ const kind = rel.startsWith('videos/') ? 'videos' : 'music';
116
+ const m = readManifest(root, kind);
117
+ let hit = false;
118
+ for (const item of m.items) {
119
+ for (const f of Array.isArray(item?.files) ? item.files : []) {
120
+ if (f.path === rel) { f.key = key; f.bytes = bytes; hit = true; }
121
+ }
122
+ }
123
+ if (!hit) m.items = [...m.items.filter((i) => i.key !== key), { key, file: rel, bytes, url: siteUrl ? `${siteUrl}/media/${key}` : null, at: new Date().toISOString() }];
124
+ writeManifest(root, kind, m);
125
+ return { kind, manifest: relative(root, manifestPath(root, kind)), entry: hit };
126
+ }