@hostr/cli 0.9.2 → 0.10.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 +2 -2
- package/dist/cli.js +595 -95
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -3492,7 +3492,11 @@ function mediaBaseFor(apiBase) {
|
|
|
3492
3492
|
async function loadConfig(dir = configDir()) {
|
|
3493
3493
|
try {
|
|
3494
3494
|
const raw = JSON.parse(await readFile(join(dir, "config.json"), "utf8"));
|
|
3495
|
-
return {
|
|
3495
|
+
return {
|
|
3496
|
+
profiles: raw.profiles ?? {},
|
|
3497
|
+
...raw.activeProfile ? { activeProfile: raw.activeProfile } : {},
|
|
3498
|
+
...typeof raw.listenSecret === "string" ? { listenSecret: raw.listenSecret } : {}
|
|
3499
|
+
};
|
|
3496
3500
|
} catch {
|
|
3497
3501
|
return { profiles: {} };
|
|
3498
3502
|
}
|
|
@@ -3505,7 +3509,9 @@ async function saveConfig(cfg, dir = configDir()) {
|
|
|
3505
3509
|
}
|
|
3506
3510
|
function resolveAuth(opts) {
|
|
3507
3511
|
const has = (s) => !!s;
|
|
3512
|
+
const keySource = has(opts.flagKey) ? "flag" : has(opts.env.HOSTR_API_KEY) ? "env" : has(opts.profile?.key) ? "profile" : "none";
|
|
3508
3513
|
return {
|
|
3514
|
+
keySource,
|
|
3509
3515
|
key: [opts.flagKey, opts.env.HOSTR_API_KEY, opts.profile?.key].find(has),
|
|
3510
3516
|
apiBase: [opts.flagApi, opts.env.HOSTR_API_BASE, opts.profile?.apiBase].find(has) ?? DEFAULT_API_BASE
|
|
3511
3517
|
};
|
|
@@ -3515,7 +3521,7 @@ var init_config = __esm({
|
|
|
3515
3521
|
"src/config.ts"() {
|
|
3516
3522
|
"use strict";
|
|
3517
3523
|
DEFAULT_API_BASE = "https://api.hostr.sh";
|
|
3518
|
-
CLI_VERSION = "0.
|
|
3524
|
+
CLI_VERSION = "0.10.0";
|
|
3519
3525
|
configDir = (env = process.env) => env.HOSTR_CONFIG_DIR ?? join(homedir(), ".config", "hostr");
|
|
3520
3526
|
setProfile = (cfg, name, patch) => ({
|
|
3521
3527
|
...cfg,
|
|
@@ -3660,7 +3666,7 @@ async function uploadGui(ctx) {
|
|
|
3660
3666
|
}
|
|
3661
3667
|
});
|
|
3662
3668
|
const server6 = createServer((req2, res) => server.emit("request", req2, res));
|
|
3663
|
-
const
|
|
3669
|
+
const listen2 = (srv, host, port2) => new Promise((resolve) => {
|
|
3664
3670
|
const onErr = (e) => {
|
|
3665
3671
|
srv.removeAllListeners("listening");
|
|
3666
3672
|
resolve(e.code ?? "ERR");
|
|
@@ -3673,10 +3679,10 @@ async function uploadGui(ctx) {
|
|
|
3673
3679
|
});
|
|
3674
3680
|
let port = 0;
|
|
3675
3681
|
for (let attempt = 0; attempt < 5 && !port; attempt++) {
|
|
3676
|
-
const e4 = await
|
|
3682
|
+
const e4 = await listen2(server, "127.0.0.1", 0);
|
|
3677
3683
|
if (e4) continue;
|
|
3678
3684
|
const candidate = server.address().port;
|
|
3679
|
-
const e6 = await
|
|
3685
|
+
const e6 = await listen2(server6, "::1", candidate);
|
|
3680
3686
|
if (e6 && e6 !== "EADDRNOTAVAIL") {
|
|
3681
3687
|
await new Promise((r) => server.close(() => r(null)));
|
|
3682
3688
|
continue;
|
|
@@ -3883,6 +3889,66 @@ var {
|
|
|
3883
3889
|
// src/context.ts
|
|
3884
3890
|
init_config();
|
|
3885
3891
|
|
|
3892
|
+
// src/pin.ts
|
|
3893
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "node:fs/promises";
|
|
3894
|
+
import { dirname, join as join2 } from "node:path";
|
|
3895
|
+
var PIN_FILE = "hostr.json";
|
|
3896
|
+
function parsePin(text, source) {
|
|
3897
|
+
let raw;
|
|
3898
|
+
try {
|
|
3899
|
+
raw = JSON.parse(text);
|
|
3900
|
+
} catch {
|
|
3901
|
+
throw new Error(`${source} is not valid JSON`);
|
|
3902
|
+
}
|
|
3903
|
+
const id = raw?.projectId;
|
|
3904
|
+
if (typeof id !== "string" || !id) throw new Error(`${source} has no "projectId"`);
|
|
3905
|
+
const name = raw.projectName;
|
|
3906
|
+
return { projectId: id, ...typeof name === "string" && name ? { projectName: name } : {}, source };
|
|
3907
|
+
}
|
|
3908
|
+
async function findPin(start, env, read = (p) => readFile2(p, "utf8").catch(() => null)) {
|
|
3909
|
+
if (env.HOSTR_PROJECT_ID) return { projectId: env.HOSTR_PROJECT_ID, source: "HOSTR_PROJECT_ID" };
|
|
3910
|
+
for (let dir = start; ; dir = dirname(dir)) {
|
|
3911
|
+
const file = join2(dir, PIN_FILE);
|
|
3912
|
+
const text = await read(file);
|
|
3913
|
+
if (text !== null) return parsePin(text, file);
|
|
3914
|
+
if (dirname(dir) === dir) return null;
|
|
3915
|
+
}
|
|
3916
|
+
}
|
|
3917
|
+
async function writePin(dir, pin) {
|
|
3918
|
+
const file = join2(dir, PIN_FILE);
|
|
3919
|
+
await writeFile2(file, JSON.stringify({ projectId: pin.projectId, projectName: pin.projectName }, null, 2) + "\n");
|
|
3920
|
+
return file;
|
|
3921
|
+
}
|
|
3922
|
+
var PinMismatch = class extends Error {
|
|
3923
|
+
};
|
|
3924
|
+
var label = (pin) => pin.projectName ? `"${pin.projectName}"` : pin.projectId;
|
|
3925
|
+
var linkedTo = (pin) => pin.source === "HOSTR_PROJECT_ID" ? `HOSTR_PROJECT_ID says to use the project ${label(pin)}` : `This folder is linked to the project ${label(pin)}`;
|
|
3926
|
+
function decidePin(i) {
|
|
3927
|
+
const sameApi = (p) => (p.apiBase ?? i.apiBase) === i.apiBase;
|
|
3928
|
+
const other = Object.entries(i.profiles).find(([name, p]) => name !== i.activeProfile && p.key && p.projectId === i.pin.projectId && sameApi(p));
|
|
3929
|
+
if (i.keySource === "none" || i.keySource === "profile") {
|
|
3930
|
+
const known = i.keySource === "profile" ? i.keyProjectId ?? i.profiles[i.activeProfile]?.projectId : void 0;
|
|
3931
|
+
const knownName = i.keyProjectName ?? i.profiles[i.activeProfile]?.projectName;
|
|
3932
|
+
if (known === i.pin.projectId) return { kind: "ok" };
|
|
3933
|
+
if (other) return { kind: "switch", profile: other[0] };
|
|
3934
|
+
if (i.keySource === "profile" && known === void 0) return { kind: "verify" };
|
|
3935
|
+
if (i.keySource === "none") return { kind: "ok" };
|
|
3936
|
+
return {
|
|
3937
|
+
kind: "mismatch",
|
|
3938
|
+
message: `${linkedTo(i.pin)}, but you're logged in to ${knownName ? `"${knownName}"` : "a different project"}.
|
|
3939
|
+
Run \`hostr login\` and choose ${label(i.pin)} in the browser. Your other login stays saved.`
|
|
3940
|
+
};
|
|
3941
|
+
}
|
|
3942
|
+
if (i.keyProjectId === void 0) return { kind: "verify" };
|
|
3943
|
+
if (i.keyProjectId === i.pin.projectId) return { kind: "ok" };
|
|
3944
|
+
const from = i.keySource === "flag" ? "--key" : "HOSTR_API_KEY";
|
|
3945
|
+
return {
|
|
3946
|
+
kind: "mismatch",
|
|
3947
|
+
message: `${linkedTo(i.pin)}, but the key in ${from} belongs to ${i.keyProjectName ? `"${i.keyProjectName}"` : "a different project"}.
|
|
3948
|
+
Use a key from ${label(i.pin)} instead. (Meant to use the other project? Run \`hostr unlink\`, or set HOSTR_PROJECT_ID=${i.keyProjectId} for this one command.)`
|
|
3949
|
+
};
|
|
3950
|
+
}
|
|
3951
|
+
|
|
3886
3952
|
// src/api.ts
|
|
3887
3953
|
var ApiError = class extends Error {
|
|
3888
3954
|
status;
|
|
@@ -3919,7 +3985,10 @@ function makeClient(opts) {
|
|
|
3919
3985
|
account: () => req2("GET", "/v1/account"),
|
|
3920
3986
|
billing: () => req2("GET", "/v1/billing"),
|
|
3921
3987
|
captionsSet: (body) => req2("PUT", "/v1/account/captions", { body }),
|
|
3922
|
-
assets: (params = {}) => req2("GET", "/v1/assets", {
|
|
3988
|
+
assets: ({ metadata, ...params } = {}) => req2("GET", "/v1/assets", {
|
|
3989
|
+
params: { ...params, ...Object.fromEntries(Object.entries(metadata ?? {}).map(([k, v]) => [`metadata.${k}`, v])) }
|
|
3990
|
+
}),
|
|
3991
|
+
assetUpdate: (id, metadata) => req2("PATCH", `/v1/assets/${id}`, { body: { metadata } }),
|
|
3923
3992
|
asset: (id) => req2("GET", `/v1/assets/${id}`),
|
|
3924
3993
|
assetTokens: (id, body = {}) => req2("POST", `/v1/assets/${id}/tokens`, { body }),
|
|
3925
3994
|
subtitles: (id) => req2("GET", `/v1/assets/${id}/subtitles`),
|
|
@@ -3986,15 +4055,55 @@ var makeOut = (json) => ({
|
|
|
3986
4055
|
console.log(json ? JSON.stringify(jsonDoc) : human);
|
|
3987
4056
|
}
|
|
3988
4057
|
});
|
|
3989
|
-
async function
|
|
3990
|
-
const
|
|
3991
|
-
const
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
|
|
4058
|
+
async function resolveCredentials(globalOpts, opts = {}) {
|
|
4059
|
+
const env = opts.env ?? process.env;
|
|
4060
|
+
const dir = configDir(env);
|
|
4061
|
+
let cfg = await loadConfig(dir);
|
|
4062
|
+
const activeName = cfg.activeProfile ?? "default";
|
|
4063
|
+
const auth = resolveAuth({ flagKey: globalOpts.key, flagApi: globalOpts.api, env, profile: activeProfile(cfg) });
|
|
4064
|
+
const pin = await findPin(opts.cwd ?? process.cwd(), env);
|
|
4065
|
+
if (!pin || opts.ignorePin) return { key: auth.key, apiBase: auth.apiBase, pin, note: null };
|
|
4066
|
+
const decide = (k) => decidePin({
|
|
4067
|
+
pin,
|
|
4068
|
+
keySource: auth.keySource,
|
|
4069
|
+
activeProfile: activeName,
|
|
4070
|
+
profiles: cfg.profiles,
|
|
4071
|
+
apiBase: auth.apiBase,
|
|
4072
|
+
...k ? { keyProjectId: k.id, keyProjectName: k.name } : {}
|
|
3996
4073
|
});
|
|
3997
|
-
|
|
4074
|
+
let d = decide();
|
|
4075
|
+
if (d.kind === "verify") {
|
|
4076
|
+
const a = await makeClient({ apiBase: auth.apiBase, key: auth.key, fetchFn: opts.fetchFn }).account();
|
|
4077
|
+
if (auth.keySource === "profile") {
|
|
4078
|
+
cfg = setProfile(cfg, activeName, { projectId: a.accountId, projectName: a.name });
|
|
4079
|
+
await saveConfig(cfg, dir).catch(() => {
|
|
4080
|
+
});
|
|
4081
|
+
}
|
|
4082
|
+
d = decide({ id: a.accountId, name: a.name });
|
|
4083
|
+
}
|
|
4084
|
+
if (d.kind === "mismatch") throw new PinMismatch(d.message);
|
|
4085
|
+
if (d.kind === "switch") {
|
|
4086
|
+
const p = cfg.profiles[d.profile];
|
|
4087
|
+
return {
|
|
4088
|
+
key: p.key,
|
|
4089
|
+
apiBase: p.apiBase ?? auth.apiBase,
|
|
4090
|
+
pin,
|
|
4091
|
+
note: `Using your "${p.projectName ?? d.profile}" login \u2014 this folder is linked to that project.`
|
|
4092
|
+
};
|
|
4093
|
+
}
|
|
4094
|
+
return { key: auth.key, apiBase: auth.apiBase, pin, note: null };
|
|
4095
|
+
}
|
|
4096
|
+
async function makeCtx(globalOpts, o = {}) {
|
|
4097
|
+
const out = makeOut(!!globalOpts.json);
|
|
4098
|
+
let c;
|
|
4099
|
+
try {
|
|
4100
|
+
c = await resolveCredentials(globalOpts, o);
|
|
4101
|
+
} catch (e) {
|
|
4102
|
+
fail(out, e);
|
|
4103
|
+
}
|
|
4104
|
+
if (c.note) process.stderr.write(`${c.note}
|
|
4105
|
+
`);
|
|
4106
|
+
return { client: makeClient({ apiBase: c.apiBase, key: c.key }), out, apiBase: c.apiBase, key: c.key, pin: c.pin };
|
|
3998
4107
|
}
|
|
3999
4108
|
function fail(out, e) {
|
|
4000
4109
|
const doc = e instanceof ApiError ? { error: e.body?.error ?? `http_${e.status}`, status: e.status, detail: e.body } : { error: e?.message ?? String(e) };
|
|
@@ -4076,9 +4185,9 @@ var openBrowser = (addr) => {
|
|
|
4076
4185
|
};
|
|
4077
4186
|
|
|
4078
4187
|
// src/env-file.ts
|
|
4079
|
-
import { chmod as chmod2, readFile as
|
|
4188
|
+
import { chmod as chmod2, readFile as readFile3, writeFile as writeFile3 } from "node:fs/promises";
|
|
4080
4189
|
import { basename } from "node:path";
|
|
4081
|
-
var readOrNull = (p) =>
|
|
4190
|
+
var readOrNull = (p) => readFile3(p, "utf8").catch(() => null);
|
|
4082
4191
|
function upsertEnvLine(existing, name, value) {
|
|
4083
4192
|
const line = `${name}=${value}`;
|
|
4084
4193
|
if (existing === null || existing.trim() === "") return `${line}
|
|
@@ -4111,15 +4220,15 @@ async function isGitignored(file) {
|
|
|
4111
4220
|
}
|
|
4112
4221
|
async function writeEnvVar(file, name, value) {
|
|
4113
4222
|
const next = upsertEnvLine(await readOrNull(file), name, value);
|
|
4114
|
-
await
|
|
4223
|
+
await writeFile3(file, next, { mode: 384 });
|
|
4115
4224
|
await chmod2(file, 384);
|
|
4116
4225
|
return { gitignored: await isGitignored(file) };
|
|
4117
4226
|
}
|
|
4118
4227
|
|
|
4119
4228
|
// src/update-check.ts
|
|
4120
4229
|
import { readFileSync } from "node:fs";
|
|
4121
|
-
import { mkdir as mkdir2, writeFile as
|
|
4122
|
-
import { join as
|
|
4230
|
+
import { mkdir as mkdir2, writeFile as writeFile4 } from "node:fs/promises";
|
|
4231
|
+
import { join as join3 } from "node:path";
|
|
4123
4232
|
var UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3;
|
|
4124
4233
|
var REGISTRY_LATEST_URL = "https://registry.npmjs.org/@hostr/cli/latest";
|
|
4125
4234
|
var FETCH_TIMEOUT_MS = 1500;
|
|
@@ -4139,7 +4248,7 @@ var isStale = (cache, now) => !cache || now - cache.checkedAt >= UPDATE_CHECK_IN
|
|
|
4139
4248
|
var updateNotice = (latest, current) => `hostr ${latest} is available (this is ${current}). Run commands as \`npx @hostr/cli@latest \u2026\`, or update a global install with \`npm i -g @hostr/cli@latest\`.`;
|
|
4140
4249
|
function readUpdateCache(dir) {
|
|
4141
4250
|
try {
|
|
4142
|
-
const raw = JSON.parse(readFileSync(
|
|
4251
|
+
const raw = JSON.parse(readFileSync(join3(dir, CACHE_FILE), "utf8"));
|
|
4143
4252
|
return typeof raw?.checkedAt === "number" && typeof raw?.latest === "string" ? raw : null;
|
|
4144
4253
|
} catch {
|
|
4145
4254
|
return null;
|
|
@@ -4152,7 +4261,7 @@ async function refreshUpdateCache(dir, now = Date.now()) {
|
|
|
4152
4261
|
const latest = (await r.json()).version;
|
|
4153
4262
|
if (typeof latest !== "string" || !parse(latest)) return null;
|
|
4154
4263
|
await mkdir2(dir, { recursive: true });
|
|
4155
|
-
await
|
|
4264
|
+
await writeFile4(join3(dir, CACHE_FILE), JSON.stringify({ checkedAt: now, latest }) + "\n");
|
|
4156
4265
|
return latest;
|
|
4157
4266
|
} catch {
|
|
4158
4267
|
return null;
|
|
@@ -4177,7 +4286,11 @@ function noticeAndRefresh(opts) {
|
|
|
4177
4286
|
var APP_KEY_CMD = 'npx @hostr/cli keys create --name "app dev" --write-env .env.local';
|
|
4178
4287
|
function computeStatus(i) {
|
|
4179
4288
|
const staleGuide = i.guideStampVersion !== null && i.guideStampVersion !== i.cliVersion;
|
|
4180
|
-
const authorized =
|
|
4289
|
+
const authorized = i.pinMismatch ? { mark: "\u2717", label: "wrong project", detail: i.pinMismatch.split("\n")[0] } : !i.hasProfileKey ? { mark: "\u2717", label: "not authorized", detail: "" } : i.account ? {
|
|
4290
|
+
mark: "\u2713",
|
|
4291
|
+
label: "authorized",
|
|
4292
|
+
detail: `${projectPhrase(i.account)} (limits tier: ${i.account.plan})${i.pinSource ? " \u2014 this folder is linked to it" : ""}`
|
|
4293
|
+
} : { mark: "?", label: "authorized", detail: "key saved; could not reach the API to confirm" };
|
|
4181
4294
|
const prepared = i.guideStampVersion === null ? { mark: "\u2717", label: "repo not prepared", detail: "" } : {
|
|
4182
4295
|
mark: "\u2713",
|
|
4183
4296
|
label: "repo prepared",
|
|
@@ -4187,7 +4300,10 @@ function computeStatus(i) {
|
|
|
4187
4300
|
const rows = [authorized, prepared, appKey];
|
|
4188
4301
|
let nextCommand = null;
|
|
4189
4302
|
let nextNote = null;
|
|
4190
|
-
if (
|
|
4303
|
+
if (i.pinMismatch) {
|
|
4304
|
+
nextCommand = "npx @hostr/cli login";
|
|
4305
|
+
nextNote = "Choose this folder's project in the browser. Your other login stays saved.";
|
|
4306
|
+
} else if (authorized.mark === "\u2717") {
|
|
4191
4307
|
nextCommand = "npx @hostr/cli login";
|
|
4192
4308
|
nextNote = "Opens a browser for your human to approve. Do NOT ask them to paste an\n API key, and do not put a key in your context.";
|
|
4193
4309
|
} else if (prepared.mark === "\u2717") {
|
|
@@ -4201,7 +4317,14 @@ function computeStatus(i) {
|
|
|
4201
4317
|
${captionsLine(i.account.captions)}` : "") + (i.account.billing ? `
|
|
4202
4318
|
${billingLine(i.account.billing)}` : "");
|
|
4203
4319
|
const update = i.latestCliVersion && isNewer(i.latestCliVersion, i.cliVersion) ? updateNotice(i.latestCliVersion, i.cliVersion) : null;
|
|
4204
|
-
|
|
4320
|
+
const project = i.account ? { id: i.account.id, name: i.account.name } : null;
|
|
4321
|
+
const organization = i.account?.organization ?? null;
|
|
4322
|
+
return { rows, nextCommand, nextNote, activity, staleGuide, update, project, organization };
|
|
4323
|
+
}
|
|
4324
|
+
function projectPhrase(a) {
|
|
4325
|
+
if (a.organization) return `project "${a.name}" in org "${a.organization.name}"`;
|
|
4326
|
+
if (a.organization === null) return `project "${a.name}" (no organization yet)`;
|
|
4327
|
+
return `project "${a.name}"`;
|
|
4205
4328
|
}
|
|
4206
4329
|
function captionsLine(c) {
|
|
4207
4330
|
const used = Math.round(c.minutesUsed * 10) / 10;
|
|
@@ -4236,31 +4359,61 @@ ${r.activity}
|
|
|
4236
4359
|
`;
|
|
4237
4360
|
}
|
|
4238
4361
|
|
|
4362
|
+
// src/profiles.ts
|
|
4363
|
+
var profileSlug = (name) => name.normalize("NFKD").replace(new RegExp("\\p{M}", "gu"), "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "project";
|
|
4364
|
+
function chooseLoginProfile(cfg, i) {
|
|
4365
|
+
if (i.explicit) {
|
|
4366
|
+
const prev = cfg.profiles[i.explicit];
|
|
4367
|
+
return prev?.projectId && prev.projectId !== i.projectId ? { name: i.explicit, replacedProject: prev.projectName ?? prev.projectId } : { name: i.explicit };
|
|
4368
|
+
}
|
|
4369
|
+
const sameApi = (p) => (p.apiBase ?? i.apiBase) === i.apiBase;
|
|
4370
|
+
const active = cfg.activeProfile ?? "default";
|
|
4371
|
+
const same = [active, ...Object.keys(cfg.profiles)].find((n) => cfg.profiles[n]?.projectId === i.projectId && sameApi(cfg.profiles[n]));
|
|
4372
|
+
if (same) return { name: same };
|
|
4373
|
+
if (!cfg.profiles.default?.key) return { name: "default" };
|
|
4374
|
+
const base = profileSlug(i.projectName);
|
|
4375
|
+
let name = base;
|
|
4376
|
+
for (let n = 2; cfg.profiles[name]?.key; n++) name = `${base}-${n}`;
|
|
4377
|
+
return { name };
|
|
4378
|
+
}
|
|
4379
|
+
function chooseLogoutProfile(cfg, i) {
|
|
4380
|
+
if (i.explicit) return cfg.profiles[i.explicit]?.key ? { ok: true, name: i.explicit } : { ok: false, reason: "unknown-profile", name: i.explicit };
|
|
4381
|
+
if (i.keyFromEnvOrFlag) return { ok: false, reason: "env-key" };
|
|
4382
|
+
if (i.linkedProjectId) {
|
|
4383
|
+
const hit = Object.keys(cfg.profiles).find((n) => cfg.profiles[n].key && cfg.profiles[n].projectId === i.linkedProjectId);
|
|
4384
|
+
return hit ? { ok: true, name: hit } : { ok: false, reason: "no-login-for-link" };
|
|
4385
|
+
}
|
|
4386
|
+
const active = cfg.activeProfile ?? "default";
|
|
4387
|
+
return cfg.profiles[active]?.key ? { ok: true, name: active } : { ok: false, reason: "not-logged-in" };
|
|
4388
|
+
}
|
|
4389
|
+
function removeProfile(cfg, name) {
|
|
4390
|
+
const profiles = { ...cfg.profiles };
|
|
4391
|
+
delete profiles[name];
|
|
4392
|
+
const wasActive = (cfg.activeProfile ?? "default") === name;
|
|
4393
|
+
const remaining = Object.keys(profiles).filter((n) => profiles[n].key);
|
|
4394
|
+
const nowActive = !wasActive ? cfg.activeProfile ?? "default" : remaining.includes("default") ? "default" : remaining[0] ?? null;
|
|
4395
|
+
const { activeProfile: _drop, ...rest } = cfg;
|
|
4396
|
+
return { cfg: { ...rest, profiles, ...nowActive && nowActive !== "default" ? { activeProfile: nowActive } : {} }, nowActive };
|
|
4397
|
+
}
|
|
4398
|
+
var keyIdOf = (key) => /^hostr_sk_live_([A-Za-z0-9]{8})/.exec(key)?.[1] ?? null;
|
|
4399
|
+
|
|
4239
4400
|
// src/commands-account.ts
|
|
4401
|
+
import { rm } from "node:fs/promises";
|
|
4240
4402
|
async function login(opts) {
|
|
4241
4403
|
const out = makeOut(!!opts.json);
|
|
4242
|
-
const
|
|
4243
|
-
const apiBase = resolveAuth({ flagApi: opts.api, env: process.env, profile: activeProfile(
|
|
4244
|
-
const name = opts.profile ?? "default";
|
|
4404
|
+
const dir = configDir();
|
|
4405
|
+
const apiBase = resolveAuth({ flagApi: opts.api, env: process.env, profile: activeProfile(await loadConfig(dir)) }).apiBase;
|
|
4245
4406
|
const explicit = opts.key ?? process.env.HOSTR_API_KEY;
|
|
4246
|
-
if (explicit) {
|
|
4247
|
-
try {
|
|
4248
|
-
const acct = await makeClient({ apiBase, key: explicit }).account();
|
|
4249
|
-
await saveConfig(setProfile(cfg, name, { key: explicit, apiBase }), configDir());
|
|
4250
|
-
out.print(
|
|
4251
|
-
`logged in to ${acct.name} (limits tier: ${acct.plan}) as profile "${name}"`,
|
|
4252
|
-
{ ok: true, accountId: acct.accountId, name: acct.name, plan: acct.plan, profile: name }
|
|
4253
|
-
);
|
|
4254
|
-
} catch (e) {
|
|
4255
|
-
fail(out, e);
|
|
4256
|
-
}
|
|
4257
|
-
return;
|
|
4258
|
-
}
|
|
4259
|
-
const dev = makeDeviceClient({ apiBase });
|
|
4260
4407
|
try {
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4408
|
+
let acct;
|
|
4409
|
+
if (explicit) {
|
|
4410
|
+
const a = await makeClient({ apiBase, key: explicit }).account();
|
|
4411
|
+
acct = { key: explicit, projectId: a.accountId, projectName: a.name, plan: a.plan };
|
|
4412
|
+
} else {
|
|
4413
|
+
const dev = makeDeviceClient({ apiBase });
|
|
4414
|
+
const start = await dev.start({ client: `hostr-cli/${CLI_VERSION}`, hostname: hostname() });
|
|
4415
|
+
process.stderr.write(
|
|
4416
|
+
`
|
|
4264
4417
|
Your code: ${start.userCode}
|
|
4265
4418
|
Approve at: ${start.verificationUriComplete}
|
|
4266
4419
|
|
|
@@ -4269,30 +4422,111 @@ async function login(opts) {
|
|
|
4269
4422
|
ask them to paste an API key.)
|
|
4270
4423
|
|
|
4271
4424
|
`
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
|
|
4278
|
-
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4425
|
+
);
|
|
4426
|
+
openBrowser(start.verificationUriComplete);
|
|
4427
|
+
const res = await awaitApproval({
|
|
4428
|
+
poll: () => dev.poll(start.deviceCode),
|
|
4429
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
4430
|
+
intervalSec: start.interval,
|
|
4431
|
+
expiresInSec: start.expiresIn,
|
|
4432
|
+
now: () => Date.now()
|
|
4433
|
+
});
|
|
4434
|
+
acct = { key: res.key, projectId: res.accountId, projectName: res.accountName, plan: res.plan };
|
|
4435
|
+
}
|
|
4436
|
+
const cfg = await learnProjects(await loadConfig(dir), apiBase);
|
|
4437
|
+
const before = activeProfile(cfg);
|
|
4438
|
+
const { name, replacedProject } = chooseLoginProfile(cfg, { explicit: opts.profile, ...acct, apiBase });
|
|
4439
|
+
const next = setProfile(cfg, name, { key: acct.key, apiBase, projectId: acct.projectId, projectName: acct.projectName });
|
|
4440
|
+
const { activeProfile: _a, ...rest } = next;
|
|
4441
|
+
await saveConfig(name === "default" ? rest : { ...rest, activeProfile: name }, dir);
|
|
4442
|
+
const kept = before?.key && before.projectId && before.projectId !== acct.projectId && !replacedProject ? `
|
|
4443
|
+
Your "${before.projectName ?? before.projectId}" login is still saved \u2014 folders linked to it keep using it.` : "";
|
|
4444
|
+
const replaced = replacedProject ? `
|
|
4445
|
+
This replaced the saved login for "${replacedProject}".` : "";
|
|
4446
|
+
const linked = await linkedElsewhereNote(acct.projectId);
|
|
4282
4447
|
out.print(
|
|
4283
|
-
`
|
|
4284
|
-
|
|
4285
|
-
{ ok: true, accountId: res.accountId, name: res.accountName, plan: res.plan, profile: name }
|
|
4448
|
+
`Logged in to "${acct.projectName}".${kept}${replaced}${linked}`,
|
|
4449
|
+
{ ok: true, accountId: acct.projectId, name: acct.projectName, plan: acct.plan, profile: name }
|
|
4286
4450
|
);
|
|
4287
4451
|
} catch (e) {
|
|
4288
4452
|
fail(out, e);
|
|
4289
4453
|
}
|
|
4290
4454
|
}
|
|
4455
|
+
async function learnProjects(cfg, apiBase) {
|
|
4456
|
+
let next = cfg;
|
|
4457
|
+
for (const [n, p] of Object.entries(cfg.profiles)) {
|
|
4458
|
+
if (!p.key || p.projectId) continue;
|
|
4459
|
+
const a = await makeClient({ apiBase: p.apiBase ?? apiBase, key: p.key }).account().catch(() => null);
|
|
4460
|
+
if (a) next = setProfile(next, n, { projectId: a.accountId, projectName: a.name });
|
|
4461
|
+
}
|
|
4462
|
+
return next;
|
|
4463
|
+
}
|
|
4464
|
+
async function linkedElsewhereNote(projectId) {
|
|
4465
|
+
const pin = await findPin(process.cwd(), process.env).catch(() => null);
|
|
4466
|
+
return pin && pin.projectId !== projectId ? `
|
|
4467
|
+
Heads up: this folder is linked to "${pin.projectName ?? pin.projectId}", so hostr commands run here keep using that project.` : "";
|
|
4468
|
+
}
|
|
4469
|
+
async function logout(opts) {
|
|
4470
|
+
const out = makeOut(!!opts.json);
|
|
4471
|
+
const dir = configDir();
|
|
4472
|
+
const cfg = await loadConfig(dir);
|
|
4473
|
+
const pin = await findPin(process.cwd(), process.env).catch(() => null);
|
|
4474
|
+
const pick = chooseLogoutProfile(cfg, {
|
|
4475
|
+
explicit: opts.profile,
|
|
4476
|
+
linkedProjectId: pin?.projectId,
|
|
4477
|
+
keyFromEnvOrFlag: !!(opts.key || process.env.HOSTR_API_KEY)
|
|
4478
|
+
});
|
|
4479
|
+
if (!pick.ok) {
|
|
4480
|
+
const why = {
|
|
4481
|
+
"env-key": "You're using a key from HOSTR_API_KEY (or --key), not a saved login, so there's nothing to log out of. Remove HOSTR_API_KEY from your environment instead.",
|
|
4482
|
+
"unknown-profile": `There's no saved login called "${pick.name}".`,
|
|
4483
|
+
"not-logged-in": "You're not logged in.",
|
|
4484
|
+
"no-login-for-link": `This folder is linked to "${pin?.projectName ?? pin?.projectId}", and you're not logged in to it.`
|
|
4485
|
+
}[pick.reason];
|
|
4486
|
+
fail(out, new Error(why));
|
|
4487
|
+
}
|
|
4488
|
+
const p = cfg.profiles[pick.name];
|
|
4489
|
+
const project = p.projectName ?? p.projectId ?? pick.name;
|
|
4490
|
+
let revoked = false;
|
|
4491
|
+
const keyId = keyIdOf(p.key);
|
|
4492
|
+
try {
|
|
4493
|
+
const client = makeClient({ apiBase: p.apiBase ?? DEFAULT_API_BASE, key: p.key });
|
|
4494
|
+
const row = keyId ? (await client.keysList()).items.find((k) => k.keyId === keyId) : void 0;
|
|
4495
|
+
if (row) revoked = (await client.keysRevoke(String(row.id))).revoked;
|
|
4496
|
+
} catch {
|
|
4497
|
+
}
|
|
4498
|
+
const { cfg: next, nowActive } = removeProfile(cfg, pick.name);
|
|
4499
|
+
await saveConfig(next, dir);
|
|
4500
|
+
const now = nowActive && next.profiles[nowActive] ? `
|
|
4501
|
+
Now using your "${next.profiles[nowActive].projectName ?? nowActive}" login.` : "\nYou're not logged in to anything now.";
|
|
4502
|
+
out.print(
|
|
4503
|
+
revoked ? `Logged out of "${project}" and deleted its key.${now}` : `Logged out of "${project}" on this computer, but couldn't delete the key on hostr.
|
|
4504
|
+
Delete it yourself on the Keys page in the dashboard so nobody else can use it.${now}`,
|
|
4505
|
+
{ ok: true, profile: pick.name, project, keyRevoked: revoked, nowActive }
|
|
4506
|
+
);
|
|
4507
|
+
}
|
|
4508
|
+
async function link(ctx, opts) {
|
|
4509
|
+
const a = await ctx.client.account();
|
|
4510
|
+
const dir = opts.dir ?? process.cwd();
|
|
4511
|
+
const file = await writePin(dir, { projectId: a.accountId, projectName: a.name });
|
|
4512
|
+
ctx.out.print(
|
|
4513
|
+
`This folder is now linked to "${a.name}".
|
|
4514
|
+
Commit ${PIN_FILE} so hostr commands here always use "${a.name}", for you and anyone else working in this repo.`,
|
|
4515
|
+
{ ok: true, file, projectId: a.accountId, projectName: a.name }
|
|
4516
|
+
);
|
|
4517
|
+
}
|
|
4518
|
+
async function unlink(ctx) {
|
|
4519
|
+
const pin = ctx.pin;
|
|
4520
|
+
if (!pin || pin.source === "HOSTR_PROJECT_ID") throw new Error("This folder isn't linked to a project.");
|
|
4521
|
+
await rm(pin.source);
|
|
4522
|
+
ctx.out.print(`This folder is no longer linked to "${pin.projectName ?? pin.projectId}".`, { ok: true, removed: pin.source });
|
|
4523
|
+
}
|
|
4291
4524
|
async function whoami(ctx) {
|
|
4292
4525
|
const a = await ctx.client.account();
|
|
4293
4526
|
ctx.out.print(
|
|
4294
4527
|
[
|
|
4295
4528
|
`account ${a.name} (${a.accountId})`,
|
|
4529
|
+
...ctx.pin ? [`folder linked to this project (${ctx.pin.source === "HOSTR_PROJECT_ID" ? "HOSTR_PROJECT_ID" : PIN_FILE})`] : [],
|
|
4296
4530
|
`limits ${a.plan} tier (billing is separate: GET /v1/billing)`,
|
|
4297
4531
|
`usage ${a.usage.assetCount}/${a.limits.maxAssets} assets, ${fmtBytes(a.usage.storageBytes)}/${fmtBytes(a.limits.maxSourceBytes)} storage, ${a.usage.uploadsToday}/${a.limits.maxUploadsPerDay} uploads today`,
|
|
4298
4532
|
...a.captions ? [captionsLine(a.captions)] : []
|
|
@@ -4511,8 +4745,8 @@ import { basename as basename2 } from "node:path";
|
|
|
4511
4745
|
import { stat } from "node:fs/promises";
|
|
4512
4746
|
|
|
4513
4747
|
// ../upload/src/node/index.ts
|
|
4514
|
-
import { open, mkdir as mkdir3, readFile as
|
|
4515
|
-
import { join as
|
|
4748
|
+
import { open, mkdir as mkdir3, readFile as readFile4, writeFile as writeFile5, unlink as unlink2 } from "node:fs/promises";
|
|
4749
|
+
import { join as join4 } from "node:path";
|
|
4516
4750
|
import { tmpdir } from "node:os";
|
|
4517
4751
|
|
|
4518
4752
|
// ../upload/src/core/session.ts
|
|
@@ -4867,7 +5101,7 @@ var Md5 = class _Md5 {
|
|
|
4867
5101
|
// ../upload/src/hash/composite.ts
|
|
4868
5102
|
function compositeEtag(md5Hexes) {
|
|
4869
5103
|
const h = new Md5();
|
|
4870
|
-
for (const
|
|
5104
|
+
for (const hex2 of md5Hexes) h.update(hexToBytes(hex2));
|
|
4871
5105
|
return `${h.digestHex()}-${md5Hexes.length}`;
|
|
4872
5106
|
}
|
|
4873
5107
|
|
|
@@ -5454,11 +5688,11 @@ async function nodeFileSource(path) {
|
|
|
5454
5688
|
}
|
|
5455
5689
|
var sanitizeKey = (key) => key.replace(/[^A-Za-z0-9._-]/g, "_");
|
|
5456
5690
|
function fileStateStore(dir) {
|
|
5457
|
-
const fileFor = (key) =>
|
|
5691
|
+
const fileFor = (key) => join4(dir, sanitizeKey(key));
|
|
5458
5692
|
return {
|
|
5459
5693
|
async get(key) {
|
|
5460
5694
|
try {
|
|
5461
|
-
return await
|
|
5695
|
+
return await readFile4(fileFor(key), "utf8");
|
|
5462
5696
|
} catch (err) {
|
|
5463
5697
|
if (err?.code === "ENOENT") return null;
|
|
5464
5698
|
throw err;
|
|
@@ -5466,11 +5700,11 @@ function fileStateStore(dir) {
|
|
|
5466
5700
|
},
|
|
5467
5701
|
async set(key, value) {
|
|
5468
5702
|
await mkdir3(dir, { recursive: true });
|
|
5469
|
-
await
|
|
5703
|
+
await writeFile5(fileFor(key), value, "utf8");
|
|
5470
5704
|
},
|
|
5471
5705
|
async delete(key) {
|
|
5472
5706
|
try {
|
|
5473
|
-
await
|
|
5707
|
+
await unlink2(fileFor(key));
|
|
5474
5708
|
} catch (err) {
|
|
5475
5709
|
if (err?.code !== "ENOENT") throw err;
|
|
5476
5710
|
}
|
|
@@ -5493,7 +5727,7 @@ function nodeTransport(fetchFn = fetch) {
|
|
|
5493
5727
|
async function createUpload(opts) {
|
|
5494
5728
|
const fetchFn = opts.fetchFn ?? fetch;
|
|
5495
5729
|
const file = await nodeFileSource(opts.file);
|
|
5496
|
-
const stateDir = opts.stateDir ??
|
|
5730
|
+
const stateDir = opts.stateDir ?? join4(tmpdir(), "hostr-upload");
|
|
5497
5731
|
const store = fileStateStore(stateDir);
|
|
5498
5732
|
const transport = opts.transport ?? nodeTransport(fetchFn);
|
|
5499
5733
|
return buildUpload({
|
|
@@ -5638,6 +5872,28 @@ function parseLimitFlag(v) {
|
|
|
5638
5872
|
return Number(v);
|
|
5639
5873
|
}
|
|
5640
5874
|
|
|
5875
|
+
// ../control/src/asset-metadata.ts
|
|
5876
|
+
var METADATA_KEY_MAX = 40;
|
|
5877
|
+
var KEY = /^[A-Za-z0-9_.-]+$/;
|
|
5878
|
+
var fail2 = (detail) => ({ ok: false, error: "invalid_metadata", detail });
|
|
5879
|
+
function checkKey(k) {
|
|
5880
|
+
if (!k.length || k.length > METADATA_KEY_MAX) return `key "${k.slice(0, 60)}" must be 1-${METADATA_KEY_MAX} characters`;
|
|
5881
|
+
if (!KEY.test(k)) return `key "${k}" may contain only letters, digits, "_", "." and "-"`;
|
|
5882
|
+
return null;
|
|
5883
|
+
}
|
|
5884
|
+
function parseMetadataPairs(pairs) {
|
|
5885
|
+
const out = {};
|
|
5886
|
+
for (const p of pairs) {
|
|
5887
|
+
const i = p.indexOf("=");
|
|
5888
|
+
if (i <= 0) return fail2(`"${p}" is not key=value`);
|
|
5889
|
+
const k = p.slice(0, i);
|
|
5890
|
+
const bad2 = checkKey(k);
|
|
5891
|
+
if (bad2) return fail2(bad2);
|
|
5892
|
+
out[k] = p.slice(i + 1) || null;
|
|
5893
|
+
}
|
|
5894
|
+
return { ok: true, pairs: out };
|
|
5895
|
+
}
|
|
5896
|
+
|
|
5641
5897
|
// src/commands-media.ts
|
|
5642
5898
|
var MIME = {
|
|
5643
5899
|
mp4: "video/mp4",
|
|
@@ -5650,11 +5906,22 @@ var MIME = {
|
|
|
5650
5906
|
mp3: "audio/mpeg"
|
|
5651
5907
|
};
|
|
5652
5908
|
var mimeOf = (file) => MIME[(/\.([A-Za-z0-9]+)$/.exec(file)?.[1] ?? "").toLowerCase()] ?? "application/octet-stream";
|
|
5909
|
+
function metaFromFlags(pairs, allowRemove = false) {
|
|
5910
|
+
if (!pairs?.length) return void 0;
|
|
5911
|
+
const r = parseMetadataPairs(pairs);
|
|
5912
|
+
if (!r.ok) throw new Error(`--meta: ${r.detail}`);
|
|
5913
|
+
if (!allowRemove) {
|
|
5914
|
+
const empty = Object.keys(r.pairs).find((k) => r.pairs[k] === null);
|
|
5915
|
+
if (empty) throw new Error(`--meta: "${empty}=" has no value`);
|
|
5916
|
+
}
|
|
5917
|
+
return r.pairs;
|
|
5918
|
+
}
|
|
5653
5919
|
async function assetsList(ctx, opts) {
|
|
5654
5920
|
const page2 = await ctx.client.assets({
|
|
5655
5921
|
status: opts.status,
|
|
5656
5922
|
cursor: opts.cursor,
|
|
5657
|
-
limit: opts.limit ? Number(opts.limit) : void 0
|
|
5923
|
+
limit: opts.limit ? Number(opts.limit) : void 0,
|
|
5924
|
+
metadata: metaFromFlags(opts.meta)
|
|
5658
5925
|
});
|
|
5659
5926
|
ctx.out.print(
|
|
5660
5927
|
table(
|
|
@@ -5675,10 +5942,21 @@ async function assetsList(ctx, opts) {
|
|
|
5675
5942
|
async function assetsGet(ctx, id) {
|
|
5676
5943
|
const a = await ctx.client.asset(id);
|
|
5677
5944
|
ctx.out.print(
|
|
5678
|
-
|
|
5945
|
+
[
|
|
5946
|
+
...Object.entries(a).filter(([, v]) => typeof v !== "object" || v === null).map(([k, v]) => `${k.padEnd(14)}${v ?? "-"}`),
|
|
5947
|
+
...metadataLines(a.metadata)
|
|
5948
|
+
].join("\n"),
|
|
5679
5949
|
a
|
|
5680
5950
|
);
|
|
5681
5951
|
}
|
|
5952
|
+
var metadataLines = (m) => m && typeof m === "object" ? Object.entries(m).map(([k, v]) => `metadata.${k} ${v}`) : [];
|
|
5953
|
+
async function assetsUpdate(ctx, id, opts) {
|
|
5954
|
+
const metadata = metaFromFlags(opts.meta, true);
|
|
5955
|
+
if (!metadata) throw new Error("nothing to update: pass --meta key=value (key= removes it)");
|
|
5956
|
+
const a = await ctx.client.assetUpdate(id, metadata);
|
|
5957
|
+
const lines = metadataLines(a.metadata);
|
|
5958
|
+
ctx.out.print(lines.length ? lines.join("\n") : `${id}: no metadata`, a);
|
|
5959
|
+
}
|
|
5682
5960
|
async function assetsToken(ctx, id, opts) {
|
|
5683
5961
|
const t = await ctx.client.assetTokens(id, {
|
|
5684
5962
|
...opts.ttl ? { ttlSec: Number(opts.ttl) } : {},
|
|
@@ -5773,8 +6051,8 @@ async function subtitlesList(ctx, id) {
|
|
|
5773
6051
|
);
|
|
5774
6052
|
}
|
|
5775
6053
|
async function subtitlesSet(ctx, id, file, opts) {
|
|
5776
|
-
const { readFile:
|
|
5777
|
-
const vtt = await
|
|
6054
|
+
const { readFile: readFile8 } = await import("node:fs/promises");
|
|
6055
|
+
const vtt = await readFile8(file, "utf8");
|
|
5778
6056
|
const r = await ctx.client.subtitleSet(id, opts.lang, vtt, { name: opts.name, default: opts.default });
|
|
5779
6057
|
ctx.out.print(`subtitle ${r.lang} ("${r.name}") attached to ${r.assetId}${r.default ? " as default" : ""}`, r);
|
|
5780
6058
|
}
|
|
@@ -5802,6 +6080,7 @@ async function upload(ctx, file, opts) {
|
|
|
5802
6080
|
const size = (await stat(file).catch(() => null))?.size;
|
|
5803
6081
|
if (!size) fail(ctx.out, new Error(`cannot read ${file}`));
|
|
5804
6082
|
const apiKey = ctx.key;
|
|
6083
|
+
const metadata = metaFromFlags(opts.meta);
|
|
5805
6084
|
const session = await createUploadSession({
|
|
5806
6085
|
endpoint: ctx.apiBase,
|
|
5807
6086
|
apiKey,
|
|
@@ -5809,7 +6088,8 @@ async function upload(ctx, file, opts) {
|
|
|
5809
6088
|
contentType: opts.contentType ?? mimeOf(file),
|
|
5810
6089
|
size,
|
|
5811
6090
|
...opts.audio ? { audio: opts.audio } : {},
|
|
5812
|
-
...captionsFromUploadFlags(opts) ? { captions: captionsFromUploadFlags(opts) } : {}
|
|
6091
|
+
...captionsFromUploadFlags(opts) ? { captions: captionsFromUploadFlags(opts) } : {},
|
|
6092
|
+
...metadata ? { metadata } : {}
|
|
5813
6093
|
});
|
|
5814
6094
|
const up = await createUpload({
|
|
5815
6095
|
endpoint: ctx.apiBase,
|
|
@@ -5910,11 +6190,11 @@ async function captionsDisable(ctx) {
|
|
|
5910
6190
|
}
|
|
5911
6191
|
|
|
5912
6192
|
// src/commands-init.ts
|
|
5913
|
-
import { mkdir as mkdir4, readFile as
|
|
5914
|
-
import { join as
|
|
6193
|
+
import { mkdir as mkdir4, readFile as readFile5, writeFile as writeFile6 } from "node:fs/promises";
|
|
6194
|
+
import { join as join5 } from "node:path";
|
|
5915
6195
|
|
|
5916
6196
|
// ../../docs/integrate.md
|
|
5917
|
-
var integrate_default = '# hostr \u2014 integration guide\n\n<!-- GENERATED FILE \u2014 DO NOT EDIT.\n Source: apps/docs/src/content/docs/** (pages with `agentGuide: true`)\n packages/openapi/src/spec.ts (the route table)\n Regenerate: pnpm build:agent-guide \xB7 CI checks this with --check -->\n\nhostr is self-hosted video infrastructure: resumable upload \u2192 transcode \u2192\nHLS delivery, with signed playback URLs. This document is the complete,\nself-contained integration reference. It is written to be executed by a\ncoding agent; every command has a `--json` mode and every flow is\nverifiable from the terminal.\n\n```\nAPI base https://api.hostr.sh (override: HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\nDocs https://docs.hostr.sh \xB7 GET {API base}/llms-full.txt (this file)\n```\n\nRun `npx @hostr/cli` in your app\'s repo. With no arguments it reports what is\nconfigured and names the single next step \u2014 authorize, prepare the repo, mint\nan application key \u2014 and it is the intended entry point for a coding agent.\nThis document is the *reference*: consult it while implementing, section by\nsection, rather than reading it front to back.\n\n## Quickstart\n\nBases you will need:\n\n```\nAPI base https://api.hostr.sh (override with HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\n```\n\n### 1. Authorize\n\n```bash\nnpx @hostr/cli login\n```\n\nThis opens your browser and mints the CLI\'s own key once you approve it. Nothing\nis pasted, which matters when a coding agent is driving your terminal \u2014 a key\ntyped into an agent\'s session lands in its context and its transcript.\n\nThe key appears on the dashboard Keys page as `CLI \u2014 <hostname>` and is revocable\nlike any other. For CI, `--key <k>` or `HOSTR_API_KEY` skips the browser.\n\nConfirm it worked:\n\n```bash\nnpx @hostr/cli whoami --json\n```\n\n### 2. Upload\n\n```bash\nnpx @hostr/cli upload video.mp4 --wait --json\n```\n\n`--wait` blocks until the transcode reaches `ready` or `failed`. Without it the\ncommand returns as soon as the bytes are verified in storage.\n\n```json\n{ "assetId": "9f8c1e2a4b6d47f0a1b2c3d4e5f60718", "status": "ready", "bytes": 104857600 }\n```\n\nThe upload is resumable. Kill it mid-transfer and run it again \u2014 it asks storage\nwhich parts landed and continues from there.\n\n### 3. Play it\n\nMint a playback token, then build the URL:\n\n```bash\nnpx @hostr/cli assets token 9f8c1e2a4b6d47f0a1b2c3d4e5f60718 --json\n```\n\n```json\n{ "playback": "eyJhbGci...", "thumbnail": "...", "storyboard": "...", "expiresAt": 1756134000000 }\n```\n\n```\nhttps://stream.hostr.sh/{assetId}.m3u8?token={playback}\n```\n\nHand that to any HLS player. Safari and iOS play it natively; elsewhere use\nhls.js. To check a video without writing any of that, open it in the dashboard\nand press Play \u2014 the asset page mints a token and plays it in place.\n\nThe token is checked once, when playback starts. It does not need to stay valid\nfor the whole viewing session, and the default one-hour lifetime is not a\none-hour viewing limit.\n\n### 4. Wire up events, not polling\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nThe signing secret is generated, written to `.env`, and never printed \u2014 only the\nvariable name is. Then verify signatures before trusting a delivery: see\n[Verifying webhooks](#verifying-webhook-signatures).\n\nProve your endpoint end to end without uploading anything else:\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\n### No Node available?\n\nEvery flow here has a raw-HTTP equivalent. Start at\n[Upload from a server](#upload-from-a-server).\n\n## Authentication\n\nEvery call sends `Authorization: Bearer <credential>` over HTTPS. There are three\ncredential classes and **no shared master key**.\n\n### API keys\n\n`hostr_sk_live_\u2026`, minted per account. SHA-256-hashed at rest; the plaintext is\nshown exactly once, at mint. Verified in constant time and cached about 60 seconds\nin the edge Worker \u2014 so **revocation takes up to a minute** to take effect.\n\nScopes are a ladder, each rung including the ones below:\n\n| Scope | Grants |\n|---|---|\n| `read` | GETs only \u2014 asset status, listing, usage, events |\n| `upload` | read, plus the whole upload lifecycle |\n| `playback` | upload, plus minting playback tokens |\n| `full` | everything: deletion, key management, webhook config |\n\n**`playback` is what an application key should be.** It can ingest video and it\ncan let viewers watch, but it cannot delete assets or mint more keys. Reach for\n`full` only where you actually manage the account.\n\n**Keep keys server-side.** Never ship one in a browser or a mobile app.\n\nRotate by creating the new key and revoking the old one:\n\n```bash\nnpx @hostr/cli keys create --name ci --scopes upload --write-env .env\nnpx @hostr/cli keys revoke <old-id>\n```\n\nRevoking the key that makes the request is allowed \u2014 it is rotation\'s last step.\n\n### Upload tokens\n\nA stateless HMAC scoped to a single `assetId`, returned as `uploadToken` from\n`POST /v1/uploads`. **This is the only credential an untrusted client should ever\nhold.** It can drive that one upload and nothing else \u2014 it grants nothing on\nplayback, and nothing on any other asset.\n\nTokens expire. For uploads that outlive one, re-mint in place with\n`POST /v1/uploads/:id/token` (an API key with `upload` scope, not the expiring\ntoken itself) \u2014 no progress is lost. The SDK does this for you through its\n`onTokenExpired` hook.\n\n### Service key\n\nThe internal transcoder credential. Never issued to tenants; documented only so\nits one route is not mistaken for a public one.\n\n### Login without pasting a key\n\n```bash\nnpx @hostr/cli login\n```\n\nBrowser-authorized, using the RFC 8628 device flow. The CLI never accepts a\npasted key from a human or an agent, for the reason given in the\n[quickstart](#quickstart). `--key` and `HOSTR_API_KEY` remain the escape\nhatch for CI and other headless callers.\n\nCredential precedence everywhere: `--key` flag, then `HOSTR_API_KEY`, then the\nsaved profile.\n\n### Auth errors\n\n| Status | Error | Meaning |\n|---|---|---|\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `account_suspended` | Contact the operator |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n\nThat 401 conflation is deliberate: distinguishing "wrong key" from "not your\nasset" would let anyone with a valid key enumerate other accounts\' asset ids.\n\n**`control_plane_unavailable` fails closed for API keys only.** If the control\nplane is unreachable, key-authenticated calls 503 \u2014 but in-flight uploads using\nupload tokens keep working, and playback is untouched, because the media Worker\nnever queries it.\n\n## Assets and lifecycle\n\nAn asset is created when you open an upload and lives until you erase it.\n\n```\nuploading -> uploaded -> queued -> processing -> playable -> ready\n \\-> failed\n \\-> aborted (deleted, from anywhere)\n```\n\n| Status | Means |\n|---|---|\n| `uploading` | Multipart session open, parts landing |\n| `uploaded` | Bytes verified against storage; part count and total size both agree |\n| `queued` | Handed to the transcode queue |\n| `processing` | Encoding. Carries live `progress` |\n| `playable` | Watchable now, at one quality. The rest of the ladder is still encoding |\n| `ready` | HLS renditions are live and playable |\n| `failed` | Transcode failed. `error` is always present |\n| `aborted` | Upload abandoned before completion \u2014 you aborted it, or it was collected after 14 days idle |\n| `deleted` | Erased. Terminal and irreversible |\n\n### Status only moves forward\n\nStatus only ever moves forward. Both encode passes report independently and\nqueue delivery is at-least-once, so a late or redelivered report that would move\na `playable` or `ready` asset backwards is ignored \u2014 including a failure. An asset\nthat is watchable is never marked `failed`; the error is recorded on the record\ninstead. Once `playable`, an asset stays playable.\n\n### Reading progress\n\nWhile `processing` or `playable`, the record carries\n`progress: { encodedSec, totalSec, speed }`, where `speed` is the encode rate as\na multiple of realtime. While `playable` it tracks the remaining qualities.\nOutside those two it is null or absent \u2014 a terminal status never shows a stale\nbar.\n\nThat is enough for a real percentage instead of a spinner, which matters more\nthan it sounds: most "the transcode is slow" reports turn out to be missing\nprogress feedback rather than slow encoding.\n\n### Stalled encodes are re-queued\n\nAn encode that stops reporting \u2014 a transcoder machine restarted mid-encode,\nsay \u2014 is noticed within about ten minutes of its last progress post and the\npass that was running is re-queued. Nothing about the asset changes: status\nstays where it was, progress resumes, and `video.playable` / `video.ready`\nstill fire exactly once. An encode that stays silent through three re-queues\n(roughly an hour) is given up on and the asset records an `error`, exactly as\na transcode failure would: it becomes `failed` if nothing was playable yet\n(`video.errored` fires), or stays `playable` with the error if it was.\n\n### Abandoned uploads are collected after 14 days\n\nAn upload that shows no activity for **14 days** is moved to `aborted`, its\nmultipart session is released, and an `upload.aborted` event fires carrying\n`reason: "abandoned"`. The asset\'s `error` says the same thing in words. An\n`aborted` asset stops counting against your asset quota \u2014 it holds no bytes.\n\n"Activity" means a call about that upload: asking for the next batch of\npresigned part URLs, or re-minting its upload token. Sending bytes to the\npresigned URLs is not visible to the API, so a client that goes quiet for a\nfortnight without asking for anything is what gets collected. In practice an\nuploader hits the API at least twice a day, because presigned part URLs expire\nin at most 12 hours.\n\nThe window is deliberately double the 7-day upload token lifetime. An upload\nwhose token expired is not lost \u2014 re-mint at `POST /v1/uploads/:id/token` and\nresume \u2014 so a shorter window would destroy uploads that were still resumable.\nIf you know an upload is finished with, `DELETE /v1/uploads/:id` releases the\nstorage immediately rather than waiting.\n\n### Prefer events to polling\n\n`GET /v1/assets/:id` returns the authoritative record, and\n`GET /v1/assets?limit=&status=&cursor=` lists your assets newest-first with\nkeyset pagination. But polling is the fallback, not the design \u2014\n[webhooks](#webhooks-and-the-event-log) push the same transitions.\n\n**The field is `assetId`, never `id`.** Every asset body \u2014 the record, each\nlist item (`{ items, nextCursor }`), the upload response, every event\'s\n`data` \u2014 names it `assetId`; `:id` in a path is that value. An event\'s own\ntop-level `id` is the event, not the asset. A list item also carries `error`\n(why a `failed` encode failed), so a library view needs no lookup per row.\n\n### playbackId is its own field\n\n`GET /v1/assets/:id` always carries `playbackId`, and carries `durationSec`\n(seconds, the same value as `renditions.durationSec`) from `playable` on. The\n`video.playable` and `video.ready` events carry both.\n\n`playbackId` equals `assetId` today. **Store it as its own field anyway.** It is\nnamed separately so the two can diverge later without forcing a migration on\nevery consumer.\n\n### Re-encoding\n\n`POST /v1/assets/:id/reencode` (`hostr assets reencode <id>`) builds a fresh\nladder from the original upload, which stays in storage. The asset keeps its id,\nits playback URLs and its subtitles; the current ladder keeps serving until the\nnew one is live, then playback tokens switch over. Nothing is overwritten and\nnothing needs purging \u2014 the new ladder lands under a new encode revision, and\nthe record\'s `rev` says which one is live.\n\nUse it when an encode was wrong without the source being wrong. The first case\nwas HDR: assets uploaded before 2026-09-10 from an iPhone in HDR were relabelled\nrather than tone-mapped and play back washed out. Re-encoding them fixes it.\n\nThe video ladder is always the current default, but the body may carry `audio`\n\u2014 the same field `POST /v1/uploads` takes, see [audio\noptions](https://docs.hostr.sh/concepts/ladder/#audio-options) \u2014 to set the asset\'s audio options for\nthis and every later encode:\n\n```\nPOST /v1/assets/:id/reencode { "audio": "asmr" } # hostr assets reencode <id> --audio asmr\n```\n\nThat is how an asset uploaded before the option existed gets normalised: the\nsource is still in storage. The new ladder\'s measurement and gain replace the\nrecord\'s `loudness` block when it lands.\n\nOnly a `ready` asset can be re-encoded, one re-encode at a time. The request\nreturns `202` and the record carries a `reencode` block while the job runs;\n`video.ready` fires again with `reencoded: true` and the new `rev` when the\nladder is live, and the block clears. Poster and storyboard images keep their\nURLs but change content \u2014 refresh anything you cached from them.\n\n### Editing: renders\n\nAn edit is an ordered list of clips \u2014 each a window of one of your own assets,\nwith its own gain \u2014 that hostr cuts and joins into one video. Nothing is\ntranscoded while the edit is being made (build and preview it however you like);\na render happens once, when you ask.\n\n```\nPOST /v1/renders { "clips": [ \u2026 ], "audio": "asmr" } # a new asset\nPOST /v1/assets/:id/render { "clips": [ \u2026 ] } # re-edit an existing one\n```\n\nEach clip is `{ assetId, inSec, outSec, gainDb? }`. A source must be `playable`\nor `ready`, and is cut from what it plays **now** \u2014 an asset that was already\nre-edited contributes its edited version, and an asset may be a source of its\nown re-edit (the join never overwrites anything). Cuts are frame-accurate. The\nfirst clip sets the picture\'s orientation, size and frame rate; later clips are\nfitted inside it, letterboxed rather than stretched or cropped. Audio is joined\nat 48 kHz stereo, each clip at its `gainDb`; a clip with no audio contributes\nsilence. `audio` is applied once, over the whole joined programme \u2014 so\nnormalisation sees the finished video, not each clip.\n\n**A new asset** (`POST /v1/renders`) behaves like an upload from the moment\nits join exists: `video.uploaded` (with `rendered: true`), then\n`video.playable` and `video.ready`. It sits `queued` while the join runs; if the\njoin fails the asset goes `failed` and `video.errored` fires. Store the\n`assetId` from the `202` straight away.\n\n**A re-edit** (`POST /v1/assets/:id/render`) is a\n[re-encode](#re-encoding) with a new edit as its source: same one-at-a-time\nrule, same `reencode` block, and the current version keeps serving until the\nnew one lands \u2014 then `video.ready` fires with `reencoded: true`,\n`rendered: true` and the new `rev`. A failure at either step leaves the current\nversion live. Because the content changed, a poster you chose is dropped (the\nnew ladder\'s own poster serves) and **subtitles stay attached but no longer\nline up** \u2014 replace them after the new version lands. The record\'s `render`\nblock describes the edit that is live, and later re-encodes build from it.\n\n### Choosing the poster\n\n`POST /v1/assets/:id/poster` with `{ "time": <seconds> }` (`hostr assets poster\n<id> --time <seconds>`) re-takes the poster\nfrom that second of the published ladder \u2014 no re-encode, no source download,\nseconds of work. The picture lands under its own immutable key and the\nrecord\'s `posterRev`/`posterTime` move to it; `thumbnail.jpg` keeps its URL,\nand thumbnail tokens minted from then on serve the new frame.\n`video.poster_ready` fires when it is live. See\n[Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n### Downloading the original\n\n`POST /v1/assets/:id/download` returns a presigned URL for the file exactly as\nit was uploaded \u2014 not a rendition \u2014 valid for 60 to 3600 seconds (default 900).\nThe response saves as an attachment under `filename`; pass your own to choose\nit, and the source\'s extension is added if yours has none.\n\n**This is off for every account until a hostr operator enables it.** It needs\na `playback` key that owns the asset *and* that grant; without the grant the\nowner gets `403 download_not_enabled`. Anyone else gets the usual `401`, so\nthe route never reveals whether an asset exists or whether its owner has\ndownloads on. Mint the URL server-side, only for callers you have authorised,\nand hand it straight to the browser \u2014 it is a bearer credential for the\noriginal bytes until `expiresAt`.\n\n### Deletion\n\n`DELETE /v1/assets/:id` purges the source and every rendition from storage, frees\nthe storage quota, and emits `video.deleted`. Idempotent \u2014 repeat calls report\n`deleted` again with `objectsDeleted: 0`.\n\nNew playback tokens stop minting immediately. Tokens already issued can replay\nedge-cached segments for at most a few hours until they expire. **There is no\nundelete.**\n\n## Quotas and limits\n\nAccounts carry three ceilings, visible any time:\n\n```bash\nnpx @hostr/cli whoami --json # or: GET /v1/account\n```\n\n```json\n{\n "limits": { "maxAssets": 10000, "maxSourceBytes": 5497558138880, "maxUploadsPerDay": 500 },\n "usage": { "assetCount": 1711, "storageBytes": 742381129728, "uploadsToday": 12 }\n}\n```\n\n| Limit | Counts |\n|---|---|\n| `maxAssets` | Assets held at once. Deletion frees this immediately |\n| `maxSourceBytes` | Total source bytes stored |\n| `maxUploadsPerDay` | Upload sessions created per UTC day |\n\n### Hitting one\n\nQuotas are checked when the upload is created, not when bytes arrive. A tripped\nquota is a `403` naming which one and its ceiling:\n\n```json\n{ "error": "quota_storage", "limit": 5497558138880 }\n```\n\nThe three codes are `quota_uploads_daily`, `quota_assets`, and `quota_storage`.\n\n**This means the account needs raising, not that the request was malformed.**\nDistinguish it from a `400` in your error handling \u2014 retrying a quota error\nwithout changing anything will fail identically forever.\n\n### Request-shaped limits\n\nThese are properties of the API rather than of your plan:\n\n| Limit | Value |\n|---|---|\n| Parts per presign request | 100 |\n| Parts per upload | 10,000 (a storage limit; raise `partSize` for very large files) |\n| Subtitle file size | 2,000,000 bytes |\n| Playback token lifetime | 60 s to 86,400 s, default 3,600 |\n| Event page size | 1 to 200, default 50 |\n\nExceeding the part ceiling for a given part size is `413 file_too_large`, and the\nresponse carries the maximum byte count that part size can address.\n\n### Usage over time\n\n```\nGET /v1/usage?from=2026-08-01&to=2026-08-25 (read)\n -> { from, to, days: [{ day, uploads, bytesUploaded, encodedSeconds,\n captionSeconds, deliveredMinutes, deliveredBytes }] }\n```\n\nDefaults to the last 30 days. Dates are UTC and must be `YYYY-MM-DD`; anything\nelse is `400 bad_date`.\n\n**Delivery** is what viewers watched. `deliveredMinutes` counts whole video\nsegments served, on any rung, so one viewer watching one minute is one minute\nwhatever quality they got; audio, posters, storyboards and subtitles add to\n`deliveredBytes` but not to minutes. Edge-cache hits count: a byte served from\nthe cache was still delivered. Delivery is rolled up hourly, so today\'s figure,\nand up to the three hours before the current one, can still grow.\n\n### Not your fault\n\nTwo failures look like integration bugs and are not:\n\n`503 control_plane_unavailable` is transient \u2014 retry with backoff. In-flight\nuploads holding upload tokens are unaffected, and playback is untouched.\n\n`500 signing_not_configured` means the playback signing key is not loaded on the\nAPI deployment. It is operator-side and not retryable from a client. Report it\nrather than working around it.\n\n## Billing\n\nhostr is pay-as-you-go: no plan and no monthly fee. You pay for what you\nencode, store, deliver and caption, and **the first $5 is free**.\n\n| Line | Price (USD) |\n|---|---|\n| Encoding | $0.015 per source minute |\n| Video storage | $0.003 per minute stored, per month |\n| Original files | $0.03 per GB stored, per month |\n| Delivery | $0.0005 per minute watched |\n| Captions: transcription | $0.006 per minute |\n| Captions: translation | $0.001 per minute, per language |\n\nStorage is the daily average over the month. Delivery counts whole video\nsegments served, on any quality rung, so one viewer watching one minute is one\nminute. Original files are the uploads hostr keeps so it can re-encode or hand\nthem back; they are billed by size because a 4K original can be many times\nlarger than its encoded video.\n\n### The free allowance, then a card\n\nEvery account\'s first **$5** of usage is free, once: about 330 minutes encoded,\nor 10,000 minutes watched. That is enough to build and test a whole integration,\nupload to playback to webhook, without a card. It counts every line above.\n\nWithout a card you can always sign in, run `npx @hostr/cli login`, create keys,\nconfigure webhooks and read the docs. Once the allowance is used up, an upload,\nrender, re-encode or captions request answers:\n\n```\n402 { "error": "payment_method_required", "billingUrl": "https://dashboard.hostr.sh/billing", "hint": "\u2026" }\n```\n\nA human adds the card at `billingUrl`. Uploads work within a minute of it being\nadded. An agent setting up an integration should finish its testing, then hand\nthe human that link before the integration goes live.\n\n`GET /v1/billing` reports where you stand: `allowance.leftUsd`, and\n`cardRequired`, which says whether the card requirement is on at all. Read\nthose rather than `status`: `needs_card` means only that no card is on file.\n\n#### During the beta\n\n**The card requirement is off, and usage is not billed.** Uploads work without a\ncard whatever you use, `cardRequired` is `false`, and a month that closes during\nthe beta is recorded as not chargeable. It does not draw on the allowance\neither: the $5 starts counting when the beta ends. Adding a card early is fine;\nnothing is charged for beta usage.\n\n### How a month becomes an invoice\n\nEach UTC month closes on the 1st. Its charges are invoiced and charged to the\ncard once the unbilled balance, after the free allowance, reaches **$5**; a\nsmaller month carries over and is added to the next. The first invoice shows the\nallowance as its own credit line. Sales tax is added where it applies.\n\nA failed charge does not stop anything: the account shows as `past_due`, the\ncharge is retried, and the dashboard says so.\n\n### Checking what you owe\n\n```\nGET /v1/billing (read)\n -> { status, cardRequired, allowance: { totalUsd, leftUsd }, billingUrl, thresholdUsd, carriedUsd,\n currentMonth: { month, lines: [{ key, quantity, unitUsd, amountUsd }], accruedUsd },\n nextInvoiceUsd, prices }\n```\n\n`currentMonth` is what has accrued so far, from the same meters as\n`GET /v1/usage`. `status` is `needs_card`, `active`, `past_due` or `exempt`;\n`cardRequired` says whether a missing card is refused right now.\n\nThe `plan` field on `GET /v1/account` (and in `whoami`) names the account\'s\nquota tier, the set of limits on [Quotas and limits](#quotas-and-limits). It is\nnot a billing plan: there is one price list, above.\n\n## Upload from a server\n\nFour calls. Bytes go straight to storage via presigned URLs and never transit\nhostr\'s compute.\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,3]} (at most 100 per call)\n -> { urls: [{ partNumber, url }], expiresIn }\n\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nPOST /v1/uploads/:id/complete {"sha256":"<hex of the whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\n```\n\nPlus `GET /v1/uploads/:id` for resume and `DELETE /v1/uploads/:id` to abort.\n\n### Completion recovery for mobile uploads\n\nSet `autoComplete: true` on **creation** to let Hostr finalize the upload when\nstorage contains every numbered part at exactly the declared sizes. It defaults\nto false, preserving the existing client-driven completion and SHA-256 flow.\nThe phone may still call `/complete`; it is serialized with server recovery.\nDo not opt in if completion must wait for a later client SHA-256 assertion:\nautomatic completion may win that race and that later assertion is not retained.\n\nAn alarm starts checking after one minute. Checks back off to fifteen minutes\nwithout progress and stop after seven days without observed progress; a new\npresign or token request rearms them. These checks never discard incomplete\nparts. The existing abandonment policy remains separate. GET upload status\nincludes `activity` with the last check, last observed progress, stored bytes,\npart numbers, and a waiting flag. That flag diagnoses inactivity, not its cause.\n\nRecovery persists the verified part manifest before completing storage, confirms\nthe object\'s size and composite ETag after a lost completion response, and\nresumes a partially published transcode job. The standard `video.uploaded` event\nand usage metering share a stable completion event ID; queue messages remain\nat-least-once. Cancel and complete are serialized for opted-in sessions.\n\nOn iOS, enqueue all available file-backed part tasks while execution is\navailable, with bounded staging concurrency and a disk-space reserve. Do not\ngate preparation of the next part on the previous network completion. iOS still\ncontrols when transfers run; force-quitting requires relaunch to resume.\n\n### The rules that bite\n\n**Every part is exactly `partSize` bytes except the last.** Use the `partSize`\nthe server returned, not one you picked.\n\n**`size` must be exact.** Completion verifies both the part count and the total\nbyte count against what actually landed. Mismatches are `409`:\n\n- `incomplete_upload` \u2014 `missing` lists the absent part numbers, first 50. Upload\n them and complete again.\n- `size_mismatch` \u2014 `declaredSize` and `receivedBytes` are both reported. Abort\n and restart; the file you declared is not the file you sent.\n\nPart count alone would accept N parts of the wrong sizes, which is why both are\nchecked.\n\n**Request presigned URLs only for the parts you are about to send.** They expire.\nMinting all of them up front for a large file wastes most of them.\n\n**Completion is idempotent.** A client that crashed after bytes landed but before\nit saw the response can safely retry and will get the stored result back rather\nthan a conflict.\n\n### Worked example\n\n```bash\nAPI=https://api.hostr.sh\nKEY=$HOSTR_API_KEY\nFILE=video.mp4\nSIZE=$(wc -c < "$FILE")\n\n# 1. Create the session.\nCREATE=$(curl -sS -X POST "$API/v1/uploads" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"filename\\":\\"$FILE\\",\\"contentType\\":\\"video/mp4\\",\\"size\\":$SIZE}")\n\nASSET=$(echo "$CREATE" | jq -r .assetId)\nPART_SIZE=$(echo "$CREATE" | jq -r .partSize)\nPARTS=$(echo "$CREATE" | jq -r .expectedParts)\n\n# 2. Split, presign, and PUT each part.\nsplit -b "$PART_SIZE" "$FILE" part-\ni=1\nfor p in part-*; do\n URL=$(curl -sS -X POST "$API/v1/uploads/$ASSET/parts" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"partNumbers\\":[$i]}" | jq -r \'.urls[0].url\')\n curl -sS -X PUT --data-binary "@$p" "$URL" -o /dev/null\n i=$((i + 1))\ndone\n\n# 3. Finalise.\ncurl -sS -X POST "$API/v1/uploads/$ASSET/complete" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"sha256\\":\\"$(shasum -a 256 "$FILE" | cut -d\' \' -f1)\\"}"\n```\n\nBatch the presign calls in real code \u2014 up to 100 part numbers per request.\n\n### Send the sha256\n\nThe `etag` completion returns is a *composite* multipart ETag: a hash of hashes,\nand dependent on the part size you used. It is not a content identity and cannot\nbe compared against a hash you computed locally.\n\nIf you want real integrity, send `sha256` \u2014 the lowercase hex SHA-256 of the\nwhole file. It is stored as object metadata and is what an independent\nverification would check against. A malformed value is ignored rather than\nrejected, since retrying the call cannot fix it.\n\n### Credentials\n\n`POST /v1/uploads` needs an API key with `upload` scope. The remaining upload\nroutes accept **either** that key or the `uploadToken` returned at creation.\n\nRe-minting a token (`POST /v1/uploads/:id/token`) requires the API key \u2014 not the\nexpiring token, which would otherwise be able to renew itself forever.\n\nUploading from a browser or mobile client? Do not ship the API key. See\n[Upload from a browser](#upload-from-a-browser).\n\n## Upload from a browser\n\nCreate the upload on your server, hand the client a narrow credential, and let it\nPUT parts directly. Upload bytes never touch your servers.\n\n### Forward only the client-safe fields\n\n`POST /v1/uploads` returns more than a client should see:\n\n```json\n{\n "assetId": "...", // safe\n "uploadToken": "...", // safe\n "tokenExpiresAt": 1756130400000, // safe\n "partSize": 8388608, // safe\n "expectedParts": 13, // safe\n\n "key": "acct_7Kd2/.../source.mp4", // storage internal \u2014 do NOT forward\n "uploadId": "2~aBcDeF..." // storage internal \u2014 do NOT forward\n}\n```\n\nForward exactly `assetId`, `uploadToken`, `tokenExpiresAt`, `partSize`, and\n`expectedParts`. Passing the whole body through leaks your storage layout to\nevery client.\n\nThe `uploadToken` is scoped to that one asset and grants nothing else \u2014 no other\nasset, and nothing on playback. That is why it is safe to hand out and an API key\nis not.\n\n### Origins: none to register\n\nPart PUTs go **directly to object storage**, not through the API. On hosted\nhostr the source bucket accepts them from **any origin** \u2014 production,\n`localhost` on any port, `*.vercel.app` previews \u2014 with nothing to ask for or\nconfigure. The presigned part URL is the credential, so there is no origin list\nto keep in sync.\n\n**Your own Content-Security-Policy can still block it.** If your app sets\n`connect-src`, add the storage host the part URLs point at\n(`https://*.r2.cloudflarestorage.com`) alongside the API host. A blocked\n`connect-src` fails exactly like being offline.\n\n**Self-hosting**, you own the bucket and set this rule on it yourself:\n\n```json\n{\n "AllowedOrigins": ["*"],\n "AllowedMethods": ["PUT"],\n "AllowedHeaders": ["*"],\n "ExposeHeaders": ["ETag"]\n}\n```\n\n`ExposeHeaders: ["ETag"]` is not optional \u2014 the client verifies each part\'s ETag\nand cannot read it otherwise. `AllowedHeaders` is not optional either: a part\nwhose `Blob` has a type sends `Content-Type`, and a preflight that refuses it\nblocks every part.\n\n### The failure mode, stated plainly\n\n**A browser that cannot reach storage is silent from the API\'s perspective.**\nThe upload session exists, zero parts ever land, and the client cycles\nrunning/stalled as though the network were flaky. To `XMLHttpRequest` a blocked\nPUT \u2014 by your CSP, an extension, a proxy, or (self-hosting) bucket CORS \u2014 is\nindistinguishable from a network error; the browser withholds the detail.\n\n**First move when a browser upload stalls at 0%: open the network tab and look\nat the part PUT** (and its `OPTIONS` preflight), not the Worker.\n\nThis cost real time on the first customer integration, which is why the SDK\nsurfaces it itself. After three consecutive network-class part failures with\n**zero bytes ever confirmed** for the session, it emits:\n\n```js\nupload.on(\'diagnostic\', d => {\n // { code: \'systemic-transport-failure\', consecutiveNetworkFailures, bytesEverConfirmed, hint }\n console.warn(d.hint);\n});\n```\n\nZero-ever-confirmed is what separates this from ordinary mid-upload network loss.\nIt is **advisory, never terminal**: a client that is genuinely offline from byte\nzero looks identical, so the upload keeps retrying regardless. The event is the\nhook for telling the user to check their connection \u2014 or for telling *you* to\ncheck your CSP.\n\n### Token expiry mid-upload\n\nLong uploads outlive their token. Handle it on your server:\n\n```js\nconst upload = createUpload({\n file,\n assetId,\n uploadToken,\n onTokenExpired: async () => {\n const r = await fetch(\'/api/hostr/refresh-token\', {\n method: \'POST\',\n body: JSON.stringify({ assetId }),\n });\n return (await r.json()).uploadToken; // your server calls POST /v1/uploads/:id/token\n },\n});\n```\n\nThe SDK re-mints and retries in place. Nothing is lost even without this \u2014 a\nkilled client resumes from what storage reports on the next launch \u2014 but the hook\navoids the interruption.\n\nSee the [SDK reference](#sdk-reference) for the full option surface.\n\n## Resume and integrity\n\n### Resume asks storage, not the client\n\n```\nGET /v1/uploads/:id\n -> { status, partSize, expectedParts, uploadedParts, parts, bytesUploaded }\n```\n\n`uploadedParts` is part numbers only; `parts` is the richer form, each entry\ncarrying `partNumber`, `size`, and `etag`.\n\nThe API derives this by listing what is actually in storage, not by reading\nbookkeeping the client sent. That is the whole design: a crash, a refresh, a\ndropped connection, or a client that lied all converge on the same truth. Resume\nis simply "send the parts that are not in that list".\n\nParts are listed only while `uploading`. Once completed or aborted the list is\nempty, because there is nothing left to resume.\n\nResume has an outer limit: an upload with no activity for 14 days is collected\nand moved to `aborted`. That window is twice the upload token\'s lifetime, so an\nexpired token never costs you the upload \u2014 re-mint at\n`POST /v1/uploads/:id/token` and carry on. See\n[Assets and lifecycle](#assets-and-lifecycle).\n\n### Three layers, three different claims\n\nEach layer proves something the others do not.\n\n**Per-part ETag.** Storage returns an ETag on every part PUT. The client compares\nit against the part it just sent, so a corrupted or truncated part is caught\nimmediately rather than at the end of a multi-gigabyte upload.\n\n**Part count and total size at completion.** Completion refuses to finalise\nunless the number of parts and the total byte count both match what was declared\nat creation. Count alone would accept N parts of the wrong sizes.\n\n**Whole-file SHA-256, if you send it.** This is the only one that proves content\nidentity.\n\n### Why the ETag is not enough\n\nThe ETag on a completed multipart object is a **hash of the part hashes**, then\nthe part count \u2014 `b8f1c0d9e2a34567-13`. Two consequences:\n\n- It cannot be compared against a hash you compute locally.\n- The same file uploaded with a different `partSize` produces a different ETag.\n\nSo it is a useful transfer check and a useless content identity. Send `sha256`\n(lowercase hex of the whole file) at completion if identity matters; it is stored\nas object metadata.\n\nIndependent verification, deriving both from the bytes actually in storage rather\nthan from anything the client claimed:\n\n```bash\npnpm verify:upload <assetId>\n```\n\n### Completion is idempotent\n\nA client that crashed after the bytes landed but before it saw the response\nretries and gets the stored `etag` and `bytes` back, not a `409`. Any genuinely\nunfinished status still conflicts with `not_uploading`.\n\nThis matters more than it sounds: the window between "storage has everything" and\n"the client knows" is exactly where flaky mobile connections die.\n\n### Aborting\n\n```\nDELETE /v1/uploads/:id\n```\n\nAn in-flight multipart session holds part storage that is invisible in normal\nlistings. Abandoning an upload without aborting leaks it. The SDK and CLI do this\nfor you; a hand-rolled client should too.\n\n## Webhooks and the event log\n\nhostr POSTs lifecycle events to one endpoint per account so you do not have to\npoll.\n\n```\nPUT /v1/webhooks {"url": "https://yourapp.example/hostr-webhook"} (full)\n -> { url, enabled, secret: "whsec_..." } # secret returned ONLY when generated, ONLY once\nGET /v1/webhooks -> { url, enabled, createdAt } # never the secret\nDELETE /v1/webhooks -> disables; delivery history is retained\n```\n\nSupply `secret` in the PUT to bring your own. Omit it and a **live endpoint\nkeeps the secret it already has**, so changing the URL or re-running setup never\nbreaks signature checks; a new endpoint (or one switched off with DELETE) gets a\ngenerated one. `rotate: true` (CLI: `--rotate`) replaces a live secret \u2014 deploy\nthe new value promptly, since deliveries are signed with it at once. Configuration is cached about 60 seconds in the Worker, the same\npropagation window as key revocation.\n\nA URL configured without a secret logs and skips: **unsigned events are never\nsent.**\n\n### The log is written first\n\nEvery event becomes a durable row **before** delivery is attempted. A consumer\noutage cannot lose one.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n**The webhook is the wake-up signal; the log is the ledger.** Delivery is\nat-least-once \u2014 three attempts with short backoff, each recorded and inspectable.\nA consumer that must not miss anything reconciles against `GET /v1/events` (or\n`GET /v1/assets/:id`) rather than trusting that every POST arrived.\n\n`npx @hostr/cli tail --follow` polls the log for you.\n\n### Building a consumer that behaves\n\n**Verify the signature before parsing.** See\n[Verifying webhooks](#verifying-webhook-signatures) \u2014 use the raw body bytes.\n\n**Dedupe on `id`.** It is the durable event row\'s id, and a redelivery reuses it\ndeliberately so your dedupe still works.\n\n**Respond `2xx` fast.** Do the real work asynchronously. Anything else is\nretried.\n\n**Expect at-least-once, and design for it.** `video.ready` and `video.errored`\nfire on the status *transition* only, so a transcoder re-posting a terminal state\ncannot duplicate them \u2014 but the network can still deliver the same event twice.\n\n### Testing locally\n\nBoth `https` and `http` endpoints are accepted, including private addresses.\nhostr does not block internal ranges, so a local receiver works in development\nexactly as production does.\n\n```bash\nnpx @hostr/cli webhooks test # redelivers the newest event to your endpoint\n```\n\nSee the [events reference](#event-reference) for every type and payload.\n\n## Verifying webhook signatures\n\nEvery delivery carries a `hostr-signature` header:\n\n```\nhostr-signature: t=1756130400,v1=3b1f2c9d8e7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c\n```\n\n`v1` is `HMAC-SHA256(secret, "<t>.<rawBody>")`, hex-encoded. `t` is a unix\ntimestamp in **seconds**. This is the Stripe scheme, and Mux\'s.\n\n### Implementation\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\n\nexport function verifyHostrWebhook(secret, header, rawBody, toleranceSec = 300) {\n const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? \'\');\n if (!m) return false;\n if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false;\n const expected = createHmac(\'sha256\', secret).update(`${m[1]}.${rawBody}`).digest(\'hex\');\n return expected.length === m[2].length &&\n timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));\n}\n```\n\nWired into Express, where getting the raw body needs care:\n\n```js\nimport express from \'express\';\n\nconst app = express();\n\napp.post(\'/hostr-webhook\',\n express.raw({ type: \'application/json\' }), // NOT express.json()\n (req, res) => {\n const raw = req.body.toString(\'utf8\');\n if (!verifyHostrWebhook(process.env.HOSTR_WEBHOOK_SECRET, req.get(\'hostr-signature\'), raw)) {\n return res.status(400).end();\n }\n const event = JSON.parse(raw); // parse only after verifying\n res.status(200).end(); // ack fast\n queue.push(event); // then do the work\n });\n```\n\n### The three ways this goes wrong\n\n**Using the parsed body instead of the raw bytes.** `JSON.parse` followed by\n`JSON.stringify` does not reproduce the original bytes \u2014 key order, whitespace,\nand number formatting can all shift. The HMAC is over what was actually sent.\nRegister the raw body parser on this route specifically.\n\n**A plain `===` on the hex digest.** String comparison short-circuits on the\nfirst differing character, which leaks timing information. Use\n`timingSafeEqual`, and compare lengths first \u2014 it throws on mismatched lengths.\n\n**Ignoring `t`.** Without a bounded timestamp window a captured delivery can be\nreplayed at any time in the future. Five minutes is a reasonable tolerance.\n\n### Do not confuse the units\n\n`t` is unix **seconds**. Elsewhere in the API, `tokenExpiresAt` and `expiresAt`\nare epoch **milliseconds**. That is deliberate \u2014 each convention matches its home\n(Stripe\'s for signatures, Mux\'s for token bundles) rather than being made falsely\nuniform. Mixing them up produces a timestamp roughly 55,000 years out, which at\nleast fails loudly.\n\n### Getting the secret\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nWritten straight to the file at mode 0600; only the variable name is printed. The\nsecret is returned **once**, in the `PUT /v1/webhooks` response, and never again\n\u2014 `GET /v1/webhooks` deliberately does not include it. Lost it? Set a new one.\n\n### Proving it end to end\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\nRedelivers the newest stored event to your endpoint, so you can confirm the whole\npath without uploading anything.\n\nIf your endpoint was down when an event fired, nothing was lost \u2014 see\n[Webhooks and the event log](#webhooks-and-the-event-log).\n\n## Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `playback`-scoped\nkey.\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"vp_5b0e2a7c9d1f4e83"} (body optional)\n -> { playback, thumbnail, storyboard, expiresAt }\n```\n\n```bash\nnpx @hostr/cli assets token <assetId> --json\n```\n\nField names mirror Mux\'s bundle, so a consumer migrating swaps its base URL and\nkeeps its client code.\n\n### Build the URL\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\nHand it to any HLS player. Safari and iOS play HLS natively; elsewhere use\nhls.js.\n\n```html\n<video id="v" controls></video>\n<script type="module">\n import Hls from \'https://cdn.jsdelivr.net/npm/hls.js@1/+esm\';\n const src = `https://stream.hostr.sh/${playbackId}.m3u8?token=${token}`;\n const v = document.getElementById(\'v\');\n if (v.canPlayType(\'application/vnd.apple.mpegurl\')) v.src = src;\n else if (Hls.isSupported()) { const h = new Hls(); h.loadSource(src); h.attachMedia(v); }\n</script>\n```\n\n### Mint timing, and what to gate on\n\nMinting works any time after the asset exists \u2014 it 404s only for unknown or\ndeleted assets. **The stream itself 404s until the asset is `ready`.**\n\nSo gate your UI on `video.ready` or the asset\'s status, never on whether minting\nsucceeded. A successful mint for a still-encoding asset is expected behaviour,\nnot a green light.\n\n### The TTL is not a viewing limit\n\nThe token is checked **once**, at the master playlist. After that the session\ncarries its own internally-managed credentials, so a viewer who starts playback\nat minute 59 of a one-hour token watches the whole video.\n\n`ttlSec` accepts 60 to 86,400 and defaults to 3,600. It only needs to be valid\nwhen playback *starts*.\n\n### viewerId\n\nOptional, opaque, and carried inside the token. It enables per-viewer concurrency\nobservation and attributes watch data to a viewer. Concurrency is currently\nobserved rather than enforced \u2014 see [Tokens and signed playback](https://docs.hostr.sh/concepts/playback/).\n\n**Send a pseudonym, never a user id or email.** The token sits in playback\nURLs, and anyone holding a URL can read its claims. Use a random id you store\nagainst the user (for example a UUID column) and send that. It stays stable\nfor the user, so watch history stays joined, and deleting it erases the link\nback to the person. Do not derive it from the user id with a hash: anyone who\ncan guess user ids can recompute it, and it cannot be erased per user. Choose\nbefore you start sending one, because changing it later splits that viewer\'s\nhistory in two. Omit it for signed-out viewers; they are counted, not\nattributed.\n\n### Things that will confuse you once\n\n**Non-browser user agents are rejected** with `403 blocked_user_agent` \u2014\ncurl, wget, yt-dlp, vlc, and ffmpeg UAs are blocked as a casual-ripping speed\nbump. A server-side smoke test must send a browser-like `User-Agent` or hit\n`/health`, which is exempt. Expect it; do not debug it.\n\n**Signed URLs are not DRM.** They stop link-sharing and hotlinking, not an\nentitled viewer recording their screen.\n\n**Deleted assets stop minting immediately**, but tokens already issued can replay\nedge-cached segments for a few hours until they expire.\n\n### Poster and scrub previews\n\nThe same mint response covers both, each URL taking its matching token:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSee [Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n### Audio only, for transcription\n\nThe `playback` token also opens an audio-only file, available seconds into the\nencode \u2014 before the asset is playable:\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nSee [Audio for speech-to-text](#audio-for-speech-to-text).\n\n## Thumbnails and storyboards\n\n`POST /v1/assets/:id/tokens` returns three tokens, not one. The `thumbnail` and\n`storyboard` values drive the poster image and the scrub preview.\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSame host, same query-token shape as the playlist. Each URL takes its **own**\ntoken \u2014 they are minted with different audiences and are not interchangeable.\n\n### Poster\n\nA single JPEG extracted during encoding. Use it as the `poster` attribute:\n\n```html\n<video poster="https://stream.hostr.sh/abc123/thumbnail.jpg?token=..." controls></video>\n```\n\n### Choosing the poster frame\n\nThe encode-time poster is a frame 10% in \u2014 a fine default and, often enough,\nthe wrong picture. `POST /v1/assets/:id/poster` re-takes it from any second:\n\n```json\nPOST /v1/assets/{id}/poster\n{ "time": 12.5 }\n\n202 { "assetId": "...", "rev": "mfc9z0aa", "time": 12.5, "queued": true }\n```\n\nThe frame is cut from the best rung the ladder has published \u2014 a `playable`\nasset works, at the quality it has so far \u2014 and lands under its own immutable\nkey. `thumbnail.jpg` keeps its URL and changes content: wait for\n`video.poster_ready` (it carries the same `rev`), then **mint a fresh token\nbundle** \u2014 a `thumbnail` token minted before the job landed keeps serving the\nold picture until it expires. Store a copy if you serve posters from your own\nCDN; that is what the event is for.\n\nPoster jobs run on their own lane: a running worker takes them beside\nwhatever it is encoding, so a frame never waits behind a ladder. What it can\nwait behind is a cold start \u2014 the fleet scales to zero and a machine is about\nseven seconds from taking a job. `POST /v1/assets/:id/poster/warm` starts one\nahead of time; call it when a user opens your thumbnail editor and the frame\nthey pick a moment later is seconds away. It is a no-op while any machine is\nup, and costs one machine\'s idle window when nothing follows.\n\nThe record says which frame is live (`posterRev`, `posterTime`) and whether a\njob is running (`poster`, `null` once it lands). One job at a time per asset;\na failed or ten-minute-stale request no longer blocks the next one. There is\nno way to render an arbitrary frame on request \u2014 the media host has no\ndecoder \u2014 so build a scrubber on the storyboard below and confirm with one\ncall, rather than previewing through this endpoint.\n\n### Storyboard\n\nA standard "thumbnails" WebVTT track. Cues point into a tiled sprite using\n`#xywh=` fragments, and **the sprite URLs inside the VTT are pre-signed for you**\n\u2014 you do not mint anything extra.\n\nThat is the convention mux-player\'s `storyboard-src`, media-chrome\'s preview\ntrack, and most other players already consume:\n\n```html\n<mux-player\n storyboard-src="https://stream.hostr.sh/abc123/storyboard.vtt?token=..."\n></mux-player>\n```\n\n### Treat both as optional\n\nAssets encoded before storyboards existed 404 on that URL, and scrubbing simply\nshows no preview. Players handle this gracefully by design \u2014 do not add error\nhandling that turns a missing preview into a broken player.\n\n## Audio for speech-to-text\n\nIf you want hostr to do the transcription, see\n[Auto-captions](#auto-captions-and-translation). This page is for bringing your own\ntranscriber.\n\nTranscription services want one audio file URL, not an HLS playlist. hostr\nmakes one per asset \u2014 `audio.m4a`, a single AAC track in an MP4 container \u2014\nand makes it **first**, ahead of every video rendition. A twenty-minute\nsource has its audio file within seconds of the job starting, minutes before\n`video.playable`.\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nIt takes the **`playback`** token from `POST /v1/assets/:id/tokens` \u2014 the same\none that opens the master playlist. There is no fourth token to mint.\n\n### When it exists\n\nListen for **`video.audio_ready`**. It fires once, the moment the file is in\nthe media bucket, and carries `assetId`, `playbackId`, `durationSec` and an\n`audio` block (`bytes`, `codec`, `channels`, `sampleRate`, `copied`).\n\n```json\n{\n "id": "evt_01j\u2026",\n "type": "video.audio_ready",\n "createdAt": "2026-09-08T10:14:07.412Z",\n "data": {\n "assetId": "9f8c1e2a\u2026",\n "playbackId": "9f8c1e2a\u2026",\n "status": "processing",\n "durationSec": 1842.4,\n "audio": { "bytes": 29478912, "codec": "aac", "channels": 2, "sampleRate": 48000, "copied": true }\n }\n}\n```\n\nThe event also carries `loudness` \u2014 the probe\'s EBU R128 measurement of the\nsource, see [the ladder](https://docs.hostr.sh/concepts/ladder/#loudness-is-measured-for-every-asset).\n\nThe same block is on the asset record as `audio`. It is **absent** until the\ntranscoder has looked at the source and **`null`** when the source has no\naudio stream \u2014 in which case `video.audio_ready` never fires, and a poller can\ntell "no audio" from "not yet" without waiting.\n\nBefore the file exists the URL is a plain `404`.\n\n### Assets you uploaded before this existed\n\nEvery upload since 2026-09-09 gets its audio file automatically. Assets\nencoded before that have none, and nothing will produce one on its own \u2014 so a\ncatalogue uploaded before you added transcription cannot be captioned until\nyou ask for it. Ask once per asset:\n\n```\nPOST /v1/assets/{id}/audio\n```\n\nIt requires the `upload` scope (this is content generation, the same rung as\nattaching a subtitle track) and answers `202 {"assetId": "\u2026", "queued": true}`.\nThe job publishes `audio.m4a` and nothing else \u2014 no re-encode of the ladder,\nno new playlists, no change to the asset\'s status \u2014 and then fires\n`video.audio_ready` exactly as a fresh upload would, so the consumer you\nalready wrote needs no second code path.\n\nTwo refusals, both `409`: `already_has_audio` when the record already carries\nan `audio` block, and `not_encoded` when the asset has no finished source\nobject yet (an upload still in flight, or aborted). Calling it twice is safe\nregardless \u2014 a job that finds the file already in the bucket reports it\ninstead of re-encoding.\n\nTo sweep a whole account, the CLI does the paging and the per-asset check:\n\n```bash\nnpx @hostr/cli assets audio --missing # every playable/ready asset with no audio\nnpx @hostr/cli assets audio <assetId> # just this one\n```\n\n`--missing` skips assets whose `audio` is `null` \u2014 the transcoder already\nlooked and the source is silent, so there is nothing to extract.\n\n### Handing it to a transcriber\n\nMint a token whose lifetime covers the fetch, then pass the URL straight\nthrough. AssemblyAI, for example:\n\n```bash\nTOKEN=$(npx @hostr/cli assets token "$ASSET" --ttl 3600 --json | jq -r .playback)\n\ncurl -X POST https://api.assemblyai.com/v2/transcript \\\n -H "authorization: $ASSEMBLYAI_API_KEY" \\\n -H \'content-type: application/json\' \\\n -d "{\\"audio_url\\":\\"https://stream.hostr.sh/$ASSET/audio.m4a?token=$TOKEN\\"}"\n```\n\nThe token only has to be valid when the fetch **starts**, but give it a few\nminutes of headroom: services queue downloads. `Range` requests are honoured\n(`206`, `Accept-Ranges: bytes`), so a fetcher that streams or resumes works.\n\n**The user-agent blocklist does not apply to this URL.** Playlists reject\ncurl-like user agents as a casual-ripping speed bump; a transcription\nservice\'s fetcher is exactly the kind of client that rule would catch, so the\naudio file is exempt. The token is still required.\n\n### What is in the file\n\n- **AAC source, no normalisation** \u2014 the stream is copied untouched: the\n source\'s channel count, sample rate and bitrate, no decode. `copied: true`.\n- **Anything else** \u2014 encoded once with the ladder\'s own audio settings (AAC,\n 48 kHz, the top group\'s bitrate, the same filter chain), so what the\n transcriber hears is what the viewer hears. `copied: false`. An asset with\n [normalisation](https://docs.hostr.sh/concepts/ladder/#audio-options) on is always this case:\n the file must carry the same gain the viewer gets.\n\nEither way the `moov` atom is at the front, and the object is versioned with\nthe asset and immutable.\n\n### Then attach the result\n\nWhen the transcript comes back, attach it with\n`PUT /v1/assets/:id/subtitles/:lang` (raw WebVTT). It is accepted as soon as the\nasset has a known duration, which `video.audio_ready` guarantees \u2014 so a\ntranscript that comes back before the asset is `playable` attaches without\nwaiting or retrying. See [Subtitles](#subtitles).\n\n## Subtitles\n\nHuman-authored WebVTT tracks, attachable any time after the asset is `ready`.\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { assetId, tracks: [{ lang, name, default, rev, source? }] }\nDELETE /v1/assets/:id/subtitles/:lang\n```\n\n```bash\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\nnpx @hostr/cli subtitles list <assetId>\nnpx @hostr/cli subtitles rm <assetId> en\n```\n\nThe PUT body is **raw WebVTT, not JSON**. It must start with `WEBVTT`, and is\ncapped at 2,000,000 bytes.\n\n```bash\ncurl -X PUT "https://api.hostr.sh/v1/assets/$ASSET/subtitles/en?name=English&default=true" \\\n -H "authorization: Bearer $HOSTR_API_KEY" \\\n -H \'content-type: text/vtt\' \\\n --data-binary @subs.vtt\n```\n\n### Rules\n\n**`:lang` is a BCP 47 tag** \u2014 `en`, `pt-BR`. One track per tag; PUT replaces.\nAnything that is not a valid tag is `400 bad_language`.\n\n**The asset must have a known duration.** Before that it is `409 not_ready` \u2014\nthe track playlist needs it. It is known from `video.audio_ready` (seconds\ninto the job, once the transcoder has probed the source) and from `playable`\nonwards; a track attached while the ladder is still encoding appears the\nmoment the master playlist is served, because tracks are injected at serve\ntime, not baked into the encode.\n\n**`source` says who wrote it.** Absent on your tracks; `auto` / `auto-translated` on\nmachine-made ones (see [Auto-captions](#auto-captions-and-translation)). A PUT always makes the\ntrack yours.\n\n**`default=true` demotes any previous default.** At most one default per asset.\n\n**Write scope is `upload`, not `playback`.** Subtitles are content, not\nentitlement.\n\n### How they reach the player\n\nTracks are injected into the master playlist at serve time as an `EXT-X-MEDIA`\nsubtitles group. The next master fetch after a PUT includes them.\n\n**No re-encode. No new playback URL. No token change.** Players pick them up in\nthe captions menu automatically.\n\nTracks are stored under a content-hash revision, so replacing one is cache-safe\neven though segments are served with a one-year immutable cache. A viewer already\nmid-session keeps the track they started with; the replacement applies to the next\nplayback session.\n\n### Deleting\n\nRemoval delists the track first \u2014 that is what makes it invisible to players. The\nunderlying versioned objects become unreachable and are purged with the asset, so\nleftovers cost nothing.\n\n`404 subtitle_not_found` means no track for that tag; `404 asset_not_found` means\nno such asset. They are distinct codes.\n\n## Auto-captions and translation\n\nhostr transcribes an asset\'s audio and attaches the result as an ordinary\n[subtitle track](#subtitles), then translates it into the languages\nyou name. Nothing re-encodes; players pick the tracks up from the master\nplaylist like any other.\n\n```\nPUT /v1/account/captions { "enabled": true } \u2014 turn captions on (full scope); off by default\nPOST /v1/uploads { ..., "captions": { "language": "auto", "translate": ["en","es"], "context": "Creator: Ana" } }\nPOST /v1/renders same field\nPOST /v1/assets/:id/render same field \u2014 applied when the new cut lands\nPOST /v1/assets/:id/captions transcribe now, or redo (body optional)\nPOST /v1/assets/:id/captions/translate { "languages": ["fr"] } \u2014 from the stored transcript\nGET /v1/assets/:id the `captions` block: transcript + per-language state\nGET /v1/assets/:id/subtitles tracks carry `source`: absent (yours), `auto`, `auto-translated`\n```\n\nWriting captions \u2014 the `captions` field and both `POST` routes \u2014 needs `upload`\nscope: captions are content, not entitlement. Reading them back\n(`GET /v1/assets/:id`, `GET \u2026/subtitles`) needs only `read`.\n\n### Turn them on\n\nCaptions are **off** for a new account, because they cost money. Turn them on\nonce, for the whole account:\n\n```\nPUT /v1/account/captions { "enabled": true } on, no monthly limit\nPUT /v1/account/captions { "enabled": true, "monthlyMinutes": 600 } on, at most 600 transcription minutes a month\nPUT /v1/account/captions { "enabled": false } off (forgets the limit)\n```\n\nThe same switch is on the dashboard\'s Usage page and in the CLI\n(`hostr captions enable [--limit <minutes>]`, `hostr captions disable`,\n`hostr captions info`). It needs a `full` key: turning on spending is an\naccount decision, the same rung as keys and webhooks. An agent integrating\nhostr should ask its human before turning captions on, not do it unasked.\nThe answer is the new allowance (below). The change reaches every API edge\nwithin a minute.\n\nTranscription is billed per minute of audio, and each translation per minute\nper language ([Billing](#billing) has the prices; `GET /v1/billing`\nlists them). Runs sit behind the same card gate as uploads. Turning captions\noff refuses new runs; runs already submitted finish and are billed.\n\n### When it runs\n\nWith `captions` on the upload, transcription starts the moment `audio.m4a`\nexists \u2014 seconds into the encode, minutes before `video.playable` \u2014 so on a\nnormal source the tracks are there before the video is. Translations follow\none by one as `captions.ready` events; the source language lands first.\n\n`POST /v1/assets/:id/captions` runs any time the asset has audio. Unlike Mux\'s\nseven-day window on generated subtitles, there is no time limit. With no body it\nreuses what was asked for last time. It answers:\n\n```\n202 { "assetId": "\u2026", "rev": "mfc9z0aa", "queued": true }\n```\n\n`rev` is the new run\'s id. Keep it: every `captions.*` event carries the `rev`\nof the run it is about, and an event whose `rev` is not the one you hold is\nfrom a run this one replaced.\n\n`context` is free text handed to the transcriber to help with names and\nvocabulary \u2014 creator, title, domain terms. It is trimmed and cut at **1000\ncharacters**; a longer value is truncated, not refused.\n\nOnly one run exists per asset at a time. While a run is at the transcription\nprovider, a second `POST` answers `409 captions_in_progress`. So does one that\narrives within two minutes of a run starting, before it has reached the\nprovider, and so does the loser of two simultaneous requests. A run that never\nproduces a transcript is failed with `asr_failed` after two hours, which\nreleases the asset for a new one.\n\nA transient answer from the provider (a rate limit, a 5xx) is not a failure:\nthe completion is retried by the provider\'s callback or by hostr\'s five-minute\npoll, and nothing is reported until the provider says the job failed or the two\nhours are up.\n\n### It is metered\n\nEvery run counts the source duration as **transcription minutes** for the UTC\ncalendar month. Translation is billed separately, per language-minute\n(`translationSeconds` on `GET /v1/usage`), and the monthly limit does not count\nit. `GET /v1/account` shows\n`captions: { minutesUsed, minutesCap, resetsAt }`, and so does the dashboard\'s\nUsage page. `minutesCap` has three meanings:\n\n| `minutesCap` | Meaning |\n|---|---|\n| `0` | Captions are off for the account. Turn them on with `PUT /v1/account/captions`. |\n| `null` | Captions are on with no monthly limit. Every minute is still counted. |\n| a number | Captions are on, up to that many minutes this month. |\n\nThe cap is only a ceiling; `minutesUsed` (and the daily `captionSeconds` on\n`GET /v1/usage`) is the count either way.\n\nThe explicit routes refuse **before** spending: `403 captions_cap_exceeded`\n(or `captions_not_enabled`) with `minutesUsed`, `minutesCap` and `resetsAt` in\nthe body. `POST \u2026/captions/translate` refuses `captions_not_enabled` the same\nway. The upload path cannot know the duration yet, so there the cap refusal is\nasynchronous: `captions.errored` with `reason: cap_exceeded`, and the `captions`\nblock on the asset says the same. A key whose account has captions off (cap 0)\nis refused at create time on `POST /v1/uploads`, `POST /v1/renders` and\n`POST /v1/assets/:id/render` with `403 captions_not_enabled`, and the same\nthree fields. A `captions` value that is not an object (`null` included) is\n`400 bad_captions`.\n\n### Your tracks win\n\nOne track per language tag. A track you upload with\n`PUT /v1/assets/:id/subtitles/:lang` has no `source` and is yours: a redo never\nreplaces it, and a requested language it owns is reported in `skippedLangs`\nrather than written. That is the correction loop: download the auto VTT, fix\nit, PUT it back, and it stays fixed through every redo. PUT over an auto track\nmakes it yours.\n\nThis is enforced when the track is written, not only when the run is planned: a\ntrack you upload while a translation is running is never replaced, and the run\nreports that language in `skippedLangs` instead.\n\nAfter a [re-edit](https://docs.hostr.sh/concepts/assets/#editing-renders) the stored request is\napplied again under a new run, because the old captions are timed to the old\ncut. A track you corrected is still skipped \u2014 the `captions.ready` event\'s\n`skippedLangs` tells you it is now mistimed and needs your attention. A plain\nre-encode changes no timing and does nothing to captions.\n\n### Languages\n\n`language` is `auto` (detected) or any BCP 47 tag you know the speech is in.\n`translate` takes any tags, at most 10 per request; a target in the source\nlanguage is dropped \u2014 any variant of it, so from Spanish speech `es-MX` is\ndropped too (that is localisation, not translation). The detected source track\nis written under the primary tag (`en`, not `en-US`): the transcriber detects\nlanguages, not regional variants. A declared one is written under exactly what\nyou said (`pt-BR`).\n\nA translated track\'s region comes only from the tag you ask for: `es-MX`,\n`es-419` (Latin America) and `es-ES` are three separate tracks, and the target\n\u2014 region included \u2014 is named to the translation model. One Spanish target is\nusually enough; `es-419` is the common choice for a Latin American audience.\n\nTrack names are the language\'s own name plus the suffix, with a region or\nscript named alongside it so variants are told apart in the player menu:\n`Espa\xF1ol (auto)`, `English (auto-translated)`, `Espa\xF1ol (M\xE9xico, auto-translated)`,\n`Portugu\xEAs (Brasil, auto)`, `\u4E2D\u6587 (\u7E41\u9AD4, auto-translated)`.\n\n### Reading progress: the `captions` block\n\n`GET /v1/assets/:id` carries the asset\'s latest run as `captions` (absent if\ncaptions were never requested). A new run, a redo or a re-edit replaces it.\n\n```json\n"captions": {\n "rev": "mg3k1z9q",\n "requestedAt": "2026-10-09T15:00:00.000Z",\n "request": { "language": "auto", "translate": ["en", "es"], "context": "Creator: Ana" },\n "transcript": { "status": "ready", "lang": "pt", "durationSec": 1842.4, "attempts": 1,\n "submittedAt": "2026-10-09T15:00:04.000Z", "finishedAt": "2026-10-09T15:02:11.000Z" },\n "tracks": {\n "pt": { "status": "ready", "source": "auto", "name": "Portugu\xEAs (auto)", "rev": "9c1e2a4b6d47", "attempts": 0, "updatedAt": "\u2026" },\n "en": { "status": "ready", "source": "auto-translated", "name": "English (auto-translated)", "rev": "b6d47f0a1b2c", "attempts": 1, "updatedAt": "\u2026" },\n "es": { "status": "pending", "source": "auto-translated", "attempts": 1, "updatedAt": "\u2026" }\n },\n "skippedLangs": [],\n "inFlight": true\n}\n```\n\n| Field | Meaning |\n|---|---|\n| `rev` | The run\'s id. Every `captions.*` event carries it; ignore events whose `rev` is not the asset\'s current one. |\n| `request` | What was asked for. A redo with no body, and every re-edit, reuses it. |\n| `transcript.status` | `pending` (waiting for the audio file), `submitted` (at the transcription provider), `ready`, or `failed`. |\n| `transcript.lang` | The spoken language, as the source track\'s tag. Detected languages use the primary tag (`en`, not `en-US`). |\n| `transcript.reason`, `transcript.error` | On `failed`: why. `reason` is one of the codes under [Events](#events). |\n| `tracks.<lang>.status` | `pending` (being made), `ready` (serving), or `failed` (`error` says why). |\n| `tracks.<lang>.source` | `auto` for the spoken language, `auto-translated` for the rest. |\n| `tracks.<lang>.rev`, `name` | The subtitle track\'s content revision and menu label, once written. |\n| `tracks.<lang>.attempts` | Translation attempts so far. |\n| `skippedLangs` | Requested languages left alone because a track you uploaded owns them. |\n| `inFlight` | `true` while the transcript or any track is still owed. Stop polling when it is `false`. |\n\nOn `GET /v1/assets/:id/subtitles`, a track\'s `source` is the same marker:\n`auto`, `auto-translated`, or absent. **Absent means hostr treats the track as\nyours**, including any machine tracks you attached with your own pipeline\nbefore you used hostr captions. A redo will not replace those. To have hostr\ntake a language over, delete that track first\n(`DELETE /v1/assets/:id/subtitles/:lang`), then redo.\n\n### From the terminal\n\n```bash\nnpx @hostr/cli upload video.mp4 --captions --translate en,es --context "Creator: Ana"\nnpx @hostr/cli captions status <assetId> # the captions block, summarised\nnpx @hostr/cli captions request <assetId> # redo with the stored request\nnpx @hostr/cli captions request <assetId> --lang pt --translate en,es\nnpx @hostr/cli captions translate <assetId> --lang fr,de\n```\n\nEvery command takes `--json`. `captions request` and `captions translate`\nneed `upload` scope.\n\n### Errors\n\n| Status | `error` | When |\n|---|---|---|\n| 400 | `bad_captions` | The body or `captions` field is malformed (`hint` says which), `null`, or a `languages` list is empty or invalid |\n| 403 | `captions_not_enabled` | Captions are off for the account (`minutesCap: 0`). Body carries `minutesUsed`, `minutesCap`, `resetsAt` |\n| 403 | `captions_cap_exceeded` | This run would pass the monthly cap. Same three fields |\n| 409 | `not_ready` | No audio yet \u2014 wait for `video.audio_ready` |\n| 409 | `no_audio` | The source has no audio stream |\n| 409 | `captions_in_progress` | A run is at the provider, started under two minutes ago, or a concurrent request won |\n| 409 | `no_transcript` | `\u2026/translate` before a transcript exists |\n| 409 | `asset_unowned` | The asset has no owning account |\n| 502 | `asr_submit_failed` | The provider refused the submission (or could not be reached); `hint` carries its error. Nothing was metered for it. A submission the provider *accepted* is metered even if another request replaced the run meanwhile \u2014 that answers `409 captions_in_progress`, not 502 |\n| 503 | `captions_unavailable` | No transcription provider on this deployment |\n| 503 | `translate_unavailable` | No translation queue or provider on this deployment |\n\n### Events\n\n| Type | When | `data` |\n|---|---|---|\n| `captions.ready` | the transcript step finished, then once per translated track | `assetId`, `playbackId`, `rev`, `lang`, `source`, `name`, `written` (false when a manual track owned the slot), `trackRev?`, `refusedCues?` (lines shipped as a placeholder), `skippedLangs`, `pending` (languages still to come) |\n| `captions.errored` | a run or one language failed | `assetId`, `rev`, `lang?`, `reason`, `error`, and on a cap refusal `minutesUsed`, `minutesCap`, `resetsAt` |\n\n`reason` is one of `not_enabled`, `cap_exceeded`, `no_audio` (silent source, or\nthe transcode failed first), `provider_unavailable`, `asr_failed`,\n`translate_unavailable`, `translate_failed`, `translate_refused`. A failed\nlanguage never blocks the others.\n\nSometimes the translation model declines a line and writes a placeholder such\nas `[Omitted]` in its place. hostr retries the language when that happens,\nand a retry usually translates it. If the last attempt still declines lines,\nthe track ships anyway with the placeholders in it, and says so: its `tracks`\nentry and its `captions.ready` event carry `refusedCues`, the number of\nlines left as placeholders. Ask again with\n`POST /v1/assets/:id/captions/translate`, or upload that language yourself.\n`translate_refused` is the failure for a last attempt the model declined\noutright, with no lines to ship.\n\n`captions.errored` fires **at most once per run-level failure** (the transcript\nstage; it has no `lang`) and **once per failed language**. A second report of\nthe same failure, such as the provider\'s callback and hostr\'s poll both seeing\nit, does not repeat it.\n\n### What is stored\n\nThe transcript and the source VTT are kept with the asset, so\n`POST /v1/assets/:id/captions/translate` never transcribes again. Both are\npurged with the asset.\n\nTwo third parties see your content. The audio goes to the transcription\nprovider (AssemblyAI). Translation sends the transcript\'s text, cue by cue, to\nan LLM provider (Anthropic). The `context` string goes only to the\ntranscription provider, never to the translation model.\n\n## Event reference\n\nDelivered body: `{ id, type, createdAt, data }`. `id` is the durable event row\'s\nid \u2014 **dedupe on it**, and note that a redelivery reuses it deliberately.\n\n| Type | Fired when | `data` highlights |\n|---|---|---|\n| `upload.created` | Multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `upload.aborted` | Session ended without completing | `assetId`, `reason` (`client_abort` \\| `abandoned`), `error?` |\n| `video.uploaded` | Bytes verified in storage, transcode queued \u2014 or a render\'s join is ready | `assetId`, `key`, `bytes`, `etag?`, `sha256?`, `rendered?` |\n| `video.audio_ready` | `audio.m4a` exists \u2014 seconds in, before anything is playable | `assetId`, `playbackId`, `durationSec`, `audio`, `loudness` |\n| `video.playable` | Watchable at one quality, ladder still encoding | `assetId`, `playbackId`, `durationSec?`, `renditions?`, `loudness?` |\n| `video.ready` | Full HLS ladder live | `assetId`, `playbackId`, `durationSec?`, `renditions?`, `loudness?` |\n| `video.errored` | Transcode failed | `assetId`, `error` (always present) |\n| `video.poster_ready` | A chosen poster frame is live behind `thumbnail.jpg` (`POST /v1/assets/:id/poster`) | `assetId`, `playbackId`, `rev`, `time` |\n| `captions.ready` | One language finished (one event per language, the source language first): its track landed, or \u2014 `written: false` \u2014 a track you uploaded owned the slot and was kept | `assetId`, `playbackId`, `rev`, `lang`, `source`, `name`, `written`, `trackRev?`, `skippedLangs`, `pending` |\n| `captions.errored` | A captions run, or one language of it, failed. At most once per run-level failure, once per failed language | `assetId`, `rev`, `lang?`, `reason`, `error`, and on a cap refusal `minutesUsed`, `minutesCap`, `resetsAt` |\n| `video.deleted` | Asset erased, playback over | `assetId` |\n| `video.input.non_standard` | The source needed a judgement call | `assetId`, `input`, `findings[]` |\n\n`upload.aborted`, `video.playable`, `video.ready` and `video.errored` fire on the\nstatus **transition** only, so a repeated abort, or a transcoder re-posting a\nstate, cannot duplicate them.\n\n`upload.aborted` has one meaning \u2014 this upload ended without completing \u2014 and\n`reason` says how it got there: `client_abort` for `DELETE /v1/uploads/:id`, or\n`abandoned` for an upload the platform collected after 14 days idle (see\n[Assets and lifecycle](#assets-and-lifecycle)). Treat both as terminal; the reason\nis for your own reporting, not for deciding whether the upload is over.\n\n`video.audio_ready` fires **once per asset**, on the first report that carries\nthe audio file, and never for a source with no audio stream \u2014 the record\'s\n`audio` field is `null` there. It is the cue to start speech-to-text: see\n[Audio for speech-to-text](#audio-for-speech-to-text).\n\n`loudness` is the probe\'s EBU R128 measurement of the source and the gain the\nencode applied \u2014 `integratedLufs`, `loudnessRangeLu`, `truePeakDbtp`, `mono`,\n`normalization` \u2014 for creator warnings and playback-side gain. `null` on a\nsource with no audio stream. See\n[the ladder](https://docs.hostr.sh/concepts/ladder/#loudness-is-measured-for-every-asset).\n\n`video.playable` carries the same fields as `video.ready`, so publishing as early\nas possible is a matter of switching which one you listen for. Its `renditions`\nlists the single quality available so far; `video.ready` then lists the full\nladder. `video.errored` fires only when nothing is watchable \u2014 a ladder that\nfails after the asset is already playable does not fire it.\n\n`error` on `video.errored` is always present. An optional error field makes every\nconsumer invent its own generic message, which is worse than a bad message.\n\n`video.ready` fires **once more** when an asset is\n[re-encoded](https://docs.hostr.sh/concepts/assets/#re-encoding), carrying `reencoded: true` and the\nnew `rev`. Same shape, same meaning \u2014 a full ladder is live \u2014 so a consumer that\nonly stores "ready" needs no change; one that caches a poster should refresh it.\nA [re-edit](https://docs.hostr.sh/concepts/assets/#editing-renders) landing also carries\n`rendered: true`: the content changed, so its duration may have too, and\ncaptions made for the old version no longer line up.\n\n### captions.*\n\nMachine captions: `captions.ready` fires once for the transcribed language and once\nper translated track; `captions.errored` for a failed run or a failed language. `rev`\nidentifies the run. Fields, `reason` values and the metering rules are in\n[Auto-captions and translation](https://docs.hostr.sh/guides/captions/#events).\n\n### video.input.non_standard\n\nAdvisory. The transcode proceeds normally and a `video.ready` still follows. It\nfires **at most once per asset**, and only when there is something to say \u2014 a\nfile with nothing unusual about it generates no traffic.\n\n| Finding code | Meaning |\n|---|---|\n| `hdr_tags_on_8bit` | Source carries HLG/PQ tags but is 8-bit. HDR is a 10-bit format, so the tag is stale \u2014 treated as SDR, pixels untouched |\n| `hdr_source` | Genuine 10-bit HLG or PQ HDR. Converted to 8-bit BT.709 SDR before the ladder split; HDR is not delivered (assets encoded before 2026-09-10 were relabelled instead \u2014 [re-encode](https://docs.hostr.sh/concepts/assets/#re-encoding) them) |\n| `wide_gamut_tags` | BT.2020 primaries without an HDR transfer. Converted to BT.709 at 10-bit; at 8-bit the tag is not trusted and the pixels are left alone |\n| `variable_frame_rate` | Container rate and measured rate disagree; normalised to CFR |\n| `frame_rate_changed` | Source rate was not a broadcast standard and was snapped to the nearest |\n\n`data.input` carries the full probe \u2014 `container`, `videoCodec`, `pixFmt`,\n`bitDepth`, `width`, `height`, `rotation`, `declaredFps`, `averageFps`,\n`targetFps`, `isVariableFrameRate`, `colorSpace`, `colorTransfer`,\n`colorPrimaries`. `width` and `height` are the *display* size \u2014 a portrait\nphone recording stores landscape pixels with a 90\xB0 rotation, and the ladder\nis built on what the viewer sees, not on how the frames are stored.\n\nThe same block is on the asset record as `input`, so it can be read back at any\ntime without replaying events.\n\n### Delivery\n\nThree attempts with short backoff, each recorded and inspectable. Every event is\nwritten to the durable log **before** the first attempt.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim\nGET /v1/events?order=desc&limit=1 newest first\nPOST /v1/events/:id/redeliver replay to the current endpoint\n```\n\nSignature verification: [Verifying webhooks](#verifying-webhook-signatures).\n\n## Error reference\n\nEvery non-2xx body is `{ "error": "<code>", ... }`. **Switch on `error`, never on\nthe message** \u2014 codes are stable, prose is not.\n\n| Status | Error | Meaning and action |\n|---|---|---|\n| 400 | `size_required` | `size` missing or not positive on `POST /v1/uploads` |\n| 400 | `partNumbers_required` | Empty or non-array `partNumbers` |\n| 400 | `too_many_parts_per_request` | More than 100 part numbers; `max` names the cap |\n| 400 | `part_out_of_range` | A part number below 1 or above `expectedParts` |\n| 400 | `bad_language` | Not a BCP 47 tag |\n| 400 | `bad_captions` | `captions` (or the body of `\u2026/captions`, `\u2026/captions/translate`) is malformed, `null`, or names no valid languages; `hint` says which |\n| 400 | `bad_captions_setting` | `PUT /v1/account/captions`: `enabled` is not a boolean, or `monthlyMinutes` is not a whole number of at least 1; `hint` says which |\n| 400 | `not_webvtt` | Subtitle body does not start with `WEBVTT` |\n| 400 | `bad_status` | Status outside the allowed set (internal route) |\n| 400 | `bad_scopes` | A scope outside `read`/`upload`/`playback`/`full` |\n| 400 | `bad_date` | A date parameter was not `YYYY-MM-DD` |\n| 400 | `authorization_pending` | Device login not yet approved \u2014 keep polling |\n| 400 | `slow_down` | Polling too fast \u2014 back off, keep polling |\n| 400 | `expired_token` | Device code expired. Start over |\n| 400 | `access_denied` | Device login was declined |\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `quota_uploads_daily` | Daily upload cap; `limit` is the ceiling |\n| 403 | `quota_assets` | Asset count cap; `limit` is the ceiling |\n| 403 | `quota_storage` | Storage cap; `limit` is the ceiling |\n| 403 | `account_suspended` | Contact the operator |\n| 403 | `captions_not_enabled` | Captions are off for the account (`minutesCap: 0`); turn them on with `PUT /v1/account/captions` (full key) or `hostr captions enable`. `minutesUsed`, `minutesCap`, `resetsAt` are in the body |\n| 403 | `captions_cap_exceeded` | This run would pass the monthly transcription minutes; same three fields. Retry after `resetsAt` or raise the cap |\n| 403 | `download_not_enabled` | Original downloads are not enabled for this account; the operator grants them |\n| 403 | `accountId_service_only` | Only the service key may name an owning account |\n| 403 | `blocked_user_agent` | Media host only \u2014 send a browser-like `User-Agent` |\n| 404 | `asset_not_found` | No such asset, or it has been deleted |\n| 404 | `subtitle_not_found` | No track for that language tag |\n| 404 | `event_not_found` | No such event for this account |\n| 404 | `key_not_found` | No such live key for this account |\n| 404 | `not_configured` | No webhook endpoint configured |\n| 404 | `account_not_found` | Key authenticated but its account row is gone |\n| 409 | `incomplete_upload` | Parts missing; `missing` lists them. Upload and re-complete |\n| 409 | `size_mismatch` | Declared size does not match bytes landed. Abort and restart |\n| 409 | `not_uploading` | Upload already finished or aborted |\n| 409 | `not_ready` | Asset is not `ready`, so its duration is unknown. On `POST \u2026/captions`: no audio file yet \u2014 wait for `video.audio_ready` |\n| 409 | `no_audio` | `POST \u2026/captions` on a source with no audio stream |\n| 409 | `captions_in_progress` | A captions run is at the provider, started under two minutes ago, or a concurrent request won. Wait for `captions.ready` / `captions.errored` |\n| 409 | `no_transcript` | `POST \u2026/captions/translate` before a transcript exists; request captions first |\n| 409 | `asset_unowned` | The asset has no owning account, so captions cannot be metered; claim it for an account first |\n| 409 | `no_webhook_endpoint` | Nothing configured or enabled to redeliver to |\n| 413 | `file_too_large` | Too many parts at this part size; raise `partSize` |\n| 413 | `subtitle_too_large` | Over 2,000,000 bytes |\n| 429 | `rate_limited` | `POST /v1/device/code` only. It is unauthenticated, so it is capped per IP. A real login makes one call \u2014 if you see this, something is looping |\n| 500 | `signing_not_configured` | Operator-side: the playback signing key is not loaded. Not your fault, not retryable. Report it |\n| 500 | `internal` | Unexpected. `message` carries truncated detail |\n| 502 | `asr_submit_failed` | The transcription provider refused a captions submission (or could not be reached); `hint` carries its error. Nothing was metered for it. Retry later. (A submission the provider accepted is metered; if another request replaced the run meanwhile, that is `409 captions_in_progress`.) |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n| 503 | `captions_unavailable` | No transcription provider is configured on this deployment. Operator-side; not retryable by you |\n| 503 | `translate_unavailable` | No translation queue or provider is configured on this deployment. Operator-side |\n\n### Retry guidance\n\n**Retry with backoff:** `503 control_plane_unavailable`, `500 internal`,\n`400 authorization_pending`, `400 slow_down`.\n\n**Fix and retry:** the `409` upload conflicts \u2014 they tell you exactly what is\nwrong (`missing` part numbers, or both byte counts).\n\n**Do not retry unchanged:** every `400`, `401`, `403`, and `404`. A quota `403`\nin particular will fail identically forever until the account limit is raised.\n\n### The 401 conflation is deliberate\n\n`unauthorized` covers both "bad credential" and "not your asset". Distinguishing\nthem would let anyone holding a valid key enumerate other accounts\' asset ids by\nwatching which error came back.\n\n## CLI reference\n\n```bash\nnpx @hostr/cli <command>\n```\n\nA single-file, zero-dependency bundle published as `hostr`. **Every command takes\n`--json`** \u2014 one JSON document on stdout, NDJSON for `tail --follow`, errors as\nJSON on stderr with exit code 1. Nothing needs prose parsing.\n\nRun it with **no arguments** for setup status and the single next step. That is\nthe intended entry point, for humans and agents alike.\n\nRun it as `npx @hostr/cli@latest`. Plain `npx @hostr/cli` can keep reusing an\nold cached copy, which still prints the advice that was true when it was\nbuilt. Once a day the CLI asks npm for the latest version; when a newer one\nexists, every command says so on stderr, and bare `hostr` leads with it (its\n`--json` report carries it as `update`). Under `--json` nothing extra goes to\nstderr. `HOSTR_NO_UPDATE_CHECK=1` turns the check off; it is also off when\n`CI` is set. `hostr --version` prints the version you are running.\n\n### Credentials\n\nPrecedence, highest first: `--key <k>`, then `HOSTR_API_KEY`, then the saved\nprofile.\n\nAPI base, highest first: `--api <url>`, then `HOSTR_API_BASE`, then the profile,\nthen production.\n\nProfiles live in `~/.config/hostr/config.json`, cleartext at mode 0600 \u2014 the\n`gh` and `wrangler` precedent.\n\n### Authentication\n\n```bash\nhostr login # browser-authorized; nothing is pasted\nhostr login --profile staging # name the profile\nhostr login --key hostr_sk_live_... # escape hatch for CI; skips the browser\nhostr whoami # account, plan, limits, live usage\n```\n\n`login` with no key opens your browser and uses the device-authorization flow.\nThe code and URL are printed to **stderr**, so `--json` stdout stays a single\nparseable document.\n\n### Uploading\n\n```bash\nhostr upload video.mp4 # returns when bytes are verified in storage\nhostr upload video.mp4 --wait # blocks until transcode is ready or failed\nhostr upload video.mp4 --content-type video/quicktime\nhostr upload # no file: opens a local drag-drop page\nhostr upload video.mp4 --captions --translate en,es --context "Creator: Ana" # captions on upload\n```\n\nWraps the SDK\'s Node adapter, so it carries the same resume and integrity\nguarantees. Kill it mid-transfer and re-run to continue.\n\n### Assets\n\n```bash\nhostr assets list --status ready --limit 20 --cursor <cursor>\nhostr assets get <id>\nhostr assets token <id> --ttl 3600 --viewer-id vp_5b0e2a7c # playback scope\nhostr assets audio <id> # audio-only rendition for an older asset (upload scope)\nhostr assets audio --missing # ...for every asset in the account that lacks one\nhostr assets reencode <id> # fresh ladder from the stored source (upload scope)\nhostr assets delete <id> # full scope\n```\n\n### Subtitles\n\n```bash\nhostr subtitles list <assetId>\nhostr subtitles set <assetId> subs.vtt --lang en --name English --default\nhostr subtitles rm <assetId> en\n```\n\n`--lang` is required and must be a BCP 47 tag. The asset must be `ready`.\n\n### Captions\n\n```bash\nhostr captions info # on/off, this month\'s minutes, prices\nhostr captions enable # turn on for the account \u2014 billed per use (full scope)\nhostr captions enable --limit 600 # on, at most 600 transcription minutes a month\nhostr captions disable # turn off; forgets the limit\nhostr captions status <assetId> # the latest run: transcript + each language\nhostr captions request <assetId> # redo with the stored request (upload scope)\nhostr captions request <assetId> --lang pt --translate en,es --context "Creator: Ana"\nhostr captions translate <assetId> --lang fr,de # from the stored transcript; billed per language-minute\n```\n\n`--captions`, `--captions-lang`, `--translate` and `--context` on `upload` ask\nfor captions on the new asset; any one of them is enough. Captions must be\non for the account (`hostr captions enable`); they start off because they are\nbilled. A refusal says how many of the month\'s minutes are used and when they\nreset. No other command turns captions on: not `init`, not `upload`. Watch `hostr tail --follow --type captions.ready`.\nSee [Auto-captions and translation](#auto-captions-and-translation).\n\n### Events\n\n```bash\nhostr tail # one page of the durable log\nhostr tail --follow # poll every 2s, NDJSON under --json\nhostr tail --type video.ready --limit 50 --after <cursor>\n```\n\n### Keys\n\n```bash\nhostr keys list\nhostr keys create --name ci --scopes upload --write-env .env\nhostr keys revoke <id>\n```\n\n`--scopes` takes a comma-separated list and **defaults to `playback`** \u2014 the\nright rung for an application key. `revoke` takes the `id` from `keys list`, not\nthe `keyId`.\n\n### Webhooks\n\n```bash\nhostr webhooks get\nhostr webhooks set https://app.example/hook --write-env .env\nhostr webhooks set https://app.example/hook --secret whsec_... # bring your own\nhostr webhooks set https://app.example/hook --rotate --write-env .env # replace the secret\nhostr webhooks delete\nhostr webhooks test # redeliver the newest event \u2014 proves your endpoint\n```\n\nRe-running `set` on a live endpoint keeps its secret (nothing is written to a\n`--write-env` file, and the output says so); pass `--rotate` to replace it.\n\n### Repo setup\n\n```bash\nhostr init # docs/hostr.md + AGENTS.md section + .env.example\nhostr init --dir ../other-repo\n```\n\nIdempotent. Re-running replaces the content between the `AGENTS.md` markers\nrather than appending.\n\n### Usage\n\n```bash\nhostr usage --from 2026-08-01 --to 2026-08-25\n```\n\nDefaults to the last 30 days.\n\n### --write-env\n\n```bash\nhostr keys create --scopes upload --write-env .env\nhostr webhooks set https://app.example/hook --write-env .env\n```\n\nWrites the secret into that file at mode 0600 and prints **only the variable\nname** \u2014 in `--json` mode too. Nothing else in the CLI ever prints a credential.\n\nThe point is that an agent driving your terminal can orchestrate a credential\nwithout the value ever entering its context or its transcript.\n\n## SDK reference\n\n```bash\nnpm install @hostr/upload\n```\n\nFour entry points, each bundled independently:\n\n| Entry | Platform | What the adapter supplies |\n|---|---|---|\n| `@hostr/upload` | **Trusted backend only** | `createUploadSession`, `mintUploadToken` \u2014 needs your API key |\n| `@hostr/upload/browser` | Web | XHR transport (for progress events), `localStorage` session store |\n| `@hostr/upload/node` | Node, scripts | `fetch` transport, filesystem session store |\n| `@hostr/upload/react-native` | Expo / RN | Native staged-parts transport; survives backgrounding |\n\nThe engine, retry policy, and integrity checks are one shared core. Adapters only\nsupply platform I/O: read bytes, PUT bytes, persist a few KB of JSON.\n\n### Browser\n\n```js\nimport { createUpload } from \'@hostr/upload/browser\';\n\n// createUploadSession runs on YOUR SERVER \u2014 it holds the API key.\n// The browser receives only { endpoint, assetId, uploadToken }.\nconst upload = await createUpload({ endpoint, uploadToken, assetId, file });\n\nupload.on(\'progress\', p => {\n const pct = (p.bytesConfirmed / p.totalBytes * 100).toFixed(1);\n console.log(`${p.partsDone}/${p.partsTotal} parts, ${pct}%`);\n});\nupload.on(\'statechange\', s => console.log(s));\n// idle -> running -> (stalled <-> running)* -> completing -> complete\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\nSession state persists in `localStorage`, keyed by `name:size:lastModified`.\nReload the page, pick the same file, and `createUpload` resumes automatically \u2014\nthere is no explicit resume call.\n\n### Node\n\n```js\nimport { createUploadSession, createUpload } from \'@hostr/upload/node\';\n\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n});\n\nconst upload = await createUpload({\n endpoint, uploadToken: session.uploadToken, assetId: session.assetId,\n file: \'/path/to/video.mp4\', // a path, not an open handle\n stateDir: \'./upload-state\', // survives a process restart\n});\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\n#### Captions on upload\n\n`createUploadSession` takes the same `captions` object as `POST /v1/uploads`\n(SDK 0.3.0 or later). hostr transcribes the audio as soon as it exists and\ntranslates it into each `translate` language; every track lands as an ordinary\nsubtitle track. The account must have captions enabled.\n\n```js\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n captions: { language: \'auto\', translate: [\'en\', \'es\'], context: \'Creator: Ana. Title: Rainy night.\' },\n});\n```\n\nSee [Auto-captions and translation](#auto-captions-and-translation) for the events, the\nmetering and the rules.\n\n### Take the endpoint from your backend\'s response\n\n**One rule for clients of a proxying backend:** take the hostr `endpoint` from\nyour backend\'s create-upload response, never from separate client config.\n`createUploadSession` returns it as `endpoint` alongside the session, so\nforwarding `{ endpoint, assetId, uploadToken }` is all the browser needs.\n\nAn upload token is only valid on the hostr instance that minted it. Endpoint\ndrift fails as a bare mid-flow `401` with nothing pointing at the cause;\nreturning the endpoint alongside the session makes the mistake structurally\nimpossible. Learned from the first consumer integration.\n\n### Events\n\n| Event | Payload |\n|---|---|\n| `progress` | `{ partsDone, partsTotal, bytesConfirmed, totalBytes, partStates }` |\n| `statechange` | `\'idle\' \\| \'running\' \\| \'stalled\' \\| \'completing\' \\| \'complete\'` |\n| `diagnostic` | `{ code, consecutiveNetworkFailures, bytesEverConfirmed, hint }` |\n\n`partStates` maps part number to `\'inflight\'` or `\'done\'` \u2014 enough for a per-part\nprogress grid.\n\nThe `diagnostic` event fires once per failure streak, on every adapter. Today its\nonly code is `systemic-transport-failure`: the browser cannot reach storage at all\n(offline, or your CSP\'s `connect-src` omits the storage host) \u2014 see [Upload from a browser](#upload-from-a-browser). It is **advisory, never\nterminal**: the upload keeps retrying regardless.\n\n### Token refresh\n\n```js\nconst upload = await createUpload({\n endpoint, assetId, uploadToken, file,\n onTokenExpired: async () => (await refreshFromYourServer(assetId)).uploadToken,\n});\n```\n\nYour server calls `POST /v1/uploads/:id/token`. The SDK retries in place; nothing\nis lost. Even without the hook a killed client resumes from what storage reports\non next launch.\n\n### Integrity\n\nThree layers, each proving something different \u2014 per-part ETag, part count and\ntotal size at completion, and an optional whole-file SHA-256. See\n[Resume and integrity](#resume-and-integrity) for what each one does and\ndoes not prove.\n\n### Import error classes from the entry you actually use\n\nEach entry is bundled independently, so `IntegrityError` from\n`@hostr/upload/node` is not the same class object as the one from\n`@hostr/upload/browser`. An `instanceof` check across entries fails. Import from\nthe entry your code uses.\n\n## The encoding ladder\n\nhostr encodes each source into several renditions at different bitrates \u2014\nthe *ladder* \u2014 and packages them as fMP4 (CMAF) HLS with H.264 video and AAC\naudio. A player picks a rung based on available bandwidth and changes rung\nmid-stream as conditions move.\n\n### Rungs are chosen by short edge\n\nA ladder defined by height assumes landscape. Defining rungs by the **short\nedge** means portrait video, square video, and unusual aspect ratios all get a\nsensible ladder instead of a broken one.\n\n**Nothing is ever upscaled.** Rungs above the source\'s short edge are dropped, so\na 480p source produces a 480p-and-below ladder rather than a blurry 1080p\nrendition that costs bandwidth and delivers nothing.\n\n### Why switching is clean\n\nTwo invariants do the work, and both are load-bearing:\n\n**Every rung shares one constant frame rate and identical keyframe intervals**,\nwith scene-cut detection disabled. This is the only reason segment boundaries\nline up across rungs. Without it a player switching quality lands mid-segment and\nstutters or re-buffers.\n\n**Variable frame rate is normalised to constant before the ladder splits.**\nSources are frequently VFR \u2014 phone recordings especially \u2014 and segment durations\ndrift apart otherwise.\n\nThere is a subtlety inside that second one worth stating, because it shipped as a\nbug once. The frame rate comes from **the container\'s declared rate, not the\nmeasured average**. On a VFR source the average is depressed *because* frames were\ndropped; using it as a ceiling compounds the loss. A 30fps source got encoded at\n25fps that way.\n\n### Audio is demuxed\n\nAudio lives in its own rendition group rather than being muxed into every video\nrung. One copy instead of four, and it is the prerequisite for multi-language\naudio later.\n\nBy default that group is AAC-LC at 128 kbps, stereo (a mono source stays mono;\n5.1 is downmixed \u2014 never the other way), 48 kHz, resampled with soxr. Nothing\nelse is done to it: no gain change, no noise reduction, no gating, no silence\ntrimming. Room tone is content, and many creators master deliberately.\n\n### Audio options\n\n`POST /v1/uploads` takes an `audio` field that changes that for one asset:\n\n```json\n{ "size": 104857600, "filename": "tapping.mov", "audio": "asmr" }\n```\n\n| Option | Default | What it does |\n|---|---|---|\n| `kbps` | `128` | Bitrate of the top audio group. At 192 or more a second **96 kbps group** is added and the smallest rung (240p) references it, so a viewer on mobile data is not made to fetch a 256 kbps track behind 400 kbps of video. |\n| `normalize` | `false` | Fixed programme gain with peak limiting \u2014 see below. |\n| `targetLufs` | `-24` | Integrated loudness target. Mux normalises to the same level at ingest. |\n| `truePeakDbtp` | `-1.5` | True-peak ceiling, verified on encoded audio. |\n\n`"asmr"` is shorthand for `{ "kbps": 256, "normalize": true }` with the default\ntarget and ceiling. The resolved options land on the record as `audioOptions`.\nAn asset that already exists takes the same field on\n[`POST /v1/assets/:id/reencode`](https://docs.hostr.sh/concepts/assets/#re-encoding).\n\n#### Normalisation raises the programme and limits peaks\n\nNormalisation is **off by default**. No upload, re-encode or account default\nchanges when you preview it. Explicitly set `audio.normalize: true` or choose\n`audio: "asmr"` to apply it.\n\nThe source\'s EBU R128 measurement sets a fixed programme gain. An oversampled\nlookahead limiter catches peaks that would cross the ceiling, with automatic\nmakeup gain disabled and latency compensated. There is no programme compressor,\ndenoise, gate or automatic levelling. Quiet passages and room tone remain.\n\nThe transcoder measures encoded AAC and corrects the fixed gain for loudness\nlost to limiting, aiming within 0.5 LU of the target. It lowers the internal\nlimiter ceiling if AAC overshoots. If five passes cannot reach the target\nunder the ceiling, a new upload ships with its original, untouched audio\nrather than failing: `normalization` is `null` and `loudness.normalizationSkipped`\nsays why. A re-encode that cannot converge fails instead, and the existing\nladder remains live. Encoded segments that miss the target still fail the job. All normalised HLS groups and `audio.m4a`\nuse the same final chain. The low bitrate group allows 1 LU of codec variation.\n\nThe source measurement stays in `loudness`; `normalization.output` holds the\nencoded result. `normalization.limiting.maxReductionDb` measures maximum\nattenuation before AAC, and `limitedSamplesPct` is the percentage of sample\nframes attenuated by more than 0.1 dB, measured at 192 kHz. `limitingDb` is\nseparately the estimated peak reduction from source peak, gain and ceiling.\nThe limiter can change loudness range; the record shows both source and output.\n\nOlder records without `normalization.mode: "peak-limiter"` describe the former\ngain clamp: `peakLimited: true` there means it landed below target. Existing\nmedia is unchanged until explicitly re-encoded.\n\n#### Listen before applying\n\nOn the dashboard asset page, choose a start second and three targets (initial\nsuggestions: **-30, -24, -18 LUFS**, each editable from -40 to -10). Make the\ncomparison to hear Original plus three normalised 30-second clips, shortened\nat the end of the video. The players show measured LUFS, true peak, loudness\nrange and limiting. Only **Re-encode at \u2026 LUFS** changes the asset.\n\nEach tier is measured over the selected sample. A full re-encode measures the\nwhole source and may need a different gain at the same target. Preview input\nis the published gain-free Original AAC, not the lossless upload. Preview jobs\nread only the needed audio fragments and run on the concurrent poster/preview\nlane, never behind a ladder encode. Worker processing is bounded to 45 seconds;\nqueue or cold-start delays are additional. Missing fast-lane configuration\nreturns 503 rather than falling back to the encode queue.\n\n```bash\nhostr assets audio-preview ASSET --time 120 --targets=-30,-24,-18\nhostr assets audio-preview ASSET --json\n# After listening and choosing a target:\nhostr assets reencode ASSET --target-lufs -24\n```\n\nThe API takes `POST /v1/assets/:id/audio-preview` with\n`{ "time": 120, "targets": [-30, -24, -18] }`. Optional `truePeakDbtp` and\n`kbps` keep their existing defaults (-1.5 and 128). Poll `GET` on the same\nroute for state and measurements, then fetch\n`GET /v1/assets/:id/audio-preview/:previewRev/:index` with a tenant key\n(index 0 is Original, 1\u20133 follow the targets). Audio remains private. A\ncomparison never changes `audioOptions`, playback, posters or the live revision.\nOnly the latest comparison is addressable. Clips remain under the asset prefix\nuntil asset deletion. A queued request older than two minutes can be retried.\n\n#### The original stays selectable\n\nWhen a gain is applied, the top audio group carries a second rendition,\n**Original** \u2014 the same encode with no gain \u2014 beside the **Normalised**\ndefault. A player\'s audio-track menu offers both, so a creator can A/B their\nown upload and nothing is ever available only normalised. It costs one more\naudio track\'s worth of storage and appears only when normalisation actually\nchanged something. The 96 kbps group does not carry it. (The original upload is\nkept in storage regardless; this is about what a viewer can pick.)\n\n### Loudness is measured for every asset\n\nWhether or not normalisation is on, the probe measures the first audio stream\n(EBU R128, true-peak mode) and the record carries the result as `loudness`,\nwhich also rides `video.audio_ready`, `video.playable` and `video.ready`:\n\n```json\n{\n "integratedLufs": -32.1,\n "loudnessRangeLu": 15.2,\n "truePeakDbtp": -0.3,\n "thresholdLufs": -42.5,\n "mono": false,\n "normalization": null\n}\n```\n\nTwo uses. **Creator warnings before publish** \u2014 `mono` for a one-channel source\n(the ladder keeps it mono; it never fakes stereo), `truePeakDbtp` near 0 for\nclipping, a very low `integratedLufs` for a recording that is quiet even by\nASMR standards. For output loudness use `normalization.output.integratedLufs`\nwhen present. `integratedLufs` itself always describes the source; adding\n`gainDb` is no longer an exact output prediction when peaks are limited.\n\nThe block is written once on first encode and replaced atomically when a\nre-encode becomes ready. It is `null` for a source with no audio stream.\n\n### What the probe found\n\nThe transcoder probes each source and records what it saw on the asset as\n`input` \u2014 codec, pixel format, bit depth, dimensions, declared and average frame\nrates, and colour metadata.\n\nWhen the source required a judgement call, that block also carries `findings`,\nand a [`video.input.non_standard`](#event-reference) event fires once. It is\nadvisory: the transcode proceeds normally and `video.ready` still follows. See\nthe [events reference](#event-reference) for the finding codes.\n\n### HDR sources\n\nThe ladder is 8-bit BT.709 SDR. A genuine HDR source \u2014 10-bit with an HLG or\nPQ transfer, which is what an iPhone records in HDR mode \u2014 is converted to SDR\nonce, before the ladder splits, so every rung, the poster and the storyboard\ncome from the same converted frames. HLG is read the way the format intends\nfor SDR screens (as an ordinary gamma curve, with the colour converted to\nBT.709 and a small contrast and saturation lift standing in for the display\nheadroom SDR lacks); PQ is tone-mapped perceptually. A BT.2020 source without\nan HDR curve has only its colour converted. The finding on the record says\nwhich was done.\n\nThe 10-bit part is the rule. HDR is a 10-bit format, so HDR tags on 8-bit\npixels are a label copied forward by an earlier transcode, and converting as\nif they were true blows the highlights; those sources are treated as SDR and\nleft alone.\n\nHDR is not delivered to viewers. Tone mapping shipped on 2026-09-10; an HDR\nasset encoded before that plays back washed out until it is\n[re-encoded](https://docs.hostr.sh/concepts/assets/#re-encoding).\n\n### Container and codec choices\n\nfMP4 rather than MPEG-TS: universal playback today, and the container that keeps\nHEVC addable later without re-packaging everything.\n\nSegments are served `immutable, max-age=1yr`, and content keys are versioned\n(`.../hls/v2/...`) rather than overwritten. Overwriting a key that is cached for\na year is how you end up needing a cache purge in production, which is a smell\nrather than a tool.\n\n## Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| GET | `/health` | \u2014 | Liveness check |\n| GET | `/llms.txt` | \u2014 | Agent documentation index |\n| GET | `/llms-full.txt` | \u2014 | Agent integration guide |\n| POST | `/v1/device/code` | \u2014 | Begin browser authorization |\n| POST | `/v1/device/token` | \u2014 | Exchange an approved device code for a key |\n| POST | `/v1/uploads` | upload | Create an asset and begin a multipart upload |\n| GET | `/v1/uploads/:id` | upload | Upload status and parts already stored |\n| DELETE | `/v1/uploads/:id` | upload | Abort an upload |\n| POST | `/v1/uploads/:id/parts` | upload | Get presigned PUT URLs for parts |\n| POST | `/v1/uploads/:id/complete` | upload | Finalise the upload and queue transcoding |\n| POST | `/v1/uploads/:id/token` | upload | Re-mint an upload token |\n| GET | `/v1/assets` | read | List your assets |\n| GET | `/v1/assets/:id` | read | Get one asset |\n| DELETE | `/v1/assets/:id` | full | Erase an asset |\n| POST | `/v1/assets/:id/tokens` | playback | Mint playback credentials |\n| POST | `/v1/assets/:id/audio` | upload | Generate the audio-only rendition |\n| POST | `/v1/assets/:id/download` | playback | Link to the original upload |\n| GET | `/v1/assets/:id/audio-preview` | read | Read the current audio comparison |\n| POST | `/v1/assets/:id/audio-preview` | upload | Compare Original audio and three loudness targets |\n| GET | `/v1/assets/:id/audio-preview/:previewRev/:index` | read | Read one private AAC comparison clip |\n| POST | `/v1/assets/:id/reencode` | upload | Re-encode an asset from its stored source |\n| POST | `/v1/renders` | upload | Create a video from an edit |\n| POST | `/v1/assets/:id/render` | upload | Re-edit an asset |\n| POST | `/v1/assets/:id/poster` | upload | Re-take the poster from a chosen second |\n| POST | `/v1/assets/:id/poster/warm` | upload | Start a worker ahead of a poster request |\n| POST | `/v1/assets/:id/status` | \u2014 | Report transcode status |\n| GET | `/v1/assets/:id/subtitles` | read | List subtitle tracks |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | Attach or replace a subtitle track |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | Remove a subtitle track |\n| POST | `/v1/assets/:id/captions` | upload | Transcribe an asset (or redo it) |\n| POST | `/v1/assets/:id/captions/translate` | upload | Translate the transcript into more languages |\n| POST | `/v1/captions/callback/:id/:rev` | \u2014 | Transcription provider callback (not for clients) |\n| POST | `/v1/billing/webhook` | \u2014 | Stripe webhook (not for clients) |\n| GET | `/v1/account` | read | Whoami |\n| PUT | `/v1/account/captions` | full | Turn captions on or off |\n| GET | `/v1/usage` | read | Daily usage |\n| GET | `/v1/billing` | read | Billing status and this month so far |\n| GET | `/v1/events` | read | Tail the durable event log |\n| POST | `/v1/events/:id/redeliver` | full | Replay a stored event |\n| GET | `/v1/webhooks` | full | Get the webhook endpoint |\n| PUT | `/v1/webhooks` | full | Configure the webhook endpoint |\n| DELETE | `/v1/webhooks` | full | Disable the webhook endpoint |\n| GET | `/v1/keys` | full | List key metadata |\n| POST | `/v1/keys` | full | Mint an API key |\n| DELETE | `/v1/keys/:id` | full | Revoke a key |\n\nGenerated from the same OpenAPI description the API is checked against.\nFull reference with schemas and examples: https://docs.hostr.sh/reference/api/\n';
|
|
6197
|
+
var integrate_default = '# hostr \u2014 integration guide\n\n<!-- GENERATED FILE \u2014 DO NOT EDIT.\n Source: apps/docs/src/content/docs/** (pages with `agentGuide: true`)\n packages/openapi/src/spec.ts (the route table)\n Regenerate: pnpm build:agent-guide \xB7 CI checks this with --check -->\n\nhostr is self-hosted video infrastructure: resumable upload \u2192 transcode \u2192\nHLS delivery, with signed playback URLs. This document is the complete,\nself-contained integration reference. It is written to be executed by a\ncoding agent; every command has a `--json` mode and every flow is\nverifiable from the terminal.\n\n```\nAPI base https://api.hostr.sh (override: HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\nDocs https://docs.hostr.sh \xB7 GET {API base}/llms-full.txt (this file)\n```\n\nRun `npx @hostr/cli` in your app\'s repo. With no arguments it reports what is\nconfigured and names the single next step \u2014 authorize, prepare the repo, mint\nan application key \u2014 and it is the intended entry point for a coding agent.\nThis document is the *reference*: consult it while implementing, section by\nsection, rather than reading it front to back.\n\n## Quickstart\n\nBases you will need:\n\n```\nAPI base https://api.hostr.sh (override with HOSTR_API_BASE)\nMedia host https://stream.hostr.sh (playback URLs are built on this)\n```\n\n### 1. Authorize\n\n```bash\nnpx @hostr/cli login\n```\n\nThis opens your browser and mints the CLI\'s own key once you approve it. Nothing\nis pasted, which matters when a coding agent is driving your terminal \u2014 a key\ntyped into an agent\'s session lands in its context and its transcript.\n\nThe key appears on the dashboard Keys page as `CLI \u2014 <hostname>` and is revocable\nlike any other. For CI, `--key <k>` or `HOSTR_API_KEY` skips the browser.\n\nConfirm it worked:\n\n```bash\nnpx @hostr/cli whoami --json\n```\n\n### 2. Upload\n\n```bash\nnpx @hostr/cli upload video.mp4 --wait --json\n```\n\n`--wait` blocks until the transcode reaches `ready` or `failed`. Without it the\ncommand returns as soon as the bytes are verified in storage.\n\n```json\n{ "assetId": "9f8c1e2a4b6d47f0a1b2c3d4e5f60718", "status": "ready", "bytes": 104857600 }\n```\n\nThe upload is resumable. Kill it mid-transfer and run it again \u2014 it asks storage\nwhich parts landed and continues from there.\n\n### 3. Play it\n\nMint a playback token, then build the URL:\n\n```bash\nnpx @hostr/cli assets token 9f8c1e2a4b6d47f0a1b2c3d4e5f60718 --json\n```\n\n```json\n{ "playback": "eyJhbGci...", "thumbnail": "...", "storyboard": "...", "expiresAt": 1756134000000 }\n```\n\n```\nhttps://stream.hostr.sh/{assetId}.m3u8?token={playback}\n```\n\nHand that to any HLS player. Safari and iOS play it natively; elsewhere use\nhls.js. To check a video without writing any of that, open it in the dashboard\nand press Play \u2014 the asset page mints a token and plays it in place.\n\nThe token is checked once, when playback starts. It does not need to stay valid\nfor the whole viewing session, and the default one-hour lifetime is not a\none-hour viewing limit.\n\n### 4. Wire up events, not polling\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nThe signing secret is generated, written to `.env`, and never printed \u2014 only the\nvariable name is. Then verify signatures before trusting a delivery: see\n[Verifying webhooks](#verifying-webhook-signatures).\n\nProve your endpoint end to end without uploading anything else:\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\n### No Node available?\n\nEvery flow here has a raw-HTTP equivalent. Start at\n[Upload from a server](#upload-from-a-server).\n\n## Authentication\n\nEvery call sends `Authorization: Bearer <credential>` over HTTPS. There are three\ncredential classes and **no shared master key**.\n\n### API keys\n\n`hostr_sk_live_\u2026`, minted per account. **An API key belongs to one project; the\nAPI calls a project an account.** In the dashboard you work in projects, grouped\nunder an organization that holds the card and the people; through the API a key\nsees its own project only \u2014 its assets, webhooks, events and limits \u2014 and\n`GET /v1/account` names the organization it belongs to. To work on two\nproducts, give each its own project and its own key.\n\nKeys are SHA-256-hashed at rest; the plaintext is\nshown exactly once, at mint. Verified in constant time and cached about 60 seconds\nin the edge Worker \u2014 so **revocation takes up to a minute** to take effect.\n\nScopes are a ladder, each rung including the ones below:\n\n| Scope | Grants |\n|---|---|\n| `read` | GETs only \u2014 asset status, listing, usage, events |\n| `upload` | read, plus the whole upload lifecycle |\n| `playback` | upload, plus minting playback tokens |\n| `full` | everything: deletion, key management, webhook config |\n\n**`playback` is what an application key should be.** It can ingest video and it\ncan let viewers watch, but it cannot delete assets or mint more keys. Reach for\n`full` only where you actually manage the account.\n\n**Keep keys server-side.** Never ship one in a browser or a mobile app.\n\nRotate by creating the new key and revoking the old one:\n\n```bash\nnpx @hostr/cli keys create --name ci --scopes upload --write-env .env\nnpx @hostr/cli keys revoke <old-id>\n```\n\nRevoking the key that makes the request is allowed \u2014 it is rotation\'s last step.\n\n### Upload tokens\n\nA stateless HMAC scoped to a single `assetId`, returned as `uploadToken` from\n`POST /v1/uploads`. **This is the only credential an untrusted client should ever\nhold.** It can drive that one upload and nothing else \u2014 it grants nothing on\nplayback, and nothing on any other asset.\n\nTokens expire. For uploads that outlive one, re-mint in place with\n`POST /v1/uploads/:id/token` (an API key with `upload` scope, not the expiring\ntoken itself) \u2014 no progress is lost. The SDK does this for you through its\n`onTokenExpired` hook.\n\n### Service key\n\nThe internal transcoder credential. Never issued to tenants; documented only so\nits one route is not mistaken for a public one.\n\n### Login without pasting a key\n\n```bash\nnpx @hostr/cli login\n```\n\nBrowser-authorized, using the RFC 8628 device flow. The CLI never accepts a\npasted key from a human or an agent, for the reason given in the\n[quickstart](#quickstart). `--key` and `HOSTR_API_KEY` remain the escape\nhatch for CI and other headless callers.\n\nCredential precedence everywhere: `--key` flag, then `HOSTR_API_KEY`, then the\nsaved profile.\n\n### Auth errors\n\n| Status | Error | Meaning |\n|---|---|---|\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `account_suspended` | Contact the operator |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n\nThat 401 conflation is deliberate: distinguishing "wrong key" from "not your\nasset" would let anyone with a valid key enumerate other accounts\' asset ids.\n\n**`control_plane_unavailable` fails closed for API keys only.** If the control\nplane is unreachable, key-authenticated calls 503 \u2014 but in-flight uploads using\nupload tokens keep working, and playback is untouched, because the media Worker\nnever queries it.\n\n## Assets and lifecycle\n\nAn asset is created when you open an upload and lives until you erase it.\n\n```\nuploading -> uploaded -> queued -> processing -> playable -> ready\n \\-> failed\n \\-> aborted (deleted, from anywhere)\n```\n\n| Status | Means |\n|---|---|\n| `uploading` | Multipart session open, parts landing |\n| `uploaded` | Bytes verified against storage; part count and total size both agree |\n| `queued` | Handed to the transcode queue |\n| `processing` | Encoding. Carries live `progress` |\n| `playable` | Watchable now, at one quality. The rest of the ladder is still encoding |\n| `ready` | HLS renditions are live and playable |\n| `failed` | Transcode failed. `error` is always present |\n| `aborted` | Upload abandoned before completion \u2014 you aborted it, or it was collected after 14 days idle |\n| `deleted` | Erased. Terminal and irreversible |\n\n### Status only moves forward\n\nStatus only ever moves forward. Both encode passes report independently and\nqueue delivery is at-least-once, so a late or redelivered report that would move\na `playable` or `ready` asset backwards is ignored \u2014 including a failure. An asset\nthat is watchable is never marked `failed`; the error is recorded on the record\ninstead. Once `playable`, an asset stays playable.\n\n### Reading progress\n\nWhile `processing` or `playable`, the record carries\n`progress: { encodedSec, totalSec, speed }`, where `speed` is the encode rate as\na multiple of realtime. While `playable` it tracks the remaining qualities.\nOutside those two it is null or absent \u2014 a terminal status never shows a stale\nbar.\n\nThat is enough for a real percentage instead of a spinner, which matters more\nthan it sounds: most "the transcode is slow" reports turn out to be missing\nprogress feedback rather than slow encoding.\n\n### Stalled encodes are re-queued\n\nAn encode that stops reporting \u2014 a transcoder machine restarted mid-encode,\nsay \u2014 is noticed within about ten minutes of its last progress post and the\npass that was running is re-queued. Nothing about the asset changes: status\nstays where it was, progress resumes, and `video.playable` / `video.ready`\nstill fire exactly once. An encode that stays silent through three re-queues\n(roughly an hour) is given up on and the asset records an `error`, exactly as\na transcode failure would: it becomes `failed` if nothing was playable yet\n(`video.errored` fires), or stays `playable` with the error if it was.\n\n### Abandoned uploads are collected after 14 days\n\nAn upload that shows no activity for **14 days** is moved to `aborted`, its\nmultipart session is released, and an `upload.aborted` event fires carrying\n`reason: "abandoned"`. The asset\'s `error` says the same thing in words. An\n`aborted` asset stops counting against your asset quota \u2014 it holds no bytes.\n\n"Activity" means a call about that upload: asking for the next batch of\npresigned part URLs, or re-minting its upload token. Sending bytes to the\npresigned URLs is not visible to the API, so a client that goes quiet for a\nfortnight without asking for anything is what gets collected. In practice an\nuploader hits the API at least twice a day, because presigned part URLs expire\nin at most 12 hours.\n\nThe window is deliberately double the 7-day upload token lifetime. An upload\nwhose token expired is not lost \u2014 re-mint at `POST /v1/uploads/:id/token` and\nresume \u2014 so a shorter window would destroy uploads that were still resumable.\nIf you know an upload is finished with, `DELETE /v1/uploads/:id` releases the\nstorage immediately rather than waiting.\n\n### Prefer events to polling\n\n`GET /v1/assets/:id` returns the authoritative record, and\n`GET /v1/assets?limit=&status=&cursor=` lists your assets newest-first with\nkeyset pagination. But polling is the fallback, not the design \u2014\n[webhooks](#webhooks-and-the-event-log) push the same transitions.\n\n**The field is `assetId`, never `id`.** Every asset body \u2014 the record, each\nlist item (`{ items, nextCursor }`), the upload response, every event\'s\n`data` \u2014 names it `assetId`; `:id` in a path is that value. An event\'s own\ntop-level `id` is the event, not the asset. A list item also carries `error`\n(why a `failed` encode failed), so a library view needs no lookup per row.\n\n### Your own metadata\n\nAttach your own labels when you create the upload, as string key/value pairs:\n\n```json\nPOST /v1/uploads\n{ "size": 104857600, "filename": "clip.mp4",\n "metadata": { "userId": "u_8812", "creatorId": "c_301", "title": "Morning routine" } }\n```\n\nThey come back on `GET /v1/assets/:id`, on every list item, and **on every event\nabout the asset as `data.metadata`** \u2014 so your webhook receiver can act on\n`video.ready` for user `u_8812` directly, with no table from `assetId` to user\nof its own. `POST /v1/renders` takes `metadata` the same way.\n\nFilter the list by them: `GET /v1/assets?metadata.userId=u_8812` (up to five\nfilters, ANDed, exact match).\n\nChange them with a merge-patch \u2014 a string sets a key, `null` removes it, keys\nyou leave out are kept:\n\n```json\nPATCH /v1/assets/:id\n{ "metadata": { "title": "Morning routine (edited)", "draft": null } }\n```\n\nRules: at most 20 keys; keys 1\u201340 characters of letters, digits, `_`, `.`, `-`;\nvalues 1\u2013500 characters, **strings only** (send numeric ids as `"42"`, not `42`,\nso a filter for `"42"` finds them). Anything else is `400 invalid_metadata`\nwith a `hint` naming the key.\n\nPrefer ids to display names. A name changes; the events already sent keep the\nold one, and every key on the project can read it. Metadata is not a place for\nsecrets or anything you would not log.\n\n### playbackId is its own field\n\n`GET /v1/assets/:id` always carries `playbackId`, and carries `durationSec`\n(seconds, the same value as `renditions.durationSec`) from `playable` on. The\n`video.playable` and `video.ready` events carry both.\n\n`playbackId` equals `assetId` today. **Store it as its own field anyway.** It is\nnamed separately so the two can diverge later without forcing a migration on\nevery consumer.\n\n### Re-encoding\n\n`POST /v1/assets/:id/reencode` (`hostr assets reencode <id>`) builds a fresh\nladder from the original upload, which stays in storage. The asset keeps its id,\nits playback URLs and its subtitles; the current ladder keeps serving until the\nnew one is live, then playback tokens switch over. Nothing is overwritten and\nnothing needs purging \u2014 the new ladder lands under a new encode revision, and\nthe record\'s `rev` says which one is live.\n\nUse it when an encode was wrong without the source being wrong. The first case\nwas HDR: assets uploaded before 2026-09-10 from an iPhone in HDR were relabelled\nrather than tone-mapped and play back washed out. Re-encoding them fixes it.\n\nThe video ladder is always the current default, but the body may carry `audio`\n\u2014 the same field `POST /v1/uploads` takes, see [audio\noptions](https://docs.hostr.sh/concepts/ladder/#audio-options) \u2014 to set the asset\'s audio options for\nthis and every later encode:\n\n```\nPOST /v1/assets/:id/reencode { "audio": "asmr" } # hostr assets reencode <id> --audio asmr\n```\n\nThat is how an asset uploaded before the option existed gets normalised: the\nsource is still in storage. The new ladder\'s measurement and gain replace the\nrecord\'s `loudness` block when it lands.\n\nOnly a `ready` asset can be re-encoded, one re-encode at a time. The request\nreturns `202` and the record carries a `reencode` block while the job runs;\n`video.ready` fires again with `reencoded: true` and the new `rev` when the\nladder is live, and the block clears. Poster and storyboard images keep their\nURLs but change content \u2014 refresh anything you cached from them.\n\n### Editing: renders\n\nAn edit is an ordered list of clips \u2014 each a window of one of your own assets,\nwith its own gain \u2014 that hostr cuts and joins into one video. Nothing is\ntranscoded while the edit is being made (build and preview it however you like);\na render happens once, when you ask.\n\n```\nPOST /v1/renders { "clips": [ \u2026 ], "audio": "asmr" } # a new asset\nPOST /v1/assets/:id/render { "clips": [ \u2026 ] } # re-edit an existing one\n```\n\nEach clip is `{ assetId, inSec, outSec, gainDb? }`. A source must be `playable`\nor `ready`, and is cut from what it plays **now** \u2014 an asset that was already\nre-edited contributes its edited version, and an asset may be a source of its\nown re-edit (the join never overwrites anything). Cuts are frame-accurate. The\nfirst clip sets the picture\'s orientation, size and frame rate; later clips are\nfitted inside it, letterboxed rather than stretched or cropped. Audio is joined\nat 48 kHz stereo, each clip at its `gainDb`; a clip with no audio contributes\nsilence. `audio` is applied once, over the whole joined programme \u2014 so\nnormalisation sees the finished video, not each clip.\n\n**A new asset** (`POST /v1/renders`) behaves like an upload from the moment\nits join exists: `video.uploaded` (with `rendered: true`), then\n`video.playable` and `video.ready`. It sits `queued` while the join runs; if the\njoin fails the asset goes `failed` and `video.errored` fires. Store the\n`assetId` from the `202` straight away.\n\n**A re-edit** (`POST /v1/assets/:id/render`) is a\n[re-encode](#re-encoding) with a new edit as its source: same one-at-a-time\nrule, same `reencode` block, and the current version keeps serving until the\nnew one lands \u2014 then `video.ready` fires with `reencoded: true`,\n`rendered: true` and the new `rev`. A failure at either step leaves the current\nversion live. Because the content changed, a poster you chose is dropped (the\nnew ladder\'s own poster serves) and **subtitles stay attached but no longer\nline up** \u2014 replace them after the new version lands. The record\'s `render`\nblock describes the edit that is live, and later re-encodes build from it.\n\n### Choosing the poster\n\n`POST /v1/assets/:id/poster` with `{ "time": <seconds> }` (`hostr assets poster\n<id> --time <seconds>`) re-takes the poster\nfrom that second of the published ladder \u2014 no re-encode, no source download,\nseconds of work. The picture lands under its own immutable key and the\nrecord\'s `posterRev`/`posterTime` move to it; `thumbnail.jpg` keeps its URL,\nand thumbnail tokens minted from then on serve the new frame.\n`video.poster_ready` fires when it is live. See\n[Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n### Downloading the original\n\n`POST /v1/assets/:id/download` returns a presigned URL for the file exactly as\nit was uploaded \u2014 not a rendition \u2014 valid for 60 to 3600 seconds (default 900).\nThe response saves as an attachment under `filename`; pass your own to choose\nit, and the source\'s extension is added if yours has none.\n\n**This is off for every account until a hostr operator enables it.** It needs\na `playback` key that owns the asset *and* that grant; without the grant the\nowner gets `403 download_not_enabled`. Anyone else gets the usual `401`, so\nthe route never reveals whether an asset exists or whether its owner has\ndownloads on. Mint the URL server-side, only for callers you have authorised,\nand hand it straight to the browser \u2014 it is a bearer credential for the\noriginal bytes until `expiresAt`.\n\n### Deletion\n\n`DELETE /v1/assets/:id` purges the source and every rendition from storage, frees\nthe storage quota, and emits `video.deleted`. Idempotent \u2014 repeat calls report\n`deleted` again with `objectsDeleted: 0`.\n\nNew playback tokens stop minting immediately. Tokens already issued can replay\nedge-cached segments for at most a few hours until they expire. **There is no\nundelete.**\n\n## Quotas and limits\n\nAccounts carry three ceilings, visible any time:\n\n```bash\nnpx @hostr/cli whoami --json # or: GET /v1/account\n```\n\n```json\n{\n "limits": { "maxAssets": 10000, "maxSourceBytes": 5497558138880, "maxUploadsPerDay": 500 },\n "usage": { "assetCount": 1711, "storageBytes": 742381129728, "uploadsToday": 12 }\n}\n```\n\n| Limit | Counts |\n|---|---|\n| `maxAssets` | Assets held at once. Deletion frees this immediately |\n| `maxSourceBytes` | Total source bytes stored |\n| `maxUploadsPerDay` | Upload sessions created per UTC day |\n\n### Hitting one\n\nQuotas are checked when the upload is created, not when bytes arrive. A tripped\nquota is a `403` naming which one and its ceiling:\n\n```json\n{ "error": "quota_storage", "limit": 5497558138880 }\n```\n\nThe three codes are `quota_uploads_daily`, `quota_assets`, and `quota_storage`.\n\n**This means the account needs raising, not that the request was malformed.**\nDistinguish it from a `400` in your error handling \u2014 retrying a quota error\nwithout changing anything will fail identically forever.\n\n### Request-shaped limits\n\nThese are properties of the API rather than of your plan:\n\n| Limit | Value |\n|---|---|\n| Parts per presign request | 100 |\n| Parts per upload | 10,000 (a storage limit; raise `partSize` for very large files) |\n| Subtitle file size | 2,000,000 bytes |\n| Playback token lifetime | 60 s to 86,400 s, default 3,600 |\n| Event page size | 1 to 200, default 50 |\n\nExceeding the part ceiling for a given part size is `413 file_too_large`, and the\nresponse carries the maximum byte count that part size can address.\n\n### Usage over time\n\n```\nGET /v1/usage?from=2026-08-01&to=2026-08-25 (read)\n -> { from, to, days: [{ day, uploads, bytesUploaded, encodedSeconds,\n captionSeconds, deliveredMinutes, deliveredBytes }] }\n```\n\nDefaults to the last 30 days. Dates are UTC and must be `YYYY-MM-DD`; anything\nelse is `400 bad_date`.\n\n**Delivery** is what viewers watched. `deliveredMinutes` counts whole video\nsegments served, on any rung, so one viewer watching one minute is one minute\nwhatever quality they got; audio, posters, storyboards and subtitles add to\n`deliveredBytes` but not to minutes. Edge-cache hits count: a byte served from\nthe cache was still delivered. Delivery is rolled up hourly, so today\'s figure,\nand up to the three hours before the current one, can still grow.\n\n### Not your fault\n\nTwo failures look like integration bugs and are not:\n\n`503 control_plane_unavailable` is transient \u2014 retry with backoff. In-flight\nuploads holding upload tokens are unaffected, and playback is untouched.\n\n`500 signing_not_configured` means the playback signing key is not loaded on the\nAPI deployment. It is operator-side and not retryable from a client. Report it\nrather than working around it.\n\n## Billing\n\nhostr is pay-as-you-go: no plan and no monthly fee. You pay for what you\nencode, store, deliver and caption, and **the first $5 is free**.\n\n| Line | Price (USD) |\n|---|---|\n| Encoding | $0.015 per source minute |\n| Video storage | $0.003 per minute stored, per month |\n| Original files | $0.03 per GB stored, per month |\n| Delivery | $0.0005 per minute watched |\n| Captions: transcription | $0.006 per minute |\n| Captions: translation | $0.001 per minute, per language |\n\nStorage is the daily average over the month. Delivery counts whole video\nsegments served, on any quality rung, so one viewer watching one minute is one\nminute. Original files are the uploads hostr keeps so it can re-encode or hand\nthem back; they are billed by size because a 4K original can be many times\nlarger than its encoded video.\n\n### Billing is per organization\n\nYou pay as an **organization**, not per project. An organization holds the card,\nthe invoices and the free allowance; a project (what the API calls an account)\nholds keys, assets, webhooks and limits. However many projects an organization\nhas, it gets **one card, one invoice a month and one $5 allowance**, and every\nproject\'s usage counts towards them together. The invoice shows a line per\nproject per meter, so you can see what each product costs.\n\nA card added to the organization opens billable work for every project in it,\nwithin a minute.\n\n### The free allowance, then a card\n\nEvery organization\'s first **$5** of usage is free, once, across all its projects: about 330 minutes encoded,\nor 10,000 minutes watched. That is enough to build and test a whole integration,\nupload to playback to webhook, without a card. It counts every line above.\n\nWithout a card you can always sign in, run `npx @hostr/cli login`, create keys,\nconfigure webhooks and read the docs. Once the allowance is used up, an upload,\nrender, re-encode or captions request answers:\n\n```\n402 { "error": "payment_method_required", "billingUrl": "https://dashboard.hostr.sh/orgs/<orgId>/billing", "hint": "\u2026" }\n```\n\nA human adds the card at `billingUrl`. Uploads work within a minute of it being\nadded. An agent setting up an integration should finish its testing, then hand\nthe human that link before the integration goes live.\n\n`GET /v1/billing` reports where you stand: `allowance.leftUsd`, and\n`cardRequired`, which says whether the card requirement is on at all. Read\nthose rather than `status`: `needs_card` means only that no card is on file.\n\n#### During the beta\n\n**The card requirement is off, and usage is not billed.** Uploads work without a\ncard whatever you use, `cardRequired` is `false`, and a month that closes during\nthe beta is recorded as not chargeable. It does not draw on the allowance\neither: the $5 starts counting when the beta ends. Adding a card early is fine;\nnothing is charged for beta usage.\n\n### How a month becomes an invoice\n\nEach UTC month closes on the 1st, per project. The organization\'s charges are invoiced and charged to the\ncard once the unbilled balance, after the free allowance, reaches **$5**; a\nsmaller month carries over and is added to the next. The first invoice shows the\nallowance as its own credit line. Sales tax is added where it applies. The $5\nthreshold is the organization\'s total: two projects at $3 each are invoiced\ntogether.\n\nA failed charge does not stop anything: the organization shows as `past_due`, the\ncharge is retried, and the dashboard says so.\n\n### Checking what you owe\n\n```\nGET /v1/billing (read)\n -> { status, cardRequired, allowance: { totalUsd, leftUsd }, billingUrl, thresholdUsd, carriedUsd,\n currentMonth: { month, lines: [{ key, quantity, unitUsd, amountUsd }], accruedUsd },\n nextInvoiceUsd, prices, organization: { id, name }, project: { accruedUsd } }\n```\n\nEvery figure is the **organization\'s**, summed over all its projects, because\nthe card requirement a key runs into is the organization\'s. `project.accruedUsd`\nis the calling key\'s own project this month. Other projects are counted in the\ntotals but never named, so a key handed to a contractor for one product does\nnot reveal the others.\n\n`currentMonth` is what has accrued so far, from the same meters as\n`GET /v1/usage`. `status` is `needs_card`, `active`, `past_due` or `exempt`;\n`cardRequired` says whether a missing card is refused right now.\n\nThe `plan` field on `GET /v1/account` (and in `whoami`) names the account\'s\nquota tier, the set of limits on [Quotas and limits](#quotas-and-limits). It is\nnot a billing plan: there is one price list, above.\n\n## Upload from a server\n\nFour calls. Bytes go straight to storage via presigned URLs and never transit\nhostr\'s compute.\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,3]} (at most 100 per call)\n -> { urls: [{ partNumber, url }], expiresIn }\n\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nPOST /v1/uploads/:id/complete {"sha256":"<hex of the whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\n```\n\nPlus `GET /v1/uploads/:id` for resume and `DELETE /v1/uploads/:id` to abort.\n\n### Completion recovery for mobile uploads\n\nSet `autoComplete: true` on **creation** to let Hostr finalize the upload when\nstorage contains every numbered part at exactly the declared sizes. It defaults\nto false, preserving the existing client-driven completion and SHA-256 flow.\nThe phone may still call `/complete`; it is serialized with server recovery.\nDo not opt in if completion must wait for a later client SHA-256 assertion:\nautomatic completion may win that race and that later assertion is not retained.\n\nAn alarm starts checking after one minute. Checks back off to fifteen minutes\nwithout progress and stop after seven days without observed progress; a new\npresign or token request rearms them. These checks never discard incomplete\nparts. The existing abandonment policy remains separate. GET upload status\nincludes `activity` with the last check, last observed progress, stored bytes,\npart numbers, and a waiting flag. That flag diagnoses inactivity, not its cause.\n\nRecovery persists the verified part manifest before completing storage, confirms\nthe object\'s size and composite ETag after a lost completion response, and\nresumes a partially published transcode job. The standard `video.uploaded` event\nand usage metering share a stable completion event ID; queue messages remain\nat-least-once. Cancel and complete are serialized for opted-in sessions.\n\nOn iOS, enqueue all available file-backed part tasks while execution is\navailable, with bounded staging concurrency and a disk-space reserve. Do not\ngate preparation of the next part on the previous network completion. iOS still\ncontrols when transfers run; force-quitting requires relaunch to resume.\n\n### The rules that bite\n\n**Every part is exactly `partSize` bytes except the last.** Use the `partSize`\nthe server returned, not one you picked.\n\n**`size` must be exact.** Completion verifies both the part count and the total\nbyte count against what actually landed. Mismatches are `409`:\n\n- `incomplete_upload` \u2014 `missing` lists the absent part numbers, first 50. Upload\n them and complete again.\n- `size_mismatch` \u2014 `declaredSize` and `receivedBytes` are both reported. Abort\n and restart; the file you declared is not the file you sent.\n\nPart count alone would accept N parts of the wrong sizes, which is why both are\nchecked.\n\n**Request presigned URLs only for the parts you are about to send.** They expire.\nMinting all of them up front for a large file wastes most of them.\n\n**Completion is idempotent.** A client that crashed after bytes landed but before\nit saw the response can safely retry and will get the stored result back rather\nthan a conflict.\n\n### Worked example\n\n```bash\nAPI=https://api.hostr.sh\nKEY=$HOSTR_API_KEY\nFILE=video.mp4\nSIZE=$(wc -c < "$FILE")\n\n# 1. Create the session.\nCREATE=$(curl -sS -X POST "$API/v1/uploads" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"filename\\":\\"$FILE\\",\\"contentType\\":\\"video/mp4\\",\\"size\\":$SIZE}")\n\nASSET=$(echo "$CREATE" | jq -r .assetId)\nPART_SIZE=$(echo "$CREATE" | jq -r .partSize)\nPARTS=$(echo "$CREATE" | jq -r .expectedParts)\n\n# 2. Split, presign, and PUT each part.\nsplit -b "$PART_SIZE" "$FILE" part-\ni=1\nfor p in part-*; do\n URL=$(curl -sS -X POST "$API/v1/uploads/$ASSET/parts" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"partNumbers\\":[$i]}" | jq -r \'.urls[0].url\')\n curl -sS -X PUT --data-binary "@$p" "$URL" -o /dev/null\n i=$((i + 1))\ndone\n\n# 3. Finalise.\ncurl -sS -X POST "$API/v1/uploads/$ASSET/complete" \\\n -H "authorization: Bearer $KEY" -H \'content-type: application/json\' \\\n -d "{\\"sha256\\":\\"$(shasum -a 256 "$FILE" | cut -d\' \' -f1)\\"}"\n```\n\nBatch the presign calls in real code \u2014 up to 100 part numbers per request.\n\n### Send the sha256\n\nThe `etag` completion returns is a *composite* multipart ETag: a hash of hashes,\nand dependent on the part size you used. It is not a content identity and cannot\nbe compared against a hash you computed locally.\n\nIf you want real integrity, send `sha256` \u2014 the lowercase hex SHA-256 of the\nwhole file. It is stored as object metadata and is what an independent\nverification would check against. A malformed value is ignored rather than\nrejected, since retrying the call cannot fix it.\n\n### Credentials\n\n`POST /v1/uploads` needs an API key with `upload` scope. The remaining upload\nroutes accept **either** that key or the `uploadToken` returned at creation.\n\nRe-minting a token (`POST /v1/uploads/:id/token`) requires the API key \u2014 not the\nexpiring token, which would otherwise be able to renew itself forever.\n\nUploading from a browser or mobile client? Do not ship the API key. See\n[Upload from a browser](#upload-from-a-browser).\n\n## Upload from a browser\n\nCreate the upload on your server, hand the client a narrow credential, and let it\nPUT parts directly. Upload bytes never touch your servers.\n\n### Forward only the client-safe fields\n\n`POST /v1/uploads` returns more than a client should see:\n\n```json\n{\n "assetId": "...", // safe\n "uploadToken": "...", // safe\n "tokenExpiresAt": 1756130400000, // safe\n "partSize": 8388608, // safe\n "expectedParts": 13, // safe\n\n "key": "acct_7Kd2/.../source.mp4", // storage internal \u2014 do NOT forward\n "uploadId": "2~aBcDeF..." // storage internal \u2014 do NOT forward\n}\n```\n\nForward exactly `assetId`, `uploadToken`, `tokenExpiresAt`, `partSize`, and\n`expectedParts`. Passing the whole body through leaks your storage layout to\nevery client.\n\nThe `uploadToken` is scoped to that one asset and grants nothing else \u2014 no other\nasset, and nothing on playback. That is why it is safe to hand out and an API key\nis not.\n\n### Origins: none to register\n\nPart PUTs go **directly to object storage**, not through the API. On hosted\nhostr the source bucket accepts them from **any origin** \u2014 production,\n`localhost` on any port, `*.vercel.app` previews \u2014 with nothing to ask for or\nconfigure. The presigned part URL is the credential, so there is no origin list\nto keep in sync.\n\n**Your own Content-Security-Policy can still block it.** If your app sets\n`connect-src`, add the storage host the part URLs point at\n(`https://*.r2.cloudflarestorage.com`) alongside the API host. A blocked\n`connect-src` fails exactly like being offline.\n\n**Self-hosting**, you own the bucket and set this rule on it yourself:\n\n```json\n{\n "AllowedOrigins": ["*"],\n "AllowedMethods": ["PUT"],\n "AllowedHeaders": ["*"],\n "ExposeHeaders": ["ETag"]\n}\n```\n\n`ExposeHeaders: ["ETag"]` is not optional \u2014 the client verifies each part\'s ETag\nand cannot read it otherwise. `AllowedHeaders` is not optional either: a part\nwhose `Blob` has a type sends `Content-Type`, and a preflight that refuses it\nblocks every part.\n\n### The failure mode, stated plainly\n\n**A browser that cannot reach storage is silent from the API\'s perspective.**\nThe upload session exists, zero parts ever land, and the client cycles\nrunning/stalled as though the network were flaky. To `XMLHttpRequest` a blocked\nPUT \u2014 by your CSP, an extension, a proxy, or (self-hosting) bucket CORS \u2014 is\nindistinguishable from a network error; the browser withholds the detail.\n\n**First move when a browser upload stalls at 0%: open the network tab and look\nat the part PUT** (and its `OPTIONS` preflight), not the Worker.\n\nThis cost real time on the first customer integration, which is why the SDK\nsurfaces it itself. After three consecutive network-class part failures with\n**zero bytes ever confirmed** for the session, it emits:\n\n```js\nupload.on(\'diagnostic\', d => {\n // { code: \'systemic-transport-failure\', consecutiveNetworkFailures, bytesEverConfirmed, hint }\n console.warn(d.hint);\n});\n```\n\nZero-ever-confirmed is what separates this from ordinary mid-upload network loss.\nIt is **advisory, never terminal**: a client that is genuinely offline from byte\nzero looks identical, so the upload keeps retrying regardless. The event is the\nhook for telling the user to check their connection \u2014 or for telling *you* to\ncheck your CSP.\n\n### Token expiry mid-upload\n\nLong uploads outlive their token. Handle it on your server:\n\n```js\nconst upload = createUpload({\n file,\n assetId,\n uploadToken,\n onTokenExpired: async () => {\n const r = await fetch(\'/api/hostr/refresh-token\', {\n method: \'POST\',\n body: JSON.stringify({ assetId }),\n });\n return (await r.json()).uploadToken; // your server calls POST /v1/uploads/:id/token\n },\n});\n```\n\nThe SDK re-mints and retries in place. Nothing is lost even without this \u2014 a\nkilled client resumes from what storage reports on the next launch \u2014 but the hook\navoids the interruption.\n\nSee the [SDK reference](#sdk-reference) for the full option surface.\n\n## Resume and integrity\n\n### Resume asks storage, not the client\n\n```\nGET /v1/uploads/:id\n -> { status, partSize, expectedParts, uploadedParts, parts, bytesUploaded }\n```\n\n`uploadedParts` is part numbers only; `parts` is the richer form, each entry\ncarrying `partNumber`, `size`, and `etag`.\n\nThe API derives this by listing what is actually in storage, not by reading\nbookkeeping the client sent. That is the whole design: a crash, a refresh, a\ndropped connection, or a client that lied all converge on the same truth. Resume\nis simply "send the parts that are not in that list".\n\nParts are listed only while `uploading`. Once completed or aborted the list is\nempty, because there is nothing left to resume.\n\nResume has an outer limit: an upload with no activity for 14 days is collected\nand moved to `aborted`. That window is twice the upload token\'s lifetime, so an\nexpired token never costs you the upload \u2014 re-mint at\n`POST /v1/uploads/:id/token` and carry on. See\n[Assets and lifecycle](#assets-and-lifecycle).\n\n### Three layers, three different claims\n\nEach layer proves something the others do not.\n\n**Per-part ETag.** Storage returns an ETag on every part PUT. The client compares\nit against the part it just sent, so a corrupted or truncated part is caught\nimmediately rather than at the end of a multi-gigabyte upload.\n\n**Part count and total size at completion.** Completion refuses to finalise\nunless the number of parts and the total byte count both match what was declared\nat creation. Count alone would accept N parts of the wrong sizes.\n\n**Whole-file SHA-256, if you send it.** This is the only one that proves content\nidentity.\n\n### Why the ETag is not enough\n\nThe ETag on a completed multipart object is a **hash of the part hashes**, then\nthe part count \u2014 `b8f1c0d9e2a34567-13`. Two consequences:\n\n- It cannot be compared against a hash you compute locally.\n- The same file uploaded with a different `partSize` produces a different ETag.\n\nSo it is a useful transfer check and a useless content identity. Send `sha256`\n(lowercase hex of the whole file) at completion if identity matters; it is stored\nas object metadata.\n\nIndependent verification, deriving both from the bytes actually in storage rather\nthan from anything the client claimed:\n\n```bash\npnpm verify:upload <assetId>\n```\n\n### Completion is idempotent\n\nA client that crashed after the bytes landed but before it saw the response\nretries and gets the stored `etag` and `bytes` back, not a `409`. Any genuinely\nunfinished status still conflicts with `not_uploading`.\n\nThis matters more than it sounds: the window between "storage has everything" and\n"the client knows" is exactly where flaky mobile connections die.\n\n### Aborting\n\n```\nDELETE /v1/uploads/:id\n```\n\nAn in-flight multipart session holds part storage that is invisible in normal\nlistings. Abandoning an upload without aborting leaks it. The SDK and CLI do this\nfor you; a hand-rolled client should too.\n\n## Webhooks and the event log\n\nhostr POSTs lifecycle events to one endpoint per account so you do not have to\npoll.\n\n```\nPUT /v1/webhooks {"url": "https://yourapp.example/hostr-webhook"} (full)\n -> { url, enabled, secret: "whsec_..." } # secret returned ONLY when generated, ONLY once\nGET /v1/webhooks -> { url, enabled, createdAt } # never the secret\nDELETE /v1/webhooks -> disables; delivery history is retained\n```\n\nSupply `secret` in the PUT to bring your own. Omit it and a **live endpoint\nkeeps the secret it already has**, so changing the URL or re-running setup never\nbreaks signature checks; a new endpoint (or one switched off with DELETE) gets a\ngenerated one. `rotate: true` (CLI: `--rotate`) replaces a live secret \u2014 deploy\nthe new value promptly, since deliveries are signed with it at once. Configuration is cached about 60 seconds in the Worker, the same\npropagation window as key revocation.\n\nA URL configured without a secret logs and skips: **unsigned events are never\nsent.**\n\n### The log is written first\n\nEvery event becomes a durable row **before** delivery is attempted. A consumer\noutage cannot lose one.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n**The webhook is the wake-up signal; the log is the ledger.** Delivery is\nat-least-once \u2014 three attempts with short backoff, each recorded and inspectable.\nA consumer that must not miss anything reconciles against `GET /v1/events` (or\n`GET /v1/assets/:id`) rather than trusting that every POST arrived.\n\n`npx @hostr/cli tail --follow` polls the log for you.\n\n### Building a consumer that behaves\n\n**Verify the signature before parsing.** See\n[Verifying webhooks](#verifying-webhook-signatures) \u2014 use the raw body bytes.\n\n**Dedupe on `id`.** It is the durable event row\'s id, and a redelivery reuses it\ndeliberately so your dedupe still works.\n\n**Respond `2xx` fast.** Do the real work asynchronously. Anything else is\nretried.\n\n**Expect at-least-once, and design for it.** `video.ready` and `video.errored`\nfire on the status *transition* only, so a transcoder re-posting a terminal state\ncannot duplicate them \u2014 but the network can still deliver the same event twice.\n\n### Testing locally\n\nBoth `https` and `http` endpoints are accepted, including private addresses.\nhostr does not block internal ranges, so a local receiver works in development\nexactly as production does.\n\n```bash\nnpx @hostr/cli webhooks test # redelivers the newest event to your endpoint\n```\n\nSee the [events reference](#event-reference) for every type and payload.\n\n## Verifying webhook signatures\n\nEvery delivery carries a `hostr-signature` header:\n\n```\nhostr-signature: t=1756130400,v1=3b1f2c9d8e7a6b5c4d3e2f1a0b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c\n```\n\n`v1` is `HMAC-SHA256(secret, "<t>.<rawBody>")`, hex-encoded. `t` is a unix\ntimestamp in **seconds**. This is the Stripe scheme, and Mux\'s.\n\n### Implementation\n\n```js\nimport { createHmac, timingSafeEqual } from \'node:crypto\';\n\nexport function verifyHostrWebhook(secret, header, rawBody, toleranceSec = 300) {\n const m = /^t=(\\d+),v1=([0-9a-f]{64})$/.exec(header ?? \'\');\n if (!m) return false;\n if (Math.abs(Date.now() / 1000 - Number(m[1])) > toleranceSec) return false;\n const expected = createHmac(\'sha256\', secret).update(`${m[1]}.${rawBody}`).digest(\'hex\');\n return expected.length === m[2].length &&\n timingSafeEqual(Buffer.from(expected), Buffer.from(m[2]));\n}\n```\n\nWired into Express, where getting the raw body needs care:\n\n```js\nimport express from \'express\';\n\nconst app = express();\n\napp.post(\'/hostr-webhook\',\n express.raw({ type: \'application/json\' }), // NOT express.json()\n (req, res) => {\n const raw = req.body.toString(\'utf8\');\n if (!verifyHostrWebhook(process.env.HOSTR_WEBHOOK_SECRET, req.get(\'hostr-signature\'), raw)) {\n return res.status(400).end();\n }\n const event = JSON.parse(raw); // parse only after verifying\n res.status(200).end(); // ack fast\n queue.push(event); // then do the work\n });\n```\n\n### The three ways this goes wrong\n\n**Using the parsed body instead of the raw bytes.** `JSON.parse` followed by\n`JSON.stringify` does not reproduce the original bytes \u2014 key order, whitespace,\nand number formatting can all shift. The HMAC is over what was actually sent.\nRegister the raw body parser on this route specifically.\n\n**A plain `===` on the hex digest.** String comparison short-circuits on the\nfirst differing character, which leaks timing information. Use\n`timingSafeEqual`, and compare lengths first \u2014 it throws on mismatched lengths.\n\n**Ignoring `t`.** Without a bounded timestamp window a captured delivery can be\nreplayed at any time in the future. Five minutes is a reasonable tolerance.\n\n### Do not confuse the units\n\n`t` is unix **seconds**. Elsewhere in the API, `tokenExpiresAt` and `expiresAt`\nare epoch **milliseconds**. That is deliberate \u2014 each convention matches its home\n(Stripe\'s for signatures, Mux\'s for token bundles) rather than being made falsely\nuniform. Mixing them up produces a timestamp roughly 55,000 years out, which at\nleast fails loudly.\n\n### Getting the secret\n\n```bash\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --write-env .env\n```\n\nWritten straight to the file at mode 0600; only the variable name is printed. The\nsecret is returned **once**, in the `PUT /v1/webhooks` response, and never again\n\u2014 `GET /v1/webhooks` deliberately does not include it. Lost it? Set a new one.\n\n### Proving it end to end\n\n```bash\nnpx @hostr/cli webhooks test\n```\n\nRedelivers the newest stored event to your endpoint, so you can confirm the whole\npath without uploading anything.\n\nIf your endpoint was down when an event fired, nothing was lost \u2014 see\n[Webhooks and the event log](#webhooks-and-the-event-log).\n\n## Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `playback`-scoped\nkey.\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"vp_5b0e2a7c9d1f4e83"} (body optional)\n -> { playback, thumbnail, storyboard, expiresAt }\n```\n\n```bash\nnpx @hostr/cli assets token <assetId> --json\n```\n\nField names mirror Mux\'s bundle, so a consumer migrating swaps its base URL and\nkeeps its client code.\n\n### Build the URL\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\nHand it to any HLS player. Safari and iOS play HLS natively; elsewhere use\nhls.js.\n\n```html\n<video id="v" controls></video>\n<script type="module">\n import Hls from \'https://cdn.jsdelivr.net/npm/hls.js@1/+esm\';\n const src = `https://stream.hostr.sh/${playbackId}.m3u8?token=${token}`;\n const v = document.getElementById(\'v\');\n if (v.canPlayType(\'application/vnd.apple.mpegurl\')) v.src = src;\n else if (Hls.isSupported()) { const h = new Hls(); h.loadSource(src); h.attachMedia(v); }\n</script>\n```\n\n### Mint timing, and what to gate on\n\nMinting works any time after the asset exists \u2014 it 404s only for unknown or\ndeleted assets. **The stream itself 404s until the asset is `ready`.**\n\nSo gate your UI on `video.ready` or the asset\'s status, never on whether minting\nsucceeded. A successful mint for a still-encoding asset is expected behaviour,\nnot a green light.\n\n### The TTL is not a viewing limit\n\nThe token is checked **once**, at the master playlist. After that the session\ncarries its own internally-managed credentials, so a viewer who starts playback\nat minute 59 of a one-hour token watches the whole video.\n\n`ttlSec` accepts 60 to 86,400 and defaults to 3,600. It only needs to be valid\nwhen playback *starts*.\n\n### viewerId\n\nOptional, opaque, and carried inside the token. It enables per-viewer concurrency\nobservation and attributes watch data to a viewer. Concurrency is currently\nobserved rather than enforced \u2014 see [Tokens and signed playback](https://docs.hostr.sh/concepts/playback/).\n\n**Send a pseudonym, never a user id or email.** The token sits in playback\nURLs, and anyone holding a URL can read its claims. Use a random id you store\nagainst the user (for example a UUID column) and send that. It stays stable\nfor the user, so watch history stays joined, and deleting it erases the link\nback to the person. Do not derive it from the user id with a hash: anyone who\ncan guess user ids can recompute it, and it cannot be erased per user. Choose\nbefore you start sending one, because changing it later splits that viewer\'s\nhistory in two. Omit it for signed-out viewers; they are counted, not\nattributed.\n\n### Things that will confuse you once\n\n**Non-browser user agents are rejected** with `403 blocked_user_agent` \u2014\ncurl, wget, yt-dlp, vlc, and ffmpeg UAs are blocked as a casual-ripping speed\nbump. A server-side smoke test must send a browser-like `User-Agent` or hit\n`/health`, which is exempt. Expect it; do not debug it.\n\n**Signed URLs are not DRM.** They stop link-sharing and hotlinking, not an\nentitled viewer recording their screen.\n\n**Deleted assets stop minting immediately**, but tokens already issued can replay\nedge-cached segments for a few hours until they expire.\n\n### Poster and scrub previews\n\nThe same mint response covers both, each URL taking its matching token:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSee [Thumbnails and storyboards](#thumbnails-and-storyboards).\n\n### Audio only, for transcription\n\nThe `playback` token also opens an audio-only file, available seconds into the\nencode \u2014 before the asset is playable:\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nSee [Audio for speech-to-text](#audio-for-speech-to-text).\n\n## Thumbnails and storyboards\n\n`POST /v1/assets/:id/tokens` returns three tokens, not one. The `thumbnail` and\n`storyboard` values drive the poster image and the scrub preview.\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nSame host, same query-token shape as the playlist. Each URL takes its **own**\ntoken \u2014 they are minted with different audiences and are not interchangeable.\n\n### Poster\n\nA single JPEG extracted during encoding. Use it as the `poster` attribute:\n\n```html\n<video poster="https://stream.hostr.sh/abc123/thumbnail.jpg?token=..." controls></video>\n```\n\n### Choosing the poster frame\n\nThe encode-time poster is a frame 10% in \u2014 a fine default and, often enough,\nthe wrong picture. `POST /v1/assets/:id/poster` re-takes it from any second:\n\n```json\nPOST /v1/assets/{id}/poster\n{ "time": 12.5 }\n\n202 { "assetId": "...", "rev": "mfc9z0aa", "time": 12.5, "queued": true }\n```\n\nThe frame is cut from the best rung the ladder has published \u2014 a `playable`\nasset works, at the quality it has so far \u2014 and lands under its own immutable\nkey. `thumbnail.jpg` keeps its URL and changes content: wait for\n`video.poster_ready` (it carries the same `rev`), then **mint a fresh token\nbundle** \u2014 a `thumbnail` token minted before the job landed keeps serving the\nold picture until it expires. Store a copy if you serve posters from your own\nCDN; that is what the event is for.\n\nPoster jobs run on their own lane: a running worker takes them beside\nwhatever it is encoding, so a frame never waits behind a ladder. What it can\nwait behind is a cold start \u2014 the fleet scales to zero and a machine is about\nseven seconds from taking a job. `POST /v1/assets/:id/poster/warm` starts one\nahead of time; call it when a user opens your thumbnail editor and the frame\nthey pick a moment later is seconds away. It is a no-op while any machine is\nup, and costs one machine\'s idle window when nothing follows.\n\nThe record says which frame is live (`posterRev`, `posterTime`) and whether a\njob is running (`poster`, `null` once it lands). One job at a time per asset;\na failed or ten-minute-stale request no longer blocks the next one. There is\nno way to render an arbitrary frame on request \u2014 the media host has no\ndecoder \u2014 so build a scrubber on the storyboard below and confirm with one\ncall, rather than previewing through this endpoint.\n\n### Storyboard\n\nA standard "thumbnails" WebVTT track. Cues point into a tiled sprite using\n`#xywh=` fragments, and **the sprite URLs inside the VTT are pre-signed for you**\n\u2014 you do not mint anything extra.\n\nThat is the convention mux-player\'s `storyboard-src`, media-chrome\'s preview\ntrack, and most other players already consume:\n\n```html\n<mux-player\n storyboard-src="https://stream.hostr.sh/abc123/storyboard.vtt?token=..."\n></mux-player>\n```\n\n### Treat both as optional\n\nAssets encoded before storyboards existed 404 on that URL, and scrubbing simply\nshows no preview. Players handle this gracefully by design \u2014 do not add error\nhandling that turns a missing preview into a broken player.\n\n## Audio for speech-to-text\n\nIf you want hostr to do the transcription, see\n[Auto-captions](#auto-captions-and-translation). This page is for bringing your own\ntranscriber.\n\nTranscription services want one audio file URL, not an HLS playlist. hostr\nmakes one per asset \u2014 `audio.m4a`, a single AAC track in an MP4 container \u2014\nand makes it **first**, ahead of every video rendition. A twenty-minute\nsource has its audio file within seconds of the job starting, minutes before\n`video.playable`.\n\n```\nhttps://stream.hostr.sh/{playbackId}/audio.m4a?token={playback}\n```\n\nIt takes the **`playback`** token from `POST /v1/assets/:id/tokens` \u2014 the same\none that opens the master playlist. There is no fourth token to mint.\n\n### When it exists\n\nListen for **`video.audio_ready`**. It fires once, the moment the file is in\nthe media bucket, and carries `assetId`, `playbackId`, `durationSec` and an\n`audio` block (`bytes`, `codec`, `channels`, `sampleRate`, `copied`).\n\n```json\n{\n "id": "evt_01j\u2026",\n "type": "video.audio_ready",\n "createdAt": "2026-09-08T10:14:07.412Z",\n "data": {\n "assetId": "9f8c1e2a\u2026",\n "playbackId": "9f8c1e2a\u2026",\n "status": "processing",\n "durationSec": 1842.4,\n "audio": { "bytes": 29478912, "codec": "aac", "channels": 2, "sampleRate": 48000, "copied": true }\n }\n}\n```\n\nThe event also carries `loudness` \u2014 the probe\'s EBU R128 measurement of the\nsource, see [the ladder](https://docs.hostr.sh/concepts/ladder/#loudness-is-measured-for-every-asset).\n\nThe same block is on the asset record as `audio`. It is **absent** until the\ntranscoder has looked at the source and **`null`** when the source has no\naudio stream \u2014 in which case `video.audio_ready` never fires, and a poller can\ntell "no audio" from "not yet" without waiting.\n\nBefore the file exists the URL is a plain `404`.\n\n### Assets you uploaded before this existed\n\nEvery upload since 2026-09-09 gets its audio file automatically. Assets\nencoded before that have none, and nothing will produce one on its own \u2014 so a\ncatalogue uploaded before you added transcription cannot be captioned until\nyou ask for it. Ask once per asset:\n\n```\nPOST /v1/assets/{id}/audio\n```\n\nIt requires the `upload` scope (this is content generation, the same rung as\nattaching a subtitle track) and answers `202 {"assetId": "\u2026", "queued": true}`.\nThe job publishes `audio.m4a` and nothing else \u2014 no re-encode of the ladder,\nno new playlists, no change to the asset\'s status \u2014 and then fires\n`video.audio_ready` exactly as a fresh upload would, so the consumer you\nalready wrote needs no second code path.\n\nTwo refusals, both `409`: `already_has_audio` when the record already carries\nan `audio` block, and `not_encoded` when the asset has no finished source\nobject yet (an upload still in flight, or aborted). Calling it twice is safe\nregardless \u2014 a job that finds the file already in the bucket reports it\ninstead of re-encoding.\n\nTo sweep a whole account, the CLI does the paging and the per-asset check:\n\n```bash\nnpx @hostr/cli assets audio --missing # every playable/ready asset with no audio\nnpx @hostr/cli assets audio <assetId> # just this one\n```\n\n`--missing` skips assets whose `audio` is `null` \u2014 the transcoder already\nlooked and the source is silent, so there is nothing to extract.\n\n### Handing it to a transcriber\n\nMint a token whose lifetime covers the fetch, then pass the URL straight\nthrough. AssemblyAI, for example:\n\n```bash\nTOKEN=$(npx @hostr/cli assets token "$ASSET" --ttl 3600 --json | jq -r .playback)\n\ncurl -X POST https://api.assemblyai.com/v2/transcript \\\n -H "authorization: $ASSEMBLYAI_API_KEY" \\\n -H \'content-type: application/json\' \\\n -d "{\\"audio_url\\":\\"https://stream.hostr.sh/$ASSET/audio.m4a?token=$TOKEN\\"}"\n```\n\nThe token only has to be valid when the fetch **starts**, but give it a few\nminutes of headroom: services queue downloads. `Range` requests are honoured\n(`206`, `Accept-Ranges: bytes`), so a fetcher that streams or resumes works.\n\n**The user-agent blocklist does not apply to this URL.** Playlists reject\ncurl-like user agents as a casual-ripping speed bump; a transcription\nservice\'s fetcher is exactly the kind of client that rule would catch, so the\naudio file is exempt. The token is still required.\n\n### What is in the file\n\n- **AAC source, no normalisation** \u2014 the stream is copied untouched: the\n source\'s channel count, sample rate and bitrate, no decode. `copied: true`.\n- **Anything else** \u2014 encoded once with the ladder\'s own audio settings (AAC,\n 48 kHz, the top group\'s bitrate, the same filter chain), so what the\n transcriber hears is what the viewer hears. `copied: false`. An asset with\n [normalisation](https://docs.hostr.sh/concepts/ladder/#audio-options) on is always this case:\n the file must carry the same gain the viewer gets.\n\nEither way the `moov` atom is at the front, and the object is versioned with\nthe asset and immutable.\n\n### Then attach the result\n\nWhen the transcript comes back, attach it with\n`PUT /v1/assets/:id/subtitles/:lang` (raw WebVTT). It is accepted as soon as the\nasset has a known duration, which `video.audio_ready` guarantees \u2014 so a\ntranscript that comes back before the asset is `playable` attaches without\nwaiting or retrying. See [Subtitles](#subtitles).\n\n## Subtitles\n\nHuman-authored WebVTT tracks, attachable any time after the asset is `ready`.\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { assetId, tracks: [{ lang, name, default, rev, source? }] }\nDELETE /v1/assets/:id/subtitles/:lang\n```\n\n```bash\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\nnpx @hostr/cli subtitles list <assetId>\nnpx @hostr/cli subtitles rm <assetId> en\n```\n\nThe PUT body is **raw WebVTT, not JSON**. It must start with `WEBVTT`, and is\ncapped at 2,000,000 bytes.\n\n```bash\ncurl -X PUT "https://api.hostr.sh/v1/assets/$ASSET/subtitles/en?name=English&default=true" \\\n -H "authorization: Bearer $HOSTR_API_KEY" \\\n -H \'content-type: text/vtt\' \\\n --data-binary @subs.vtt\n```\n\n### Rules\n\n**`:lang` is a BCP 47 tag** \u2014 `en`, `pt-BR`. One track per tag; PUT replaces.\nAnything that is not a valid tag is `400 bad_language`.\n\n**The asset must have a known duration.** Before that it is `409 not_ready` \u2014\nthe track playlist needs it. It is known from `video.audio_ready` (seconds\ninto the job, once the transcoder has probed the source) and from `playable`\nonwards; a track attached while the ladder is still encoding appears the\nmoment the master playlist is served, because tracks are injected at serve\ntime, not baked into the encode.\n\n**`source` says who wrote it.** Absent on your tracks; `auto` / `auto-translated` on\nmachine-made ones (see [Auto-captions](#auto-captions-and-translation)). A PUT always makes the\ntrack yours.\n\n**`default=true` demotes any previous default.** At most one default per asset.\n\n**Write scope is `upload`, not `playback`.** Subtitles are content, not\nentitlement.\n\n### How they reach the player\n\nTracks are injected into the master playlist at serve time as an `EXT-X-MEDIA`\nsubtitles group. The next master fetch after a PUT includes them.\n\n**No re-encode. No new playback URL. No token change.** Players pick them up in\nthe captions menu automatically.\n\nTracks are stored under a content-hash revision, so replacing one is cache-safe\neven though segments are served with a one-year immutable cache. A viewer already\nmid-session keeps the track they started with; the replacement applies to the next\nplayback session.\n\n### Deleting\n\nRemoval delists the track first \u2014 that is what makes it invisible to players. The\nunderlying versioned objects become unreachable and are purged with the asset, so\nleftovers cost nothing.\n\n`404 subtitle_not_found` means no track for that tag; `404 asset_not_found` means\nno such asset. They are distinct codes.\n\n## Auto-captions and translation\n\nhostr transcribes an asset\'s audio and attaches the result as an ordinary\n[subtitle track](#subtitles), then translates it into the languages\nyou name. Nothing re-encodes; players pick the tracks up from the master\nplaylist like any other.\n\n```\nPUT /v1/account/captions { "enabled": true } \u2014 turn captions on (full scope); off by default\nPOST /v1/uploads { ..., "captions": { "language": "auto", "translate": ["en","es"], "context": "Creator: Ana" } }\nPOST /v1/renders same field\nPOST /v1/assets/:id/render same field \u2014 applied when the new cut lands\nPOST /v1/assets/:id/captions transcribe now, or redo (body optional)\nPOST /v1/assets/:id/captions/translate { "languages": ["fr"] } \u2014 from the stored transcript\nGET /v1/assets/:id the `captions` block: transcript + per-language state\nGET /v1/assets/:id/subtitles tracks carry `source`: absent (yours), `auto`, `auto-translated`\n```\n\nWriting captions \u2014 the `captions` field and both `POST` routes \u2014 needs `upload`\nscope: captions are content, not entitlement. Reading them back\n(`GET /v1/assets/:id`, `GET \u2026/subtitles`) needs only `read`.\n\n### Turn them on\n\nCaptions are **off** for a new account, because they cost money. Turn them on\nonce, for the whole account:\n\n```\nPUT /v1/account/captions { "enabled": true } on, no monthly limit\nPUT /v1/account/captions { "enabled": true, "monthlyMinutes": 600 } on, at most 600 transcription minutes a month\nPUT /v1/account/captions { "enabled": false } off (forgets the limit)\n```\n\nThe same switch is on the dashboard\'s Usage page and in the CLI\n(`hostr captions enable [--limit <minutes>]`, `hostr captions disable`,\n`hostr captions info`). It needs a `full` key: turning on spending is an\naccount decision, the same rung as keys and webhooks. An agent integrating\nhostr should ask its human before turning captions on, not do it unasked.\nThe answer is the new allowance (below). The change reaches every API edge\nwithin a minute.\n\nTranscription is billed per minute of audio, and each translation per minute\nper language ([Billing](#billing) has the prices; `GET /v1/billing`\nlists them). Runs sit behind the same card gate as uploads. Turning captions\noff refuses new runs; runs already submitted finish and are billed.\n\n### When it runs\n\nWith `captions` on the upload, transcription starts the moment `audio.m4a`\nexists \u2014 seconds into the encode, minutes before `video.playable` \u2014 so on a\nnormal source the tracks are there before the video is. Translations follow\none by one as `captions.ready` events; the source language lands first.\n\n`POST /v1/assets/:id/captions` runs any time the asset has audio. Unlike Mux\'s\nseven-day window on generated subtitles, there is no time limit. With no body it\nreuses what was asked for last time. It answers:\n\n```\n202 { "assetId": "\u2026", "rev": "mfc9z0aa", "queued": true }\n```\n\n`rev` is the new run\'s id. Keep it: every `captions.*` event carries the `rev`\nof the run it is about, and an event whose `rev` is not the one you hold is\nfrom a run this one replaced.\n\n`context` is free text handed to the transcriber to help with names and\nvocabulary \u2014 creator, title, domain terms. It is trimmed and cut at **1000\ncharacters**; a longer value is truncated, not refused.\n\nOnly one run exists per asset at a time. While a run is at the transcription\nprovider, a second `POST` answers `409 captions_in_progress`. So does one that\narrives within two minutes of a run starting, before it has reached the\nprovider, and so does the loser of two simultaneous requests. A run that never\nproduces a transcript is failed with `asr_failed` after two hours, which\nreleases the asset for a new one.\n\nA transient answer from the provider (a rate limit, a 5xx) is not a failure:\nthe completion is retried by the provider\'s callback or by hostr\'s five-minute\npoll, and nothing is reported until the provider says the job failed or the two\nhours are up.\n\n### It is metered\n\nEvery run counts the source duration as **transcription minutes** for the UTC\ncalendar month. Translation is billed separately, per language-minute\n(`translationSeconds` on `GET /v1/usage`), and the monthly limit does not count\nit. `GET /v1/account` shows\n`captions: { minutesUsed, minutesCap, resetsAt }`, and so does the dashboard\'s\nUsage page. `minutesCap` has three meanings:\n\n| `minutesCap` | Meaning |\n|---|---|\n| `0` | Captions are off for the account. Turn them on with `PUT /v1/account/captions`. |\n| `null` | Captions are on with no monthly limit. Every minute is still counted. |\n| a number | Captions are on, up to that many minutes this month. |\n\nThe cap is only a ceiling; `minutesUsed` (and the daily `captionSeconds` on\n`GET /v1/usage`) is the count either way.\n\nThe explicit routes refuse **before** spending: `403 captions_cap_exceeded`\n(or `captions_not_enabled`) with `minutesUsed`, `minutesCap` and `resetsAt` in\nthe body. `POST \u2026/captions/translate` refuses `captions_not_enabled` the same\nway. The upload path cannot know the duration yet, so there the cap refusal is\nasynchronous: `captions.errored` with `reason: cap_exceeded`, and the `captions`\nblock on the asset says the same. A key whose account has captions off (cap 0)\nis refused at create time on `POST /v1/uploads`, `POST /v1/renders` and\n`POST /v1/assets/:id/render` with `403 captions_not_enabled`, and the same\nthree fields. A `captions` value that is not an object (`null` included) is\n`400 bad_captions`.\n\n### Your tracks win\n\nOne track per language tag. A track you upload with\n`PUT /v1/assets/:id/subtitles/:lang` has no `source` and is yours: a redo never\nreplaces it, and a requested language it owns is reported in `skippedLangs`\nrather than written. That is the correction loop: download the auto VTT, fix\nit, PUT it back, and it stays fixed through every redo. PUT over an auto track\nmakes it yours.\n\nThis is enforced when the track is written, not only when the run is planned: a\ntrack you upload while a translation is running is never replaced, and the run\nreports that language in `skippedLangs` instead.\n\nAfter a [re-edit](https://docs.hostr.sh/concepts/assets/#editing-renders) the stored request is\napplied again under a new run, because the old captions are timed to the old\ncut. A track you corrected is still skipped \u2014 the `captions.ready` event\'s\n`skippedLangs` tells you it is now mistimed and needs your attention. A plain\nre-encode changes no timing and does nothing to captions.\n\n### Languages\n\n`language` is `auto` (detected) or any BCP 47 tag you know the speech is in.\n`translate` takes any tags, at most 10 per request; a target in the source\nlanguage is dropped \u2014 any variant of it, so from Spanish speech `es-MX` is\ndropped too (that is localisation, not translation). The detected source track\nis written under the primary tag (`en`, not `en-US`): the transcriber detects\nlanguages, not regional variants. A declared one is written under exactly what\nyou said (`pt-BR`).\n\nA translated track\'s region comes only from the tag you ask for: `es-MX`,\n`es-419` (Latin America) and `es-ES` are three separate tracks, and the target\n\u2014 region included \u2014 is named to the translation model. One Spanish target is\nusually enough; `es-419` is the common choice for a Latin American audience.\n\nTrack names are the language\'s own name plus the suffix, with a region or\nscript named alongside it so variants are told apart in the player menu:\n`Espa\xF1ol (auto)`, `English (auto-translated)`, `Espa\xF1ol (M\xE9xico, auto-translated)`,\n`Portugu\xEAs (Brasil, auto)`, `\u4E2D\u6587 (\u7E41\u9AD4, auto-translated)`.\n\n### Reading progress: the `captions` block\n\n`GET /v1/assets/:id` carries the asset\'s latest run as `captions` (absent if\ncaptions were never requested). A new run, a redo or a re-edit replaces it.\n\n```json\n"captions": {\n "rev": "mg3k1z9q",\n "requestedAt": "2026-10-09T15:00:00.000Z",\n "request": { "language": "auto", "translate": ["en", "es"], "context": "Creator: Ana" },\n "transcript": { "status": "ready", "lang": "pt", "durationSec": 1842.4, "attempts": 1,\n "submittedAt": "2026-10-09T15:00:04.000Z", "finishedAt": "2026-10-09T15:02:11.000Z" },\n "tracks": {\n "pt": { "status": "ready", "source": "auto", "name": "Portugu\xEAs (auto)", "rev": "9c1e2a4b6d47", "attempts": 0, "updatedAt": "\u2026" },\n "en": { "status": "ready", "source": "auto-translated", "name": "English (auto-translated)", "rev": "b6d47f0a1b2c", "attempts": 1, "updatedAt": "\u2026" },\n "es": { "status": "pending", "source": "auto-translated", "attempts": 1, "updatedAt": "\u2026" }\n },\n "skippedLangs": [],\n "inFlight": true\n}\n```\n\n| Field | Meaning |\n|---|---|\n| `rev` | The run\'s id. Every `captions.*` event carries it; ignore events whose `rev` is not the asset\'s current one. |\n| `request` | What was asked for. A redo with no body, and every re-edit, reuses it. |\n| `transcript.status` | `pending` (waiting for the audio file), `submitted` (at the transcription provider), `ready`, or `failed`. |\n| `transcript.lang` | The spoken language, as the source track\'s tag. Detected languages use the primary tag (`en`, not `en-US`). |\n| `transcript.reason`, `transcript.error` | On `failed`: why. `reason` is one of the codes under [Events](#events). |\n| `tracks.<lang>.status` | `pending` (being made), `ready` (serving), or `failed` (`error` says why). |\n| `tracks.<lang>.source` | `auto` for the spoken language, `auto-translated` for the rest. |\n| `tracks.<lang>.rev`, `name` | The subtitle track\'s content revision and menu label, once written. |\n| `tracks.<lang>.attempts` | Translation attempts so far. |\n| `skippedLangs` | Requested languages left alone because a track you uploaded owns them. |\n| `inFlight` | `true` while the transcript or any track is still owed. Stop polling when it is `false`. |\n\nOn `GET /v1/assets/:id/subtitles`, a track\'s `source` is the same marker:\n`auto`, `auto-translated`, or absent. **Absent means hostr treats the track as\nyours**, including any machine tracks you attached with your own pipeline\nbefore you used hostr captions. A redo will not replace those. To have hostr\ntake a language over, delete that track first\n(`DELETE /v1/assets/:id/subtitles/:lang`), then redo.\n\n### From the terminal\n\n```bash\nnpx @hostr/cli upload video.mp4 --captions --translate en,es --context "Creator: Ana"\nnpx @hostr/cli captions status <assetId> # the captions block, summarised\nnpx @hostr/cli captions request <assetId> # redo with the stored request\nnpx @hostr/cli captions request <assetId> --lang pt --translate en,es\nnpx @hostr/cli captions translate <assetId> --lang fr,de\n```\n\nEvery command takes `--json`. `captions request` and `captions translate`\nneed `upload` scope.\n\n### Errors\n\n| Status | `error` | When |\n|---|---|---|\n| 400 | `bad_captions` | The body or `captions` field is malformed (`hint` says which), `null`, or a `languages` list is empty or invalid |\n| 403 | `captions_not_enabled` | Captions are off for the account (`minutesCap: 0`). Body carries `minutesUsed`, `minutesCap`, `resetsAt` |\n| 403 | `captions_cap_exceeded` | This run would pass the monthly cap. Same three fields |\n| 409 | `not_ready` | No audio yet \u2014 wait for `video.audio_ready` |\n| 409 | `no_audio` | The source has no audio stream |\n| 409 | `captions_in_progress` | A run is at the provider, started under two minutes ago, or a concurrent request won |\n| 409 | `no_transcript` | `\u2026/translate` before a transcript exists |\n| 409 | `asset_unowned` | The asset has no owning account |\n| 502 | `asr_submit_failed` | The provider refused the submission (or could not be reached); `hint` carries its error. Nothing was metered for it. A submission the provider *accepted* is metered even if another request replaced the run meanwhile \u2014 that answers `409 captions_in_progress`, not 502 |\n| 503 | `captions_unavailable` | No transcription provider on this deployment |\n| 503 | `translate_unavailable` | No translation queue or provider on this deployment |\n\n### Events\n\n| Type | When | `data` |\n|---|---|---|\n| `captions.ready` | the transcript step finished, then once per translated track | `assetId`, `playbackId`, `rev`, `lang`, `source`, `name`, `written` (false when a manual track owned the slot), `trackRev?`, `refusedCues?` (lines shipped as a placeholder), `skippedLangs`, `pending` (languages still to come) |\n| `captions.errored` | a run or one language failed | `assetId`, `rev`, `lang?`, `reason`, `error`, and on a cap refusal `minutesUsed`, `minutesCap`, `resetsAt` |\n\n`reason` is one of `not_enabled`, `cap_exceeded`, `no_audio` (silent source, or\nthe transcode failed first), `provider_unavailable`, `asr_failed`,\n`translate_unavailable`, `translate_failed`, `translate_refused`. A failed\nlanguage never blocks the others.\n\nSometimes the translation model declines a line and writes a placeholder such\nas `[Omitted]` in its place. hostr retries the language when that happens,\nand a retry usually translates it. If the last attempt still declines lines,\nthe track ships anyway with the placeholders in it, and says so: its `tracks`\nentry and its `captions.ready` event carry `refusedCues`, the number of\nlines left as placeholders. Ask again with\n`POST /v1/assets/:id/captions/translate`, or upload that language yourself.\n`translate_refused` is the failure for a last attempt the model declined\noutright, with no lines to ship.\n\n`captions.errored` fires **at most once per run-level failure** (the transcript\nstage; it has no `lang`) and **once per failed language**. A second report of\nthe same failure, such as the provider\'s callback and hostr\'s poll both seeing\nit, does not repeat it.\n\n### What is stored\n\nThe transcript and the source VTT are kept with the asset, so\n`POST /v1/assets/:id/captions/translate` never transcribes again. Both are\npurged with the asset.\n\nTwo third parties see your content. The audio goes to the transcription\nprovider (AssemblyAI). Translation sends the transcript\'s text, cue by cue, to\nan LLM provider (Anthropic). The `context` string goes only to the\ntranscription provider, never to the translation model.\n\n## Event reference\n\nDelivered body: `{ id, type, createdAt, data }`. `id` is the durable event row\'s\nid \u2014 **dedupe on it**, and note that a redelivery reuses it deliberately.\n\n| Type | Fired when | `data` highlights |\n|---|---|---|\n| `upload.created` | Multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `upload.aborted` | Session ended without completing | `assetId`, `reason` (`client_abort` \\| `abandoned`), `error?` |\n| `video.uploaded` | Bytes verified in storage, transcode queued \u2014 or a render\'s join is ready | `assetId`, `key`, `bytes`, `etag?`, `sha256?`, `rendered?` |\n| `video.audio_ready` | `audio.m4a` exists \u2014 seconds in, before anything is playable | `assetId`, `playbackId`, `durationSec`, `audio`, `loudness` |\n| `video.playable` | Watchable at one quality, ladder still encoding | `assetId`, `playbackId`, `durationSec?`, `renditions?`, `loudness?` |\n| `video.ready` | Full HLS ladder live | `assetId`, `playbackId`, `durationSec?`, `renditions?`, `loudness?` |\n| `video.errored` | Transcode failed | `assetId`, `error` (always present) |\n| `video.poster_ready` | A chosen poster frame is live behind `thumbnail.jpg` (`POST /v1/assets/:id/poster`) | `assetId`, `playbackId`, `rev`, `time` |\n| `captions.ready` | One language finished (one event per language, the source language first): its track landed, or \u2014 `written: false` \u2014 a track you uploaded owned the slot and was kept | `assetId`, `playbackId`, `rev`, `lang`, `source`, `name`, `written`, `trackRev?`, `skippedLangs`, `pending` |\n| `captions.errored` | A captions run, or one language of it, failed. At most once per run-level failure, once per failed language | `assetId`, `rev`, `lang?`, `reason`, `error`, and on a cap refusal `minutesUsed`, `minutesCap`, `resetsAt` |\n| `video.deleted` | Asset erased, playback over | `assetId` |\n| `video.input.non_standard` | The source needed a judgement call | `assetId`, `input`, `findings[]` |\n\n**Every event about an asset with [metadata](https://docs.hostr.sh/concepts/assets/#your-own-metadata)\ncarries it as `data.metadata`** \u2014 the labels you set on the upload, as they\nwere when the event fired. That is how a receiver knows whose video\n`video.ready` is without keeping a table from `assetId` to user. Absent when\nthe asset has none. A redelivery replays what was sent at the time.\n\n`upload.aborted`, `video.playable`, `video.ready` and `video.errored` fire on the\nstatus **transition** only, so a repeated abort, or a transcoder re-posting a\nstate, cannot duplicate them.\n\n`upload.aborted` has one meaning \u2014 this upload ended without completing \u2014 and\n`reason` says how it got there: `client_abort` for `DELETE /v1/uploads/:id`, or\n`abandoned` for an upload the platform collected after 14 days idle (see\n[Assets and lifecycle](#assets-and-lifecycle)). Treat both as terminal; the reason\nis for your own reporting, not for deciding whether the upload is over.\n\n`video.audio_ready` fires **once per asset**, on the first report that carries\nthe audio file, and never for a source with no audio stream \u2014 the record\'s\n`audio` field is `null` there. It is the cue to start speech-to-text: see\n[Audio for speech-to-text](#audio-for-speech-to-text).\n\n`loudness` is the probe\'s EBU R128 measurement of the source and the gain the\nencode applied \u2014 `integratedLufs`, `loudnessRangeLu`, `truePeakDbtp`, `mono`,\n`normalization` \u2014 for creator warnings and playback-side gain. `null` on a\nsource with no audio stream. See\n[the ladder](https://docs.hostr.sh/concepts/ladder/#loudness-is-measured-for-every-asset).\n\n`video.playable` carries the same fields as `video.ready`, so publishing as early\nas possible is a matter of switching which one you listen for. Its `renditions`\nlists the single quality available so far; `video.ready` then lists the full\nladder. `video.errored` fires only when nothing is watchable \u2014 a ladder that\nfails after the asset is already playable does not fire it.\n\n`error` on `video.errored` is always present. An optional error field makes every\nconsumer invent its own generic message, which is worse than a bad message.\n\n`video.ready` fires **once more** when an asset is\n[re-encoded](https://docs.hostr.sh/concepts/assets/#re-encoding), carrying `reencoded: true` and the\nnew `rev`. Same shape, same meaning \u2014 a full ladder is live \u2014 so a consumer that\nonly stores "ready" needs no change; one that caches a poster should refresh it.\nA [re-edit](https://docs.hostr.sh/concepts/assets/#editing-renders) landing also carries\n`rendered: true`: the content changed, so its duration may have too, and\ncaptions made for the old version no longer line up.\n\n### captions.*\n\nMachine captions: `captions.ready` fires once for the transcribed language and once\nper translated track; `captions.errored` for a failed run or a failed language. `rev`\nidentifies the run. Fields, `reason` values and the metering rules are in\n[Auto-captions and translation](https://docs.hostr.sh/guides/captions/#events).\n\n### video.input.non_standard\n\nAdvisory. The transcode proceeds normally and a `video.ready` still follows. It\nfires **at most once per asset**, and only when there is something to say \u2014 a\nfile with nothing unusual about it generates no traffic.\n\n| Finding code | Meaning |\n|---|---|\n| `hdr_tags_on_8bit` | Source carries HLG/PQ tags but is 8-bit. HDR is a 10-bit format, so the tag is stale \u2014 treated as SDR, pixels untouched |\n| `hdr_source` | Genuine 10-bit HLG or PQ HDR. Converted to 8-bit BT.709 SDR before the ladder split; HDR is not delivered (assets encoded before 2026-09-10 were relabelled instead \u2014 [re-encode](https://docs.hostr.sh/concepts/assets/#re-encoding) them) |\n| `wide_gamut_tags` | BT.2020 primaries without an HDR transfer. Converted to BT.709 at 10-bit; at 8-bit the tag is not trusted and the pixels are left alone |\n| `variable_frame_rate` | Container rate and measured rate disagree; normalised to CFR |\n| `frame_rate_changed` | Source rate was not a broadcast standard and was snapped to the nearest |\n\n`data.input` carries the full probe \u2014 `container`, `videoCodec`, `pixFmt`,\n`bitDepth`, `width`, `height`, `rotation`, `declaredFps`, `averageFps`,\n`targetFps`, `isVariableFrameRate`, `colorSpace`, `colorTransfer`,\n`colorPrimaries`. `width` and `height` are the *display* size \u2014 a portrait\nphone recording stores landscape pixels with a 90\xB0 rotation, and the ladder\nis built on what the viewer sees, not on how the frames are stored.\n\nThe same block is on the asset record as `input`, so it can be read back at any\ntime without replaying events.\n\n### Delivery\n\nThree attempts with short backoff, each recorded and inspectable. Every event is\nwritten to the durable log **before** the first attempt.\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending tail; reuse nextCursor verbatim\nGET /v1/events?order=desc&limit=1 newest first\nPOST /v1/events/:id/redeliver replay to the current endpoint\n```\n\nSignature verification: [Verifying webhooks](#verifying-webhook-signatures).\n\n## Error reference\n\nEvery non-2xx body is `{ "error": "<code>", ... }`. **Switch on `error`, never on\nthe message** \u2014 codes are stable, prose is not.\n\n| Status | Error | Meaning and action |\n|---|---|---|\n| 400 | `size_required` | `size` missing or not positive on `POST /v1/uploads` |\n| 400 | `partNumbers_required` | Empty or non-array `partNumbers` |\n| 400 | `too_many_parts_per_request` | More than 100 part numbers; `max` names the cap |\n| 400 | `part_out_of_range` | A part number below 1 or above `expectedParts` |\n| 400 | `bad_language` | Not a BCP 47 tag |\n| 400 | `bad_captions` | `captions` (or the body of `\u2026/captions`, `\u2026/captions/translate`) is malformed, `null`, or names no valid languages; `hint` says which |\n| 400 | `bad_captions_setting` | `PUT /v1/account/captions`: `enabled` is not a boolean, or `monthlyMinutes` is not a whole number of at least 1; `hint` says which |\n| 400 | `not_webvtt` | Subtitle body does not start with `WEBVTT` |\n| 400 | `bad_status` | Status outside the allowed set (internal route) |\n| 400 | `bad_scopes` | A scope outside `read`/`upload`/`playback`/`full` |\n| 400 | `bad_date` | A date parameter was not `YYYY-MM-DD` |\n| 400 | `authorization_pending` | Device login not yet approved \u2014 keep polling |\n| 400 | `slow_down` | Polling too fast \u2014 back off, keep polling |\n| 400 | `expired_token` | Device code expired. Start over |\n| 400 | `access_denied` | Device login was declined |\n| 401 | `unauthorized` | Bad, missing, or revoked credential \u2014 **or a resource you do not own.** hostr does not reveal which |\n| 403 | `insufficient_scope` | Scope too low; `need` names the rung required |\n| 403 | `quota_uploads_daily` | Daily upload cap; `limit` is the ceiling |\n| 403 | `quota_assets` | Asset count cap; `limit` is the ceiling |\n| 403 | `quota_storage` | Storage cap; `limit` is the ceiling |\n| 403 | `account_suspended` | Contact the operator |\n| 403 | `captions_not_enabled` | Captions are off for the account (`minutesCap: 0`); turn them on with `PUT /v1/account/captions` (full key) or `hostr captions enable`. `minutesUsed`, `minutesCap`, `resetsAt` are in the body |\n| 403 | `captions_cap_exceeded` | This run would pass the monthly transcription minutes; same three fields. Retry after `resetsAt` or raise the cap |\n| 403 | `download_not_enabled` | Original downloads are not enabled for this account; the operator grants them |\n| 403 | `accountId_service_only` | Only the service key may name an owning account |\n| 403 | `blocked_user_agent` | Media host only \u2014 send a browser-like `User-Agent` |\n| 404 | `asset_not_found` | No such asset, or it has been deleted |\n| 404 | `subtitle_not_found` | No track for that language tag |\n| 404 | `event_not_found` | No such event for this account |\n| 404 | `key_not_found` | No such live key for this account |\n| 404 | `not_configured` | No webhook endpoint configured |\n| 404 | `account_not_found` | Key authenticated but its account row is gone |\n| 409 | `incomplete_upload` | Parts missing; `missing` lists them. Upload and re-complete |\n| 409 | `size_mismatch` | Declared size does not match bytes landed. Abort and restart |\n| 409 | `not_uploading` | Upload already finished or aborted |\n| 409 | `not_ready` | Asset is not `ready`, so its duration is unknown. On `POST \u2026/captions`: no audio file yet \u2014 wait for `video.audio_ready` |\n| 409 | `no_audio` | `POST \u2026/captions` on a source with no audio stream |\n| 409 | `captions_in_progress` | A captions run is at the provider, started under two minutes ago, or a concurrent request won. Wait for `captions.ready` / `captions.errored` |\n| 409 | `no_transcript` | `POST \u2026/captions/translate` before a transcript exists; request captions first |\n| 409 | `asset_unowned` | The asset has no owning account, so captions cannot be metered; claim it for an account first |\n| 409 | `no_webhook_endpoint` | Nothing configured or enabled to redeliver to |\n| 413 | `file_too_large` | Too many parts at this part size; raise `partSize` |\n| 413 | `subtitle_too_large` | Over 2,000,000 bytes |\n| 429 | `rate_limited` | `POST /v1/device/code` only. It is unauthenticated, so it is capped per IP. A real login makes one call \u2014 if you see this, something is looping |\n| 500 | `signing_not_configured` | Operator-side: the playback signing key is not loaded. Not your fault, not retryable. Report it |\n| 500 | `internal` | Unexpected. `message` carries truncated detail |\n| 502 | `asr_submit_failed` | The transcription provider refused a captions submission (or could not be reached); `hint` carries its error. Nothing was metered for it. Retry later. (A submission the provider accepted is metered; if another request replaced the run meanwhile, that is `409 captions_in_progress`.) |\n| 503 | `control_plane_unavailable` | Transient. Retry with backoff |\n| 503 | `captions_unavailable` | No transcription provider is configured on this deployment. Operator-side; not retryable by you |\n| 503 | `translate_unavailable` | No translation queue or provider is configured on this deployment. Operator-side |\n\n### Retry guidance\n\n**Retry with backoff:** `503 control_plane_unavailable`, `500 internal`,\n`400 authorization_pending`, `400 slow_down`.\n\n**Fix and retry:** the `409` upload conflicts \u2014 they tell you exactly what is\nwrong (`missing` part numbers, or both byte counts).\n\n**Do not retry unchanged:** every `400`, `401`, `403`, and `404`. A quota `403`\nin particular will fail identically forever until the account limit is raised.\n\n### The 401 conflation is deliberate\n\n`unauthorized` covers both "bad credential" and "not your asset". Distinguishing\nthem would let anyone holding a valid key enumerate other accounts\' asset ids by\nwatching which error came back.\n\n## CLI reference\n\n```bash\nnpx @hostr/cli <command>\n```\n\nA single-file, zero-dependency bundle published as `hostr`. **Every command takes\n`--json`** \u2014 one JSON document on stdout, NDJSON for `tail --follow`, errors as\nJSON on stderr with exit code 1. Nothing needs prose parsing.\n\nRun it with **no arguments** for setup status and the single next step. That is\nthe intended entry point, for humans and agents alike.\n\nRun it as `npx @hostr/cli@latest`. Plain `npx @hostr/cli` can keep reusing an\nold cached copy, which still prints the advice that was true when it was\nbuilt. Once a day the CLI asks npm for the latest version; when a newer one\nexists, every command says so on stderr, and bare `hostr` leads with it (its\n`--json` report carries it as `update`). Under `--json` nothing extra goes to\nstderr. `HOSTR_NO_UPDATE_CHECK=1` turns the check off; it is also off when\n`CI` is set. `hostr --version` prints the version you are running.\n\n### Credentials\n\nPrecedence, highest first: `--key <k>`, then `HOSTR_API_KEY`, then the saved\nprofile.\n\nAPI base, highest first: `--api <url>`, then `HOSTR_API_BASE`, then the profile,\nthen production.\n\nProfiles live in `~/.config/hostr/config.json`, cleartext at mode 0600 \u2014 the\n`gh` and `wrangler` precedent.\n\n#### Linking a repo to a project\n\n```bash\nhostr link # writes hostr.json for the project you\'re logged in to \u2014 commit it\nhostr unlink # removes it\n```\n\n`hostr.json` holds the project\'s id and name, never a key. Every hostr command\nrun in that folder (or below it) uses that project. If you\'re logged in to it,\nthat login is used even when you logged in to something else more recently, so\ntwo repos on one machine each reach their own project with no flags. If you\naren\'t, or `--key` / `HOSTR_API_KEY` is for another project, the command\n**refuses** and names both projects instead of quietly using the wrong one.\n`hostr init` writes the file for you. `HOSTR_PROJECT_ID=<id>` overrides it for\none command, and the key must still match.\n\n#### Several projects\n\n```bash\nhostr login # pick the project in the browser\nhostr logout # deletes the key on hostr, then forgets it here\nhostr logout --profile azimuth\n```\n\nLogging in to a second project **keeps** your first login: it\'s saved under the\nproject\'s name, and the CLI says so. Logging in again to the same project\njust refreshes that login. Outside a linked folder, the login you made most\nrecently is the one used.\n\n`logout` picks the login for this folder\'s project, or the current one outside\na linked folder, or the one named with `--profile`. It deletes the key on hostr\nfirst, so a copy elsewhere stops working too; if it can\'t reach hostr it still\nforgets the key here and tells you to delete it on the Keys page.\n\n### Authentication\n\n```bash\nhostr login # browser-authorized; nothing is pasted\nhostr login --profile staging # choose the saved login\'s name yourself\nhostr login --key hostr_sk_live_... # escape hatch for CI; skips the browser\nhostr whoami # account, plan, limits, live usage\n```\n\n`login` with no key opens your browser and uses the device-authorization flow.\nThe code and URL are printed to **stderr**, so `--json` stdout stays a single\nparseable document.\n\n### Uploading\n\n```bash\nhostr upload video.mp4 # returns when bytes are verified in storage\nhostr upload video.mp4 --wait # blocks until transcode is ready or failed\nhostr upload video.mp4 --content-type video/quicktime\nhostr upload # no file: opens a local drag-drop page\nhostr upload video.mp4 --captions --translate en,es --context "Creator: Ana" # captions on upload\nhostr upload video.mp4 --meta userId=u_8812 --meta title="Morning routine" # your labels\n```\n\nWraps the SDK\'s Node adapter, so it carries the same resume and integrity\nguarantees. Kill it mid-transfer and re-run to continue.\n\n### Assets\n\n```bash\nhostr assets list --status ready --limit 20 --cursor <cursor>\nhostr assets get <id>\nhostr assets token <id> --ttl 3600 --viewer-id vp_5b0e2a7c # playback scope\nhostr assets audio <id> # audio-only rendition for an older asset (upload scope)\nhostr assets audio --missing # ...for every asset in the account that lacks one\nhostr assets reencode <id> # fresh ladder from the stored source (upload scope)\nhostr assets delete <id> # full scope\n```\n\n### Subtitles\n\n```bash\nhostr subtitles list <assetId>\nhostr subtitles set <assetId> subs.vtt --lang en --name English --default\nhostr subtitles rm <assetId> en\n```\n\n`--lang` is required and must be a BCP 47 tag. The asset must be `ready`.\n\n### Captions\n\n```bash\nhostr captions info # on/off, this month\'s minutes, prices\nhostr captions enable # turn on for the account \u2014 billed per use (full scope)\nhostr captions enable --limit 600 # on, at most 600 transcription minutes a month\nhostr captions disable # turn off; forgets the limit\nhostr captions status <assetId> # the latest run: transcript + each language\nhostr captions request <assetId> # redo with the stored request (upload scope)\nhostr captions request <assetId> --lang pt --translate en,es --context "Creator: Ana"\nhostr captions translate <assetId> --lang fr,de # from the stored transcript; billed per language-minute\n```\n\n`--captions`, `--captions-lang`, `--translate` and `--context` on `upload` ask\nfor captions on the new asset; any one of them is enough. Captions must be\non for the account (`hostr captions enable`); they start off because they are\nbilled. A refusal says how many of the month\'s minutes are used and when they\nreset. No other command turns captions on: not `init`, not `upload`. Watch `hostr tail --follow --type captions.ready`.\nSee [Auto-captions and translation](#auto-captions-and-translation).\n\n`--meta key=value` (repeatable) sets [metadata](https://docs.hostr.sh/concepts/assets/#your-own-metadata).\nChange it later, or filter by it:\n\n```bash\nhostr assets update <id> --meta title="New title" --meta draft= # key= removes\nhostr assets list --meta userId=u_8812\n```\n\n### Events\n\n```bash\nhostr tail # one page of the durable log\nhostr tail --follow # poll every 2s, NDJSON under --json\nhostr tail --type video.ready --limit 50 --after <cursor>\n```\n\n#### Testing your receiver locally\n\n```bash\nhostr listen --forward-to http://localhost:3000/api/hostr/webhook --env-file .env.local\nhostr listen --forward-to http://localhost:3000/hook --replay 5 # resend the newest 5 first\nhostr listen --forward-to http://localhost:3000/hook --events video.ready,video.errored\n```\n\nForwards each new event in the project\'s log to a local URL, with the same\nbody and `hostr-signature` header a webhook delivery carries, so your\nverification code runs unchanged. Your real endpoint keeps receiving as\nbefore; nothing forwarded counts as a delivery.\n\nIt signs with `HOSTR_WEBHOOK_SECRET` \u2014 from `--secret`, the environment, or the\n`--env-file` (read if it is there, written if not). With none of those it\ngenerates one, saves it for next time, and prints it once for you to give your\nreceiver. Each line shows the receiver\'s status code; a refused connection\nsays so rather than failing.\n\n### Keys\n\n```bash\nhostr keys list\nhostr keys create --name ci --scopes upload --write-env .env\nhostr keys revoke <id>\n```\n\n`--scopes` takes a comma-separated list and **defaults to `playback`** \u2014 the\nright rung for an application key. `revoke` takes the `id` from `keys list`, not\nthe `keyId`.\n\n### Webhooks\n\n```bash\nhostr webhooks get\nhostr webhooks set https://app.example/hook --write-env .env\nhostr webhooks set https://app.example/hook --secret whsec_... # bring your own\nhostr webhooks set https://app.example/hook --rotate --write-env .env # replace the secret\nhostr webhooks delete\nhostr webhooks test # redeliver the newest event \u2014 proves your endpoint\n```\n\nRe-running `set` on a live endpoint keeps its secret (nothing is written to a\n`--write-env` file, and the output says so); pass `--rotate` to replace it.\n\n### Repo setup\n\n```bash\nhostr init # docs/hostr.md + AGENTS.md section + .env.example\nhostr init --dir ../other-repo\n```\n\nIdempotent. Re-running replaces the content between the `AGENTS.md` markers\nrather than appending.\n\n### Usage\n\n```bash\nhostr usage --from 2026-08-01 --to 2026-08-25\n```\n\nDefaults to the last 30 days.\n\n### --write-env\n\n```bash\nhostr keys create --scopes upload --write-env .env\nhostr webhooks set https://app.example/hook --write-env .env\n```\n\nWrites the secret into that file at mode 0600 and prints **only the variable\nname** \u2014 in `--json` mode too. Nothing else in the CLI ever prints a credential.\n\nThe point is that an agent driving your terminal can orchestrate a credential\nwithout the value ever entering its context or its transcript.\n\n## SDK reference\n\n```bash\nnpm install @hostr/upload\n```\n\nFour entry points, each bundled independently:\n\n| Entry | Platform | What the adapter supplies |\n|---|---|---|\n| `@hostr/upload` | **Trusted backend only** | `createUploadSession`, `mintUploadToken` \u2014 needs your API key |\n| `@hostr/upload/browser` | Web | XHR transport (for progress events), `localStorage` session store |\n| `@hostr/upload/node` | Node, scripts | `fetch` transport, filesystem session store |\n| `@hostr/upload/react-native` | Expo / RN | Native staged-parts transport; survives backgrounding |\n\nThe engine, retry policy, and integrity checks are one shared core. Adapters only\nsupply platform I/O: read bytes, PUT bytes, persist a few KB of JSON.\n\n### Browser\n\n```js\nimport { createUpload } from \'@hostr/upload/browser\';\n\n// createUploadSession runs on YOUR SERVER \u2014 it holds the API key.\n// The browser receives only { endpoint, assetId, uploadToken }.\nconst upload = await createUpload({ endpoint, uploadToken, assetId, file });\n\nupload.on(\'progress\', p => {\n const pct = (p.bytesConfirmed / p.totalBytes * 100).toFixed(1);\n console.log(`${p.partsDone}/${p.partsTotal} parts, ${pct}%`);\n});\nupload.on(\'statechange\', s => console.log(s));\n// idle -> running -> (stalled <-> running)* -> completing -> complete\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\nSession state persists in `localStorage`, keyed by `name:size:lastModified`.\nReload the page, pick the same file, and `createUpload` resumes automatically \u2014\nthere is no explicit resume call.\n\n### Node\n\n```js\nimport { createUploadSession, createUpload } from \'@hostr/upload/node\';\n\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n});\n\nconst upload = await createUpload({\n endpoint, uploadToken: session.uploadToken, assetId: session.assetId,\n file: \'/path/to/video.mp4\', // a path, not an open handle\n stateDir: \'./upload-state\', // survives a process restart\n});\n\nupload.start();\nconst { etag, sha256 } = await upload.done;\n```\n\n#### Captions on upload\n\n`createUploadSession` takes the same `captions` object as `POST /v1/uploads`\n(SDK 0.3.0 or later). hostr transcribes the audio as soon as it exists and\ntranslates it into each `translate` language; every track lands as an ordinary\nsubtitle track. The account must have captions enabled.\n\n```js\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n captions: { language: \'auto\', translate: [\'en\', \'es\'], context: \'Creator: Ana. Title: Rainy night.\' },\n});\n```\n\nSee [Auto-captions and translation](#auto-captions-and-translation) for the events, the\nmetering and the rules.\n\n#### Metadata on upload\n\n`createUploadSession` takes `metadata` (SDK 0.5.0 or later): your own string\nlabels, carried on every event about the asset as `data.metadata`.\n\n```js\nconst session = await createUploadSession({\n endpoint, apiKey, filename: \'video.mp4\', contentType: \'video/mp4\', size,\n metadata: { userId: String(user.id), title: \'Morning routine\' },\n});\n```\n\nSee [Your own metadata](https://docs.hostr.sh/concepts/assets/#your-own-metadata) for the rules.\n\n### Take the endpoint from your backend\'s response\n\n**One rule for clients of a proxying backend:** take the hostr `endpoint` from\nyour backend\'s create-upload response, never from separate client config.\n`createUploadSession` returns it as `endpoint` alongside the session, so\nforwarding `{ endpoint, assetId, uploadToken }` is all the browser needs.\n\nAn upload token is only valid on the hostr instance that minted it. Endpoint\ndrift fails as a bare mid-flow `401` with nothing pointing at the cause;\nreturning the endpoint alongside the session makes the mistake structurally\nimpossible. Learned from the first consumer integration.\n\n### Events\n\n| Event | Payload |\n|---|---|\n| `progress` | `{ partsDone, partsTotal, bytesConfirmed, totalBytes, partStates }` |\n| `statechange` | `\'idle\' \\| \'running\' \\| \'stalled\' \\| \'completing\' \\| \'complete\'` |\n| `diagnostic` | `{ code, consecutiveNetworkFailures, bytesEverConfirmed, hint }` |\n\n`partStates` maps part number to `\'inflight\'` or `\'done\'` \u2014 enough for a per-part\nprogress grid.\n\nThe `diagnostic` event fires once per failure streak, on every adapter. Today its\nonly code is `systemic-transport-failure`: the browser cannot reach storage at all\n(offline, or your CSP\'s `connect-src` omits the storage host) \u2014 see [Upload from a browser](#upload-from-a-browser). It is **advisory, never\nterminal**: the upload keeps retrying regardless.\n\n### Token refresh\n\n```js\nconst upload = await createUpload({\n endpoint, assetId, uploadToken, file,\n onTokenExpired: async () => (await refreshFromYourServer(assetId)).uploadToken,\n});\n```\n\nYour server calls `POST /v1/uploads/:id/token`. The SDK retries in place; nothing\nis lost. Even without the hook a killed client resumes from what storage reports\non next launch.\n\n### Integrity\n\nThree layers, each proving something different \u2014 per-part ETag, part count and\ntotal size at completion, and an optional whole-file SHA-256. See\n[Resume and integrity](#resume-and-integrity) for what each one does and\ndoes not prove.\n\n### Import error classes from the entry you actually use\n\nEach entry is bundled independently, so `IntegrityError` from\n`@hostr/upload/node` is not the same class object as the one from\n`@hostr/upload/browser`. An `instanceof` check across entries fails. Import from\nthe entry your code uses.\n\n## The encoding ladder\n\nhostr encodes each source into several renditions at different bitrates \u2014\nthe *ladder* \u2014 and packages them as fMP4 (CMAF) HLS with H.264 video and AAC\naudio. A player picks a rung based on available bandwidth and changes rung\nmid-stream as conditions move.\n\n### Rungs are chosen by short edge\n\nA ladder defined by height assumes landscape. Defining rungs by the **short\nedge** means portrait video, square video, and unusual aspect ratios all get a\nsensible ladder instead of a broken one.\n\n**Nothing is ever upscaled.** Rungs above the source\'s short edge are dropped, so\na 480p source produces a 480p-and-below ladder rather than a blurry 1080p\nrendition that costs bandwidth and delivers nothing.\n\n### Why switching is clean\n\nTwo invariants do the work, and both are load-bearing:\n\n**Every rung shares one constant frame rate and identical keyframe intervals**,\nwith scene-cut detection disabled. This is the only reason segment boundaries\nline up across rungs. Without it a player switching quality lands mid-segment and\nstutters or re-buffers.\n\n**Variable frame rate is normalised to constant before the ladder splits.**\nSources are frequently VFR \u2014 phone recordings especially \u2014 and segment durations\ndrift apart otherwise.\n\nThere is a subtlety inside that second one worth stating, because it shipped as a\nbug once. The frame rate comes from **the container\'s declared rate, not the\nmeasured average**. On a VFR source the average is depressed *because* frames were\ndropped; using it as a ceiling compounds the loss. A 30fps source got encoded at\n25fps that way.\n\n### Audio is demuxed\n\nAudio lives in its own rendition group rather than being muxed into every video\nrung. One copy instead of four, and it is the prerequisite for multi-language\naudio later.\n\nBy default that group is AAC-LC at 128 kbps, stereo (a mono source stays mono;\n5.1 is downmixed \u2014 never the other way), 48 kHz, resampled with soxr. Nothing\nelse is done to it: no gain change, no noise reduction, no gating, no silence\ntrimming. Room tone is content, and many creators master deliberately.\n\n### Audio options\n\n`POST /v1/uploads` takes an `audio` field that changes that for one asset:\n\n```json\n{ "size": 104857600, "filename": "tapping.mov", "audio": "asmr" }\n```\n\n| Option | Default | What it does |\n|---|---|---|\n| `kbps` | `128` | Bitrate of the top audio group. At 192 or more a second **96 kbps group** is added and the smallest rung (240p) references it, so a viewer on mobile data is not made to fetch a 256 kbps track behind 400 kbps of video. |\n| `normalize` | `false` | Fixed programme gain with peak limiting \u2014 see below. |\n| `targetLufs` | `-24` | Integrated loudness target. Mux normalises to the same level at ingest. |\n| `truePeakDbtp` | `-1.5` | True-peak ceiling, verified on encoded audio. |\n\n`"asmr"` is shorthand for `{ "kbps": 256, "normalize": true }` with the default\ntarget and ceiling. The resolved options land on the record as `audioOptions`.\nAn asset that already exists takes the same field on\n[`POST /v1/assets/:id/reencode`](https://docs.hostr.sh/concepts/assets/#re-encoding).\n\n#### Normalisation raises the programme and limits peaks\n\nNormalisation is **off by default**. No upload, re-encode or account default\nchanges when you preview it. Explicitly set `audio.normalize: true` or choose\n`audio: "asmr"` to apply it.\n\nThe source\'s EBU R128 measurement sets a fixed programme gain. An oversampled\nlookahead limiter catches peaks that would cross the ceiling, with automatic\nmakeup gain disabled and latency compensated. There is no programme compressor,\ndenoise, gate or automatic levelling. Quiet passages and room tone remain.\n\nThe transcoder measures encoded AAC and corrects the fixed gain for loudness\nlost to limiting, aiming within 0.5 LU of the target. It lowers the internal\nlimiter ceiling if AAC overshoots. If five passes cannot reach the target\nunder the ceiling, a new upload ships with its original, untouched audio\nrather than failing: `normalization` is `null` and `loudness.normalizationSkipped`\nsays why. A re-encode that cannot converge fails instead, and the existing\nladder remains live. Encoded segments that miss the target still fail the job. All normalised HLS groups and `audio.m4a`\nuse the same final chain. The low bitrate group allows 1 LU of codec variation.\n\nThe source measurement stays in `loudness`; `normalization.output` holds the\nencoded result. `normalization.limiting.maxReductionDb` measures maximum\nattenuation before AAC, and `limitedSamplesPct` is the percentage of sample\nframes attenuated by more than 0.1 dB, measured at 192 kHz. `limitingDb` is\nseparately the estimated peak reduction from source peak, gain and ceiling.\nThe limiter can change loudness range; the record shows both source and output.\n\nOlder records without `normalization.mode: "peak-limiter"` describe the former\ngain clamp: `peakLimited: true` there means it landed below target. Existing\nmedia is unchanged until explicitly re-encoded.\n\n#### Listen before applying\n\nOn the dashboard asset page, choose a start second and three targets (initial\nsuggestions: **-30, -24, -18 LUFS**, each editable from -40 to -10). Make the\ncomparison to hear Original plus three normalised 30-second clips, shortened\nat the end of the video. The players show measured LUFS, true peak, loudness\nrange and limiting. Only **Re-encode at \u2026 LUFS** changes the asset.\n\nEach tier is measured over the selected sample. A full re-encode measures the\nwhole source and may need a different gain at the same target. Preview input\nis the published gain-free Original AAC, not the lossless upload. Preview jobs\nread only the needed audio fragments and run on the concurrent poster/preview\nlane, never behind a ladder encode. Worker processing is bounded to 45 seconds;\nqueue or cold-start delays are additional. Missing fast-lane configuration\nreturns 503 rather than falling back to the encode queue.\n\n```bash\nhostr assets audio-preview ASSET --time 120 --targets=-30,-24,-18\nhostr assets audio-preview ASSET --json\n# After listening and choosing a target:\nhostr assets reencode ASSET --target-lufs -24\n```\n\nThe API takes `POST /v1/assets/:id/audio-preview` with\n`{ "time": 120, "targets": [-30, -24, -18] }`. Optional `truePeakDbtp` and\n`kbps` keep their existing defaults (-1.5 and 128). Poll `GET` on the same\nroute for state and measurements, then fetch\n`GET /v1/assets/:id/audio-preview/:previewRev/:index` with a tenant key\n(index 0 is Original, 1\u20133 follow the targets). Audio remains private. A\ncomparison never changes `audioOptions`, playback, posters or the live revision.\nOnly the latest comparison is addressable. Clips remain under the asset prefix\nuntil asset deletion. A queued request older than two minutes can be retried.\n\n#### The original stays selectable\n\nWhen a gain is applied, the top audio group carries a second rendition,\n**Original** \u2014 the same encode with no gain \u2014 beside the **Normalised**\ndefault. A player\'s audio-track menu offers both, so a creator can A/B their\nown upload and nothing is ever available only normalised. It costs one more\naudio track\'s worth of storage and appears only when normalisation actually\nchanged something. The 96 kbps group does not carry it. (The original upload is\nkept in storage regardless; this is about what a viewer can pick.)\n\n### Loudness is measured for every asset\n\nWhether or not normalisation is on, the probe measures the first audio stream\n(EBU R128, true-peak mode) and the record carries the result as `loudness`,\nwhich also rides `video.audio_ready`, `video.playable` and `video.ready`:\n\n```json\n{\n "integratedLufs": -32.1,\n "loudnessRangeLu": 15.2,\n "truePeakDbtp": -0.3,\n "thresholdLufs": -42.5,\n "mono": false,\n "normalization": null\n}\n```\n\nTwo uses. **Creator warnings before publish** \u2014 `mono` for a one-channel source\n(the ladder keeps it mono; it never fakes stereo), `truePeakDbtp` near 0 for\nclipping, a very low `integratedLufs` for a recording that is quiet even by\nASMR standards. For output loudness use `normalization.output.integratedLufs`\nwhen present. `integratedLufs` itself always describes the source; adding\n`gainDb` is no longer an exact output prediction when peaks are limited.\n\nThe block is written once on first encode and replaced atomically when a\nre-encode becomes ready. It is `null` for a source with no audio stream.\n\n### What the probe found\n\nThe transcoder probes each source and records what it saw on the asset as\n`input` \u2014 codec, pixel format, bit depth, dimensions, declared and average frame\nrates, and colour metadata.\n\nWhen the source required a judgement call, that block also carries `findings`,\nand a [`video.input.non_standard`](#event-reference) event fires once. It is\nadvisory: the transcode proceeds normally and `video.ready` still follows. See\nthe [events reference](#event-reference) for the finding codes.\n\n### HDR sources\n\nThe ladder is 8-bit BT.709 SDR. A genuine HDR source \u2014 10-bit with an HLG or\nPQ transfer, which is what an iPhone records in HDR mode \u2014 is converted to SDR\nonce, before the ladder splits, so every rung, the poster and the storyboard\ncome from the same converted frames. HLG is read the way the format intends\nfor SDR screens (as an ordinary gamma curve, with the colour converted to\nBT.709 and a small contrast and saturation lift standing in for the display\nheadroom SDR lacks); PQ is tone-mapped perceptually. A BT.2020 source without\nan HDR curve has only its colour converted. The finding on the record says\nwhich was done.\n\nThe 10-bit part is the rule. HDR is a 10-bit format, so HDR tags on 8-bit\npixels are a label copied forward by an earlier transcode, and converting as\nif they were true blows the highlights; those sources are treated as SDR and\nleft alone.\n\nHDR is not delivered to viewers. Tone mapping shipped on 2026-09-10; an HDR\nasset encoded before that plays back washed out until it is\n[re-encoded](https://docs.hostr.sh/concepts/assets/#re-encoding).\n\n### Container and codec choices\n\nfMP4 rather than MPEG-TS: universal playback today, and the container that keeps\nHEVC addable later without re-packaging everything.\n\nSegments are served `immutable, max-age=1yr`, and content keys are versioned\n(`.../hls/v2/...`) rather than overwritten. Overwriting a key that is cached for\na year is how you end up needing a cache purge in production, which is a smell\nrather than a tool.\n\n## Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| GET | `/health` | \u2014 | Liveness check |\n| GET | `/llms.txt` | \u2014 | Agent documentation index |\n| GET | `/llms-full.txt` | \u2014 | Agent integration guide |\n| POST | `/v1/device/code` | \u2014 | Begin browser authorization |\n| POST | `/v1/device/token` | \u2014 | Exchange an approved device code for a key |\n| POST | `/v1/uploads` | upload | Create an asset and begin a multipart upload |\n| GET | `/v1/uploads/:id` | upload | Upload status and parts already stored |\n| DELETE | `/v1/uploads/:id` | upload | Abort an upload |\n| POST | `/v1/uploads/:id/parts` | upload | Get presigned PUT URLs for parts |\n| POST | `/v1/uploads/:id/complete` | upload | Finalise the upload and queue transcoding |\n| POST | `/v1/uploads/:id/token` | upload | Re-mint an upload token |\n| GET | `/v1/assets` | read | List your assets |\n| GET | `/v1/assets/:id` | read | Get one asset |\n| PATCH | `/v1/assets/:id` | upload | Change an asset\'s metadata |\n| DELETE | `/v1/assets/:id` | full | Erase an asset |\n| POST | `/v1/assets/:id/tokens` | playback | Mint playback credentials |\n| POST | `/v1/assets/:id/audio` | upload | Generate the audio-only rendition |\n| POST | `/v1/assets/:id/download` | playback | Link to the original upload |\n| GET | `/v1/assets/:id/audio-preview` | read | Read the current audio comparison |\n| POST | `/v1/assets/:id/audio-preview` | upload | Compare Original audio and three loudness targets |\n| GET | `/v1/assets/:id/audio-preview/:previewRev/:index` | read | Read one private AAC comparison clip |\n| POST | `/v1/assets/:id/reencode` | upload | Re-encode an asset from its stored source |\n| POST | `/v1/renders` | upload | Create a video from an edit |\n| POST | `/v1/assets/:id/render` | upload | Re-edit an asset |\n| POST | `/v1/assets/:id/poster` | upload | Re-take the poster from a chosen second |\n| POST | `/v1/assets/:id/poster/warm` | upload | Start a worker ahead of a poster request |\n| POST | `/v1/assets/:id/status` | \u2014 | Report transcode status |\n| GET | `/v1/assets/:id/subtitles` | read | List subtitle tracks |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | Attach or replace a subtitle track |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | Remove a subtitle track |\n| POST | `/v1/assets/:id/captions` | upload | Transcribe an asset (or redo it) |\n| POST | `/v1/assets/:id/captions/translate` | upload | Translate the transcript into more languages |\n| POST | `/v1/captions/callback/:id/:rev` | \u2014 | Transcription provider callback (not for clients) |\n| POST | `/v1/billing/webhook` | \u2014 | Stripe webhook (not for clients) |\n| GET | `/v1/account` | read | Whoami |\n| PUT | `/v1/account/captions` | full | Turn captions on or off |\n| GET | `/v1/usage` | read | Daily usage |\n| GET | `/v1/billing` | read | Billing status and this month so far |\n| GET | `/v1/events` | read | Tail the durable event log |\n| POST | `/v1/events/:id/redeliver` | full | Replay a stored event |\n| GET | `/v1/webhooks` | full | Get the webhook endpoint |\n| PUT | `/v1/webhooks` | full | Configure the webhook endpoint |\n| DELETE | `/v1/webhooks` | full | Disable the webhook endpoint |\n| GET | `/v1/keys` | full | List key metadata |\n| POST | `/v1/keys` | full | Mint an API key |\n| DELETE | `/v1/keys/:id` | full | Revoke a key |\n\nGenerated from the same OpenAPI description the API is checked against.\nFull reference with schemas and examples: https://docs.hostr.sh/reference/api/\n';
|
|
5918
6198
|
|
|
5919
6199
|
// src/commands-init.ts
|
|
5920
6200
|
init_config();
|
|
@@ -5970,54 +6250,68 @@ transcode, HLS playback with signed URLs.
|
|
|
5970
6250
|
`;
|
|
5971
6251
|
|
|
5972
6252
|
// src/commands-init.ts
|
|
5973
|
-
var readOrNull2 = (p) =>
|
|
6253
|
+
var readOrNull2 = (p) => readFile5(p, "utf8").catch(() => null);
|
|
5974
6254
|
async function init(opts) {
|
|
5975
6255
|
const out = makeOut(!!opts.json);
|
|
5976
6256
|
const dir = opts.dir ?? process.cwd();
|
|
5977
6257
|
const apiBase = opts.api ?? process.env.HOSTR_API_BASE ?? DEFAULT_API_BASE;
|
|
5978
6258
|
const actions = {};
|
|
5979
|
-
|
|
5980
|
-
const
|
|
6259
|
+
let pinLine = "";
|
|
6260
|
+
const existing = await findPin(dir, {}).catch(() => null);
|
|
6261
|
+
const profile = activeProfile(await loadConfig(configDir()));
|
|
6262
|
+
if (existing) actions[PIN_FILE] = "unchanged";
|
|
6263
|
+
else if (profile?.projectId && profile.projectName) {
|
|
6264
|
+
await writePin(dir, { projectId: profile.projectId, projectName: profile.projectName });
|
|
6265
|
+
actions[PIN_FILE] = "written";
|
|
6266
|
+
pinLine = `
|
|
6267
|
+
Linked this folder to the project "${profile.projectName}" (saved in ${PIN_FILE} \u2014 commit it).
|
|
6268
|
+
Wrong project? Run \`npx @hostr/cli login\`, choose the right one, then \`npx @hostr/cli link\`.
|
|
6269
|
+
`;
|
|
6270
|
+
} else {
|
|
6271
|
+
pinLine = "\nNot linked to a project yet. Run `npx @hostr/cli login`, then `npx @hostr/cli link`.\n";
|
|
6272
|
+
}
|
|
6273
|
+
await mkdir4(join5(dir, "docs"), { recursive: true });
|
|
6274
|
+
const docPath = join5(dir, "docs/hostr.md");
|
|
5981
6275
|
const stamped = `${guideStamp(CLI_VERSION)}
|
|
5982
6276
|
${integrate_default}`;
|
|
5983
6277
|
const prevDoc = await readOrNull2(docPath);
|
|
5984
6278
|
if (prevDoc === stamped) actions["docs/hostr.md"] = "unchanged";
|
|
5985
6279
|
else {
|
|
5986
|
-
await
|
|
6280
|
+
await writeFile6(docPath, stamped);
|
|
5987
6281
|
actions["docs/hostr.md"] = prevDoc === null ? "written" : "updated";
|
|
5988
6282
|
}
|
|
5989
|
-
const agentsPath =
|
|
6283
|
+
const agentsPath = join5(dir, "AGENTS.md");
|
|
5990
6284
|
const prevAgents = await readOrNull2(agentsPath);
|
|
5991
6285
|
const nextAgents = spliceMarkers(prevAgents, agentsSection(apiBase));
|
|
5992
6286
|
if (nextAgents === prevAgents) actions["AGENTS.md"] = "unchanged";
|
|
5993
6287
|
else {
|
|
5994
|
-
await
|
|
6288
|
+
await writeFile6(agentsPath, nextAgents);
|
|
5995
6289
|
actions["AGENTS.md"] = prevAgents === null ? "written" : "updated";
|
|
5996
6290
|
}
|
|
5997
|
-
const envPath =
|
|
6291
|
+
const envPath = join5(dir, ".env.example");
|
|
5998
6292
|
const prevEnv = await readOrNull2(envPath);
|
|
5999
6293
|
const missing = missingEnvLines(prevEnv);
|
|
6000
6294
|
if (!missing.length) actions[".env.example"] = "unchanged";
|
|
6001
6295
|
else {
|
|
6002
6296
|
const base = prevEnv === null ? "" : prevEnv.replace(/\n*$/, "\n");
|
|
6003
|
-
await
|
|
6297
|
+
await writeFile6(envPath, `${base}${missing.join("\n")}
|
|
6004
6298
|
`);
|
|
6005
6299
|
actions[".env.example"] = prevEnv === null ? "written" : "updated";
|
|
6006
6300
|
}
|
|
6007
6301
|
out.print(
|
|
6008
|
-
Object.entries(actions).map(([f, a]) => `${a.padEnd(10)}${f}`).join("\n") + "\n\nNext: read docs/hostr.md \u2014 it is the complete integration guide.",
|
|
6302
|
+
Object.entries(actions).map(([f, a]) => `${a.padEnd(10)}${f}`).join("\n") + "\n" + pinLine + "\nNext: read docs/hostr.md \u2014 it is the complete integration guide.",
|
|
6009
6303
|
{ ok: true, actions }
|
|
6010
6304
|
);
|
|
6011
6305
|
}
|
|
6012
6306
|
|
|
6013
6307
|
// src/commands-status.ts
|
|
6014
6308
|
init_config();
|
|
6015
|
-
import { readFile as
|
|
6016
|
-
import { join as
|
|
6017
|
-
var readOrNull3 = (p) =>
|
|
6309
|
+
import { readFile as readFile6 } from "node:fs/promises";
|
|
6310
|
+
import { join as join6 } from "node:path";
|
|
6311
|
+
var readOrNull3 = (p) => readFile6(p, "utf8").catch(() => null);
|
|
6018
6312
|
async function findEnvKey(dir) {
|
|
6019
6313
|
for (const f of [".env.local", ".env"]) {
|
|
6020
|
-
const body = await readOrNull3(
|
|
6314
|
+
const body = await readOrNull3(join6(dir, f));
|
|
6021
6315
|
if (body && /^\s*HOSTR_API_KEY=\S/m.test(body)) return f;
|
|
6022
6316
|
}
|
|
6023
6317
|
return null;
|
|
@@ -6026,21 +6320,35 @@ async function status(opts) {
|
|
|
6026
6320
|
const out = makeOut(!!opts.json);
|
|
6027
6321
|
const dir = process.cwd();
|
|
6028
6322
|
const cfg = await loadConfig(configDir());
|
|
6029
|
-
|
|
6323
|
+
let { key, apiBase } = resolveAuth({
|
|
6030
6324
|
flagKey: opts.key,
|
|
6031
6325
|
flagApi: opts.api,
|
|
6032
6326
|
env: process.env,
|
|
6033
6327
|
profile: activeProfile(cfg)
|
|
6034
6328
|
});
|
|
6329
|
+
let pinSource;
|
|
6330
|
+
let pinMismatch;
|
|
6331
|
+
try {
|
|
6332
|
+
const c = await resolveCredentials(opts);
|
|
6333
|
+
({ key, apiBase } = c);
|
|
6334
|
+
pinSource = c.pin?.source;
|
|
6335
|
+
} catch (e) {
|
|
6336
|
+
if (e instanceof PinMismatch) {
|
|
6337
|
+
pinMismatch = e.message;
|
|
6338
|
+
key = void 0;
|
|
6339
|
+
}
|
|
6340
|
+
}
|
|
6035
6341
|
const input = {
|
|
6036
6342
|
hasProfileKey: !!key,
|
|
6037
|
-
guideStampVersion: readGuideStamp(await readOrNull3(
|
|
6343
|
+
guideStampVersion: readGuideStamp(await readOrNull3(join6(dir, "docs/hostr.md"))),
|
|
6038
6344
|
cliVersion: CLI_VERSION,
|
|
6039
6345
|
latestCliVersion: null,
|
|
6040
6346
|
envKeyFile: await findEnvKey(dir),
|
|
6041
6347
|
account: null,
|
|
6042
6348
|
webhookUrl: null,
|
|
6043
|
-
online: false
|
|
6349
|
+
online: false,
|
|
6350
|
+
...pinSource ? { pinSource } : {},
|
|
6351
|
+
...pinMismatch ? { pinMismatch } : {}
|
|
6044
6352
|
};
|
|
6045
6353
|
const latest = latestVersion(configDir(), process.env);
|
|
6046
6354
|
if (key) {
|
|
@@ -6049,9 +6357,11 @@ async function status(opts) {
|
|
|
6049
6357
|
const a = await client.account();
|
|
6050
6358
|
const billing = await client.billing().catch(() => null);
|
|
6051
6359
|
input.account = {
|
|
6360
|
+
id: a.accountId,
|
|
6052
6361
|
name: a.name,
|
|
6053
6362
|
plan: a.plan,
|
|
6054
6363
|
assetCount: a.usage.assetCount,
|
|
6364
|
+
...a.organization !== void 0 ? { organization: a.organization } : {},
|
|
6055
6365
|
...a.captions ? { captions: a.captions } : {},
|
|
6056
6366
|
...billing ? { billing: { status: billing.status, billingUrl: billing.billingUrl, cardRequired: billing.cardRequired, allowanceLeftUsd: billing.allowance?.leftUsd } } : {}
|
|
6057
6367
|
};
|
|
@@ -6065,6 +6375,180 @@ async function status(opts) {
|
|
|
6065
6375
|
out.print(renderStatus(report), { ...report, cliVersion: CLI_VERSION });
|
|
6066
6376
|
}
|
|
6067
6377
|
|
|
6378
|
+
// src/commands-listen.ts
|
|
6379
|
+
import { readFile as readFile7 } from "node:fs/promises";
|
|
6380
|
+
|
|
6381
|
+
// ../control/src/keys.ts
|
|
6382
|
+
var ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
6383
|
+
var randChars = (n) => {
|
|
6384
|
+
let out = "";
|
|
6385
|
+
while (out.length < n) {
|
|
6386
|
+
const buf = new Uint8Array(n * 2);
|
|
6387
|
+
crypto.getRandomValues(buf);
|
|
6388
|
+
for (const b of buf) if (b < 248 && out.length < n) out += ALPHABET[b % 62];
|
|
6389
|
+
}
|
|
6390
|
+
return out;
|
|
6391
|
+
};
|
|
6392
|
+
var mintWebhookSecret = () => `whsec_${randChars(32)}`;
|
|
6393
|
+
|
|
6394
|
+
// src/commands-listen.ts
|
|
6395
|
+
init_config();
|
|
6396
|
+
|
|
6397
|
+
// ../../apps/api/src/webhooks.ts
|
|
6398
|
+
var enc = new TextEncoder();
|
|
6399
|
+
var cachedKey = null;
|
|
6400
|
+
function hmacKey(secret) {
|
|
6401
|
+
if (cachedKey?.secret !== secret) {
|
|
6402
|
+
cachedKey = {
|
|
6403
|
+
secret,
|
|
6404
|
+
key: crypto.subtle.importKey("raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign", "verify"])
|
|
6405
|
+
};
|
|
6406
|
+
}
|
|
6407
|
+
return cachedKey.key;
|
|
6408
|
+
}
|
|
6409
|
+
var hex = (buf) => Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
|
|
6410
|
+
async function signWebhook(secret, timestampSec, rawBody) {
|
|
6411
|
+
const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(`${timestampSec}.${rawBody}`));
|
|
6412
|
+
return `t=${timestampSec},v1=${hex(sig)}`;
|
|
6413
|
+
}
|
|
6414
|
+
async function webhookHeaders(secret, rawBody, nowSec) {
|
|
6415
|
+
return { "content-type": "application/json", "hostr-signature": await signWebhook(secret, nowSec, rawBody) };
|
|
6416
|
+
}
|
|
6417
|
+
|
|
6418
|
+
// src/listen.ts
|
|
6419
|
+
var webhookBody = (e) => JSON.stringify({ id: e.id, type: e.type, createdAt: e.createdAt, data: e.payload });
|
|
6420
|
+
async function pollNew(fetchDesc, seen, maxPages = 5) {
|
|
6421
|
+
const fresh = [];
|
|
6422
|
+
let after;
|
|
6423
|
+
for (let i = 0; i < maxPages; i++) {
|
|
6424
|
+
const page2 = await fetchDesc(after);
|
|
6425
|
+
let hitSeen = false;
|
|
6426
|
+
for (const e of page2.items) {
|
|
6427
|
+
if (seen.has(e.id)) {
|
|
6428
|
+
hitSeen = true;
|
|
6429
|
+
continue;
|
|
6430
|
+
}
|
|
6431
|
+
fresh.push(e);
|
|
6432
|
+
}
|
|
6433
|
+
if (hitSeen || !page2.items.length || page2.nextCursor === after) break;
|
|
6434
|
+
after = page2.nextCursor ?? void 0;
|
|
6435
|
+
if (!after) break;
|
|
6436
|
+
}
|
|
6437
|
+
return fresh.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
|
|
6438
|
+
}
|
|
6439
|
+
function remember(seen, ids, max = 2e3) {
|
|
6440
|
+
for (const id of ids) seen.add(id);
|
|
6441
|
+
for (const id of seen) {
|
|
6442
|
+
if (seen.size <= max) break;
|
|
6443
|
+
seen.delete(id);
|
|
6444
|
+
}
|
|
6445
|
+
}
|
|
6446
|
+
async function forwardEvent(opts) {
|
|
6447
|
+
const now = opts.now ?? Date.now;
|
|
6448
|
+
const body = webhookBody(opts.event);
|
|
6449
|
+
const t0 = now();
|
|
6450
|
+
try {
|
|
6451
|
+
const res = await (opts.fetchFn ?? fetch)(opts.url, {
|
|
6452
|
+
method: "POST",
|
|
6453
|
+
headers: await webhookHeaders(opts.secret, body, Math.floor(now() / 1e3)),
|
|
6454
|
+
body,
|
|
6455
|
+
signal: AbortSignal.timeout(opts.timeoutMs ?? 1e4)
|
|
6456
|
+
});
|
|
6457
|
+
await res.arrayBuffer().catch(() => {
|
|
6458
|
+
});
|
|
6459
|
+
return { status: res.status, ms: now() - t0 };
|
|
6460
|
+
} catch (e) {
|
|
6461
|
+
const err = e;
|
|
6462
|
+
const why = err.cause?.code === "ECONNREFUSED" ? "couldn't connect \u2014 is your app running?" : err.name === "TimeoutError" ? "your app took longer than 10 seconds to answer" : err.message ?? String(e);
|
|
6463
|
+
return { status: null, ms: now() - t0, error: why };
|
|
6464
|
+
}
|
|
6465
|
+
}
|
|
6466
|
+
function resolveListenSecret(i) {
|
|
6467
|
+
if (i.flag) return { secret: i.flag, source: "flag" };
|
|
6468
|
+
if (i.env) return { secret: i.env, source: "env" };
|
|
6469
|
+
if (i.envFile) return { secret: i.envFile, source: "env-file" };
|
|
6470
|
+
if (i.stored) return { secret: i.stored, source: "profile" };
|
|
6471
|
+
return { secret: i.mint(), source: "new" };
|
|
6472
|
+
}
|
|
6473
|
+
function readEnvValue(text, name) {
|
|
6474
|
+
if (!text) return null;
|
|
6475
|
+
const line = text.split(/\r?\n/).find((l) => l.trimStart().startsWith(`${name}=`));
|
|
6476
|
+
if (!line) return null;
|
|
6477
|
+
const v = line.slice(line.indexOf("=") + 1).trim().replace(/^(['"])(.*)\1$/, "$2");
|
|
6478
|
+
return v || null;
|
|
6479
|
+
}
|
|
6480
|
+
|
|
6481
|
+
// src/commands-listen.ts
|
|
6482
|
+
var SECRET_VAR = "HOSTR_WEBHOOK_SECRET";
|
|
6483
|
+
async function listen(ctx, opts) {
|
|
6484
|
+
let url;
|
|
6485
|
+
try {
|
|
6486
|
+
url = new URL(opts.forwardTo);
|
|
6487
|
+
} catch {
|
|
6488
|
+
throw new Error(`--forward-to needs a full address, like http://localhost:3000/api/hostr/webhook (got "${opts.forwardTo}")`);
|
|
6489
|
+
}
|
|
6490
|
+
const types = opts.events ? new Set(opts.events.split(",").map((s) => s.trim()).filter(Boolean)) : null;
|
|
6491
|
+
const replay = opts.replay ? Math.min(Math.max(Number(opts.replay) || 0, 0), 50) : 0;
|
|
6492
|
+
const intervalMs = Math.max(Number(opts.interval ?? 2) || 2, 1) * 1e3;
|
|
6493
|
+
const say = (line) => process.stderr.write(`${line}
|
|
6494
|
+
`);
|
|
6495
|
+
const dir = configDir();
|
|
6496
|
+
const cfg = await loadConfig(dir);
|
|
6497
|
+
const envFileText = opts.envFile ? await readFile7(opts.envFile, "utf8").catch(() => null) : null;
|
|
6498
|
+
const { secret, source } = resolveListenSecret({
|
|
6499
|
+
flag: opts.secret,
|
|
6500
|
+
env: process.env[SECRET_VAR],
|
|
6501
|
+
envFile: readEnvValue(envFileText, SECRET_VAR),
|
|
6502
|
+
stored: cfg.listenSecret,
|
|
6503
|
+
mint: mintWebhookSecret
|
|
6504
|
+
});
|
|
6505
|
+
if (source === "new") await saveConfig({ ...cfg, listenSecret: secret }, dir);
|
|
6506
|
+
if ((source === "new" || source === "profile") && opts.envFile) {
|
|
6507
|
+
const { gitignored } = await writeEnvVar(opts.envFile, SECRET_VAR, secret);
|
|
6508
|
+
say(`Saved ${SECRET_VAR} in ${opts.envFile} \u2014 your app uses it to check events really came from hostr. Restart your app so it picks it up.` + (gitignored ? "" : `
|
|
6509
|
+
Warning: ${opts.envFile} isn't in .gitignore, so it could get committed.`));
|
|
6510
|
+
} else if (source === "new" || source === "profile") {
|
|
6511
|
+
say(`Put this in your app's environment so it can check events really came from hostr:
|
|
6512
|
+
${SECRET_VAR}=${secret}
|
|
6513
|
+
(Or add --env-file .env.local and it gets saved there for you.)`);
|
|
6514
|
+
} else {
|
|
6515
|
+
say(`Using ${SECRET_VAR} from ${source === "env-file" ? opts.envFile : source === "env" ? "your environment" : "--secret"}.`);
|
|
6516
|
+
}
|
|
6517
|
+
const seen = /* @__PURE__ */ new Set();
|
|
6518
|
+
const fetchDesc = (after) => ctx.client.events({ order: "desc", limit: 50, ...after ? { after } : {} });
|
|
6519
|
+
const head = await fetchDesc();
|
|
6520
|
+
const backlog = head.items.slice(0, replay).reverse();
|
|
6521
|
+
remember(seen, head.items.slice(replay).map((e) => e.id));
|
|
6522
|
+
say(`Sending ${types ? [...types].join(", ") : "new hostr events"} to ${url.href} as they happen. Press Ctrl+C to stop.`);
|
|
6523
|
+
let stopped = false;
|
|
6524
|
+
process.once("SIGINT", () => {
|
|
6525
|
+
stopped = true;
|
|
6526
|
+
say("\nStopped.");
|
|
6527
|
+
process.exit(0);
|
|
6528
|
+
});
|
|
6529
|
+
const deliver = async (batch) => {
|
|
6530
|
+
remember(seen, batch.map((e) => e.id));
|
|
6531
|
+
for (const event of batch) {
|
|
6532
|
+
if (types && !types.has(event.type)) continue;
|
|
6533
|
+
const r = await forwardEvent({ url: url.href, secret, event });
|
|
6534
|
+
const outcome = r.status === null ? `\u2717 ${r.error}` : `${r.status >= 200 && r.status < 300 ? "\u2192" : "\u2717"} ${r.status}`;
|
|
6535
|
+
ctx.out.record(
|
|
6536
|
+
`${fmtWhen(event.createdAt)} ${event.type.padEnd(22)} ${event.assetId ?? "-"} ${outcome} (${r.ms} ms)`,
|
|
6537
|
+
{ id: event.id, type: event.type, assetId: event.assetId, status: r.status, ms: r.ms, ...r.error ? { error: r.error } : {} }
|
|
6538
|
+
);
|
|
6539
|
+
}
|
|
6540
|
+
};
|
|
6541
|
+
await deliver(backlog);
|
|
6542
|
+
while (!stopped) {
|
|
6543
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
6544
|
+
try {
|
|
6545
|
+
await deliver(await pollNew(fetchDesc, seen));
|
|
6546
|
+
} catch (e) {
|
|
6547
|
+
say(`Couldn't reach hostr, trying again: ${e.message}`);
|
|
6548
|
+
}
|
|
6549
|
+
}
|
|
6550
|
+
}
|
|
6551
|
+
|
|
6068
6552
|
// src/cli.ts
|
|
6069
6553
|
init_config();
|
|
6070
6554
|
var program2 = new Command("hostr").description("hostr \u2014 self-hosted video infrastructure (upload, transcode, HLS delivery)\nRun `npx @hostr/cli` with no arguments for setup status and the next step.").version(CLI_VERSION).option("--key <key>", "API key (overrides HOSTR_API_KEY and the saved profile)").option("--api <url>", "API base URL (overrides HOSTR_API_BASE and the saved profile)").option("--json", "machine-readable output: one JSON document (NDJSON for tail --follow)");
|
|
@@ -6072,13 +6556,16 @@ var globals = () => program2.opts();
|
|
|
6072
6556
|
program2.action(async () => {
|
|
6073
6557
|
await status(globals());
|
|
6074
6558
|
});
|
|
6559
|
+
program2.command("status").description("setup status: project, organization, and the next step").action(async () => {
|
|
6560
|
+
await status(globals());
|
|
6561
|
+
});
|
|
6075
6562
|
program2.hook("preAction", (_cmd, action) => {
|
|
6076
|
-
if (action === program2) return;
|
|
6563
|
+
if (action === program2 || action.parent === program2 && action.name() === "status") return;
|
|
6077
6564
|
noticeAndRefresh({ dir: configDir(), env: process.env, current: CLI_VERSION, json: !!globals().json });
|
|
6078
6565
|
});
|
|
6079
|
-
var run = (fn) => async (...args) => {
|
|
6566
|
+
var run = (fn, o = {}) => async (...args) => {
|
|
6080
6567
|
const g = globals();
|
|
6081
|
-
const ctx = await makeCtx(g);
|
|
6568
|
+
const ctx = await makeCtx(g, o);
|
|
6082
6569
|
try {
|
|
6083
6570
|
await fn(ctx, ...args);
|
|
6084
6571
|
} catch (e) {
|
|
@@ -6086,7 +6573,7 @@ var run = (fn) => async (...args) => {
|
|
|
6086
6573
|
fail(ctx.out, e);
|
|
6087
6574
|
}
|
|
6088
6575
|
};
|
|
6089
|
-
program2.command("login").description("authorize in the browser and save the key as a profile").option("--profile <name>", "
|
|
6576
|
+
program2.command("login").description("authorize in the browser and save the key as a profile").option("--profile <name>", "save under this name (default: chosen for you; a login for another project is never overwritten)").action(async (opts) => {
|
|
6090
6577
|
try {
|
|
6091
6578
|
await login({ ...globals(), ...opts });
|
|
6092
6579
|
} catch (e) {
|
|
@@ -6094,6 +6581,16 @@ program2.command("login").description("authorize in the browser and save the key
|
|
|
6094
6581
|
fail(makeOut(!!globals().json), e);
|
|
6095
6582
|
}
|
|
6096
6583
|
});
|
|
6584
|
+
program2.command("logout").description("log out: delete the saved key on hostr and on this computer").option("--profile <name>", "which saved login (default: the one for this folder's project, else the current one)").action(async (opts) => {
|
|
6585
|
+
try {
|
|
6586
|
+
await logout({ ...globals(), ...opts });
|
|
6587
|
+
} catch (e) {
|
|
6588
|
+
await settleUpdateCheck();
|
|
6589
|
+
fail(makeOut(!!globals().json), e);
|
|
6590
|
+
}
|
|
6591
|
+
});
|
|
6592
|
+
program2.command("link").description("link this folder to the project you're logged in to (writes hostr.json \u2014 commit it)").option("--dir <dir>", "where to write hostr.json (default: cwd)").action(run((ctx, opts) => link(ctx, opts), { ignorePin: true }));
|
|
6593
|
+
program2.command("unlink").description("unlink this folder from its project (removes hostr.json)").action(run((ctx) => unlink(ctx), { ignorePin: true }));
|
|
6097
6594
|
program2.command("whoami").description("account, plan, limits, live usage").action(run(whoami));
|
|
6098
6595
|
program2.command("usage").description("daily usage rows").option("--from <date>", "YYYY-MM-DD (default: 30 days ago)").option("--to <date>", "YYYY-MM-DD (default: today)").action(run(usage));
|
|
6099
6596
|
var keys = program2.command("keys").description("API key management");
|
|
@@ -6116,9 +6613,11 @@ captionsCmd.command("disable").description("turn captions off for the account; f
|
|
|
6116
6613
|
captionsCmd.command("status <assetId>").description("the asset's latest captions run: transcript and per-language state").action(run((ctx, id) => captionsStatus(ctx, id)));
|
|
6117
6614
|
captionsCmd.command("request <assetId>").description("transcribe the asset, or redo it; no flags reuses the stored request (requires upload scope)").option("--lang <tag>", "spoken language as a BCP 47 tag, or auto (default: auto)").option("--translate <tags>", "comma-separated target languages, e.g. en,es (at most 10)").option("--context <text>", "context for the transcriber: creator, title, terms (trimmed; cut at 1000 characters)").action(run((ctx, id, opts) => captionsRequest(ctx, id, opts)));
|
|
6118
6615
|
captionsCmd.command("translate <assetId>").description("more languages from the stored transcript; billed per language-minute (requires upload scope)").requiredOption("--lang <tags>", "comma-separated target languages, e.g. fr,de").action(run((ctx, id, opts) => captionsTranslate(ctx, id, opts)));
|
|
6616
|
+
var collect = (v, prev = []) => [...prev, v];
|
|
6119
6617
|
var assets = program2.command("assets").description("asset listing and status");
|
|
6120
|
-
assets.command("list").option("--status <status>").option("--limit <n>").option("--cursor <cursor>").action(run(assetsList));
|
|
6618
|
+
assets.command("list").option("--status <status>").option("--limit <n>").option("--cursor <cursor>").option("--meta <key=value>", "only assets with this metadata value (repeatable, ANDed)", collect).action(run(assetsList));
|
|
6121
6619
|
assets.command("get <id>").action(run((ctx, id) => assetsGet(ctx, id)));
|
|
6620
|
+
assets.command("update <id>").description("change an asset's metadata: key=value sets, key= removes (requires upload scope)").option("--meta <key=value>", "repeatable", collect).action(run((ctx, id, opts) => assetsUpdate(ctx, id, opts)));
|
|
6122
6621
|
assets.command("delete <id>").description("erase an asset: R2 objects purged, playback ends (requires full scope)").action(run((ctx, id) => assetsDelete(ctx, id)));
|
|
6123
6622
|
assets.command("audio [id]").description("generate the audio-only rendition for an asset encoded before it existed (requires upload scope)").option("--missing", "sweep the account: every playable/ready asset with no audio yet").action(run((ctx, id, opts) => assetsAudio(ctx, id, opts)));
|
|
6124
6623
|
assets.command("reencode <id>").description("re-encode a ready asset from its stored source; id and URLs unchanged (requires upload scope)").option("--audio <preset>", "set the asset's audio options for this and later encodes: asmr (256k + peak-limited loudness normalisation)").option("--target-lufs <number>", "opt in to normalisation at this target (-40 to -10 LUFS)").option("--true-peak <number>", "true-peak ceiling in dBTP (-9 to 0)").option("--audio-kbps <number>", "top audio bitrate (64 to 320 kbps)").action(run((ctx, id, opts) => assetsReencode(ctx, id, opts)));
|
|
@@ -6126,10 +6625,11 @@ assets.command("audio-preview <id>").description("make a 30-second Original/norm
|
|
|
6126
6625
|
assets.command("poster <id>").description("re-take the poster from a chosen second of the video; thumbnail.jpg URL unchanged (requires upload scope)").requiredOption("--time <seconds>", "seconds into the video").action(run((ctx, id, opts) => assetsPoster(ctx, id, opts)));
|
|
6127
6626
|
assets.command("token <id>").description("mint playback credentials (requires playback scope)").option("--ttl <sec>").option("--viewer-id <id>").action(run((ctx, id, opts) => assetsToken(ctx, id, opts)));
|
|
6128
6627
|
program2.command("tail").description("event log (durable; ascending). One page by default.").option("-f, --follow", "poll every 2s").option("--type <type>", "filter: upload.created | video.uploaded | video.ready | video.errored | captions.ready | captions.errored").option("--limit <n>").option("--after <cursor>", "start after this event id").action(run(tail));
|
|
6628
|
+
program2.command("listen").description("forward this project's events to a local URL, signed like real webhooks \u2014 test your receiver in development").requiredOption("--forward-to <url>", "your receiver, e.g. http://localhost:3000/api/hostr/webhook").option("--env-file <file>", "sign with HOSTR_WEBHOOK_SECRET from this file, or write a new one into it").option("--secret <secret>", "sign with this secret").option("--events <types>", "comma-separated event types to forward (default: all)").option("--replay <n>", "also forward the newest n events at start (max 50)").option("--interval <seconds>", "poll interval (default 2)").action(run((ctx, opts) => listen(ctx, opts)));
|
|
6129
6629
|
program2.command("init").description("make this repo agent-ready: docs/hostr.md + AGENTS.md section + .env.example entries").option("--dir <dir>", "target directory (default: cwd)").action(async (opts) => {
|
|
6130
6630
|
await init({ ...globals(), ...opts });
|
|
6131
6631
|
});
|
|
6132
|
-
program2.command("upload [file]").description("upload a video \u2014 with no file, opens a local drag-drop page in the browser").option("--content-type <type>").option("--audio <preset>", "audio preset for the encode: asmr (256k + peak-limited loudness normalisation)").option("--wait", "block until transcode reaches ready/failed (file mode)").option("--captions", "transcribe the video once its audio exists (needs captions on: hostr captions enable)").option("--captions-lang <tag>", "spoken language for captions, or auto (implies --captions)").option("--translate <tags>", "caption translations, comma-separated, e.g. en,es (implies --captions)").option("--context <text>", "context for the transcriber: creator, title, terms (implies --captions)").action(run((ctx, file, opts) => upload(ctx, file, opts)));
|
|
6632
|
+
program2.command("upload [file]").description("upload a video \u2014 with no file, opens a local drag-drop page in the browser").option("--content-type <type>").option("--audio <preset>", "audio preset for the encode: asmr (256k + peak-limited loudness normalisation)").option("--wait", "block until transcode reaches ready/failed (file mode)").option("--captions", "transcribe the video once its audio exists (needs captions on: hostr captions enable)").option("--captions-lang <tag>", "spoken language for captions, or auto (implies --captions)").option("--translate <tags>", "caption translations, comma-separated, e.g. en,es (implies --captions)").option("--context <text>", "context for the transcriber: creator, title, terms (implies --captions)").option("--meta <key=value>", "your label for the asset, carried on every event (repeatable)", collect).action(run((ctx, file, opts) => upload(ctx, file, opts)));
|
|
6133
6633
|
program2.parseAsync().catch((e) => {
|
|
6134
6634
|
console.error(`error: ${e?.message ?? e}`);
|
|
6135
6635
|
process.exit(1);
|