@wji.ai/cli 0.6.1 → 0.7.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.
Files changed (35) hide show
  1. package/README.md +19 -2
  2. package/dist/commands/backreel-enquiry.js +171 -0
  3. package/dist/commands/backreel-episode.js +243 -0
  4. package/dist/commands/backreel-fund.js +299 -0
  5. package/dist/commands/backreel.js +141 -18
  6. package/dist/commands/gen.js +5 -1
  7. package/dist/commands/npc-album.js +201 -0
  8. package/dist/commands/npc-live.js +316 -0
  9. package/dist/commands/npc.js +26 -2
  10. package/dist/core/backreel.js +23 -0
  11. package/dist/core/client.js +18 -1
  12. package/dist/core/image.js +106 -0
  13. package/dist/core/npc.js +1 -1
  14. package/dist/core/vod.js +149 -0
  15. package/package.json +1 -1
  16. package/skill/backreel/SKILL.md +171 -0
  17. package/skill/backreel/reference/crowdfunding.md +82 -0
  18. package/skill/backreel/reference/enquiries.md +80 -0
  19. package/skill/backreel/reference/episodes.md +104 -0
  20. package/skill/backreel/reference/images.md +76 -0
  21. package/skill/npc-character/SKILL.md +111 -0
  22. package/skill/npc-character/examples/nagi.character.md +117 -0
  23. package/skill/npc-character/examples/nagi.stage.md +34 -0
  24. package/skill/npc-character/examples/yuzu.character.md +110 -0
  25. package/skill/npc-character/examples/yuzu.stage.md +30 -0
  26. package/skill/npc-character/reference/character-craft.md +153 -0
  27. package/skill/npc-character/reference/document-format.md +151 -0
  28. package/skill/npc-character/reference/interview.md +44 -0
  29. package/skill/npc-character/reference/life.md +92 -0
  30. package/skill/npc-character/reference/runtime.md +95 -0
  31. package/skill/npc-character/reference/stage-craft.md +109 -0
  32. package/skill/npc-character/reference/visual-kit.md +81 -0
  33. package/skill/npc-character/reference/voice.md +101 -0
  34. package/skill/wji/SKILL.md +1 -1
  35. package/skill/wji/reference/commands.md +25 -5
package/README.md CHANGED
@@ -102,8 +102,8 @@ wji --no-org ws list # one-off personal call
102
102
  | `wji asset` | upload, import from URL, search, status, download, summary |
103
103
  | `wji canvas` | Infinite Canvas: linked canvas + selection, nodes, references, generate nodes, submit, owners, color tags, copy between canvases |
104
104
  | `wji class` | teach with a workspace: roster, one canvas per student, copy nodes to every student, progress, collect results |
105
- | `wji npc` | NPC City NPCs you own: profile, publish, work history (jobs), soul, character document, transfers |
106
- | `wji backreel` | Backreel posts: drafts, publish, cast and crew credits (NPCs included) |
105
+ | `wji npc` | NPC City NPCs you own: profile, publish, work history (jobs), soul, character document, transfers; memories, live readiness, stage documents |
106
+ | `wji backreel` | Backreel posts: drafts, publish, images per slot, episodes on VOD, crowdfunding and donations, enquiries, cast and crew credits (NPCs included) |
107
107
  | `wji gen` | `image` / `video` / `audio` generation plus `cost` estimation |
108
108
  | `wji history` | generation history: search, poll, bookmark, download |
109
109
  | `wji models` | list models and their form parameters |
@@ -210,7 +210,11 @@ wji npc publish @mika
210
210
 
211
211
  wji backreel create "Harbour" -d @synopsis.txt --cover <assetId>
212
212
  wji backreel credit add <postId> --npc @mika --role "飾 阿明" --job appearance
213
+ wji backreel image poster.png --slot portrait --post <postId> # size check + upload + set
214
+ wji backreel episode add <postId> E01.mp4 -t "第一集" # VOD upload
215
+ wji backreel check <postId>
213
216
  wji backreel publish <postId>
