@wji.ai/cli 0.9.0 → 0.9.1
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 -0
- package/dist/index.js +152 -6
- package/package.json +1 -1
- package/skill/backreel/SKILL.md +5 -1
- package/skill/backreel/reference/episodes.md +38 -0
package/README.md
CHANGED
|
@@ -215,6 +215,7 @@ wji backreel create "Harbour" -d @synopsis.txt --cover <assetId>
|
|
|
215
215
|
wji backreel credit add <postId> --npc @mika --role "飾 阿明" --job appearance
|
|
216
216
|
wji backreel image poster.png --slot portrait --post <postId> # size check + upload + set
|
|
217
217
|
wji backreel episode add <postId> E01.mp4 -t "第一集" # VOD upload
|
|
218
|
+
wji backreel episode subs add <postId> 1 E01.zh-Hant.srt # subtitles, one per language
|
|
218
219
|
wji backreel check <postId>
|
|
219
220
|
wji backreel publish <postId>
|
|
220
221
|
wji backreel enquiry list --unread
|
package/dist/index.js
CHANGED
|
@@ -281,6 +281,8 @@ var MIME_TYPES = {
|
|
|
281
281
|
mp3: "audio/mpeg",
|
|
282
282
|
wav: "audio/wav",
|
|
283
283
|
m4a: "audio/mp4",
|
|
284
|
+
srt: "application/x-subrip",
|
|
285
|
+
vtt: "text/vtt",
|
|
284
286
|
pdf: "application/pdf",
|
|
285
287
|
csv: "text/csv",
|
|
286
288
|
txt: "text/plain",
|
|
@@ -6348,8 +6350,9 @@ async function uploadToVod(token, file, onProgress) {
|
|
|
6348
6350
|
var MAX_EPISODES = 30;
|
|
6349
6351
|
var VIDEO_EXTS = [".mp4", ".mov", ".m4v", ".webm"];
|
|
6350
6352
|
var ASSET_VIDEO_MAX_BYTES = 200 * 1024 * 1024;
|
|
6353
|
+
var subtitlesOf = (e) => (e.subtitles || []).map((s) => ({ id: s.id, lang: s.lang, ...s.label ? { label: s.label } : {} }));
|
|
6351
6354
|
function toDto(e, order) {
|
|
6352
|
-
const base = { title: e.title, order, tier: e.tier || "public" };
|
|
6355
|
+
const base = { title: e.title, order, tier: e.tier || "public", subtitles: subtitlesOf(e) };
|
|
6353
6356
|
return e.vodVid ? { ...base, vodVid: e.vodVid } : { ...base, video: idOf2(e.video) };
|
|
6354
6357
|
}
|
|
6355
6358
|
async function loadEpisodes(client, postId) {
|
|
@@ -6393,9 +6396,62 @@ function printEpisodes(post) {
|
|
|
6393
6396
|
{ header: "tier", value: (e) => e.tier },
|
|
6394
6397
|
{ header: "source", value: (e) => e.vodVid ? `vod ${e.vodVid}` : e.video ? `asset ${idOf2(e.video)}` : "-" },
|
|
6395
6398
|
{ header: "status", value: (e) => e.vodVid ? e.vodStatus || "?" : e.video ? "ready" : "" },
|
|
6396
|
-
{ header: "duration", value: (e) => e.durationSec ? `${Math.round(e.durationSec)}s` : "" }
|
|
6399
|
+
{ header: "duration", value: (e) => e.durationSec ? `${Math.round(e.durationSec)}s` : "" },
|
|
6400
|
+
{ header: "subs", value: (e) => (e.subtitles || []).map((s) => s.lang).join(" ") }
|
|
6397
6401
|
]);
|
|
6398
6402
|
}
|
|
6403
|
+
var MAX_SUBTITLES = 8;
|
|
6404
|
+
var SUBTITLE_EXTS = [".srt", ".vtt"];
|
|
6405
|
+
var LANG_RE = /^[a-z]{2,3}(-[A-Za-z0-9]{2,8}){0,3}$/;
|
|
6406
|
+
function guessSubtitleLang(fileName) {
|
|
6407
|
+
const padded = `.${basename6(fileName).toLowerCase().replace(/\.(srt|vtt)$/, "")}.`;
|
|
6408
|
+
const hints = [
|
|
6409
|
+
[/(zh[-_]?(hant|tw|hk)|繁|cht)/, "zh-Hant"],
|
|
6410
|
+
[/(zh[-_]?(hans|cn)|简|chs)/, "zh-Hans"],
|
|
6411
|
+
[/(yue|粵|粤)/, "yue"],
|
|
6412
|
+
[/[._ -](en|eng|english)[._ -]/, "en"],
|
|
6413
|
+
[/[._ -](ja|jp|jpn)[._ -]/, "ja"],
|
|
6414
|
+
[/[._ -](ko|kr|kor)[._ -]/, "ko"],
|
|
6415
|
+
[/[._ -](th|tha)[._ -]/, "th"],
|
|
6416
|
+
[/[._ -](vi|vie)[._ -]/, "vi"]
|
|
6417
|
+
];
|
|
6418
|
+
return hints.find(([re]) => re.test(padded))?.[1];
|
|
6419
|
+
}
|
|
6420
|
+
function printSubtitles(episode) {
|
|
6421
|
+
const subs = episode.subtitles || [];
|
|
6422
|
+
if (!subs.length) return info(`Episode ${(episode.order ?? 0) + 1} has no subtitles`);
|
|
6423
|
+
table(subs, [
|
|
6424
|
+
{ header: "lang", value: (s) => s.lang },
|
|
6425
|
+
{ header: "label", value: (s) => s.label || "" },
|
|
6426
|
+
{ header: "default", value: (s) => s === subs[0] ? color.green("\u2605") : "" },
|
|
6427
|
+
{ header: "id", value: (s) => color.dim(s.id) }
|
|
6428
|
+
]);
|
|
6429
|
+
}
|
|
6430
|
+
async function saveSubtitles(client, postId, episodes, i, message) {
|
|
6431
|
+
const updated = await saveEpisodes(client, postId, episodes);
|
|
6432
|
+
const episode = [...updated.episodes || []].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[i];
|
|
6433
|
+
render(episode?.subtitles || [], () => {
|
|
6434
|
+
success(message);
|
|
6435
|
+
printSubtitles(episode);
|
|
6436
|
+
});
|
|
6437
|
+
}
|
|
6438
|
+
function findTrack(subs, lang, i) {
|
|
6439
|
+
const at = subs.findIndex((s) => s.lang.toLowerCase() === lang.toLowerCase());
|
|
6440
|
+
if (at < 0) {
|
|
6441
|
+
const have = subs.map((s) => s.lang).join(", ") || "none";
|
|
6442
|
+
throw new Error(`Episode ${i + 1} has no ${lang} subtitles (has: ${have})`);
|
|
6443
|
+
}
|
|
6444
|
+
return at;
|
|
6445
|
+
}
|
|
6446
|
+
function pickRendition(renditions, want) {
|
|
6447
|
+
const w = want.toLowerCase();
|
|
6448
|
+
const hit = renditions.find((r) => String(r.definition || "").toLowerCase() === w) || renditions.find((r) => r.height && String(r.height) === w.replace(/p$/, ""));
|
|
6449
|
+
if (!hit) {
|
|
6450
|
+
const have = renditions.map((r) => r.definition || `${r.height}p`).join(", ") || "only the default";
|
|
6451
|
+
throw new Error(`No ${want} rendition (available: ${have})`);
|
|
6452
|
+
}
|
|
6453
|
+
return hit;
|
|
6454
|
+
}
|
|
6399
6455
|
var sourceOptions = (cmd) => cmd.option("--vid <vid>", "a Vid already in the Backreel VOD space").option("--asset <assetId>", "an uploaded WJI video asset (legacy path, public URL)").option("--legacy", "upload the file as a WJI asset (\u2264200 MB) instead of VOD");
|
|
6400
6456
|
function episodeCommand(globals2) {
|
|
6401
6457
|
const ep = new Command19("episode").aliases(["episodes", "ep"]).description("Episodes of a showcase post: upload, reorder, gate by tier, check transcoding");
|
|
@@ -6417,7 +6473,7 @@ function episodeCommand(globals2) {
|
|
|
6417
6473
|
const at = opts.at === void 0 ? episodes.length : episodeIndex(opts.at, episodes.length + 1);
|
|
6418
6474
|
const source = await resolveSource(client, opts, postId, file);
|
|
6419
6475
|
if (!source) throw new Error("Give the video: a file, --vid <vid> or --asset <assetId>");
|
|
6420
|
-
episodes.splice(at, 0, { title: opts.title, order: at, tier, ...source });
|
|
6476
|
+
episodes.splice(at, 0, { title: opts.title, order: at, tier, subtitles: [], ...source });
|
|
6421
6477
|
const updated = await saveEpisodes(client, postId, episodes);
|
|
6422
6478
|
render(updated.episodes, () => {
|
|
6423
6479
|
success(`Added episode ${at + 1} "${opts.title}" to "${updated.title}"`);
|
|
@@ -6433,12 +6489,21 @@ function episodeCommand(globals2) {
|
|
|
6433
6489
|
const i = episodeIndex(n, episodes.length);
|
|
6434
6490
|
const source = await resolveSource(client, { ...opts, title: opts.title || episodes[i].title }, postId, file);
|
|
6435
6491
|
if (!opts.title && !opts.tier && !source) throw new Error("Nothing to change \u2014 pass --title, --tier or a new video");
|
|
6436
|
-
const next = {
|
|
6492
|
+
const next = {
|
|
6493
|
+
title: opts.title || episodes[i].title,
|
|
6494
|
+
order: i,
|
|
6495
|
+
tier: opts.tier ? oneOf2(opts.tier, TIERS, "--tier") : episodes[i].tier,
|
|
6496
|
+
// a new cut of the same episode usually keeps its timing, so the tracks stay
|
|
6497
|
+
subtitles: episodes[i].subtitles
|
|
6498
|
+
};
|
|
6437
6499
|
episodes[i] = source ? { ...next, ...source } : { ...episodes[i], ...next };
|
|
6438
6500
|
const updated = await saveEpisodes(client, postId, episodes);
|
|
6439
6501
|
render(updated.episodes, () => {
|
|
6440
6502
|
success(`Updated episode ${i + 1}`);
|
|
6441
6503
|
printEpisodes(updated);
|
|
6504
|
+
if (source && next.subtitles.length) {
|
|
6505
|
+
info(color.dim(`Kept ${next.subtitles.length} subtitle track(s) \u2014 \`wji br episode subs rm\` any that no longer line up`));
|
|
6506
|
+
}
|
|
6442
6507
|
});
|
|
6443
6508
|
});
|
|
6444
6509
|
ep.command("rm <postId> <n...>").description("Remove episodes (later ones move up)").option("-y, --yes", "skip confirmation").action(async (postId, ns, opts) => {
|
|
@@ -6513,18 +6578,99 @@ function episodeCommand(globals2) {
|
|
|
6513
6578
|
if (rows.some((r) => r.status === "failed")) warn("A failed transcode needs a new upload: `episode set <post> <n> <file>`");
|
|
6514
6579
|
});
|
|
6515
6580
|
});
|
|
6516
|
-
ep.command("play <postId> <n>").description("Print a short-lived play URL for episode n (as the owner)").action(async (postId, n) => {
|
|
6581
|
+
ep.command("play <postId> <n>").description("Print a short-lived play URL for episode n (as the owner) \u2014 the best quality unless --quality").option("-q, --quality <q>", "a rendition such as 720p or 480p").option("--qualities", "list every rendition instead of printing one URL").action(async (postId, n, opts) => {
|
|
6517
6582
|
const client = backreelClient(globals2);
|
|
6518
6583
|
const post = await loadPost(client, postId);
|
|
6519
6584
|
const i = episodeIndex(n, (post.episodes || []).length);
|
|
6520
6585
|
const order = [...post.episodes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[i].order ?? i;
|
|
6521
6586
|
const play = await client.get(`${POSTS}/list/${postId}/episodes/${order}/play`);
|
|
6522
|
-
|
|
6587
|
+
const renditions = play.renditions || [];
|
|
6588
|
+
if (opts.qualities) {
|
|
6589
|
+
return render(renditions, (rows) => {
|
|
6590
|
+
if (!rows.length) return info("Only one version (legacy asset, or VOD returned no renditions)");
|
|
6591
|
+
table(rows, [
|
|
6592
|
+
{ header: "quality", value: (r) => r.definition || "" },
|
|
6593
|
+
{ header: "size", value: (r) => r.width && r.height ? `${r.width}\xD7${r.height}` : "" },
|
|
6594
|
+
{ header: "bitrate", value: (r) => r.bitrate ? `${Math.round(r.bitrate / 1e3)} kbps` : "" }
|
|
6595
|
+
]);
|
|
6596
|
+
if (play.expiresAt) info(color.dim(`URLs expire ${play.expiresAt} \u2014 --json to get them`));
|
|
6597
|
+
});
|
|
6598
|
+
}
|
|
6599
|
+
const url = opts.quality ? pickRendition(renditions, opts.quality).url : play.url;
|
|
6600
|
+
render({ ...play, url }, (p) => {
|
|
6523
6601
|
process.stdout.write(`${p.url}
|
|
6524
6602
|
`);
|
|
6525
6603
|
if (p.expiresAt) info(color.dim(`expires ${p.expiresAt}`));
|
|
6526
6604
|
});
|
|
6527
6605
|
});
|
|
6606
|
+
const subs = ep.command("subs").aliases(["subtitles", "sub"]).description("Subtitle tracks of an episode: .srt / .vtt, one per language, \u22648, the first is the default");
|
|
6607
|
+
subs.command("list <postId> <n>", { isDefault: true }).description("List episode n's subtitle tracks").action(async (postId, n) => {
|
|
6608
|
+
const client = backreelClient(globals2);
|
|
6609
|
+
const post = await loadPost(client, postId);
|
|
6610
|
+
const i = episodeIndex(n, (post.episodes || []).length);
|
|
6611
|
+
const episode = [...post.episodes].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))[i];
|
|
6612
|
+
render(episode.subtitles || [], () => printSubtitles(episode));
|
|
6613
|
+
});
|
|
6614
|
+
subs.command("add <postId> <n> <file>").description("Upload a .srt / .vtt and attach it to episode n").option("-l, --lang <lang>", "BCP 47 language: en, ja, ko, zh-Hant, zh-Hans, yue\u2026 (guessed from the file name if omitted)").option("--label <label>", "name shown in the player (default: the language's own name)").option("--default", "make it the default track (first in the list)").option("--replace", "replace the episode's existing track in that language").action(async (postId, n, file, opts) => {
|
|
6615
|
+
const client = backreelClient(globals2);
|
|
6616
|
+
const ext = extname2(file).toLowerCase();
|
|
6617
|
+
if (!SUBTITLE_EXTS.includes(ext)) throw new Error(`${basename6(file)}: subtitles must be .srt or .vtt`);
|
|
6618
|
+
if ((await stat5(file)).size > 1024 * 1024) throw new Error(`${basename6(file)} is over 1 MB`);
|
|
6619
|
+
const lang = opts.lang || guessSubtitleLang(file);
|
|
6620
|
+
if (!lang) throw new Error(`Can't tell the language from "${basename6(file)}" \u2014 pass --lang (e.g. en, zh-Hant)`);
|
|
6621
|
+
if (!LANG_RE.test(lang)) throw new Error(`--lang ${lang} isn't a language code like en, ja or zh-Hant`);
|
|
6622
|
+
const label = opts.label ? String(opts.label).trim() : void 0;
|
|
6623
|
+
if (label && label.length > 40) throw new Error("--label is at most 40 characters");
|
|
6624
|
+
const { post, episodes } = await loadEpisodes(client, postId);
|
|
6625
|
+
const i = episodeIndex(n, episodes.length);
|
|
6626
|
+
const duration = [...post.episodes].sort((x, y) => (x.order ?? 0) - (y.order ?? 0))[i]?.durationSec;
|
|
6627
|
+
const list = [...episodes[i].subtitles];
|
|
6628
|
+
const existing = list.findIndex((s) => s.lang.toLowerCase() === lang.toLowerCase());
|
|
6629
|
+
if (existing >= 0 && !opts.replace) {
|
|
6630
|
+
throw new Error(`Episode ${i + 1} already has ${lang} subtitles \u2014 add --replace to swap them`);
|
|
6631
|
+
}
|
|
6632
|
+
if (existing < 0 && list.length >= MAX_SUBTITLES) {
|
|
6633
|
+
throw new Error(`Episode ${i + 1} already has ${MAX_SUBTITLES} subtitle tracks`);
|
|
6634
|
+
}
|
|
6635
|
+
const uploaded = await client.upload("/backreel/subtitle/upload", file, {});
|
|
6636
|
+
const keptLabel = label ?? (existing >= 0 ? list[existing].label : void 0);
|
|
6637
|
+
const track = { id: uploaded.id, lang, ...keptLabel ? { label: keptLabel } : {} };
|
|
6638
|
+
if (existing >= 0) list.splice(existing, 1, track);
|
|
6639
|
+
else list.push(track);
|
|
6640
|
+
if (opts.default) list.unshift(...list.splice(list.indexOf(track), 1));
|
|
6641
|
+
episodes[i].subtitles = list;
|
|
6642
|
+
await saveSubtitles(client, postId, episodes, i, `${existing >= 0 ? "Replaced" : "Added"} ${lang} subtitles on episode ${i + 1} (${uploaded.cueCount} lines)`);
|
|
6643
|
+
if (duration && uploaded.lastCueSec > duration + 2) {
|
|
6644
|
+
warn(`The last line is at ${Math.round(uploaded.lastCueSec)}s but the episode is ${Math.round(duration)}s \u2014 is this the right file?`);
|
|
6645
|
+
}
|
|
6646
|
+
});
|
|
6647
|
+
subs.command("rm <postId> <n> <lang...>").description("Remove episode n's tracks in these languages").action(async (postId, n, langs) => {
|
|
6648
|
+
const client = backreelClient(globals2);
|
|
6649
|
+
const { episodes } = await loadEpisodes(client, postId);
|
|
6650
|
+
const i = episodeIndex(n, episodes.length);
|
|
6651
|
+
const drop = new Set(langs.map((l) => findTrack(episodes[i].subtitles, l, i)));
|
|
6652
|
+
episodes[i].subtitles = episodes[i].subtitles.filter((_, at) => !drop.has(at));
|
|
6653
|
+
await saveSubtitles(client, postId, episodes, i, `Removed ${langs.join(", ")} from episode ${i + 1}`);
|
|
6654
|
+
});
|
|
6655
|
+
subs.command("default <postId> <n> <lang>").description("Make a track the default (moves it first)").action(async (postId, n, lang) => {
|
|
6656
|
+
const client = backreelClient(globals2);
|
|
6657
|
+
const { episodes } = await loadEpisodes(client, postId);
|
|
6658
|
+
const i = episodeIndex(n, episodes.length);
|
|
6659
|
+
const list = [...episodes[i].subtitles];
|
|
6660
|
+
list.unshift(...list.splice(findTrack(list, lang, i), 1));
|
|
6661
|
+
episodes[i].subtitles = list;
|
|
6662
|
+
await saveSubtitles(client, postId, episodes, i, `${list[0].lang} is now episode ${i + 1}'s default`);
|
|
6663
|
+
});
|
|
6664
|
+
subs.command("label <postId> <n> <lang> [label]").description("Set the name the player shows for a track (omit it to go back to the language name)").action(async (postId, n, lang, label) => {
|
|
6665
|
+
const client = backreelClient(globals2);
|
|
6666
|
+
if (label && label.trim().length > 40) throw new Error("A label is at most 40 characters");
|
|
6667
|
+
const { episodes } = await loadEpisodes(client, postId);
|
|
6668
|
+
const i = episodeIndex(n, episodes.length);
|
|
6669
|
+
const at = findTrack(episodes[i].subtitles, lang, i);
|
|
6670
|
+
const { label: _old, ...rest } = episodes[i].subtitles[at];
|
|
6671
|
+
episodes[i].subtitles[at] = label?.trim() ? { ...rest, label: label.trim() } : rest;
|
|
6672
|
+
await saveSubtitles(client, postId, episodes, i, `Updated the ${rest.lang} label on episode ${i + 1}`);
|
|
6673
|
+
});
|
|
6528
6674
|
return ep;
|
|
6529
6675
|
}
|
|
6530
6676
|
|
package/package.json
CHANGED
package/skill/backreel/SKILL.md
CHANGED
|
@@ -97,6 +97,10 @@ wji br episode set <postId> 3 E03_v2.mp4 # replace the video
|
|
|
97
97
|
wji br episode move <postId> 4 2
|
|
98
98
|
wji br episode rm <postId> 6 -y
|
|
99
99
|
wji br episode play <postId> 1 # short-lived URL to watch it yourself
|
|
100
|
+
wji br episode play <postId> 1 --qualities # renditions VOD made (1080p / 720p / …)
|
|
101
|
+
wji br episode subs add <postId> 1 E01.zh-Hant.srt # language guessed from the name
|
|
102
|
+
wji br episode subs add <postId> 1 E01_en.vtt --lang en --default
|
|
103
|
+
wji br episode subs <postId> 1 # list tracks; the first is the default
|
|
100
104
|
```
|
|
101
105
|
|
|
102
106
|
Episodes exist on **showcase** posts only (≤30). A local file goes straight
|
|
@@ -105,7 +109,7 @@ nothing on the page can be copied and shared. Transcoding takes a few minutes;
|
|
|
105
109
|
an episode stays `processing` until then and viewers get "still
|
|
106
110
|
transcoding". Numbers on the command line are 1-based positions. Read
|
|
107
111
|
[reference/episodes.md](reference/episodes.md) for the trailer, the legacy
|
|
108
|
-
asset path, tier strategy and failure handling.
|
|
112
|
+
asset path, tier strategy, subtitles and failure handling.
|
|
109
113
|
|
|
110
114
|
## 4. Crowdfunding and donations
|
|
111
115
|
|
|
@@ -78,6 +78,44 @@ wji br episode rm <postId> 7 8 -y
|
|
|
78
78
|
Removing an episode does not delete the video from VOD; re-adding it later
|
|
79
79
|
with `--vid` works.
|
|
80
80
|
|
|
81
|
+
## Subtitles
|
|
82
|
+
|
|
83
|
+
Each episode takes up to 8 subtitle tracks, one per language, from `.srt` or
|
|
84
|
+
`.vtt` files (≤1 MB). The server cleans them into plain WebVTT — italics,
|
|
85
|
+
colours and `{\an8}` positioning are dropped. The **first** track is the
|
|
86
|
+
default; viewers who never chose one get the track matching the site's
|
|
87
|
+
language, else the default.
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
wji br episode subs add <postId> 2 E02.zh-Hant.srt # zh-Hant guessed from the name
|
|
91
|
+
wji br episode subs add <postId> 2 E02.srt --lang en --label "English (CC)"
|
|
92
|
+
wji br episode subs add <postId> 2 E02_en_v2.srt --lang en --replace
|
|
93
|
+
wji br episode subs default <postId> 2 en
|
|
94
|
+
wji br episode subs label <postId> 2 en # back to the language name
|
|
95
|
+
wji br episode subs rm <postId> 2 ja ko
|
|
96
|
+
wji br episode subs <postId> 2
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Languages are BCP 47 tags: `zh-Hant`, `zh-Hans`, `yue`, `en`, `ja`, `ko`,
|
|
100
|
+
`th`, `vi`, `id`, `ms`, `es`, `pt`, `fr`, `de` (others are accepted, but the
|
|
101
|
+
player shows the raw code). A file the server can't read fails at upload,
|
|
102
|
+
before the episode changes. `add` warns when the last line runs past the
|
|
103
|
+
episode's length — usually the wrong episode's file.
|
|
104
|
+
|
|
105
|
+
Subtitles follow the episode's tier: a locked episode never serves its text.
|
|
106
|
+
Replacing the video (`episode set <post> <n> new.mp4`) keeps the tracks; remove
|
|
107
|
+
them if the new cut changed the timing.
|
|
108
|
+
|
|
109
|
+
## Quality
|
|
110
|
+
|
|
111
|
+
VOD transcodes each upload into the renditions its template defines. The
|
|
112
|
+
player offers Auto plus each one; with a single rendition the menu hides.
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
wji br episode play <postId> 1 --qualities # what VOD made
|
|
116
|
+
wji br episode play <postId> 1 -q 720p # URL of one rendition
|
|
117
|
+
```
|
|
118
|
+
|
|
81
119
|
## Who can watch: tiers
|
|
82
120
|
|
|
83
121
|
`public` anyone · `member` signed in · `supporter` has donated to this post ·
|