@hostr/cli 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/cli.js +401 -96
  2. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -12,6 +12,14 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
12
12
  if (typeof require !== "undefined") return require.apply(this, arguments);
13
13
  throw Error('Dynamic require of "' + x + '" is not supported');
14
14
  });
15
+ var __esm = (fn, res, err) => function __init() {
16
+ if (err) throw err[0];
17
+ try {
18
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
19
+ } catch (e) {
20
+ throw err = [e], e;
21
+ }
22
+ };
15
23
  var __commonJS = (cb, mod) => function __require2() {
16
24
  try {
17
25
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
@@ -19,6 +27,10 @@ var __commonJS = (cb, mod) => function __require2() {
19
27
  throw mod = 0, e;
20
28
  }
21
29
  };
30
+ var __export = (target, all) => {
31
+ for (var name in all)
32
+ __defProp(target, name, { get: all[name], enumerable: true });
33
+ };
22
34
  var __copyProps = (to, from, except, desc) => {
23
35
  if (from && typeof from === "object" || typeof from === "function") {
24
36
  for (let key of __getOwnPropNames(from))
@@ -3462,6 +3474,344 @@ var require_commander = __commonJS({
3462
3474
  }
3463
3475
  });
3464
3476
 
3477
+ // ../upload/src/api/client.ts
3478
+ async function req(fetchFn, endpoint, bearer, path, init2 = {}) {
3479
+ const url = `${trimEndpoint(endpoint)}${path}`;
3480
+ const headers = { authorization: `Bearer ${bearer}` };
3481
+ let body;
3482
+ if (init2.body !== void 0) {
3483
+ headers["content-type"] = "application/json";
3484
+ body = JSON.stringify(init2.body);
3485
+ }
3486
+ let res;
3487
+ try {
3488
+ res = await fetchFn(url, { method: init2.method ?? "GET", headers, body });
3489
+ } catch {
3490
+ throw new NetworkError();
3491
+ }
3492
+ const text = await res.text();
3493
+ const data = text ? JSON.parse(text) : {};
3494
+ if (!res.ok) {
3495
+ const code = data && typeof data === "object" && typeof data.error === "string" ? data.error : void 0;
3496
+ throw new ApiError2(res.status, code);
3497
+ }
3498
+ return data;
3499
+ }
3500
+ async function createUploadSession(opts, fetchFn = fetch) {
3501
+ const { endpoint, apiKey, ...body } = opts;
3502
+ return req(fetchFn, endpoint, apiKey, "/v1/uploads", { method: "POST", body });
3503
+ }
3504
+ function mintUploadToken(opts, fetchFn = fetch) {
3505
+ const { endpoint, apiKey, assetId, tokenTtlSec } = opts;
3506
+ return req(fetchFn, endpoint, apiKey, `/v1/uploads/${assetId}/token`, {
3507
+ method: "POST",
3508
+ body: tokenTtlSec === void 0 ? {} : { tokenTtlSec }
3509
+ });
3510
+ }
3511
+ var ApiError2, NetworkError, trimEndpoint, HostrApi;
3512
+ var init_client = __esm({
3513
+ "../upload/src/api/client.ts"() {
3514
+ "use strict";
3515
+ ApiError2 = class extends Error {
3516
+ status;
3517
+ code;
3518
+ constructor(status, code) {
3519
+ super(code ? `hostr api error ${status}: ${code}` : `hostr api error ${status}`);
3520
+ this.name = "ApiError";
3521
+ this.status = status;
3522
+ this.code = code;
3523
+ }
3524
+ };
3525
+ NetworkError = class extends Error {
3526
+ constructor(message = "network error") {
3527
+ super(message);
3528
+ this.name = "NetworkError";
3529
+ }
3530
+ };
3531
+ trimEndpoint = (endpoint) => endpoint.replace(/\/$/, "");
3532
+ HostrApi = class {
3533
+ #endpoint;
3534
+ #bearer;
3535
+ #fetch;
3536
+ constructor(endpoint, bearer, fetchFn = fetch) {
3537
+ this.#endpoint = endpoint;
3538
+ this.#bearer = bearer;
3539
+ this.#fetch = fetchFn;
3540
+ }
3541
+ setBearer(bearer) {
3542
+ this.#bearer = bearer;
3543
+ }
3544
+ getUpload(assetId) {
3545
+ return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`);
3546
+ }
3547
+ presignParts(assetId, partNumbers, expiresIn) {
3548
+ return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/parts`, {
3549
+ method: "POST",
3550
+ body: expiresIn === void 0 ? { partNumbers } : { partNumbers, expiresIn }
3551
+ });
3552
+ }
3553
+ complete(assetId, body) {
3554
+ return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/complete`, {
3555
+ method: "POST",
3556
+ body
3557
+ });
3558
+ }
3559
+ async abort(assetId) {
3560
+ await req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`, { method: "DELETE" });
3561
+ }
3562
+ };
3563
+ }
3564
+ });
3565
+
3566
+ // src/gui.ts
3567
+ var gui_exports = {};
3568
+ __export(gui_exports, {
3569
+ uploadGui: () => uploadGui
3570
+ });
3571
+ import { createServer } from "node:http";
3572
+ import { randomBytes } from "node:crypto";
3573
+ import { spawn } from "node:child_process";
3574
+ async function uploadGui(ctx) {
3575
+ const secret = Array.from(randomBytes(16), (b) => b.toString(16).padStart(2, "0")).join("");
3576
+ const server = createServer(async (req2, res) => {
3577
+ const url = new URL(req2.url ?? "/", "http://localhost");
3578
+ const send = (status, body, type = "application/json") => {
3579
+ res.writeHead(status, { "content-type": type, "cache-control": "no-store" });
3580
+ res.end(body);
3581
+ };
3582
+ try {
3583
+ if (url.pathname === "/" && url.searchParams.get("s") === secret)
3584
+ return send(200, page(ctx.apiBase, secret), "text/html; charset=utf-8");
3585
+ if (url.searchParams.get("s") !== secret) return send(403, '{"error":"forbidden"}');
3586
+ if (url.pathname === "/api/session" && req2.method === "POST") {
3587
+ const chunks = [];
3588
+ for await (const c of req2) chunks.push(c);
3589
+ const { filename, size, contentType } = JSON.parse(Buffer.concat(chunks).toString());
3590
+ const s = await createUploadSession({ endpoint: ctx.apiBase, apiKey: ctx.key, filename, size, contentType });
3591
+ return send(200, JSON.stringify(s));
3592
+ }
3593
+ if (url.pathname.startsWith("/api/status/") && req2.method === "GET") {
3594
+ const a = await ctx.client.asset(url.pathname.slice("/api/status/".length));
3595
+ return send(200, JSON.stringify(a));
3596
+ }
3597
+ if (url.pathname.startsWith("/api/playback/") && req2.method === "POST") {
3598
+ try {
3599
+ const t = await ctx.client.assetTokens(url.pathname.slice("/api/playback/".length));
3600
+ return send(200, JSON.stringify(t));
3601
+ } catch {
3602
+ return send(200, JSON.stringify({ unavailable: true }));
3603
+ }
3604
+ }
3605
+ return send(404, '{"error":"not_found"}');
3606
+ } catch (e) {
3607
+ return send(500, JSON.stringify({ error: String(e.message).slice(0, 300) }));
3608
+ }
3609
+ });
3610
+ const server6 = createServer((req2, res) => server.emit("request", req2, res));
3611
+ const listen = (srv, host, port2) => new Promise((resolve) => {
3612
+ const onErr = (e) => {
3613
+ srv.removeAllListeners("listening");
3614
+ resolve(e.code ?? "ERR");
3615
+ };
3616
+ srv.once("error", onErr);
3617
+ srv.listen(port2, host, () => {
3618
+ srv.removeListener("error", onErr);
3619
+ resolve(null);
3620
+ });
3621
+ });
3622
+ let port = 0;
3623
+ for (const candidate of CORS_PORTS) {
3624
+ const e4 = await listen(server, "127.0.0.1", candidate);
3625
+ if (e4) continue;
3626
+ const e6 = await listen(server6, "::1", candidate);
3627
+ if (e6 && e6 !== "EADDRNOTAVAIL") {
3628
+ await new Promise((r) => server.close(() => r(null)));
3629
+ continue;
3630
+ }
3631
+ port = candidate;
3632
+ break;
3633
+ }
3634
+ if (!port) throw new Error("no free port among the CORS-allowed set (3000-3005, 5173, 8899)");
3635
+ const addr = `http://localhost:${port}/?s=${secret}`;
3636
+ process.stderr.write(`upload page ready: ${addr}
3637
+ (drop a video in the browser; Ctrl+C here when done)
3638
+ `);
3639
+ const opener = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
3640
+ spawn(opener, [addr], { stdio: "ignore", detached: true }).unref();
3641
+ await new Promise(() => {
3642
+ });
3643
+ }
3644
+ var CORS_PORTS, page;
3645
+ var init_gui = __esm({
3646
+ "src/gui.ts"() {
3647
+ "use strict";
3648
+ init_client();
3649
+ CORS_PORTS = [3005, 3004, 3003, 3002, 3001, 5173, 3e3, 8899];
3650
+ page = (apiBase, secret) => `<!doctype html>
3651
+ <meta charset="utf-8"><title>hostr upload</title>
3652
+ <meta name="viewport" content="width=device-width,initial-scale=1">
3653
+ <style>
3654
+ :root { --bg:#fdf6e3; --ink:#111; --surface:#fff; --accent:#2b4cff; --ok:#0a8f4d; --danger:#e0301e; }
3655
+ * { box-sizing:border-box; }
3656
+ body { background:var(--bg); color:var(--ink); font:15px/1.5 system-ui, sans-serif; margin:0; padding:40px 20px; }
3657
+ main { max-width:640px; margin:0 auto; }
3658
+ h1 { font-size:22px; font-weight:800; margin:0 0 4px; }
3659
+ .sub { color:#5a5a5a; font-size:13px; margin:0 0 24px; }
3660
+ #drop { background:var(--surface); border:2px dashed var(--ink); border-radius:6px; padding:56px 24px;
3661
+ text-align:center; font-weight:700; cursor:pointer; }
3662
+ #drop.over { background:#eef1ff; border-style:solid; }
3663
+ .card { background:var(--surface); border:2px solid var(--ink); border-radius:6px;
3664
+ box-shadow:3px 3px 0 var(--ink); padding:16px; margin-top:16px; }
3665
+ .bar { height:14px; border:2px solid var(--ink); border-radius:4px; overflow:hidden; margin:8px 0; }
3666
+ .bar > div { height:100%; background:var(--accent); width:0%; transition:width .2s; }
3667
+ .bar.enc > div { background:var(--ok); }
3668
+ .mono { font-family:ui-monospace,monospace; font-size:12px; word-break:break-all; }
3669
+ .err { color:var(--danger); font-weight:700; }
3670
+ video { width:100%; border:2px solid var(--ink); border-radius:6px; margin-top:10px; background:#000; }
3671
+ .hint { font-size:12px; color:#5a5a5a; }
3672
+ </style>
3673
+ <main>
3674
+ <h1>hostr upload</h1>
3675
+ <p class="sub">drop a video \u2014 it uploads straight to storage, transcodes, and plays back below</p>
3676
+ <div id="drop">drop a video here<br><span class="hint">or click to choose</span></div>
3677
+ <input id="file" type="file" accept="video/*,.mkv,.mov,.mp4,.webm" style="display:none">
3678
+ <div id="jobs"></div>
3679
+ </main>
3680
+ <script>
3681
+ const API = ${JSON.stringify(apiBase)}, S = ${JSON.stringify(secret)};
3682
+ const drop = document.getElementById('drop'), input = document.getElementById('file');
3683
+ drop.onclick = () => input.click();
3684
+ input.onchange = () => [...input.files].forEach(go);
3685
+ ['dragover','dragenter'].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.add('over'); }));
3686
+ ['dragleave','drop'].forEach(e => drop.addEventListener(e, ev => { ev.preventDefault(); drop.classList.remove('over'); }));
3687
+ drop.addEventListener('drop', ev => [...ev.dataTransfer.files].forEach(go));
3688
+
3689
+ // A bar that fills with no numbers beside it is indistinguishable from a hung
3690
+ // one \u2014 which is exactly how a 10-minute encode read before these existed.
3691
+ // Every long phase gets elapsed, rate, and a remaining estimate.
3692
+ const mmss = s => Math.floor(s / 60) + ':' + String(Math.floor(s % 60)).padStart(2, '0');
3693
+ const left = s => s >= 90 ? '~' + Math.round(s / 60) + 'm left' : '~' + Math.max(1, Math.round(s)) + 's left';
3694
+ const size = b => b >= 1e9 ? (b / 1e9).toFixed(2) + ' GB' : (b / 1e6).toFixed(0) + ' MB';
3695
+
3696
+ const local = (p, o) => fetch(p + '?s=' + S, o).then(r => r.json());
3697
+ const api = (p, tok, body) => fetch(API + p, { method:'POST',
3698
+ headers:{ authorization:'Bearer ' + tok, 'content-type':'application/json' },
3699
+ body: JSON.stringify(body ?? {}) }).then(async r => { const j = await r.json(); if (!r.ok) throw new Error(j.error || r.status); return j; });
3700
+
3701
+ async function go(file) {
3702
+ const el = document.createElement('div'); el.className = 'card'; document.getElementById('jobs').prepend(el);
3703
+ el.innerHTML = '<b>' + file.name + '</b><div class="bar"><div></div></div><div class="st">creating session\u2026</div>';
3704
+ const bar = el.querySelector('.bar > div'), st = el.querySelector('.st');
3705
+ try {
3706
+ const sess = await local('/api/session', { method:'POST', body: JSON.stringify({
3707
+ filename: file.name, size: file.size, contentType: file.type || 'application/octet-stream' }) });
3708
+ if (sess.error) throw new Error(sess.error);
3709
+ const { assetId, uploadToken, partSize, expectedParts } = sess;
3710
+
3711
+ // Part URLs in batches of 100 (the API's cap), uploads 4 at a time via
3712
+ // XHR \u2014 fetch has no upload progress.
3713
+ const done = new Array(expectedParts).fill(0);
3714
+ // Cumulative average rather than an instantaneous rate: it smooths a
3715
+ // fluctuating connection instead of flickering between wild numbers, and
3716
+ // over a multi-minute upload it converges on the honest figure anyway.
3717
+ let upStart = 0;
3718
+ const paint = () => {
3719
+ const sent = done.reduce((a,b)=>a+b,0);
3720
+ bar.style.width = (sent / file.size * 100).toFixed(1) + '%';
3721
+ // Freeze once every byte is sent. R2 can take a beat to acknowledge the
3722
+ // last part, and paint() keeps firing through it \u2014 with sent pinned at
3723
+ // file.size and elapsed still growing, the rate visibly decays and reads
3724
+ // as a dying connection at the exact moment the upload succeeded.
3725
+ // 'finalizing\u2026' takes over from here anyway.
3726
+ if (!upStart || !sent || sent >= file.size) return;
3727
+ const el = (Date.now() - upStart) / 1000;
3728
+ const rate = sent / el;
3729
+ st.textContent = 'uploading \xB7 ' + size(sent) + ' / ' + size(file.size)
3730
+ + ' \xB7 ' + (rate / 1e6).toFixed(1) + ' MB/s'
3731
+ + (rate > 0 ? ' \xB7 ' + left((file.size - sent) / rate) : '');
3732
+ };
3733
+ let urls = [];
3734
+ for (let b = 1; b <= expectedParts; b += 100) {
3735
+ const nums = []; for (let n = b; n < b + 100 && n <= expectedParts; n++) nums.push(n);
3736
+ urls = urls.concat((await api('/v1/uploads/' + assetId + '/parts', uploadToken, { partNumbers: nums })).urls);
3737
+ }
3738
+ st.textContent = 'uploading\u2026';
3739
+ upStart = Date.now();
3740
+ let idx = 0;
3741
+ await Promise.all(Array.from({ length: 4 }, async function worker() {
3742
+ while (idx < urls.length) {
3743
+ const u = urls[idx++];
3744
+ const start = (u.partNumber - 1) * partSize;
3745
+ const blob = file.slice(start, Math.min(start + partSize, file.size));
3746
+ await new Promise((res, rej) => {
3747
+ const x = new XMLHttpRequest();
3748
+ x.open('PUT', u.url);
3749
+ x.upload.onprogress = e => { done[u.partNumber - 1] = e.loaded; paint(); };
3750
+ x.onload = () => x.status < 300 ? (done[u.partNumber - 1] = blob.size, paint(), res()) : rej(new Error('part ' + u.partNumber + ' -> ' + x.status));
3751
+ x.onerror = () => rej(new Error('part ' + u.partNumber + ' network error'));
3752
+ x.send(blob);
3753
+ });
3754
+ }
3755
+ }));
3756
+ st.textContent = 'finalizing\u2026';
3757
+ await api('/v1/uploads/' + assetId + '/complete', uploadToken);
3758
+
3759
+ el.insertAdjacentHTML('beforeend', '<div class="bar enc"><div></div></div>');
3760
+ const enc = el.querySelectorAll('.bar > div')[1];
3761
+ st.textContent = 'queued \xB7 waking the encoder\u2026';
3762
+ for (;;) {
3763
+ await new Promise(r => setTimeout(r, 2500));
3764
+ const a = await local('/api/status/' + assetId);
3765
+ const p = a.progress;
3766
+ if (p?.totalSec) enc.style.width = (p.encodedSec / p.totalSec * 100).toFixed(1) + '%';
3767
+ if (a.status === 'ready') break;
3768
+ if (a.status === 'failed') throw new Error('transcode failed: ' + (a.error || 'unknown'));
3769
+ // Three distinct waits, and conflating them is what made this read as a
3770
+ // hang: the machine scales to zero so it may be booting; then it pulls
3771
+ // the source out of R2 and probes it; only then does progress exist.
3772
+ // Until the first progress post there is nothing to put in the bar, so
3773
+ // the label has to carry the state instead.
3774
+ st.textContent =
3775
+ p?.totalSec
3776
+ ? 'transcoding \xB7 ' + mmss(p.encodedSec) + ' / ' + mmss(p.totalSec)
3777
+ + (p.speed ? ' \xB7 ' + p.speed.toFixed(2) + '\xD7' : '')
3778
+ // speed is optional on the wire (the API forwards it only when it
3779
+ // is a number), so the estimate goes rather than reading NaN.
3780
+ + (p.speed > 0 ? ' \xB7 ' + left((p.totalSec - p.encodedSec) / p.speed) : '')
3781
+ : a.status === 'processing' ? 'transcoding \xB7 reading the source\u2026'
3782
+ : 'queued \xB7 waking the encoder\u2026';
3783
+ }
3784
+ enc.style.width = '100%';
3785
+
3786
+ const t = await local('/api/playback/' + assetId, { method: 'POST' });
3787
+ const mediaHost = API.replace('api.', 'stream.');
3788
+ if (t.unavailable) {
3789
+ st.innerHTML = 'ready \u2014 asset <span class="mono">' + assetId + '</span><br><span class="hint">key lacks full scope, so no preview token \u2014 mint one server-side to play</span>';
3790
+ return;
3791
+ }
3792
+ const src = mediaHost + '/' + assetId + '.m3u8?token=' + encodeURIComponent(t.playback);
3793
+ st.innerHTML = 'ready \u2014 asset <span class="mono">' + assetId + '</span>';
3794
+ const v = document.createElement('video');
3795
+ v.controls = true; v.playsInline = true;
3796
+ v.poster = mediaHost + '/' + assetId + '/thumbnail.jpg?token=' + encodeURIComponent(t.thumbnail);
3797
+ el.appendChild(v);
3798
+ if (v.canPlayType('application/vnd.apple.mpegurl')) v.src = src;
3799
+ else {
3800
+ const s = document.createElement('script');
3801
+ s.src = 'https://cdn.jsdelivr.net/npm/hls.js@1';
3802
+ s.onload = () => { const h = new Hls(); h.loadSource(src); h.attachMedia(v); };
3803
+ document.head.appendChild(s);
3804
+ }
3805
+ el.insertAdjacentHTML('beforeend',
3806
+ '<p class="hint">playback URL (token expires in 1h \u2014 mint per view in production):</p><p class="mono">' + src + '</p>');
3807
+ } catch (e) {
3808
+ st.innerHTML = '<span class="err">' + e.message + '</span>';
3809
+ }
3810
+ }
3811
+ </script>`;
3812
+ }
3813
+ });
3814
+
3465
3815
  // ../../node_modules/.pnpm/commander@14.0.3/node_modules/commander/esm.mjs