217
+ wji backreel enquiry list --unread
214
218
  ```
215
219
 
216
220
  Creating NPCs needs NPC City spawn permission on your account. Admin actions
@@ -313,6 +317,19 @@ cp -r "$(npm root -g)/@wji.ai/cli/skill/social-media" ~/.claude/skills/social-me
313
317
  Install it the same way (`cp -r skill/short-drama ~/.claude/skills/short-drama`)
314
318
  next to `wji`; `node skill/short-drama/scripts/selftest.mjs` runs its self-test.
315
319
 
320
+ `skill/backreel/` is a creator's skill for Backreel: create and publish a
321
+ work, prepare images at the right size per slot (`wji br image`), upload and
322
+ maintain episodes on VOD, run a crowdfunding campaign and donation tiers, and
323
+ answer enquiries. Install it the same way
324
+ (`cp -r skill/backreel ~/.claude/skills/backreel`).
325
+
326
+ `skill/npc-character/` teaches an agent to write an NPC City character that
327
+ feels like a person — want / need / contradiction, memories as scenes, a voice
328
+ the reply model can imitate, protocols, on-camera habits — and its live
329
+ stages, placing each word where the live runtime actually reads it, then
330
+ applying them with `wji npc doc` / `memory` / `stage`. Install it the same way
331
+ (`cp -r skill/npc-character ~/.claude/skills/npc-character`).
332
+
316
333
  `skill/wji-classroom/` is a teacher's skill on top of `wji`: set up a class
317
334
  workspace, invite students, give each a canvas, hand out prompts and starter
318
335
  nodes, and check or collect their work. Install it the same way
@@ -0,0 +1,171 @@
1
+ import { writeFile } from 'node:fs/promises';
2
+ import { basename } from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { backreelClient, oneOf } from '../core/backreel.js';
5
+ import { color, detail, info, render, relativeTime, success, table, truncate } from '../core/output.js';
6
+ import { readValue } from '../core/prompt.js';
7
+ const ENQ = '/backreel/enquiry';
8
+ const TYPES = ['collaboration', 'new_project', 'investment', 'licensing', 'access_request', 'general'];
9
+ const STATUSES = ['new', 'in_discussion', 'accepted', 'declined', 'archived', 'withdrawn'];
10
+ /** What the recipient (post owner) may set; the sender may only withdraw. */
11
+ const SETTABLE = ['in_discussion', 'accepted', 'declined', 'archived', 'withdrawn'];
12
+ const MAX_ATTACHMENTS = 5;
13
+ const MESSAGE_MAX = 4000;
14
+ const person = (p) => (p ? `${p.name || p.id}${p.verifiedKinds?.length ? color.dim(` ✓${p.verifiedKinds.join(',')}`) : ''}` : color.dim('system'));
15
+ /** Enquiry attachments are private Backreel files (purpose=enquiry: documents and images ≤20 MB). */
16
+ async function uploadAttachments(client, files = []) {
17
+ if (files.length > MAX_ATTACHMENTS)
18
+ throw new Error(`At most ${MAX_ATTACHMENTS} attachments per message`);
19
+ const ids = [];
20
+ for (const file of files) {
21
+ info(color.dim(`Uploading ${basename(file)}…`));
22
+ const uploaded = await client.upload('/backreel/file/upload?purpose=enquiry', file, {});
23
+ ids.push(uploaded.id);
24
+ }
25
+ return ids;
26
+ }
27
+ function printSummaryTable(rows) {
28
+ table(rows, [
29
+ { header: 'id', value: (e) => e.id },
30
+ { header: '', value: (e) => (e.unread ? color.yellow('●') : '') },
31
+ { header: 'type', value: (e) => e.type },
32
+ { header: 'status', value: (e) => e.status + (e.archived && e.status !== 'archived' ? color.dim(' (archived)') : '') },
33
+ { header: 'from', value: (e) => truncate(e.side === 'sender' ? `→ ${e.recipient?.name}` : e.sender?.name, 20) },
34
+ { header: 'post', value: (e) => truncate(e.post?.title, 20) },
35
+ { header: 'last', value: (e) => truncate(e.lastMessagePreview || e.subject, 36) },
36
+ { header: 'when', value: (e) => relativeTime(e.lastMessageAt) },
37
+ ]);
38
+ }
39
+ function printEnquiry(e, messages) {
40
+ const extra = [];
41
+ if (e.projectInvite) {
42
+ const p = e.projectInvite;
43
+ const budget = p.budgetMin || p.budgetMax ? `${p.budgetMin ?? '?'}–${p.budgetMax ?? '?'} ${p.currency || ''}` : '';
44
+ extra.push(['project', [p.projectType, p.title, budget, p.location, p.roleNeeded].filter(Boolean).join(' · ')]);
45
+ if (p.timelineStart || p.timelineEnd)
46
+ extra.push(['timeline', `${p.timelineStart ?? '?'} → ${p.timelineEnd ?? '?'}`]);
47
+ }
48
+ if (e.licensing)
49
+ extra.push(['licensing', [e.licensing.territory, e.licensing.media, e.licensing.term].filter(Boolean).join(' · ')]);
50
+ if (e.investment)
51
+ extra.push(['investment', `${e.investment.ticketSize ?? '?'} ${e.investment.currency || ''}`]);
52
+ if (e.accessPurpose)
53
+ extra.push(['access purpose', e.accessPurpose]);
54
+ const c = e.contact || {};
55
+ detail([
56
+ ['id', e.id],
57
+ ['type', e.type],
58
+ ['status', e.status + (e.archived && e.status !== 'archived' ? ' (archived)' : '')],
59
+ ['subject', e.subject],
60
+ ['post', `${e.post?.title} (${e.post?.id})`],
61
+ ['from', person(e.sender)],
62
+ ['to', `${person(e.recipient)} (${e.recipientRole})`],
63
+ ['contact', [c.name, c.email, c.phone, c.company].filter(Boolean).join(' · ')],
64
+ ...extra,
65
+ ['sender access', e.senderAccess ? `${e.senderAccess.partnerState}${e.senderAccess.supporter ? ', supporter' : ''}` : undefined],
66
+ ['can', Object.entries(e.permissions || {}).filter(([, v]) => v).map(([k]) => k).join(', ')],
67
+ ['attachments', (e.attachments || []).map((f) => `${f.name} (${f.id})`).join(', ')],
68
+ ['created', e.createdAt],
69
+ ]);
70
+ process.stdout.write('\n');
71
+ for (const m of messages) {
72
+ const who = m.kind === 'system' ? color.dim('— system') : color.bold(person(m.sender));
73
+ process.stdout.write(`${who} ${color.dim(relativeTime(m.createdAt) ?? '')}\n`);
74
+ if (m.body)
75
+ process.stdout.write(`${m.body}\n`);
76
+ for (const f of m.attachments || [])
77
+ process.stdout.write(color.dim(` 📎 ${f.name} (${f.id})\n`));
78
+ process.stdout.write('\n');
79
+ }
80
+ }
81
+ export function enquiryCommand(globals) {
82
+ const enq = new Command('enquiry')
83
+ .aliases(['enquiries', 'enq'])
84
+ .description('Enquiries about your posts: read, reply, accept / decline, grant partner access');
85
+ enq.command('list', { isDefault: true })
86
+ .description('Your inbox (received) or what you sent, newest activity first')
87
+ .option('--box <box>', 'received | sent', 'received')
88
+ .option('--post <postId>', 'only this post')
89
+ .option('--status <status>', STATUSES.join(' | '))
90
+ .option('--type <type>', TYPES.join(' | '))
91
+ .option('--unread', 'only unread')
92
+ .option('--archived', 'show the archive instead of the inbox')
93
+ .option('--page <n>', 'page number', '1')
94
+ .option('--limit <n>', 'page size', '20')
95
+ .action(async (opts) => {
96
+ const client = backreelClient(globals);
97
+ const result = await client.get(`${ENQ}/search`, {
98
+ box: oneOf(opts.box, ['received', 'sent'], '--box'),
99
+ post: opts.post,
100
+ status: opts.status ? oneOf(opts.status, STATUSES, '--status') : undefined,
101
+ type: opts.type ? oneOf(opts.type, TYPES, '--type') : undefined,
102
+ unread: opts.unread ? 'true' : undefined,
103
+ archived: opts.archived ? 'true' : undefined,
104
+ page: Number(opts.page),
105
+ limit: Number(opts.limit),
106
+ });
107
+ render(result, (r) => {
108
+ printSummaryTable(r?.data || []);
109
+ const p = r?.pagination;
110
+ if (p)
111
+ info(color.dim(`${p.total} total · page ${p.page}${p.hasNext ? ` · more: --page ${p.page + 1}` : ''}`));
112
+ });
113
+ });
114
+ enq.command('unread')
115
+ .description('Unread counts')
116
+ .action(async () => {
117
+ const client = backreelClient(globals);
118
+ const counts = await client.get(`${ENQ}/unread-count`);
119
+ render(counts, (c) => detail([['received', c.received], ['sent', c.sent], ['total', c.total]]));
120
+ });
121
+ enq.command('get <id>')
122
+ .description('Show an enquiry and its whole thread (marks it read)')
123
+ .action(async (id) => {
124
+ const client = backreelClient(globals);
125
+ const e = await client.getFresh(`${ENQ}/list/${id}`);
126
+ const messages = await client.getFresh(`${ENQ}/list/${id}/messages`);
127
+ render({ ...e, messages }, () => printEnquiry(e, messages || []));
128
+ });
129
+ enq.command('reply <id> <message>')
130
+ .description(`Reply in the thread (≤${MESSAGE_MAX} chars; @file / - supported). The other side gets an in-app notice and, at most every 30 min, an email`)
131
+ .option('-a, --attach <file...>', `attach files (≤${MAX_ATTACHMENTS}; documents / images ≤20 MB)`)
132
+ .action(async (id, message, opts) => {
133
+ const client = backreelClient(globals);
134
+ const body = (await readValue(message)).trim();
135
+ if (!body && !opts.attach?.length)
136
+ throw new Error('Empty reply');
137
+ if (body.length > MESSAGE_MAX)
138
+ throw new Error(`Reply is ${body.length} chars; the limit is ${MESSAGE_MAX}`);
139
+ const attachments = await uploadAttachments(client, opts.attach);
140
+ const sent = await client.post(`${ENQ}/list/${id}/messages`, { body, attachments: attachments.length ? attachments : undefined });
141
+ render(sent, () => success('Reply sent'));
142
+ });
143
+ enq.command('status <id> <status>')
144
+ .description(`Move an enquiry: ${SETTABLE.join(' | ')} (accepting an access_request grants partner access)`)
145
+ .action(async (id, status) => {
146
+ const client = backreelClient(globals);
147
+ const updated = await client.post(`${ENQ}/list/${id}/status`, { status: oneOf(status, SETTABLE, 'status') });
148
+ render(updated, (e) => success(`Enquiry ${id} is now ${e.status}`));
149
+ });
150
+ enq.command('grant <id>')
151
+ .description('Give the sender partner access to the post (creator only; also accepts an open enquiry)')
152
+ .action(async (id) => {
153
+ const client = backreelClient(globals);
154
+ const updated = await client.post(`${ENQ}/list/${id}/grant`);
155
+ render(updated, (e) => success(`Granted partner access to ${e.sender?.name || 'the sender'} — status ${e.status}`));
156
+ });
157
+ enq.command('download <fileId>')
158
+ .description('Download an attachment (private file; the signed link lasts 60 s)')
159
+ .option('-o, --out <path>', 'output path (default: the original file name)')
160
+ .action(async (fileId, opts) => {
161
+ const client = backreelClient(globals);
162
+ const link = await client.get(`/backreel/file/${fileId}/download`);
163
+ const res = await fetch(link.url);
164
+ if (!res.ok)
165
+ throw new Error(`Download failed: HTTP ${res.status}`);
166
+ const out = opts.out || basename(link.name || fileId);
167
+ await writeFile(out, Buffer.from(await res.arrayBuffer()));
168
+ render({ ...link, path: out }, () => success(`Saved ${out}`));
169
+ });
170
+ return enq;
171
+ }
@@ -0,0 +1,243 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { basename, extname } from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { ApiError } from '../core/client.js';
5
+ import { POSTS, TIERS, backreelClient, episodeIndex, idOf, loadPost, oneOf } from '../core/backreel.js';
6
+ import { color, info, render, success, table, truncate, warn } from '../core/output.js';
7
+ import { confirm } from '../core/prompt.js';
8
+ import { uploadToVod } from '../core/vod.js';
9
+ const MAX_EPISODES = 30;
10
+ const VIDEO_EXTS = ['.mp4', '.mov', '.m4v', '.webm'];
11
+ /** /assets/v2/upload cap for video — only the --legacy path goes through it. */
12
+ const ASSET_VIDEO_MAX_BYTES = 200 * 1024 * 1024;
13
+ /** The fields the update DTO takes back; vodStatus / poster / duration are server-owned. */
14
+ function toDto(e, order) {
15
+ const base = { title: e.title, order, tier: e.tier || 'public' };
16
+ return e.vodVid ? { ...base, vodVid: e.vodVid } : { ...base, video: idOf(e.video) };
17
+ }
18
+ async function loadEpisodes(client, postId) {
19
+ const post = await loadPost(client, postId);
20
+ if (post.kind !== 'showcase') {
21
+ throw new Error(`"${post.title}" is a ${post.kind} post — only showcase posts have episodes`);
22
+ }
23
+ const episodes = [...(post.episodes || [])]
24
+ .sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
25
+ .map((e, i) => toDto(e, i));
26
+ return { post, episodes };
27
+ }
28
+ /** The server re-sorts by `order`, re-indexes, and re-checks every vodVid with VOD. */
29
+ function saveEpisodes(client, postId, episodes) {
30
+ if (episodes.length > MAX_EPISODES)
31
+ throw new Error(`A post can have at most ${MAX_EPISODES} episodes`);
32
+ return client.put(`${POSTS}/list/${postId}`, { episodes: episodes.map((e, i) => ({ ...e, order: i })) });
33
+ }
34
+ /**
35
+ * Turn `[file] / --vid / --asset` into an episode source. A local file goes to
36
+ * Backreel's VOD space by default (signed, expiring play URLs); `--legacy`
37
+ * uploads it as a plain WJI asset with a permanent public URL instead.
38
+ */
39
+ async function resolveSource(client, opts, postId, file) {
40
+ const given = [file, opts.vid, opts.asset].filter(Boolean).length;
41
+ if (given > 1)
42
+ throw new Error('Give one video source: a file, --vid or --asset');
43
+ if (opts.vid)
44
+ return { vodVid: opts.vid };
45
+ if (opts.asset)
46
+ return { video: opts.asset };
47
+ if (!file)
48
+ return null;
49
+ const ext = extname(file).toLowerCase();
50
+ if (!VIDEO_EXTS.includes(ext))
51
+ throw new Error(`${basename(file)}: episodes must be ${VIDEO_EXTS.join(' / ')}`);
52
+ const size = (await stat(file)).size;
53
+ if (opts.legacy) {
54
+ if (size > ASSET_VIDEO_MAX_BYTES)
55
+ throw new Error(`${basename(file)} is over 200 MB — drop --legacy to upload it to VOD`);
56
+ info(color.dim(`Uploading ${basename(file)} as a WJI asset…`));
57
+ const asset = await client.upload('/assets/v2/upload', file, {});
58
+ return { video: asset.id };
59
+ }
60
+ const token = await client.post('/backreel/vod/upload-token');
61
+ const vid = await uploadToVod(token, file, (step) => info(color.dim(`VOD: ${step}…`)));
62
+ // claim checks the space, tags it with the post, publishes the media and starts transcoding.
63
+ const claimed = await client.post('/backreel/vod/claim', { vid, postId, title: opts.title?.slice(0, 128) });
64
+ info(color.dim(`VOD ${vid}: ${claimed?.status ?? 'claimed'}`));
65
+ return { vodVid: vid };
66
+ }
67
+ function printEpisodes(post) {
68
+ const rows = [...(post.episodes || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
69
+ table(rows, [
70
+ { header: '#', value: (e) => (e.order ?? 0) + 1 },
71
+ { header: 'title', value: (e) => truncate(e.title, 32) },
72
+ { header: 'tier', value: (e) => e.tier },
73
+ { header: 'source', value: (e) => (e.vodVid ? `vod ${e.vodVid}` : e.video ? `asset ${idOf(e.video)}` : '-') },
74
+ { header: 'status', value: (e) => (e.vodVid ? e.vodStatus || '?' : e.video ? 'ready' : '') },
75
+ { header: 'duration', value: (e) => (e.durationSec ? `${Math.round(e.durationSec)}s` : '') },
76
+ ]);
77
+ }
78
+ const sourceOptions = (cmd) => cmd
79
+ .option('--vid <vid>', 'a Vid already in the Backreel VOD space')
80
+ .option('--asset <assetId>', 'an uploaded WJI video asset (legacy path, public URL)')
81
+ .option('--legacy', 'upload the file as a WJI asset (≤200 MB) instead of VOD');
82
+ export function episodeCommand(globals) {
83
+ const ep = new Command('episode')
84
+ .aliases(['episodes', 'ep'])
85
+ .description('Episodes of a showcase post: upload, reorder, gate by tier, check transcoding');
86
+ ep.command('list <postId>', { isDefault: true })
87
+ .description('List the episodes with their tier, source and VOD status')
88
+ .action(async (postId) => {
89
+ const client = backreelClient(globals);
90
+ const post = await loadPost(client, postId);
91
+ render(post.episodes || [], () => {
92
+ if (!(post.episodes || []).length)
93
+ return info(`"${post.title}" has no episodes yet`);
94
+ printEpisodes(post);
95
+ });
96
+ });
97
+ sourceOptions(ep.command('add <postId> [file]')
98
+ .description('Add an episode: upload a local video to VOD, or use --vid / --asset')
99
+ .requiredOption('-t, --title <title>', 'episode title')
100
+ .option('--tier <tier>', `who can watch: ${TIERS.join(' | ')}`, 'public')
101
+ .option('--at <n>', 'insert as episode n (default: last)')).action(async (postId, file, opts) => {
102
+ const client = backreelClient(globals);
103
+ const tier = oneOf(opts.tier, TIERS, '--tier');
104
+ const { post, episodes } = await loadEpisodes(client, postId);
105
+ if (episodes.length >= MAX_EPISODES)
106
+ throw new Error(`"${post.title}" already has ${MAX_EPISODES} episodes`);
107
+ const at = opts.at === undefined ? episodes.length : episodeIndex(opts.at, episodes.length + 1);
108
+ const source = await resolveSource(client, opts, postId, file);
109
+ if (!source)
110
+ throw new Error('Give the video: a file, --vid <vid> or --asset <assetId>');
111
+ episodes.splice(at, 0, { title: opts.title, order: at, tier, ...source });
112
+ const updated = await saveEpisodes(client, postId, episodes);
113
+ render(updated.episodes, () => {
114
+ success(`Added episode ${at + 1} "${opts.title}" to "${updated.title}"`);
115
+ printEpisodes(updated);
116
+ if ('vodVid' in source)
117
+ info(color.dim('VOD transcoding takes a few minutes — `wji backreel episode status` to check'));
118
+ });
119
+ });
120
+ sourceOptions(ep.command('set <postId> <n> [file]')
121
+ .description('Change episode n: title, tier, or replace its video')
122
+ .option('-t, --title <title>', 'new title')
123
+ .option('--tier <tier>', TIERS.join(' | '))).action(async (postId, n, file, opts) => {
124
+ const client = backreelClient(globals);
125
+ const { episodes } = await loadEpisodes(client, postId);
126
+ const i = episodeIndex(n, episodes.length);
127
+ const source = await resolveSource(client, { ...opts, title: opts.title || episodes[i].title }, postId, file);
128
+ if (!opts.title && !opts.tier && !source)
129
+ throw new Error('Nothing to change — pass --title, --tier or a new video');
130
+ const next = { title: opts.title || episodes[i].title, order: i, tier: opts.tier ? oneOf(opts.tier, TIERS, '--tier') : episodes[i].tier };
131
+ episodes[i] = source ? { ...next, ...source } : { ...episodes[i], ...next };
132
+ const updated = await saveEpisodes(client, postId, episodes);
133
+ render(updated.episodes, () => {
134
+ success(`Updated episode ${i + 1}`);
135
+ printEpisodes(updated);
136
+ });
137
+ });
138
+ ep.command('rm <postId> <n...>')
139
+ .description('Remove episodes (later ones move up)')
140
+ .option('-y, --yes', 'skip confirmation')
141
+ .action(async (postId, ns, opts) => {
142
+ const client = backreelClient(globals);
143
+ const { episodes } = await loadEpisodes(client, postId);
144
+ const drop = new Set(ns.map((n) => episodeIndex(n, episodes.length)));
145
+ const names = [...drop].sort((a, b) => a - b).map((i) => `${i + 1}. ${episodes[i].title}`).join(', ');
146
+ if (!(await confirm(`Remove ${names}?`, opts.yes)))
147
+ return info('Aborted');
148
+ const updated = await saveEpisodes(client, postId, episodes.filter((_, i) => !drop.has(i)));
149
+ render(updated.episodes, () => {
150
+ success(`Removed ${drop.size} episode(s)`);
151
+ printEpisodes(updated);
152
+ });
153
+ });
154
+ ep.command('move <postId> <from> <to>')
155
+ .description('Move episode <from> to position <to>')
156
+ .action(async (postId, from, to) => {
157
+ const client = backreelClient(globals);
158
+ const { episodes } = await loadEpisodes(client, postId);
159
+ const a = episodeIndex(from, episodes.length);
160
+ const b = episodeIndex(to, episodes.length);
161
+ const [moved] = episodes.splice(a, 1);
162
+ episodes.splice(b, 0, moved);
163
+ const updated = await saveEpisodes(client, postId, episodes);
164
+ render(updated.episodes, () => {
165
+ success(`Moved "${moved.title}" to episode ${b + 1}`);
166
+ printEpisodes(updated);
167
+ });
168
+ });
169
+ ep.command('tiers <postId>')
170
+ .description('Tier ladder: public up to episode N, members up to M, the rest at --rest')
171
+ .option('--public-to <n>', 'episodes 1..n are public', '0')
172
+ .option('--member-to <n>', 'then up to episode n for signed-in members')
173
+ .option('--rest <tier>', `everything after: ${TIERS.join(' | ')}`, 'supporter')
174
+ .action(async (postId, opts) => {
175
+ const client = backreelClient(globals);
176
+ const { episodes } = await loadEpisodes(client, postId);
177
+ const pub = Number(opts.publicTo);
178
+ const mem = opts.memberTo === undefined ? pub : Number(opts.memberTo);
179
+ if (!Number.isInteger(pub) || pub < 0 || !Number.isInteger(mem) || mem < pub) {
180
+ throw new Error('--public-to and --member-to are episode numbers, with --member-to ≥ --public-to');
181
+ }
182
+ const rest = oneOf(opts.rest, TIERS, '--rest');
183
+ episodes.forEach((e, i) => {
184
+ e.tier = i < pub ? 'public' : i < mem ? 'member' : rest;
185
+ });
186
+ const updated = await saveEpisodes(client, postId, episodes);
187
+ render(updated.episodes, () => {
188
+ success('Episode tiers updated');
189
+ printEpisodes(updated);
190
+ });
191
+ });
192
+ ep.command('status <postId>')
193
+ .description('Refresh VOD transcoding state (it only updates when someone asks to play)')
194
+ .action(async (postId) => {
195
+ const client = backreelClient(globals);
196
+ const post = await loadPost(client, postId);
197
+ const results = [];
198
+ for (const e of [...(post.episodes || [])].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) {
199
+ const row = { n: (e.order ?? 0) + 1, title: e.title, source: e.vodVid ? 'vod' : 'asset', status: 'ready', duration: e.durationSec };
200
+ if (e.vodVid) {
201
+ try {
202
+ const play = await client.get(`${POSTS}/list/${postId}/episodes/${e.order}/play`);
203
+ row.duration = play.durationSec ?? row.duration;
204
+ }
205
+ catch (err) {
206
+ if (err instanceof ApiError && err.status === 409)
207
+ row.status = err.body?.status || 'processing';
208
+ else
209
+ row.status = `error: ${err.message}`;
210
+ }
211
+ }
212
+ results.push(row);
213
+ }
214
+ render(results, (rows) => {
215
+ if (!rows.length)
216
+ return info(`"${post.title}" has no episodes yet`);
217
+ table(rows, [
218
+ { header: '#', value: (r) => r.n },
219
+ { header: 'title', value: (r) => truncate(r.title, 32) },
220
+ { header: 'source', value: (r) => r.source },
221
+ { header: 'status', value: (r) => (r.status === 'ready' ? color.green('ready') : color.yellow(r.status)) },
222
+ { header: 'duration', value: (r) => (r.duration ? `${Math.round(r.duration)}s` : '') },
223
+ ]);
224
+ if (rows.some((r) => r.status === 'failed'))
225
+ warn('A failed transcode needs a new upload: `episode set <post> <n> <file>`');
226
+ });
227
+ });
228
+ ep.command('play <postId> <n>')
229
+ .description('Print a short-lived play URL for episode n (as the owner)')
230
+ .action(async (postId, n) => {
231
+ const client = backreelClient(globals);
232
+ const post = await loadPost(client, postId);
233
+ const i = episodeIndex(n, (post.episodes || []).length);
234
+ const order = [...post.episodes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[i].order ?? i;
235
+ const play = await client.get(`${POSTS}/list/${postId}/episodes/${order}/play`);
236
+ render(play, (p) => {
237
+ process.stdout.write(`${p.url}\n`);
238
+ if (p.expiresAt)
239
+ info(color.dim(`expires ${p.expiresAt}`));
240
+ });
241
+ });
242
+ return ep;
243
+ }