@myvillage/cli 1.91.0 → 2.1.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.
- package/README.md +1 -1
- package/package.json +3 -2
- package/src/commands/podcast.js +406 -0
- package/src/commands/publish-episode.js +347 -0
- package/src/index.js +68 -0
- package/src/utils/api.js +88 -0
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@myvillage/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "MyVillageOS CLI for community developers",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"myvillage": "./bin/myvillage.js"
|
|
8
8
|
},
|
|
9
9
|
"engines": {
|
|
10
|
-
"node": ">=
|
|
10
|
+
"node": ">=22.0.0"
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"bin/",
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"conf": "^12.0.0",
|
|
40
40
|
"inquirer": "^9.2.12",
|
|
41
41
|
"jpeg-js": "^0.4.4",
|
|
42
|
+
"music-metadata": "^11.14.0",
|
|
42
43
|
"open": "^10.0.3",
|
|
43
44
|
"ora": "^8.0.1",
|
|
44
45
|
"p-limit": "^5.0.0",
|
|
@@ -0,0 +1,406 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
2
|
+
import { resolve, basename, extname } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
import { villageSpinner } from '../utils/brand.js';
|
|
7
|
+
import { isAuthenticated } from '../utils/auth.js';
|
|
8
|
+
import { getConfig } from '../utils/config.js';
|
|
9
|
+
import {
|
|
10
|
+
confirmPodcastUpload,
|
|
11
|
+
createPodcast,
|
|
12
|
+
getMyPodcasts,
|
|
13
|
+
getPodcastDetail,
|
|
14
|
+
getPodcastUploadUrl,
|
|
15
|
+
submitPodcast,
|
|
16
|
+
} from '../utils/api.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* podcast — show management. The setup half of the Listen pillar;
|
|
20
|
+
* publish-episode is the half people run every week.
|
|
21
|
+
*
|
|
22
|
+
* A show exists mostly to carry the metadata Apple validates before it will
|
|
23
|
+
* accept a feed: owner email, category, language, explicit flag, and square
|
|
24
|
+
* cover art. Getting any of those wrong is not a local error — the feed
|
|
25
|
+
* publishes, looks correct, and is refused during directory submission days
|
|
26
|
+
* later. So this command validates as much as it can on this machine, where the
|
|
27
|
+
* author is still looking at the thing they typed.
|
|
28
|
+
*
|
|
29
|
+
* See the platform's docs/features/podcasts.md.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** Apple's top-level categories. Mirrors APPLE_CATEGORIES server-side. */
|
|
33
|
+
const CATEGORIES = [
|
|
34
|
+
'Arts', 'Business', 'Comedy', 'Education', 'Fiction', 'Government', 'History',
|
|
35
|
+
'Health & Fitness', 'Kids & Family', 'Leisure', 'Music', 'News',
|
|
36
|
+
'Religion & Spirituality', 'Science', 'Society & Culture', 'Sports',
|
|
37
|
+
'Technology', 'True Crime', 'TV & Film',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const COVER_MIN_PX = 1400;
|
|
41
|
+
const COVER_MAX_PX = 3000;
|
|
42
|
+
|
|
43
|
+
function fail(json, message) {
|
|
44
|
+
if (json) {
|
|
45
|
+
console.log(JSON.stringify({ success: false, error: message }));
|
|
46
|
+
} else {
|
|
47
|
+
console.log(chalk.red(` ✗ ${message}`));
|
|
48
|
+
}
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function siteUrl() {
|
|
53
|
+
return getConfig().platformBaseUrl.replace(/\/api(\/v1)?$/, '');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ============================================================
|
|
57
|
+
// Cover art
|
|
58
|
+
// ============================================================
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read image dimensions from the file header.
|
|
62
|
+
*
|
|
63
|
+
* Header parsing rather than decoding: the only question is how many pixels
|
|
64
|
+
* wide and tall, and fully decoding a 3000x3000 JPEG to answer it wastes a
|
|
65
|
+
* second and a few hundred megabytes for nothing.
|
|
66
|
+
*
|
|
67
|
+
* Returns null when the format is not recognised, which is treated as "cannot
|
|
68
|
+
* check" rather than "invalid" — the server does not enforce dimensions, Apple
|
|
69
|
+
* does, and a warning is the honest thing to emit when we genuinely do not know.
|
|
70
|
+
*/
|
|
71
|
+
function readImageSize(filePath) {
|
|
72
|
+
const buf = readFileSync(filePath);
|
|
73
|
+
|
|
74
|
+
// PNG: fixed-position IHDR.
|
|
75
|
+
if (buf.length > 24 && buf.toString('hex', 0, 8) === '89504e470d0a1a0a') {
|
|
76
|
+
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// JPEG: walk the segment chain to the first SOF marker.
|
|
80
|
+
if (buf.length > 4 && buf[0] === 0xff && buf[1] === 0xd8) {
|
|
81
|
+
let offset = 2;
|
|
82
|
+
while (offset < buf.length - 9) {
|
|
83
|
+
if (buf[offset] !== 0xff) {
|
|
84
|
+
offset++;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const marker = buf[offset + 1];
|
|
88
|
+
// SOF0-SOF15, excluding DHT (c4), JPG (c8) and DAC (cc), which share the range.
|
|
89
|
+
if (marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc) {
|
|
90
|
+
return { height: buf.readUInt16BE(offset + 5), width: buf.readUInt16BE(offset + 7) };
|
|
91
|
+
}
|
|
92
|
+
offset += 2 + buf.readUInt16BE(offset + 2);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Author-facing complaints about cover art. Empty means Apple should accept it. */
|
|
100
|
+
function coverProblems(filePath) {
|
|
101
|
+
const size = readImageSize(filePath);
|
|
102
|
+
if (!size) return [];
|
|
103
|
+
|
|
104
|
+
const problems = [];
|
|
105
|
+
if (size.width !== size.height) {
|
|
106
|
+
problems.push(`It is ${size.width}x${size.height}. Podcast art has to be square.`);
|
|
107
|
+
}
|
|
108
|
+
if (size.width < COVER_MIN_PX || size.height < COVER_MIN_PX) {
|
|
109
|
+
problems.push(`It is ${size.width}x${size.height}. Apple needs at least ${COVER_MIN_PX}x${COVER_MIN_PX}.`);
|
|
110
|
+
}
|
|
111
|
+
if (size.width > COVER_MAX_PX || size.height > COVER_MAX_PX) {
|
|
112
|
+
problems.push(`It is ${size.width}x${size.height}. Apple's ceiling is ${COVER_MAX_PX}x${COVER_MAX_PX}.`);
|
|
113
|
+
}
|
|
114
|
+
return problems;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ============================================================
|
|
118
|
+
// podcast create
|
|
119
|
+
// ============================================================
|
|
120
|
+
|
|
121
|
+
export async function podcastCreateCommand(options = {}) {
|
|
122
|
+
const json = !!options.json;
|
|
123
|
+
const nonInteractive = json || !!options.yes;
|
|
124
|
+
|
|
125
|
+
if (!isAuthenticated()) {
|
|
126
|
+
return fail(json, "Authentication required. Run 'myvillage login' first.");
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let title = options.title?.trim() || null;
|
|
130
|
+
let description = options.description?.trim() || null;
|
|
131
|
+
let category = options.category?.trim() || null;
|
|
132
|
+
let ownerEmail = options.email?.trim() || null;
|
|
133
|
+
let authorName = options.author?.trim() || null;
|
|
134
|
+
|
|
135
|
+
if (category && !CATEGORIES.includes(category)) {
|
|
136
|
+
return fail(
|
|
137
|
+
json,
|
|
138
|
+
`"${category}" is not one of Apple's categories. Pick one of: ${CATEGORIES.join(', ')}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (nonInteractive) {
|
|
143
|
+
if (!title) return fail(json, 'A title is required. Pass --title.');
|
|
144
|
+
if (!category) category = 'Kids & Family';
|
|
145
|
+
} else {
|
|
146
|
+
const answers = await inquirer.prompt([
|
|
147
|
+
{
|
|
148
|
+
type: 'input',
|
|
149
|
+
name: 'title',
|
|
150
|
+
message: 'Show title:',
|
|
151
|
+
when: !title,
|
|
152
|
+
validate: (v) => (v.trim() ? true : 'A title is required.'),
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
type: 'input',
|
|
156
|
+
name: 'description',
|
|
157
|
+
message: 'What is the show about?',
|
|
158
|
+
when: !description,
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
type: 'list',
|
|
162
|
+
name: 'category',
|
|
163
|
+
message: 'Category (Apple shows this on the listing page):',
|
|
164
|
+
choices: CATEGORIES,
|
|
165
|
+
default: 'Kids & Family',
|
|
166
|
+
when: !category,
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
type: 'input',
|
|
170
|
+
name: 'author',
|
|
171
|
+
message: 'Who is it by? (the byline listeners see)',
|
|
172
|
+
when: !authorName,
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
type: 'input',
|
|
176
|
+
name: 'email',
|
|
177
|
+
message: 'Owner email (Apple mails this to verify the feed):',
|
|
178
|
+
when: !ownerEmail,
|
|
179
|
+
validate: (v) =>
|
|
180
|
+
!v.trim() || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim())
|
|
181
|
+
? true
|
|
182
|
+
: 'That does not look like an email address.',
|
|
183
|
+
},
|
|
184
|
+
]);
|
|
185
|
+
title = title || answers.title.trim();
|
|
186
|
+
if (!description && answers.description?.trim()) description = answers.description.trim();
|
|
187
|
+
category = category || answers.category;
|
|
188
|
+
if (!authorName && answers.author?.trim()) authorName = answers.author.trim();
|
|
189
|
+
if (!ownerEmail && answers.email?.trim()) ownerEmail = answers.email.trim();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const spinner = json ? null : villageSpinner('Creating the show...').start();
|
|
193
|
+
let podcast;
|
|
194
|
+
try {
|
|
195
|
+
const created = await createPodcast({
|
|
196
|
+
title,
|
|
197
|
+
description: description || undefined,
|
|
198
|
+
category,
|
|
199
|
+
authorName: authorName || undefined,
|
|
200
|
+
ownerEmail: ownerEmail || undefined,
|
|
201
|
+
explicit: options.explicit === true ? true : undefined,
|
|
202
|
+
language: options.language?.trim() || undefined,
|
|
203
|
+
});
|
|
204
|
+
podcast = created.podcast;
|
|
205
|
+
if (spinner) spinner.succeed(`Show created: ${chalk.bold(podcast.slug)}`);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
if (spinner) spinner.stop();
|
|
208
|
+
return fail(json, err.response?.data?.error || err.message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (json) {
|
|
212
|
+
console.log(JSON.stringify({ success: true, podcast, feedUrl: `${siteUrl()}/podcasts/${podcast.slug}/feed.xml` }));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
console.log('');
|
|
217
|
+
console.log(` ${chalk.dim('Next:')} add cover art, then submit the show for review.`);
|
|
218
|
+
console.log(` ${chalk.cyan(`myvillage podcast cover ${podcast.slug} <image.jpg>`)}`);
|
|
219
|
+
console.log(` ${chalk.cyan(`myvillage podcast submit ${podcast.slug}`)}`);
|
|
220
|
+
console.log('');
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ============================================================
|
|
224
|
+
// podcast list
|
|
225
|
+
// ============================================================
|
|
226
|
+
|
|
227
|
+
export async function podcastListCommand(options = {}) {
|
|
228
|
+
const json = !!options.json;
|
|
229
|
+
|
|
230
|
+
if (!isAuthenticated()) {
|
|
231
|
+
return fail(json, "Authentication required. Run 'myvillage login' first.");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let podcasts;
|
|
235
|
+
try {
|
|
236
|
+
const result = await getMyPodcasts();
|
|
237
|
+
podcasts = result.podcasts || [];
|
|
238
|
+
} catch (err) {
|
|
239
|
+
return fail(json, err.response?.data?.error || err.message);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (json) {
|
|
243
|
+
console.log(JSON.stringify({ success: true, podcasts }));
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (podcasts.length === 0) {
|
|
248
|
+
console.log('');
|
|
249
|
+
console.log(` You do not have a show yet. ${chalk.cyan('myvillage podcast create')} starts one.`);
|
|
250
|
+
console.log('');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
console.log('');
|
|
255
|
+
for (const p of podcasts) {
|
|
256
|
+
const episodes = p._count?.episodes ?? 0;
|
|
257
|
+
console.log(` ${chalk.bold(p.title)} ${chalk.dim(`(${p.slug})`)}`);
|
|
258
|
+
console.log(
|
|
259
|
+
` ${chalk.dim('Status:')} ${p.status.toLowerCase()} ${chalk.dim('Episodes:')} ${episodes}`,
|
|
260
|
+
);
|
|
261
|
+
if (p.status === 'PUBLISHED') {
|
|
262
|
+
console.log(` ${chalk.dim('Feed:')} ${chalk.cyan(`${siteUrl()}/podcasts/${p.slug}/feed.xml`)}`);
|
|
263
|
+
}
|
|
264
|
+
console.log('');
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ============================================================
|
|
269
|
+
// podcast cover
|
|
270
|
+
// ============================================================
|
|
271
|
+
|
|
272
|
+
export async function podcastCoverCommand(slug, imagePath, options = {}) {
|
|
273
|
+
const json = !!options.json;
|
|
274
|
+
|
|
275
|
+
if (!isAuthenticated()) {
|
|
276
|
+
return fail(json, "Authentication required. Run 'myvillage login' first.");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const resolved = resolve(imagePath);
|
|
280
|
+
if (!existsSync(resolved)) {
|
|
281
|
+
return fail(json, `File not found: ${resolved}`);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const ext = extname(resolved).slice(1).toLowerCase();
|
|
285
|
+
if (!['jpg', 'jpeg', 'png'].includes(ext)) {
|
|
286
|
+
return fail(json, 'Cover art has to be a JPEG or PNG.');
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const fileSize = statSync(resolved).size;
|
|
290
|
+
if (fileSize === 0) return fail(json, 'That image is empty.');
|
|
291
|
+
|
|
292
|
+
const problems = coverProblems(resolved);
|
|
293
|
+
if (problems.length > 0 && !options.force) {
|
|
294
|
+
return fail(
|
|
295
|
+
json,
|
|
296
|
+
`Apple will reject this cover. ${problems.join(' ')} Fix it, or pass --force to upload anyway.`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const fileName = basename(resolved);
|
|
301
|
+
const spinner = json ? null : villageSpinner(`Uploading ${fileName}...`).start();
|
|
302
|
+
try {
|
|
303
|
+
const urlResult = await getPodcastUploadUrl(slug, { fileName, fileSize });
|
|
304
|
+
|
|
305
|
+
await axios.put(urlResult.uploadUrl, readFileSync(resolved), {
|
|
306
|
+
headers: { 'Content-Type': urlResult.contentType },
|
|
307
|
+
maxBodyLength: Infinity,
|
|
308
|
+
maxContentLength: Infinity,
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
const confirmed = await confirmPodcastUpload(slug, { s3Key: urlResult.s3Key });
|
|
312
|
+
if (spinner) spinner.succeed('Cover art uploaded');
|
|
313
|
+
|
|
314
|
+
if (json) {
|
|
315
|
+
console.log(JSON.stringify({ success: true, podcast: confirmed.podcast, feedBlockers: confirmed.feedBlockers }));
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const blockers = confirmed.feedBlockers || [];
|
|
320
|
+
if (blockers.length > 0) {
|
|
321
|
+
console.log('');
|
|
322
|
+
console.log(chalk.yellow(' Still needed before this show can go out:'));
|
|
323
|
+
for (const b of blockers) console.log(` • ${b}`);
|
|
324
|
+
console.log('');
|
|
325
|
+
}
|
|
326
|
+
} catch (err) {
|
|
327
|
+
if (spinner) spinner.stop();
|
|
328
|
+
return fail(json, err.response?.data?.error || err.message);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ============================================================
|
|
333
|
+
// podcast submit
|
|
334
|
+
// ============================================================
|
|
335
|
+
|
|
336
|
+
export async function podcastSubmitCommand(slug, options = {}) {
|
|
337
|
+
const json = !!options.json;
|
|
338
|
+
|
|
339
|
+
if (!isAuthenticated()) {
|
|
340
|
+
return fail(json, "Authentication required. Run 'myvillage login' first.");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const spinner = json ? null : villageSpinner('Submitting the show for review...').start();
|
|
344
|
+
try {
|
|
345
|
+
const result = await submitPodcast(slug);
|
|
346
|
+
if (spinner) spinner.succeed('Submitted for review');
|
|
347
|
+
|
|
348
|
+
if (json) {
|
|
349
|
+
console.log(JSON.stringify({ success: true, podcast: result.podcast }));
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
console.log('');
|
|
354
|
+
console.log(' An admin reviews it, then publishes it. Once published, the feed');
|
|
355
|
+
console.log(` lives at ${chalk.cyan(`${siteUrl()}/podcasts/${slug}/feed.xml`)}`);
|
|
356
|
+
console.log(' and that is the URL you give Apple Podcasts and Spotify.');
|
|
357
|
+
console.log('');
|
|
358
|
+
} catch (err) {
|
|
359
|
+
if (spinner) spinner.stop();
|
|
360
|
+
// The submit endpoint returns Apple's requirements as author-facing copy.
|
|
361
|
+
return fail(json, err.response?.data?.error || err.message);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ============================================================
|
|
366
|
+
// podcast info
|
|
367
|
+
// ============================================================
|
|
368
|
+
|
|
369
|
+
export async function podcastInfoCommand(slug, options = {}) {
|
|
370
|
+
const json = !!options.json;
|
|
371
|
+
|
|
372
|
+
if (!isAuthenticated()) {
|
|
373
|
+
return fail(json, "Authentication required. Run 'myvillage login' first.");
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
let result;
|
|
377
|
+
try {
|
|
378
|
+
result = await getPodcastDetail(slug);
|
|
379
|
+
} catch (err) {
|
|
380
|
+
return fail(json, err.response?.data?.error || err.message);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (json) {
|
|
384
|
+
console.log(JSON.stringify({ success: true, ...result }));
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const p = result.podcast;
|
|
389
|
+
console.log('');
|
|
390
|
+
console.log(` ${chalk.bold(p.title)} ${chalk.dim(`(${p.slug})`)}`);
|
|
391
|
+
console.log(` ${chalk.dim('Status:')} ${p.status.toLowerCase()}`);
|
|
392
|
+
console.log(` ${chalk.dim('Category:')} ${p.category}${p.subcategory ? ` / ${p.subcategory}` : ''}`);
|
|
393
|
+
console.log(` ${chalk.dim('Author:')} ${p.authorName}`);
|
|
394
|
+
console.log(` ${chalk.dim('Episodes:')} ${p.episodes?.length ?? 0}`);
|
|
395
|
+
if (p.status === 'PUBLISHED') {
|
|
396
|
+
console.log(` ${chalk.dim('Feed:')} ${chalk.cyan(result.feedUrl)}`);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const blockers = result.feedBlockers || [];
|
|
400
|
+
if (blockers.length > 0) {
|
|
401
|
+
console.log('');
|
|
402
|
+
console.log(chalk.yellow(' Before this show can go out:'));
|
|
403
|
+
for (const b of blockers) console.log(` • ${b}`);
|
|
404
|
+
}
|
|
405
|
+
console.log('');
|
|
406
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
2
|
+
import { resolve, basename, extname } from 'path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import axios from 'axios';
|
|
5
|
+
import inquirer from 'inquirer';
|
|
6
|
+
import { villageSpinner } from '../utils/brand.js';
|
|
7
|
+
import { isAuthenticated } from '../utils/auth.js';
|
|
8
|
+
import { getConfig } from '../utils/config.js';
|
|
9
|
+
import {
|
|
10
|
+
createPodcastEpisode,
|
|
11
|
+
confirmEpisodeUpload,
|
|
12
|
+
getEpisodeUploadUrl,
|
|
13
|
+
getMyPodcasts,
|
|
14
|
+
submitEpisode,
|
|
15
|
+
} from '../utils/api.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* publish-episode — upload an audio file as a podcast episode.
|
|
19
|
+
*
|
|
20
|
+
* The Listen pillar's counterpart to `publish-book`. Same shape: create the
|
|
21
|
+
* row, PUT the file straight to storage via a presigned URL, confirm, submit
|
|
22
|
+
* for review. The one addition is duration, which is read from the file's own
|
|
23
|
+
* headers here rather than server-side — the platform never decodes audio, so
|
|
24
|
+
* whoever holds the file is the only party that can cheaply know how long it is.
|
|
25
|
+
*
|
|
26
|
+
* See the platform's docs/features/podcasts.md.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Mirrors ACCEPTED_AUDIO_EXTENSIONS server-side. Quoted so the failure is local. */
|
|
30
|
+
const ACCEPTED_EXTENSIONS = ['mp3', 'm4a', 'mp4', 'aac', 'wav'];
|
|
31
|
+
|
|
32
|
+
const EPISODE_TYPES = ['full', 'trailer', 'bonus'];
|
|
33
|
+
|
|
34
|
+
// Server-enforced too. 90 minutes of 320kbps stereo is ~216MB.
|
|
35
|
+
const MAX_AUDIO_MB = 500;
|
|
36
|
+
|
|
37
|
+
function fail(json, message) {
|
|
38
|
+
if (json) {
|
|
39
|
+
console.log(JSON.stringify({ success: false, error: message }));
|
|
40
|
+
} else {
|
|
41
|
+
console.log(chalk.red(` ✗ ${message}`));
|
|
42
|
+
}
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function titleFromFilename(audioPath) {
|
|
47
|
+
return basename(audioPath)
|
|
48
|
+
.replace(/\.[^.]+$/, '')
|
|
49
|
+
.replace(/[-_]+/g, ' ')
|
|
50
|
+
.replace(/\s+/g, ' ')
|
|
51
|
+
.trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function parsePositiveInt(value, label) {
|
|
55
|
+
if (value === undefined || value === null || value === '') return { value: undefined };
|
|
56
|
+
const n = Number(value);
|
|
57
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
58
|
+
return { error: `${label} must be a whole number.` };
|
|
59
|
+
}
|
|
60
|
+
return { value: n };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read the duration out of the file's own headers.
|
|
65
|
+
*
|
|
66
|
+
* Never fatal. <itunes:duration> is an optional tag, and an episode that
|
|
67
|
+
* publishes without a duration is a cosmetic loss; an episode that refuses to
|
|
68
|
+
* publish because a tag parser choked on an unusual encoder is a real one. The
|
|
69
|
+
* server independently sanity-checks whatever comes back against the byte count.
|
|
70
|
+
*/
|
|
71
|
+
async function readDuration(audioPath) {
|
|
72
|
+
try {
|
|
73
|
+
const { parseFile } = await import('music-metadata');
|
|
74
|
+
const metadata = await parseFile(audioPath, { duration: true });
|
|
75
|
+
const seconds = metadata?.format?.duration;
|
|
76
|
+
if (typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0) {
|
|
77
|
+
return Math.round(seconds);
|
|
78
|
+
}
|
|
79
|
+
} catch {
|
|
80
|
+
// Fall through — unknown duration is a supported state.
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatDuration(seconds) {
|
|
86
|
+
if (!seconds) return 'unknown';
|
|
87
|
+
const h = Math.floor(seconds / 3600);
|
|
88
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
89
|
+
const s = seconds % 60;
|
|
90
|
+
return h > 0
|
|
91
|
+
? `${h}h ${String(m).padStart(2, '0')}m`
|
|
92
|
+
: `${m}m ${String(s).padStart(2, '0')}s`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Pick which show the episode belongs to.
|
|
97
|
+
*
|
|
98
|
+
* Returns null when the author has no shows at all — publish-episode
|
|
99
|
+
* deliberately will not create one on the fly, because a show carries the
|
|
100
|
+
* owner email and cover art that Apple validates, and inventing those silently
|
|
101
|
+
* produces a feed that gets rejected weeks later.
|
|
102
|
+
*/
|
|
103
|
+
async function selectShow(json, nonInteractive) {
|
|
104
|
+
const spinner = json ? null : villageSpinner('Loading your shows...').start();
|
|
105
|
+
let podcasts;
|
|
106
|
+
try {
|
|
107
|
+
const result = await getMyPodcasts();
|
|
108
|
+
podcasts = result.podcasts || [];
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (spinner) spinner.fail(`Could not load your shows: ${err.message}`);
|
|
111
|
+
return { error: err.response?.data?.error || err.message };
|
|
112
|
+
}
|
|
113
|
+
if (spinner) spinner.stop();
|
|
114
|
+
|
|
115
|
+
if (podcasts.length === 0) {
|
|
116
|
+
return {
|
|
117
|
+
error:
|
|
118
|
+
"You do not have a show yet. Run 'myvillage podcast create' first — a show carries the cover art and owner email Apple checks before it will accept a feed.",
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
if (podcasts.length === 1) return { show: podcasts[0] };
|
|
122
|
+
if (nonInteractive) {
|
|
123
|
+
return {
|
|
124
|
+
error: `You have ${podcasts.length} shows. Pass --show <slug> to say which one this episode belongs to.`,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const { slug } = await inquirer.prompt([
|
|
129
|
+
{
|
|
130
|
+
type: 'list',
|
|
131
|
+
name: 'slug',
|
|
132
|
+
message: 'Which show?',
|
|
133
|
+
choices: podcasts.map((p) => ({
|
|
134
|
+
name: `${p.title} ${chalk.dim(`(${p.slug}, ${p.status.toLowerCase()})`)}`,
|
|
135
|
+
value: p.slug,
|
|
136
|
+
})),
|
|
137
|
+
},
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
return { show: podcasts.find((p) => p.slug === slug) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export async function publishEpisodeCommand(audioPath, options = {}) {
|
|
144
|
+
const json = !!options.json;
|
|
145
|
+
const nonInteractive = json || !!options.yes;
|
|
146
|
+
|
|
147
|
+
if (!isAuthenticated()) {
|
|
148
|
+
return fail(json, "Authentication required. Run 'myvillage login' first.");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Validate the file locally, before any API call ──
|
|
152
|
+
const resolved = resolve(audioPath);
|
|
153
|
+
if (!existsSync(resolved)) {
|
|
154
|
+
return fail(json, `File not found: ${resolved}`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const ext = extname(resolved).slice(1).toLowerCase();
|
|
158
|
+
if (!ACCEPTED_EXTENSIONS.includes(ext)) {
|
|
159
|
+
return fail(
|
|
160
|
+
json,
|
|
161
|
+
`"${ext || 'that file'}" is not a format podcast apps can play. Use one of: ${ACCEPTED_EXTENSIONS.join(', ')}.`,
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const fileSize = statSync(resolved).size;
|
|
166
|
+
if (fileSize === 0) {
|
|
167
|
+
return fail(json, 'That audio file is empty.');
|
|
168
|
+
}
|
|
169
|
+
if (fileSize > MAX_AUDIO_MB * 1024 * 1024) {
|
|
170
|
+
return fail(
|
|
171
|
+
json,
|
|
172
|
+
`The file is ${Math.round(fileSize / 1024 / 1024)}MB — the limit is ${MAX_AUDIO_MB}MB. Export it at a lower bitrate.`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Flags ──
|
|
177
|
+
const episodeNumber = parsePositiveInt(options.episode, 'Episode number');
|
|
178
|
+
if (episodeNumber.error) return fail(json, episodeNumber.error);
|
|
179
|
+
const seasonNumber = parsePositiveInt(options.season, 'Season number');
|
|
180
|
+
if (seasonNumber.error) return fail(json, seasonNumber.error);
|
|
181
|
+
|
|
182
|
+
let episodeType = (options.type || 'full').toLowerCase();
|
|
183
|
+
if (!EPISODE_TYPES.includes(episodeType)) {
|
|
184
|
+
return fail(json, `Unknown episode type "${options.type}". Use one of: ${EPISODE_TYPES.join(', ')}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const durationOverride = parsePositiveInt(options.duration, 'Duration');
|
|
188
|
+
if (durationOverride.error) return fail(json, durationOverride.error);
|
|
189
|
+
|
|
190
|
+
// ── Which show ──
|
|
191
|
+
let show;
|
|
192
|
+
if (options.show) {
|
|
193
|
+
const result = await getMyPodcasts().catch((err) => ({ error: err }));
|
|
194
|
+
if (result.error) {
|
|
195
|
+
return fail(json, result.error.response?.data?.error || result.error.message);
|
|
196
|
+
}
|
|
197
|
+
show = (result.podcasts || []).find(
|
|
198
|
+
(p) => p.slug === options.show || p.id === options.show,
|
|
199
|
+
);
|
|
200
|
+
if (!show) {
|
|
201
|
+
return fail(json, `You do not have a show called "${options.show}".`);
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
const picked = await selectShow(json, nonInteractive);
|
|
205
|
+
if (picked.error) return fail(json, picked.error);
|
|
206
|
+
show = picked.show;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ── Metadata: flags first, prompts for the gaps ──
|
|
210
|
+
let title = options.title?.trim() || null;
|
|
211
|
+
let description = options.description?.trim() || null;
|
|
212
|
+
|
|
213
|
+
if (nonInteractive) {
|
|
214
|
+
if (!title) title = titleFromFilename(resolved);
|
|
215
|
+
} else {
|
|
216
|
+
const answers = await inquirer.prompt([
|
|
217
|
+
{
|
|
218
|
+
type: 'input',
|
|
219
|
+
name: 'title',
|
|
220
|
+
message: 'Episode title:',
|
|
221
|
+
default: title || titleFromFilename(resolved),
|
|
222
|
+
when: !title,
|
|
223
|
+
validate: (v) => (v.trim() ? true : 'A title is required.'),
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
type: 'input',
|
|
227
|
+
name: 'description',
|
|
228
|
+
message: 'Short description (shows in podcast apps):',
|
|
229
|
+
when: !description,
|
|
230
|
+
},
|
|
231
|
+
]);
|
|
232
|
+
title = title || answers.title.trim();
|
|
233
|
+
if (!description && answers.description?.trim()) description = answers.description.trim();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ── Duration, read locally ──
|
|
237
|
+
let spinner = json ? null : villageSpinner('Reading the audio...').start();
|
|
238
|
+
const durationSeconds = durationOverride.value ?? (await readDuration(resolved));
|
|
239
|
+
if (spinner) {
|
|
240
|
+
spinner.succeed(
|
|
241
|
+
durationSeconds
|
|
242
|
+
? `${formatDuration(durationSeconds)} of audio`
|
|
243
|
+
: 'Audio read (could not determine length — the episode will publish without a duration)',
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// ── Create → upload → confirm → submit ──
|
|
248
|
+
spinner = json ? null : villageSpinner('Creating the episode...').start();
|
|
249
|
+
let episode;
|
|
250
|
+
try {
|
|
251
|
+
const created = await createPodcastEpisode(show.id, {
|
|
252
|
+
title,
|
|
253
|
+
description: description || undefined,
|
|
254
|
+
episodeNumber: episodeNumber.value,
|
|
255
|
+
seasonNumber: seasonNumber.value,
|
|
256
|
+
episodeType: episodeType.toUpperCase(),
|
|
257
|
+
explicit: options.explicit === true ? true : undefined,
|
|
258
|
+
});
|
|
259
|
+
episode = created.episode;
|
|
260
|
+
if (spinner) spinner.succeed(`Episode created: ${chalk.bold(episode.slug)}`);
|
|
261
|
+
} catch (err) {
|
|
262
|
+
if (spinner) spinner.stop();
|
|
263
|
+
return fail(json, err.response?.data?.error || err.message);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const fileName = basename(resolved);
|
|
267
|
+
spinner = json
|
|
268
|
+
? null
|
|
269
|
+
: villageSpinner(`Uploading ${fileName} (${(fileSize / 1024 / 1024).toFixed(1)}MB)...`).start();
|
|
270
|
+
try {
|
|
271
|
+
const urlResult = await getEpisodeUploadUrl(show.id, episode.id, {
|
|
272
|
+
fileName,
|
|
273
|
+
assetType: 'EPISODE_AUDIO',
|
|
274
|
+
fileSize,
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
await axios.put(urlResult.uploadUrl, readFileSync(resolved), {
|
|
278
|
+
headers: { 'Content-Type': urlResult.contentType },
|
|
279
|
+
maxBodyLength: Infinity,
|
|
280
|
+
maxContentLength: Infinity,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await confirmEpisodeUpload(show.id, episode.id, {
|
|
284
|
+
s3Key: urlResult.s3Key,
|
|
285
|
+
assetType: 'EPISODE_AUDIO',
|
|
286
|
+
durationSeconds: durationSeconds ?? undefined,
|
|
287
|
+
});
|
|
288
|
+
if (spinner) spinner.succeed('Audio uploaded');
|
|
289
|
+
} catch (err) {
|
|
290
|
+
if (spinner) spinner.stop();
|
|
291
|
+
return fail(json, err.response?.data?.error || err.message);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
spinner = json ? null : villageSpinner('Submitting for review...').start();
|
|
295
|
+
try {
|
|
296
|
+
await submitEpisode(show.id, episode.id);
|
|
297
|
+
if (spinner) spinner.succeed('Submitted');
|
|
298
|
+
} catch (err) {
|
|
299
|
+
if (spinner) spinner.stop();
|
|
300
|
+
return fail(json, err.response?.data?.error || err.message);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const config = getConfig();
|
|
304
|
+
const siteUrl = config.platformBaseUrl.replace(/\/api(\/v1)?$/, '');
|
|
305
|
+
const portalUrl = `${siteUrl}/podcasts`;
|
|
306
|
+
const feedUrl = `${siteUrl}/podcasts/${show.slug}/feed.xml`;
|
|
307
|
+
|
|
308
|
+
if (json) {
|
|
309
|
+
console.log(
|
|
310
|
+
JSON.stringify({
|
|
311
|
+
success: true,
|
|
312
|
+
showId: show.id,
|
|
313
|
+
showSlug: show.slug,
|
|
314
|
+
showStatus: show.status,
|
|
315
|
+
episodeId: episode.id,
|
|
316
|
+
episodeSlug: episode.slug,
|
|
317
|
+
title,
|
|
318
|
+
durationSeconds,
|
|
319
|
+
fileSize,
|
|
320
|
+
status: 'SUBMITTED',
|
|
321
|
+
feedUrl,
|
|
322
|
+
portalUrl,
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
console.log('');
|
|
329
|
+
console.log(chalk.green(' ✓ Your episode is in the review queue.'));
|
|
330
|
+
console.log(` ${chalk.dim('Show:')} ${show.title}`);
|
|
331
|
+
console.log(` ${chalk.dim('Episode:')} ${title}`);
|
|
332
|
+
console.log(` ${chalk.dim('Length:')} ${formatDuration(durationSeconds)}`);
|
|
333
|
+
console.log('');
|
|
334
|
+
|
|
335
|
+
// The show's own state decides whether "published" will mean anything, so say
|
|
336
|
+
// so now rather than letting the author discover it after approval.
|
|
337
|
+
if (show.status === 'PUBLISHED') {
|
|
338
|
+
console.log(` Once an admin approves it, it appears in the feed at`);
|
|
339
|
+
console.log(` ${chalk.cyan(feedUrl)}`);
|
|
340
|
+
} else {
|
|
341
|
+
console.log(
|
|
342
|
+
` ${chalk.yellow('Note:')} the show itself is ${chalk.bold(show.status.toLowerCase())}, so its feed is not live yet.`,
|
|
343
|
+
);
|
|
344
|
+
console.log(` Episodes cannot go out until the show is published at ${chalk.cyan(portalUrl)}.`);
|
|
345
|
+
}
|
|
346
|
+
console.log('');
|
|
347
|
+
}
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,14 @@ import { createGameCommand } from './commands/create-game.js';
|
|
|
19
19
|
import { createCommand } from './commands/create-app.js';
|
|
20
20
|
import { deployCommand } from './commands/deploy.js';
|
|
21
21
|
import { publishBookCommand } from './commands/publish-book.js';
|
|
22
|
+
import { publishEpisodeCommand } from './commands/publish-episode.js';
|
|
23
|
+
import {
|
|
24
|
+
podcastCreateCommand,
|
|
25
|
+
podcastListCommand,
|
|
26
|
+
podcastCoverCommand,
|
|
27
|
+
podcastSubmitCommand,
|
|
28
|
+
podcastInfoCommand,
|
|
29
|
+
} from './commands/podcast.js';
|
|
22
30
|
import { statusCommand } from './commands/status.js';
|
|
23
31
|
import { feedCommand } from './commands/feed.js';
|
|
24
32
|
import {
|
|
@@ -243,6 +251,66 @@ export function run() {
|
|
|
243
251
|
.option('--json', 'Output the result as JSON (implies non-interactive)')
|
|
244
252
|
.action((pdf, options) => publishBookCommand(pdf, options));
|
|
245
253
|
|
|
254
|
+
program
|
|
255
|
+
.command('publish-episode <audio>')
|
|
256
|
+
.description('Publish an audio file as a podcast episode')
|
|
257
|
+
.option('-s, --show <slug>', 'Which show it belongs to (skips the prompt)')
|
|
258
|
+
.option('-t, --title <title>', 'Episode title (defaults to the file name)')
|
|
259
|
+
.option('-d, --description <text>', 'Short description, shown in podcast apps')
|
|
260
|
+
.option('-e, --episode <n>', 'Episode number')
|
|
261
|
+
.option('--season <n>', 'Season number')
|
|
262
|
+
.option('--type <type>', 'full | trailer | bonus', 'full')
|
|
263
|
+
.option('--duration <seconds>', 'Override the length read from the file')
|
|
264
|
+
.option('--explicit', 'Mark this episode explicit')
|
|
265
|
+
.option('-y, --yes', 'Non-interactive: defaults instead of prompts')
|
|
266
|
+
.option('--json', 'Output the result as JSON (implies non-interactive)')
|
|
267
|
+
.action((audio, options) => publishEpisodeCommand(audio, options));
|
|
268
|
+
|
|
269
|
+
// ── Podcast show management ────────────────────────
|
|
270
|
+
|
|
271
|
+
const podcastCmd = program
|
|
272
|
+
.command('podcast')
|
|
273
|
+
.description('Manage podcast shows (the feeds episodes go into)');
|
|
274
|
+
|
|
275
|
+
podcastCmd
|
|
276
|
+
.command('create')
|
|
277
|
+
.description('Create a show')
|
|
278
|
+
.option('-t, --title <title>', 'Show title')
|
|
279
|
+
.option('-d, --description <text>', 'What the show is about')
|
|
280
|
+
.option('-c, --category <category>', "One of Apple's categories, e.g. 'Kids & Family'")
|
|
281
|
+
.option('-a, --author <name>', 'The byline listeners see')
|
|
282
|
+
.option('-e, --email <email>', 'Owner email — Apple mails this to verify the feed')
|
|
283
|
+
.option('--language <lang>', 'BCP-47 language tag (default: en-us)')
|
|
284
|
+
.option('--explicit', 'Mark the show explicit')
|
|
285
|
+
.option('-y, --yes', 'Non-interactive: defaults instead of prompts')
|
|
286
|
+
.option('--json', 'Output the result as JSON (implies non-interactive)')
|
|
287
|
+
.action((options) => podcastCreateCommand(options));
|
|
288
|
+
|
|
289
|
+
podcastCmd
|
|
290
|
+
.command('list')
|
|
291
|
+
.description('List your shows')
|
|
292
|
+
.option('--json', 'Output the result as JSON')
|
|
293
|
+
.action((options) => podcastListCommand(options));
|
|
294
|
+
|
|
295
|
+
podcastCmd
|
|
296
|
+
.command('info <slug>')
|
|
297
|
+
.description('Show detail, including what still blocks its feed')
|
|
298
|
+
.option('--json', 'Output the result as JSON')
|
|
299
|
+
.action((slug, options) => podcastInfoCommand(slug, options));
|
|
300
|
+
|
|
301
|
+
podcastCmd
|
|
302
|
+
.command('cover <slug> <image>')
|
|
303
|
+
.description('Upload square cover art (1400-3000px)')
|
|
304
|
+
.option('--force', 'Upload even if the dimensions look wrong')
|
|
305
|
+
.option('--json', 'Output the result as JSON')
|
|
306
|
+
.action((slug, image, options) => podcastCoverCommand(slug, image, options));
|
|
307
|
+
|
|
308
|
+
podcastCmd
|
|
309
|
+
.command('submit <slug>')
|
|
310
|
+
.description('Submit a show for review')
|
|
311
|
+
.option('--json', 'Output the result as JSON')
|
|
312
|
+
.action((slug, options) => podcastSubmitCommand(slug, options));
|
|
313
|
+
|
|
246
314
|
program
|
|
247
315
|
.command('status')
|
|
248
316
|
.description('Check deployment status of your games')
|
package/src/utils/api.js
CHANGED
|
@@ -632,6 +632,94 @@ export async function getMyStoryBooks() {
|
|
|
632
632
|
return response.data;
|
|
633
633
|
}
|
|
634
634
|
|
|
635
|
+
// ── Podcasts API (/api/v1/podcasts) ────────────────────
|
|
636
|
+
//
|
|
637
|
+
// Shows and episodes are separate resources on purpose: a show is created once
|
|
638
|
+
// and lives for years, episodes flow through it. Episode endpoints nest under
|
|
639
|
+
// their show rather than sitting at /podcasts/episodes/... — a show whose title
|
|
640
|
+
// slugged to "episodes" would otherwise shadow the whole route.
|
|
641
|
+
|
|
642
|
+
export async function createPodcast(data) {
|
|
643
|
+
const client = getApiClient();
|
|
644
|
+
const response = await client.post('/podcasts', data);
|
|
645
|
+
return response.data;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
export async function getMyPodcasts() {
|
|
649
|
+
const client = getApiClient();
|
|
650
|
+
const response = await client.get('/podcasts');
|
|
651
|
+
return response.data;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
export async function getPodcastDetail(idOrSlug) {
|
|
655
|
+
const client = getApiClient();
|
|
656
|
+
const response = await client.get(`/podcasts/${encodeURIComponent(idOrSlug)}`);
|
|
657
|
+
return response.data;
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
export async function updatePodcast(idOrSlug, data) {
|
|
661
|
+
const client = getApiClient();
|
|
662
|
+
const response = await client.patch(`/podcasts/${encodeURIComponent(idOrSlug)}`, data);
|
|
663
|
+
return response.data;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
export async function getPodcastUploadUrl(idOrSlug, data) {
|
|
667
|
+
const client = getApiClient();
|
|
668
|
+
const response = await client.post(`/podcasts/${encodeURIComponent(idOrSlug)}/upload-url`, data);
|
|
669
|
+
return response.data;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export async function confirmPodcastUpload(idOrSlug, data) {
|
|
673
|
+
const client = getApiClient();
|
|
674
|
+
const response = await client.post(`/podcasts/${encodeURIComponent(idOrSlug)}/confirm-upload`, data);
|
|
675
|
+
return response.data;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
export async function submitPodcast(idOrSlug) {
|
|
679
|
+
const client = getApiClient();
|
|
680
|
+
const response = await client.post(`/podcasts/${encodeURIComponent(idOrSlug)}/submit`, {});
|
|
681
|
+
return response.data;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export async function createPodcastEpisode(showId, data) {
|
|
685
|
+
const client = getApiClient();
|
|
686
|
+
const response = await client.post(`/podcasts/${encodeURIComponent(showId)}/episodes`, data);
|
|
687
|
+
return response.data;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export async function getPodcastEpisodes(showId) {
|
|
691
|
+
const client = getApiClient();
|
|
692
|
+
const response = await client.get(`/podcasts/${encodeURIComponent(showId)}/episodes`);
|
|
693
|
+
return response.data;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export async function getEpisodeUploadUrl(showId, episodeId, data) {
|
|
697
|
+
const client = getApiClient();
|
|
698
|
+
const response = await client.post(
|
|
699
|
+
`/podcasts/${encodeURIComponent(showId)}/episodes/${encodeURIComponent(episodeId)}/upload-url`,
|
|
700
|
+
data,
|
|
701
|
+
);
|
|
702
|
+
return response.data;
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
export async function confirmEpisodeUpload(showId, episodeId, data) {
|
|
706
|
+
const client = getApiClient();
|
|
707
|
+
const response = await client.post(
|
|
708
|
+
`/podcasts/${encodeURIComponent(showId)}/episodes/${encodeURIComponent(episodeId)}/confirm-upload`,
|
|
709
|
+
data,
|
|
710
|
+
);
|
|
711
|
+
return response.data;
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export async function submitEpisode(showId, episodeId) {
|
|
715
|
+
const client = getApiClient();
|
|
716
|
+
const response = await client.post(
|
|
717
|
+
`/podcasts/${encodeURIComponent(showId)}/episodes/${encodeURIComponent(episodeId)}/submit`,
|
|
718
|
+
{},
|
|
719
|
+
);
|
|
720
|
+
return response.data;
|
|
721
|
+
}
|
|
722
|
+
|
|
635
723
|
// ── GameKit SDK preflight ──────────────────────────────
|
|
636
724
|
|
|
637
725
|
// Fetch the GUID allowlist for a specific SDK version. Public endpoint —
|