3466
3816
  var import_index = __toESM(require_commander(), 1);
3467
3817
  var {
@@ -3536,9 +3886,9 @@ function makeClient(opts) {
3536
3886
  method,
3537
3887
  headers: {
3538
3888
  authorization: `Bearer ${opts.key}`,
3539
- ...o.body !== void 0 ? { "content-type": "application/json" } : {}
3889
+ ...o.rawBody ? { "content-type": o.rawBody.contentType } : o.body !== void 0 ? { "content-type": "application/json" } : {}
3540
3890
  },
3541
- ...o.body !== void 0 ? { body: JSON.stringify(o.body) } : {}
3891
+ ...o.rawBody ? { body: o.rawBody.text } : o.body !== void 0 ? { body: JSON.stringify(o.body) } : {}
3542
3892
  });
3543
3893
  const body = await res.json().catch(() => ({}));
3544
3894
  if (!res.ok) throw new ApiError(res.status, body);
@@ -3549,6 +3899,13 @@ function makeClient(opts) {
3549
3899
  assets: (params = {}) => req2("GET", "/v1/assets", { params }),
3550
3900
  asset: (id) => req2("GET", `/v1/assets/${id}`),
3551
3901
  assetTokens: (id, body = {}) => req2("POST", `/v1/assets/${id}/tokens`, { body }),
3902
+ subtitles: (id) => req2("GET", `/v1/assets/${id}/subtitles`),
3903
+ subtitleSet: (id, lang, vtt, o = {}) => req2(
3904
+ "PUT",
3905
+ `/v1/assets/${id}/subtitles/${lang}`,
3906
+ { params: { name: o.name, default: o.default ? "true" : void 0 }, rawBody: { text: vtt, contentType: "text/vtt" } }
3907
+ ),
3908
+ subtitleDelete: (id, lang) => req2("DELETE", `/v1/assets/${id}/subtitles/${lang}`),
3552
3909
  assetDelete: (id) => req2("DELETE", `/v1/assets/${id}`),
3553
3910
  usage: (params = {}) => req2("GET", "/v1/usage", { params }),
3554
3911
  events: (params = {}) => req2("GET", "/v1/events", { params }),
@@ -3771,90 +4128,8 @@ function partSizeOf(s, n) {
3771
4128
  return s.partSize;
3772
4129
  }
3773
4130
 
3774
- // ../upload/src/api/client.ts
3775
- var ApiError2 = class extends Error {
3776
- status;
3777
- code;
3778
- constructor(status, code) {
3779
- super(code ? `hostr api error ${status}: ${code}` : `hostr api error ${status}`);
3780
- this.name = "ApiError";
3781
- this.status = status;
3782
- this.code = code;
3783
- }
3784
- };
3785
- var NetworkError = class extends Error {
3786
- constructor(message = "network error") {
3787
- super(message);
3788
- this.name = "NetworkError";
3789
- }
3790
- };
3791
- var trimEndpoint = (endpoint) => endpoint.replace(/\/$/, "");
3792
- async function req(fetchFn, endpoint, bearer, path, init2 = {}) {
3793
- const url = `${trimEndpoint(endpoint)}${path}`;
3794
- const headers = { authorization: `Bearer ${bearer}` };
3795
- let body;
3796
- if (init2.body !== void 0) {
3797
- headers["content-type"] = "application/json";
3798
- body = JSON.stringify(init2.body);
3799
- }
3800
- let res;
3801
- try {
3802
- res = await fetchFn(url, { method: init2.method ?? "GET", headers, body });
3803
- } catch {
3804
- throw new NetworkError();
3805
- }
3806
- const text = await res.text();
3807
- const data = text ? JSON.parse(text) : {};
3808
- if (!res.ok) {
3809
- const code = data && typeof data === "object" && typeof data.error === "string" ? data.error : void 0;
3810
- throw new ApiError2(res.status, code);
3811
- }
3812
- return data;
3813
- }
3814
- var HostrApi = class {
3815
- #endpoint;
3816
- #bearer;
3817
- #fetch;
3818
- constructor(endpoint, bearer, fetchFn = fetch) {
3819
- this.#endpoint = endpoint;
3820
- this.#bearer = bearer;
3821
- this.#fetch = fetchFn;
3822
- }
3823
- setBearer(bearer) {
3824
- this.#bearer = bearer;
3825
- }
3826
- getUpload(assetId) {
3827
- return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`);
3828
- }
3829
- presignParts(assetId, partNumbers, expiresIn) {
3830
- return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/parts`, {
3831
- method: "POST",
3832
- body: expiresIn === void 0 ? { partNumbers } : { partNumbers, expiresIn }
3833
- });
3834
- }
3835
- complete(assetId, body) {
3836
- return req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}/complete`, {
3837
- method: "POST",
3838
- body
3839
- });
3840
- }
3841
- async abort(assetId) {
3842
- await req(this.#fetch, this.#endpoint, this.#bearer, `/v1/uploads/${assetId}`, { method: "DELETE" });
3843
- }
3844
- };
3845
- async function createUploadSession(opts, fetchFn = fetch) {
3846
- const { endpoint, apiKey, ...body } = opts;
3847
- return req(fetchFn, endpoint, apiKey, "/v1/uploads", { method: "POST", body });
3848
- }
3849
- function mintUploadToken(opts, fetchFn = fetch) {
3850
- const { endpoint, apiKey, assetId, tokenTtlSec } = opts;
3851
- return req(fetchFn, endpoint, apiKey, `/v1/uploads/${assetId}/token`, {
3852
- method: "POST",
3853
- body: tokenTtlSec === void 0 ? {} : { tokenTtlSec }
3854
- });
3855
- }
3856
-
3857
4131
  // ../upload/src/core/retry.ts
4132
+ init_client();
3858
4133
  var RETRY_BUDGET = 6;
3859
4134
  function classifyPut(res, err) {
3860
4135
  if (err instanceof NetworkError) return "network";
@@ -3924,6 +4199,9 @@ function computeProgress(i) {
3924
4199
  return progress;
3925
4200
  }
3926
4201
 
4202
+ // ../upload/src/core/engine.ts
4203
+ init_client();
4204
+
3927
4205
  // ../upload/src/hash/hex.ts
3928
4206
  var HEX = "0123456789abcdef";
3929
4207
  function bytesToHex(b) {
@@ -4670,6 +4948,7 @@ var UploadEngine = class {
4670
4948
  };
4671
4949
 
4672
4950
  // ../upload/src/core/upload.ts
4951
+ init_client();
4673
4952
  var realClock = { now: () => Date.now(), sleep: (ms) => new Promise((res) => setTimeout(res, ms)) };
4674
4953
  async function buildUpload(opts) {
4675
4954
  const clock = opts.clock ?? realClock;
@@ -4739,6 +5018,7 @@ var NodeMd5 = class {
4739
5018
  var nodeHasher = { md5: () => new NodeMd5() };
4740
5019
 
4741
5020
  // ../upload/src/node/index.ts
5021
+ init_client();
4742
5022
  async function nodeFileSource(path) {
4743
5023
  const handle = await open(path, "r");
4744
5024
  const stat2 = await handle.stat();
@@ -4825,9 +5105,9 @@ async function tailLoop(opts) {
4825
5105
  let cursor = opts.after;
4826
5106
  const poll = async () => {
4827
5107
  try {
4828
- const page = await opts.fetchPage(cursor);
4829
- if (page.items.length) opts.onBatch(page.items);
4830
- cursor = page.nextCursor ?? cursor;
5108
+ const page2 = await opts.fetchPage(cursor);
5109
+ if (page2.items.length) opts.onBatch(page2.items);
5110
+ cursor = page2.nextCursor ?? cursor;
4831
5111
  return true;
4832
5112
  } catch {
4833
5113
  return false;
@@ -4858,14 +5138,14 @@ var MIME = {
4858
5138
  };
4859
5139
  var mimeOf = (file) => MIME[(/\.([A-Za-z0-9]+)$/.exec(file)?.[1] ?? "").toLowerCase()] ?? "application/octet-stream";
4860
5140
  async function assetsList(ctx, opts) {
4861
- const page = await ctx.client.assets({
5141
+ const page2 = await ctx.client.assets({
4862
5142
  status: opts.status,
4863
5143
  cursor: opts.cursor,
4864
5144
  limit: opts.limit ? Number(opts.limit) : void 0
4865
5145
  });
4866
5146
  ctx.out.print(
4867
5147
  table(
4868
- page.items.map((a) => ({ ...a, bytes: fmtBytes(a.bytes), createdAt: fmtWhen(a.createdAt) })),
5148
+ page2.items.map((a) => ({ ...a, bytes: fmtBytes(a.bytes), createdAt: fmtWhen(a.createdAt) })),
4869
5149
  [
4870
5150
  { key: "assetId", label: "ASSET" },
4871
5151
  { key: "status", label: "STATUS" },
@@ -4874,9 +5154,9 @@ async function assetsList(ctx, opts) {
4874
5154
  { key: "durationSec", label: "DURATION" },
4875
5155
  { key: "createdAt", label: "CREATED" }
4876
5156
  ]
4877
- ) + (page.nextCursor ? `
4878
- (more: --cursor ${page.nextCursor})` : ""),
4879
- page
5157
+ ) + (page2.nextCursor ? `
5158
+ (more: --cursor ${page2.nextCursor})` : ""),
5159
+ page2
4880
5160
  );
4881
5161
  }
4882
5162
  async function assetsGet(ctx, id) {
@@ -4902,6 +5182,23 @@ async function assetsDelete(ctx, id) {
4902
5182
  const r = await ctx.client.assetDelete(id);
4903
5183
  ctx.out.print(`deleted ${r.assetId} (${r.objectsDeleted} objects purged)`, r);
4904
5184
  }
5185
+ async function subtitlesList(ctx, id) {
5186
+ const r = await ctx.client.subtitles(id);
5187
+ ctx.out.print(
5188
+ r.tracks.length ? r.tracks.map((t) => `${t.lang.padEnd(8)}${t.name.padEnd(24)}${t.default ? "default" : ""}`).join("\n") : "(no subtitle tracks)",
5189
+ r
5190
+ );
5191
+ }
5192
+ async function subtitlesSet(ctx, id, file, opts) {
5193
+ const { readFile: readFile4 } = await import("node:fs/promises");
5194
+ const vtt = await readFile4(file, "utf8");
5195
+ const r = await ctx.client.subtitleSet(id, opts.lang, vtt, { name: opts.name, default: opts.default });
5196
+ ctx.out.print(`subtitle ${r.lang} ("${r.name}") attached to ${r.assetId}${r.default ? " as default" : ""}`, r);
5197
+ }
5198
+ async function subtitlesRm(ctx, id, lang) {
5199
+ const r = await ctx.client.subtitleDelete(id, lang);
5200
+ ctx.out.print(`subtitle ${lang} removed from ${r.assetId}`, r);
5201
+ }
4905
5202
  async function tail(ctx, opts) {
4906
5203
  const fmt = (e) => `${fmtWhen(e.createdAt)} ${e.type.padEnd(16)} ${e.assetId ?? "-"}`;
4907
5204
  await tailLoop({
@@ -4915,6 +5212,10 @@ async function tail(ctx, opts) {
4915
5212
  }
4916
5213
  async function upload(ctx, file, opts) {
4917
5214
  if (!ctx.key) fail(ctx.out, new Error(NOT_LOGGED_IN));
5215
+ if (!file) {
5216
+ const { uploadGui: uploadGui2 } = await Promise.resolve().then(() => (init_gui(), gui_exports));
5217
+ return uploadGui2({ apiBase: ctx.apiBase, key: ctx.key, client: ctx.client });
5218
+ }
4918
5219
  const size = (await stat(file).catch(() => null))?.size;
4919
5220
  if (!size) fail(ctx.out, new Error(`cannot read ${file}`));
4920
5221
  const apiKey = ctx.key;
@@ -4966,7 +5267,7 @@ import { mkdir as mkdir3, readFile as readFile3, writeFile as writeFile3 } from
4966
5267
  import { join as join3 } from "node:path";
4967
5268
 
4968
5269
  // ../../docs/integrate.md
4969
- var integrate_default = '# hostr \u2014 integration guide\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 GET {API base}/llms.txt \xB7 GET {API base}/llms-full.txt (this file)\n```\n\n## 0. TL;DR for an integrating agent\n\n```bash\nnpx @hostr/cli login --key $HOSTR_API_KEY # validate + save the key (or export HOSTR_API_KEY)\nnpx @hostr/cli init # write docs/hostr.md + AGENTS.md section + .env.example entries\nnpx @hostr/cli upload demo.mp4 --wait --json # -> { assetId, status: "ready", ... }\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --json # -> { secret: "whsec_..." } ONCE\nnpx @hostr/cli tail --follow # watch the event log live\n```\n\nThen in your app: store `assetId`/`playbackId` per video, mint playback\ntokens server-side (\xA76), and hand players\n`{Media host}/{playbackId}.m3u8?token=\u2026`. Verify webhooks with the snippet\nin \xA75. That is the whole integration.\n\n## 1. Authentication\n\nEvery API call sends `Authorization: Bearer <credential>`. There are two\ncredential kinds an integrator sees:\n\n- **API key** \u2014 `hostr_sk_live_\u2026`, minted per account in the hostr\n dashboard or via `POST /v1/keys` / `npx @hostr/cli keys create`. Scoped:\n - `read` \u2014 GETs only (status, listing, usage, events)\n - `upload` \u2014 read + the upload lifecycle\n - `full` \u2014 everything, including playback-token minting and key management\n Keys are hashed at rest; the plaintext is shown exactly once at mint.\n Rotation = create new + revoke old (`DELETE /v1/keys/:id`); revocation\n takes effect within ~60 s. **Keep keys server-side.** Never ship one in a\n browser or mobile app.\n- **Upload token** \u2014 short-lived HMAC scoped to a single upload\n (`uploadToken` in the create-upload response). This is the only\n credential an untrusted client (browser/mobile) should ever hold: it can\n drive that one upload and nothing else.\n\nAuth errors: `401 {"error":"unauthorized"}` (bad/missing/revoked key, or a\nresource you don\'t own \u2014 hostr does not reveal which),\n`403 {"error":"insufficient_scope","need":"upload"}`,\n`403 {"error":"account_suspended"}`,\n`503 {"error":"control_plane_unavailable"}` (transient \u2014 retry with backoff;\nin-flight uploads with upload tokens are unaffected).\n\n## 2. Upload\n\nThree ways, strongest first:\n\n### 2a. CLI (recommended \u2014 resumable, integrity-checked)\n\n```bash\nnpx @hostr/cli upload path/to/video.mp4 --json # returns when bytes are verified in R2\nnpx @hostr/cli upload path/to/video.mp4 --wait --json # returns when transcode is ready/failed\n```\n\nOutput: `{ "assetId": "\u2026", "status": "queued"|"ready", "bytes": n, "etag": "\u2026", "sha256": "\u2026" }`.\nThe CLI wraps hostr\'s upload SDK: chunked multipart direct to storage,\nresume after crash/restart (server-authoritative \u2014 it asks storage which\nparts landed), retry with backoff, and three-layer integrity verification.\nExit code 1 + JSON on stderr on failure.\n\n### 2b. Raw HTTP (any language, no SDK)\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n (forward ONLY assetId/uploadToken/partSize/expectedParts/tokenExpiresAt to untrusted clients)\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,...]} (\u2264100 per call)\n -> { urls: [{partNumber, url}], expiresIn }\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nGET /v1/uploads/:id -> { status, uploadedParts, parts, bytesUploaded } (resume)\nPOST /v1/uploads/:id/complete {"sha256":"<hex of whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\nDELETE /v1/uploads/:id abort\nPOST /v1/uploads/:id/token re-mint the upload token (API key with upload scope)\n```\n\nRules: every part is `partSize` bytes except the last; `size` must be\nexact \u2014 completion verifies both part count and total bytes against what\nactually landed and returns `409 incomplete_upload` (with the missing part\nnumbers) or `409 size_mismatch` otherwise. Completion is idempotent \u2014 a\ncrashed client retries it safely. Uploads are quota-gated:\n`403 {"error":"quota_uploads_daily"|"quota_assets"|"quota_storage","limit":n}`.\n\n### 2c. Browser/mobile clients\n\nCreate the upload server-side (2b), hand the client only\n`{assetId, uploadToken, partSize, expectedParts}`, and let it PUT parts\ndirectly to the presigned URLs \u2014 upload bytes never touch your servers.\n(A browser needs the storage bucket to allow your origin via CORS \u2014 if\npart PUTs stall at 0% with network-looking errors, that\'s the cause.)\n\n## 3. Asset lifecycle\n\n`uploading \u2192 uploaded \u2192 queued \u2192 processing \u2192 ready | failed` (plus\n`aborted`). Poll `GET /v1/assets/:id` (`read` scope) or \u2014 better \u2014 use\nwebhooks (\xA75). While `processing`, the asset carries live transcode\nprogress \u2014 `progress: { encodedSec, totalSec, speed }` (speed is the\nencode multiple of realtime; the field is null/absent outside\n`processing`) \u2014 so a UI can show a real percentage instead of a spinner. `GET /v1/assets?limit=&status=&cursor=` lists your assets\nnewest-first with keyset pagination (`nextCursor`).\n\nWhen `ready`, the asset record and the `video.ready` event both carry\n`playbackId` (equals `assetId` today \u2014 store it as its own field) and\n`durationSec`.\n\n## 4. Account, usage, keys\n\n```\nGET /v1/account -> { accountId, name, plan, limits, usage } (read)\nGET /v1/usage?from&to -> { days: [{day, uploads, bytesUploaded, encodedSeconds}] } (read)\nPOST /v1/keys {"name":"ci","scopes":["upload"]} -> { key: "hostr_sk_live_..." } ONCE (full)\nGET /v1/keys -> metadata only, never hashes (full)\nDELETE /v1/keys/:id -> { revoked: true } (full)\n```\n\n## 5. Webhooks + the event log\n\nConfigure one endpoint per account:\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 retained)\n```\n\nEvents \u2014 body `{ id, type, createdAt, data }`; `id` is the durable event\nrow\'s id (dedupe on it):\n\n| type | fired when | data highlights |\n|------------------|----------------------------------------|------------------------------------------|\n| `upload.created` | multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `video.uploaded` | bytes verified in storage, transcode queued | `assetId`, `bytes`, `etag`, `sha256?` |\n| `video.ready` | HLS renditions live | `assetId`, `playbackId`, `durationSec?` |\n| `video.errored` | transcode failed | `assetId`, `error` (always present) |\n| `video.deleted` | asset erased; playback over | `assetId` |\n\nEndpoint URLs: both `https` and `http` are accepted, including private\naddresses \u2014 hostr does not currently block internal ranges, so a local\nreceiver works in development exactly like production.\n\nEvery delivery carries `hostr-signature`, a comma-separated list currently\ncontaining `t=<unix-seconds>` and `v1=<hex>` where\n`v1 = HMAC-SHA256(secret, "<t>.<rawBody>")` \u2014 the Stripe scheme. Parse it\nas a list (future fields may be added); verify before trusting\n(constant-time compare, bounded timestamp):\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// Use the RAW request body bytes \u2014 parse JSON only after verification.\n```\n\nDelivery is at-least-once (3 attempts with backoff, each recorded). The\nwebhook is the wake-up signal; the **durable event log** is the ledger:\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (e.g. the latest event) (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n`npx @hostr/cli tail --follow` polls this for you; `npx @hostr/cli webhooks test`\nredelivers the newest event to prove your endpoint end-to-end. Respond\n`2xx` fast (do the work async) \u2014 anything else is retried.\n\n## 6. Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `full` key:\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"user-123"} (optional body)\n -> { playback, thumbnail, storyboard, expiresAt } (expiresAt in ms)\nnpx @hostr/cli assets token <assetId> --json (CLI equivalent)\n```\n\nMinting works any time after the asset exists (it 404s only for unknown\nassets); the stream itself returns 404 until the asset is `ready` \u2014 gate\nyour UI on `video.ready` / asset status, not on whether minting succeeds.\n\nBuild the URL and hand it to any HLS player:\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\n- Use hls.js in browsers without native HLS; Safari/iOS play it natively.\n- The token is checked once at the master playlist; the stream then works\n for the whole viewing session (segment credentials are handled\n internally). Default TTL 1 h \u2014 it only needs to be valid when playback\n *starts*.\n- `viewerId` enables per-viewer concurrency observation.\n- **Non-browser user agents are rejected** (`403 blocked_user_agent`):\n curl/wget/ffmpeg/vlc UAs are blocked as a casual-ripping speed bump. For\n server-side smoke tests send a browser-like `User-Agent`.\n- Signed URLs are not DRM: they stop link-sharing and hotlinking, not an\n entitled viewer recording their screen.\n- `DELETE /v1/assets/:id` erases an asset for good: source and renditions\n are purged from storage, the storage quota is freed, and `video.deleted`\n is emitted. Deletion is idempotent (repeat calls return `deleted`\n again). New playback tokens stop minting immediately; tokens already\n issued can replay edge-cached segments for at most a few hours until\n they expire. There is no undelete.\n\nThe token bundle also covers the poster image and scrub previews \u2014 same\nhost, same query-token shape, each URL taking its matching token from the\nmint response:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nThe storyboard is a standard "thumbnails" WebVTT track (cues point into a\ntiled sprite with `#xywh=` fragments) \u2014 hand it to mux-player\'s\n`storyboard-src`, media-chrome\'s preview track, or any player that consumes\nthat convention; the sprite URLs inside the VTT are pre-signed for you.\nPlayers treat the track as optional: for assets encoded before storyboards\nexisted the URL 404s and scrubbing simply shows no preview.\n\n## 7. Error handling summary\n\n| status | error | meaning / action |\n|--------|------------------------------|------------------|\n| 401 | `unauthorized` | bad/revoked key, or not your resource \u2014 check key + assetId |\n| 403 | `insufficient_scope` | key scope too low; `need` names the required scope |\n| 403 | `quota_*` | account limit hit; `limit` is the ceiling |\n| 403 | `account_suspended` | contact the operator |\n| 409 | `incomplete_upload` | parts missing (`missing` lists them) \u2014 upload them, re-complete |\n| 409 | `size_mismatch` | declared size \u2260 bytes landed \u2014 abort and restart |\n| 409 | `not_uploading` | upload already finished/aborted |\n| 413 | `file_too_large` | raise `partSize` or split |\n| 503 | `control_plane_unavailable` | transient \u2014 retry with backoff |\n| 500 | `signing_not_configured` | operator-side: the playback signing key isn\'t loaded on this API deployment. Not your integration\'s fault and not retryable from the client \u2014 report it |\n\n## 8. Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| POST | `/v1/uploads` | upload | create upload session |\n| GET | `/v1/uploads/:id` | upload* | status + landed parts (resume) |\n| POST | `/v1/uploads/:id/parts` | upload* | presigned part URLs |\n| POST | `/v1/uploads/:id/complete` | upload* | verify + finalize |\n| POST | `/v1/uploads/:id/token` | upload | re-mint upload token |\n| DELETE | `/v1/uploads/:id` | upload* | abort |\n| GET | `/v1/assets` | read | list own assets |\n| GET | `/v1/assets/:id` | read | asset record |\n| POST | `/v1/assets/:id/tokens` | full | playback credentials |\n| DELETE | `/v1/assets/:id` | full | erase asset: R2 purged, quota freed, `video.deleted` emitted |\n| GET | `/v1/account` | read | whoami: plan, limits, usage |\n| GET | `/v1/usage` | read | daily usage rows |\n| GET | `/v1/events` | read | durable event log (ascending tail; `order=desc` for newest-first) |\n| POST | `/v1/events/:id/redeliver` | full | replay a webhook |\n| GET/PUT/DELETE | `/v1/webhooks` | full | endpoint config |\n| POST/GET | `/v1/keys` | full | mint / list keys |\n| DELETE | `/v1/keys/:id` | full | revoke key |\n\n\\* also accepted: that upload\'s own `uploadToken`.\n\n## 9. CLI reference\n\n`npx @hostr/cli <cmd>` \u2014 every command takes `--json` (one JSON document on\nstdout; NDJSON for `tail --follow`; errors as JSON on stderr, exit 1).\nCredentials: `--key` flag > `HOSTR_API_KEY` env > saved profile\n(`hostr login`). API base: `--api` > `HOSTR_API_BASE` > profile > default.\n\n```\nlogin \xB7 whoami \xB7 usage \xB7 init\nkeys create|list|revoke\nwebhooks get|set|delete|test\nassets list|get \xB7 assets token <id> [--ttl <sec>] \xB7 upload <file> [--wait] \xB7 tail [--follow]\n```\n';
5270
+ var integrate_default = '# hostr \u2014 integration guide\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 GET {API base}/llms.txt \xB7 GET {API base}/llms-full.txt (this file)\n```\n\n## 0. TL;DR for an integrating agent\n\n```bash\nnpx @hostr/cli login --key $HOSTR_API_KEY # validate + save the key (or export HOSTR_API_KEY)\nnpx @hostr/cli init # write docs/hostr.md + AGENTS.md section + .env.example entries\nnpx @hostr/cli upload demo.mp4 --wait --json # -> { assetId, status: "ready", ... }\nnpx @hostr/cli upload # no file: opens a local drag-drop page in the browser\nnpx @hostr/cli webhooks set https://yourapp.example/hostr-webhook --json # -> { secret: "whsec_..." } ONCE\nnpx @hostr/cli tail --follow # watch the event log live\n```\n\nThen in your app: store `assetId`/`playbackId` per video, mint playback\ntokens server-side (\xA76), and hand players\n`{Media host}/{playbackId}.m3u8?token=\u2026`. Verify webhooks with the snippet\nin \xA75. That is the whole integration.\n\n## 1. Authentication\n\nEvery API call sends `Authorization: Bearer <credential>`. There are two\ncredential kinds an integrator sees:\n\n- **API key** \u2014 `hostr_sk_live_\u2026`, minted per account in the hostr\n dashboard or via `POST /v1/keys` / `npx @hostr/cli keys create`. Scoped:\n - `read` \u2014 GETs only (status, listing, usage, events)\n - `upload` \u2014 read + the upload lifecycle\n - `full` \u2014 everything, including playback-token minting and key management\n Keys are hashed at rest; the plaintext is shown exactly once at mint.\n Rotation = create new + revoke old (`DELETE /v1/keys/:id`); revocation\n takes effect within ~60 s. **Keep keys server-side.** Never ship one in a\n browser or mobile app.\n- **Upload token** \u2014 short-lived HMAC scoped to a single upload\n (`uploadToken` in the create-upload response). This is the only\n credential an untrusted client (browser/mobile) should ever hold: it can\n drive that one upload and nothing else.\n\nAuth errors: `401 {"error":"unauthorized"}` (bad/missing/revoked key, or a\nresource you don\'t own \u2014 hostr does not reveal which),\n`403 {"error":"insufficient_scope","need":"upload"}`,\n`403 {"error":"account_suspended"}`,\n`503 {"error":"control_plane_unavailable"}` (transient \u2014 retry with backoff;\nin-flight uploads with upload tokens are unaffected).\n\n## 2. Upload\n\nThree ways, strongest first:\n\n### 2a. CLI (recommended \u2014 resumable, integrity-checked)\n\n```bash\nnpx @hostr/cli upload path/to/video.mp4 --json # returns when bytes are verified in R2\nnpx @hostr/cli upload path/to/video.mp4 --wait --json # returns when transcode is ready/failed\n```\n\nOutput: `{ "assetId": "\u2026", "status": "queued"|"ready", "bytes": n, "etag": "\u2026", "sha256": "\u2026" }`.\nThe CLI wraps hostr\'s upload SDK: chunked multipart direct to storage,\nresume after crash/restart (server-authoritative \u2014 it asks storage which\nparts landed), retry with backoff, and three-layer integrity verification.\nExit code 1 + JSON on stderr on failure.\n\n### 2b. Raw HTTP (any language, no SDK)\n\n```\nPOST /v1/uploads {"filename":"a.mp4","size":<bytes>,"contentType":"video/mp4"}\n -> 201 { assetId, partSize, expectedParts, uploadToken, tokenExpiresAt, key, uploadId }\n (forward ONLY assetId/uploadToken/partSize/expectedParts/tokenExpiresAt to untrusted clients)\n\nPOST /v1/uploads/:id/parts {"partNumbers":[1,2,...]} (\u2264100 per call)\n -> { urls: [{partNumber, url}], expiresIn }\nPUT <each presigned url> body = that part\'s bytes; capture the ETag response header\n\nGET /v1/uploads/:id -> { status, uploadedParts, parts, bytesUploaded } (resume)\nPOST /v1/uploads/:id/complete {"sha256":"<hex of whole file>"} (sha256 optional)\n -> { assetId, status: "uploaded"|"queued", bytes, etag }\nDELETE /v1/uploads/:id abort\nPOST /v1/uploads/:id/token re-mint the upload token (API key with upload scope)\n```\n\nRules: every part is `partSize` bytes except the last; `size` must be\nexact \u2014 completion verifies both part count and total bytes against what\nactually landed and returns `409 incomplete_upload` (with the missing part\nnumbers) or `409 size_mismatch` otherwise. Completion is idempotent \u2014 a\ncrashed client retries it safely. Uploads are quota-gated:\n`403 {"error":"quota_uploads_daily"|"quota_assets"|"quota_storage","limit":n}`.\n\n### 2c. Browser/mobile clients\n\nCreate the upload server-side (2b), hand the client only\n`{assetId, uploadToken, partSize, expectedParts}`, and let it PUT parts\ndirectly to the presigned URLs \u2014 upload bytes never touch your servers.\n(A browser needs the storage bucket to allow your origin via CORS \u2014 if\npart PUTs stall at 0% with network-looking errors, that\'s the cause.)\n\n## 3. Asset lifecycle\n\n`uploading \u2192 uploaded \u2192 queued \u2192 processing \u2192 ready | failed` (plus\n`aborted`). Poll `GET /v1/assets/:id` (`read` scope) or \u2014 better \u2014 use\nwebhooks (\xA75). While `processing`, the asset carries live transcode\nprogress \u2014 `progress: { encodedSec, totalSec, speed }` (speed is the\nencode multiple of realtime; the field is null/absent outside\n`processing`) \u2014 so a UI can show a real percentage instead of a spinner. `GET /v1/assets?limit=&status=&cursor=` lists your assets\nnewest-first with keyset pagination (`nextCursor`).\n\nWhen `ready`, the asset record and the `video.ready` event both carry\n`playbackId` (equals `assetId` today \u2014 store it as its own field) and\n`durationSec`.\n\n## 4. Account, usage, keys\n\n```\nGET /v1/account -> { accountId, name, plan, limits, usage } (read)\nGET /v1/usage?from&to -> { days: [{day, uploads, bytesUploaded, encodedSeconds}] } (read)\nPOST /v1/keys {"name":"ci","scopes":["upload"]} -> { key: "hostr_sk_live_..." } ONCE (full)\nGET /v1/keys -> metadata only, never hashes (full)\nDELETE /v1/keys/:id -> { revoked: true } (full)\n```\n\n## 5. Webhooks + the event log\n\nConfigure one endpoint per account:\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 retained)\n```\n\nEvents \u2014 body `{ id, type, createdAt, data }`; `id` is the durable event\nrow\'s id (dedupe on it):\n\n| type | fired when | data highlights |\n|------------------|----------------------------------------|------------------------------------------|\n| `upload.created` | multipart session opened | `assetId`, `filename`, `declaredSize` |\n| `video.uploaded` | bytes verified in storage, transcode queued | `assetId`, `bytes`, `etag`, `sha256?` |\n| `video.ready` | HLS renditions live | `assetId`, `playbackId`, `durationSec?` |\n| `video.errored` | transcode failed | `assetId`, `error` (always present) |\n| `video.deleted` | asset erased; playback over | `assetId` |\n| `video.input.non_standard` | the source needed a judgement call | `assetId`, `input`, `findings[]` |\n\n`video.input.non_standard` is advisory \u2014 the transcode proceeds normally and\na `video.ready` still follows. It fires at most once per asset, only when\nthere is something to say, and reports what the encoder had to assume:\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 HDR. The ladder outputs 8-bit SDR; no tone mapping is applied |\n| `wide_gamut_tags` | BT.2020 primaries without an HDR transfer; output is BT.709, colour not converted |\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: `videoCodec`, `pixFmt`, `bitDepth`,\n`width`, `height`, `declaredFps`, `averageFps`, `targetFps`,\n`isVariableFrameRate`, `colorSpace`, `colorTransfer`, `colorPrimaries`. The\nsame block is on the asset record as `input`, so it can be read back at any\ntime without replaying events.\n\nEndpoint URLs: both `https` and `http` are accepted, including private\naddresses \u2014 hostr does not currently block internal ranges, so a local\nreceiver works in development exactly like production.\n\nEvery delivery carries `hostr-signature`, a comma-separated list currently\ncontaining `t=<unix-seconds>` and `v1=<hex>` where\n`v1 = HMAC-SHA256(secret, "<t>.<rawBody>")` \u2014 the Stripe scheme. Parse it\nas a list (future fields may be added); verify before trusting\n(constant-time compare, bounded timestamp):\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// Use the RAW request body bytes \u2014 parse JSON only after verification.\n```\n\nDelivery is at-least-once (3 attempts with backoff, each recorded). The\nwebhook is the wake-up signal; the **durable event log** is the ledger:\n\n```\nGET /v1/events?after=<cursor>&limit=&type= ascending; reuse nextCursor verbatim (read)\nGET /v1/events?order=desc&limit=1 newest first (e.g. the latest event) (read)\nPOST /v1/events/:id/redeliver replay any stored event (full)\n```\n\n`npx @hostr/cli tail --follow` polls this for you; `npx @hostr/cli webhooks test`\nredelivers the newest event to prove your endpoint end-to-end. Respond\n`2xx` fast (do the work async) \u2014 anything else is retried.\n\n## 6. Playback\n\nPlayback is token-gated. Mint per view, server-side, with a `full` key:\n\n```\nPOST /v1/assets/:id/tokens {"ttlSec":3600,"viewerId":"user-123"} (optional body)\n -> { playback, thumbnail, storyboard, expiresAt } (expiresAt in ms)\nnpx @hostr/cli assets token <assetId> --json (CLI equivalent)\n```\n\nMinting works any time after the asset exists (it 404s only for unknown\nassets); the stream itself returns 404 until the asset is `ready` \u2014 gate\nyour UI on `video.ready` / asset status, not on whether minting succeeds.\n\nBuild the URL and hand it to any HLS player:\n\n```\nhttps://stream.hostr.sh/{playbackId}.m3u8?token={playback}\n```\n\n- Use hls.js in browsers without native HLS; Safari/iOS play it natively.\n- The token is checked once at the master playlist; the stream then works\n for the whole viewing session (segment credentials are handled\n internally). Default TTL 1 h \u2014 it only needs to be valid when playback\n *starts*.\n- `viewerId` enables per-viewer concurrency observation.\n- **Non-browser user agents are rejected** (`403 blocked_user_agent`):\n curl/wget/ffmpeg/vlc UAs are blocked as a casual-ripping speed bump. For\n server-side smoke tests send a browser-like `User-Agent`.\n- Signed URLs are not DRM: they stop link-sharing and hotlinking, not an\n entitled viewer recording their screen.\n- `DELETE /v1/assets/:id` erases an asset for good: source and renditions\n are purged from storage, the storage quota is freed, and `video.deleted`\n is emitted. Deletion is idempotent (repeat calls return `deleted`\n again). New playback tokens stop minting immediately; tokens already\n issued can replay edge-cached segments for at most a few hours until\n they expire. There is no undelete.\n\nThe token bundle also covers the poster image and scrub previews \u2014 same\nhost, same query-token shape, each URL taking its matching token from the\nmint response:\n\n```\nhttps://stream.hostr.sh/{playbackId}/thumbnail.jpg?token={thumbnail}\nhttps://stream.hostr.sh/{playbackId}/storyboard.vtt?token={storyboard}\n```\n\nThe storyboard is a standard "thumbnails" WebVTT track (cues point into a\ntiled sprite with `#xywh=` fragments) \u2014 hand it to mux-player\'s\n`storyboard-src`, media-chrome\'s preview track, or any player that consumes\nthat convention; the sprite URLs inside the VTT are pre-signed for you.\nPlayers treat the track as optional: for assets encoded before storyboards\nexisted the URL 404s and scrubbing simply shows no preview.\n\n## 6b. Subtitles\n\nHuman-authored WebVTT tracks, attached any time after the asset is `ready`\n(409 `not_ready` before that \u2014 the playlist needs the duration):\n\n```\nPUT /v1/assets/:id/subtitles/:lang?name=English&default=true (body: raw WebVTT, upload scope)\nGET /v1/assets/:id/subtitles -> { tracks: [{ lang, name, default, rev }] }\nDELETE /v1/assets/:id/subtitles/:lang\nnpx @hostr/cli subtitles set <assetId> subs.vtt --lang en --name English --default\n```\n\n- `:lang` is a BCP 47 tag (`en`, `pt-BR`); one track per tag, PUT replaces.\n- Tracks appear in the master playlist automatically (`EXT-X-MEDIA`\n subtitles group) \u2014 the next master fetch after a PUT includes them, no\n re-encode, no new playback URL. Players show them in the captions menu.\n- `default=true` demotes any previous default.\n- Replacements propagate on the next playback session; a viewer mid-session\n keeps the track they started with.\n\n## 7. Error handling summary\n\n| status | error | meaning / action |\n|--------|------------------------------|------------------|\n| 401 | `unauthorized` | bad/revoked key, or not your resource \u2014 check key + assetId |\n| 403 | `insufficient_scope` | key scope too low; `need` names the required scope |\n| 403 | `quota_*` | account limit hit; `limit` is the ceiling |\n| 403 | `account_suspended` | contact the operator |\n| 409 | `incomplete_upload` | parts missing (`missing` lists them) \u2014 upload them, re-complete |\n| 409 | `size_mismatch` | declared size \u2260 bytes landed \u2014 abort and restart |\n| 409 | `not_uploading` | upload already finished/aborted |\n| 413 | `file_too_large` | raise `partSize` or split |\n| 503 | `control_plane_unavailable` | transient \u2014 retry with backoff |\n| 500 | `signing_not_configured` | operator-side: the playback signing key isn\'t loaded on this API deployment. Not your integration\'s fault and not retryable from the client \u2014 report it |\n\n## 8. Full route table\n\n| Method | Path | Scope | Purpose |\n|---|---|---|---|\n| POST | `/v1/uploads` | upload | create upload session |\n| GET | `/v1/uploads/:id` | upload* | status + landed parts (resume) |\n| POST | `/v1/uploads/:id/parts` | upload* | presigned part URLs |\n| POST | `/v1/uploads/:id/complete` | upload* | verify + finalize |\n| POST | `/v1/uploads/:id/token` | upload | re-mint upload token |\n| DELETE | `/v1/uploads/:id` | upload* | abort |\n| GET | `/v1/assets` | read | list own assets |\n| GET | `/v1/assets/:id` | read | asset record |\n| POST | `/v1/assets/:id/tokens` | full | playback credentials |\n| DELETE | `/v1/assets/:id` | full | erase asset: R2 purged, quota freed, `video.deleted` emitted |\n| PUT | `/v1/assets/:id/subtitles/:lang` | upload | attach/replace WebVTT track |\n| GET | `/v1/assets/:id/subtitles` | read | list subtitle tracks |\n| DELETE | `/v1/assets/:id/subtitles/:lang` | upload | remove subtitle track |\n| GET | `/v1/account` | read | whoami: plan, limits, usage |\n| GET | `/v1/usage` | read | daily usage rows |\n| GET | `/v1/events` | read | durable event log (ascending tail; `order=desc` for newest-first) |\n| POST | `/v1/events/:id/redeliver` | full | replay a webhook |\n| GET/PUT/DELETE | `/v1/webhooks` | full | endpoint config |\n| POST/GET | `/v1/keys` | full | mint / list keys |\n| DELETE | `/v1/keys/:id` | full | revoke key |\n\n\\* also accepted: that upload\'s own `uploadToken`.\n\n## 9. CLI reference\n\n`npx @hostr/cli <cmd>` \u2014 every command takes `--json` (one JSON document on\nstdout; NDJSON for `tail --follow`; errors as JSON on stderr, exit 1).\nCredentials: `--key` flag > `HOSTR_API_KEY` env > saved profile\n(`hostr login`). API base: `--api` > `HOSTR_API_BASE` > profile > default.\n\n```\nlogin \xB7 whoami \xB7 usage \xB7 init\nkeys create|list|revoke\nwebhooks get|set|delete|test\nassets list|get \xB7 assets token <id> [--ttl <sec>] \xB7 upload <file> [--wait] \xB7 tail [--follow]\n```\n';
4970
5271
 
4971
5272
  // src/init-content.ts
4972
5273
  var BEGIN = "<!-- hostr:begin -->";
@@ -5055,7 +5356,7 @@ async function init(opts) {
5055
5356
  }
5056
5357
 
5057
5358
  // src/cli.ts
5058
- var program2 = new Command("hostr").description("hostr \u2014 self-hosted video infrastructure (upload, transcode, HLS delivery)").version("0.1.2").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)");
5359
+ var program2 = new Command("hostr").description("hostr \u2014 self-hosted video infrastructure (upload, transcode, HLS delivery)").version("0.1.3").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)");
5059
5360
  var globals = () => program2.opts();
5060
5361
  var run = (fn) => async (...args) => {
5061
5362
  const g = globals();
@@ -5084,6 +5385,10 @@ webhooks.command("get").action(run(webhooksGet));
5084
5385
  webhooks.command("set <url>").option("--secret <secret>", "bring your own whsec (default: generated, shown once)").action(run((ctx, url, opts) => webhooksSet(ctx, url, opts)));
5085
5386
  webhooks.command("delete").action(run(webhooksDelete));
5086
5387
  webhooks.command("test").description("redeliver the newest stored event to your endpoint").action(run(webhooksTest));
5388
+ var subtitles = program2.command("subtitles").description("subtitle tracks (WebVTT)");
5389
+ subtitles.command("list <assetId>").action(run((ctx, id) => subtitlesList(ctx, id)));
5390
+ subtitles.command("set <assetId> <file>").description("attach or replace a WebVTT track (requires upload scope)").requiredOption("--lang <tag>", "BCP 47 language tag, e.g. en or pt-BR").option("--name <label>", "menu label; defaults to the lang tag").option("--default", "make this the default track").action(run((ctx, id, file, opts) => subtitlesSet(ctx, id, file, opts)));
5391
+ subtitles.command("rm <assetId> <lang>").action(run((ctx, id, lang) => subtitlesRm(ctx, id, lang)));
5087
5392
  var assets = program2.command("assets").description("asset listing and status");
5088
5393
  assets.command("list").option("--status <status>").option("--limit <n>").option("--cursor <cursor>").action(run(assetsList));
5089
5394
  assets.command("get <id>").action(run((ctx, id) => assetsGet(ctx, id)));
@@ -5093,7 +5398,7 @@ program2.command("tail").description("event log (durable; ascending). One page b
5093
5398
  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) => {
5094
5399
  await init({ ...globals(), ...opts });
5095
5400
  });
5096
- program2.command("upload <file>").description("resumable multipart upload via the hostr SDK").option("--content-type <type>").option("--wait", "block until transcode reaches ready/failed").action(run((ctx, file, opts) => upload(ctx, file, opts)));
5401
+ 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("--wait", "block until transcode reaches ready/failed (file mode)").action(run((ctx, file, opts) => upload(ctx, file, opts)));
5097
5402
  program2.parseAsync().catch((e) => {
5098
5403
  console.error(`error: ${e?.message ?? e}`);
5099
5404
  process.exit(1);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hostr/cli",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "CLI for hostr \u2014 self-hosted video infrastructure (upload, transcode, HLS delivery)",
5
5
  "license": "MIT",
6
6
  "type": "module",