@zyris/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 zyris.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,32 @@
1
+ # Zyris Node.js SDK
2
+
3
+ The official Node.js SDK for Zyris.
4
+
5
+ ## Documentation
6
+
7
+ - [Node.js SDK reference](https://www.zyris.ai/build/sdks/nodejs) — install, quick start, every resource, response shapes, recipes.
8
+ - [HTTP API reference](https://www.zyris.ai/build/api-docs) — wire protocol if you are calling the API without the SDK.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ npm install @zyris/sdk
14
+ ```
15
+
16
+ Requires Node.js 18+.
17
+
18
+ ## Quick start
19
+
20
+ ```javascript
21
+ import { Client } from '@zyris/sdk';
22
+
23
+ const client = new Client({
24
+ apiKey: 'sk_...',
25
+ baseUrl: 'https://playground.zyris.ai',
26
+ });
27
+
28
+ const collections = await client.collections.list({ page: 1, pageSize: 10 });
29
+ console.log(`${collections.total} collection(s)`);
30
+ ```
31
+
32
+ For full usage — every resource, response shapes, and recipes — see the [Node.js SDK reference](https://www.zyris.ai/build/sdks/nodejs).
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "@zyris/sdk",
3
+ "version": "0.1.0",
4
+ "description": "Node.js SDK for Zyris.",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "exports": {
8
+ ".": "./src/index.js"
9
+ },
10
+ "publishConfig": {
11
+ "access": "public"
12
+ },
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/Mikshi-VLM/nodejs_sdk.git"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "test": "node --test",
24
+ "check": "node --check src/index.js"
25
+ },
26
+ "engines": {
27
+ "node": ">=18"
28
+ },
29
+ "keywords": [
30
+ "video",
31
+ "sdk",
32
+ "api",
33
+ "zyris",
34
+ "vlm",
35
+ "collections",
36
+ "embeddings",
37
+ "search",
38
+ "chat"
39
+ ],
40
+ "dependencies": {
41
+ "axios": "^1.18.0"
42
+ },
43
+ "author": "zyris.ai <contact@zyris.ai>",
44
+ "license": "MIT",
45
+ "homepage": "https://www.zyris.ai/",
46
+ "bugs": {
47
+ "email": "contact@zyris.ai"
48
+ }
49
+ }
@@ -0,0 +1 @@
1
+ export const VERSION = "0.1.0";
package/src/client.js ADDED
@@ -0,0 +1,195 @@
1
+ import axios from 'axios';
2
+ import { VERSION } from './_version.js';
3
+ import {
4
+ APIError,
5
+ AuthenticationError,
6
+ BadGatewayError,
7
+ BadRequestError,
8
+ ConflictError,
9
+ InsufficientCreditsError,
10
+ InternalServerError,
11
+ NotFoundError,
12
+ PayloadTooLargeError,
13
+ PermissionDeniedError,
14
+ RateLimitError,
15
+ ServiceUnavailableError,
16
+ UnprocessableEntityError,
17
+ } from './exceptions.js';
18
+ import { ChatResource } from './resources/chat.js';
19
+ import { CollectionsResource } from './resources/collections.js';
20
+ import { EmbeddingsResource } from './resources/embeddings.js';
21
+ import { SearchResource } from './resources/search.js';
22
+ import { UploadsResource } from './resources/uploads.js';
23
+ import { VideosResource } from './resources/videos.js';
24
+
25
+ const DEFAULT_BASE_URL = "https://playground.zyris.ai";
26
+ const DEFAULT_TIMEOUT = 30000;
27
+
28
+ export class Client {
29
+ constructor({ apiKey, baseUrl = DEFAULT_BASE_URL, timeout = DEFAULT_TIMEOUT } = {}) {
30
+ if (!apiKey) throw new Error("apiKey is required");
31
+
32
+ this.apiKey = apiKey;
33
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
34
+ this.timeout = timeout;
35
+
36
+ this.axios = axios.create({
37
+ baseURL: this.baseUrl,
38
+ timeout: this.timeout,
39
+ headers: {
40
+ "Accept": "application/json",
41
+ "X-API-Key": apiKey,
42
+ "User-Agent": `zyris-node/${VERSION}`,
43
+ },
44
+ });
45
+
46
+ this.collections = new CollectionsResource(this);
47
+ this.uploads = new UploadsResource(this);
48
+ this.videos = new VideosResource(this);
49
+ this.search = new SearchResource(this);
50
+ this.chat = new ChatResource(this);
51
+ this.embeddings = new EmbeddingsResource(this);
52
+ }
53
+
54
+ /**
55
+ * Release any underlying HTTP resources. Axios has no persistent client to
56
+ * close, so this is a no-op — provided for API parity with the Python SDK.
57
+ */
58
+ close() {
59
+ // no-op
60
+ }
61
+
62
+ async _request(method, path, { params, data, headers, ...config } = {}) {
63
+ try {
64
+ const response = await this.axios.request({
65
+ method,
66
+ url: path,
67
+ params,
68
+ data,
69
+ headers,
70
+ ...config,
71
+ });
72
+ return response.data;
73
+ } catch (error) {
74
+ this._handleError(error);
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Stream Server-Sent Events from the API. Async-iterates `{ event, data }`
80
+ * pairs where `data` is the parsed JSON payload (or `{ raw }` on parse failure).
81
+ *
82
+ * On non-2xx the response body is read and routed through the standard
83
+ * error handler — same exception types as `_request`.
84
+ */
85
+ async *_streamSse(method, path, { data: body } = {}) {
86
+ let response;
87
+ try {
88
+ response = await this.axios.request({
89
+ method,
90
+ url: path,
91
+ data: body,
92
+ headers: { Accept: "text/event-stream" },
93
+ responseType: "stream",
94
+ });
95
+ } catch (error) {
96
+ // axios with responseType:'stream' may not have parsed the error body yet;
97
+ // try to materialize it before handing off to _handleError.
98
+ if (error.response && error.response.data && typeof error.response.data.on === "function") {
99
+ const chunks = [];
100
+ for await (const chunk of error.response.data) chunks.push(chunk);
101
+ const text = Buffer.concat(chunks).toString("utf8");
102
+ try {
103
+ error.response.data = JSON.parse(text);
104
+ } catch {
105
+ error.response.data = text;
106
+ }
107
+ }
108
+ this._handleError(error);
109
+ return;
110
+ }
111
+
112
+ const stream = response.data;
113
+ let buffer = "";
114
+ let eventName = "message";
115
+ let dataLines = [];
116
+
117
+ const flush = () => {
118
+ if (dataLines.length === 0) return null;
119
+ const raw = dataLines.join("\n");
120
+ let payload;
121
+ try {
122
+ payload = JSON.parse(raw);
123
+ } catch {
124
+ payload = { raw };
125
+ }
126
+ const ev = { event: eventName, data: payload };
127
+ eventName = "message";
128
+ dataLines = [];
129
+ return ev;
130
+ };
131
+
132
+ for await (const chunk of stream) {
133
+ buffer += chunk.toString("utf8");
134
+ let nlIndex;
135
+ while ((nlIndex = buffer.indexOf("\n")) !== -1) {
136
+ let line = buffer.slice(0, nlIndex);
137
+ buffer = buffer.slice(nlIndex + 1);
138
+ if (line.endsWith("\r")) line = line.slice(0, -1);
139
+ if (line === "") {
140
+ const ev = flush();
141
+ if (ev) yield ev;
142
+ continue;
143
+ }
144
+ if (line.startsWith(":")) continue;
145
+ if (line.startsWith("event:")) {
146
+ eventName = line.slice("event:".length).trim();
147
+ } else if (line.startsWith("data:")) {
148
+ dataLines.push(line.slice("data:".length).replace(/^\s/, ""));
149
+ }
150
+ }
151
+ }
152
+
153
+ if (buffer.length > 0) {
154
+ let line = buffer;
155
+ if (line.endsWith("\r")) line = line.slice(0, -1);
156
+ if (line !== "" && !line.startsWith(":")) {
157
+ if (line.startsWith("event:")) {
158
+ eventName = line.slice("event:".length).trim();
159
+ } else if (line.startsWith("data:")) {
160
+ dataLines.push(line.slice("data:".length).replace(/^\s/, ""));
161
+ }
162
+ }
163
+ }
164
+ const ev = flush();
165
+ if (ev) yield ev;
166
+ }
167
+
168
+ _handleError(error) {
169
+ if (!error.response) throw error;
170
+ const { status, statusText, data } = error.response;
171
+ const message = `${status} ${statusText}: ${typeof data === "string" ? data : JSON.stringify(data)}`;
172
+
173
+ if (status === 400) throw new BadRequestError(message, status, data);
174
+ if (status === 401) throw new AuthenticationError(message, status, data);
175
+ if (status === 402) throw new InsufficientCreditsError(message, status, data);
176
+ if (status === 403) throw new PermissionDeniedError(message, status, data);
177
+ if (status === 404) throw new NotFoundError(message, status, data);
178
+ if (status === 409) throw new ConflictError(message, status, data);
179
+ if (status === 413) throw new PayloadTooLargeError(message, status, data);
180
+ if (status === 422) throw new UnprocessableEntityError(message, status, data);
181
+ if (status === 429) {
182
+ const header = error.response.headers?.["retry-after"];
183
+ let retryAfter = null;
184
+ if (header != null) {
185
+ const parsed = Number(header);
186
+ retryAfter = Number.isFinite(parsed) ? parsed : null;
187
+ }
188
+ throw new RateLimitError(message, status, data, retryAfter);
189
+ }
190
+ if (status === 502) throw new BadGatewayError(message, status, data);
191
+ if (status === 503) throw new ServiceUnavailableError(message, status, data);
192
+ if (status >= 500) throw new InternalServerError(message, status, data);
193
+ throw new APIError(message, status, data);
194
+ }
195
+ }
@@ -0,0 +1,125 @@
1
+ import fs from 'node:fs';
2
+ import { spawnSync } from 'node:child_process';
3
+
4
+ // ISO-BMFF top-level box types we accept as a signal the file is MP4/MOV.
5
+ const BMFF_TOP_TYPES = new Set(['ftyp', 'moov', 'free', 'skip', 'mdat', 'wide', 'pnot']);
6
+
7
+ /**
8
+ * Best-effort detection of a local video's duration in seconds.
9
+ *
10
+ * Tries two dependency-free strategies, in order:
11
+ * 1. Parse the MP4/MOV `moov` → `mvhd` header directly (covers `.mp4`,
12
+ * `.m4v`, `.mov` — no external tools).
13
+ * 2. Shell out to `ffprobe` if it is on PATH (mkv, webm, avi, …).
14
+ *
15
+ * Throws if neither yields a positive duration — catch it and pass
16
+ * `videoDurationSeconds` explicitly. Used automatically by
17
+ * `uploads.uploadFile` / `uploads.direct` when the caller omits the duration.
18
+ * (Not used for YouTube imports — those are probed server-side.)
19
+ */
20
+ export function probeDuration(filePath) {
21
+ const fromMp4 = _durationFromMp4(filePath);
22
+ if (fromMp4 != null && fromMp4 > 0) return fromMp4;
23
+ const fromFfprobe = _durationFromFfprobe(filePath);
24
+ if (fromFfprobe != null) return fromFfprobe;
25
+ throw new Error(
26
+ `Could not determine the duration of ${filePath}. ` +
27
+ 'Pass videoDurationSeconds explicitly (e.g. measured via ffprobe).',
28
+ );
29
+ }
30
+
31
+ function _readAt(fd, position, length) {
32
+ const buf = Buffer.alloc(length);
33
+ const n = fs.readSync(fd, buf, 0, length, position);
34
+ return n === length ? buf : buf.subarray(0, n);
35
+ }
36
+
37
+ function _durationFromMp4(filePath) {
38
+ let fd;
39
+ try {
40
+ fd = fs.openSync(filePath, 'r');
41
+ const head = _readAt(fd, 0, 8);
42
+ if (head.length < 8 || !BMFF_TOP_TYPES.has(head.toString('latin1', 4, 8))) {
43
+ return null;
44
+ }
45
+ return _findMvhd(fd, 0, fs.fstatSync(fd).size);
46
+ } catch {
47
+ return null;
48
+ } finally {
49
+ if (fd !== undefined) {
50
+ try { fs.closeSync(fd); } catch { /* ignore */ }
51
+ }
52
+ }
53
+ }
54
+
55
+ // Walk the boxes in [start, end); invoke cb(type, contentStart, contentSize)
56
+ // for each. The first cb result that isn't `undefined` is returned.
57
+ function _iterBoxes(fd, start, end, cb) {
58
+ let pos = start;
59
+ while (pos + 8 <= end) {
60
+ const header = _readAt(fd, pos, 8);
61
+ if (header.length < 8) break;
62
+ let size = header.readUInt32BE(0);
63
+ const type = header.toString('latin1', 4, 8);
64
+ let headerSize = 8;
65
+ if (size === 1) { // 64-bit largesize follows the 8-byte header
66
+ const ext = _readAt(fd, pos + 8, 8);
67
+ if (ext.length < 8) break;
68
+ size = Number(ext.readBigUInt64BE(0));
69
+ headerSize = 16;
70
+ } else if (size === 0) { // box extends to end of range
71
+ size = end - pos;
72
+ }
73
+ if (size < headerSize) break;
74
+ const result = cb(type, pos + headerSize, size - headerSize);
75
+ if (result !== undefined) return result;
76
+ pos += size;
77
+ }
78
+ return undefined;
79
+ }
80
+
81
+ function _findMvhd(fd, start, end) {
82
+ const found = _iterBoxes(fd, start, end, (type, cStart, cSize) => {
83
+ if (type !== 'moov') return undefined;
84
+ const dur = _iterBoxes(fd, cStart, cStart + cSize, (t2, c2Start, c2Size) => {
85
+ if (t2 === 'mvhd') return _parseMvhd(_readAt(fd, c2Start, Math.min(c2Size, 32)));
86
+ return undefined;
87
+ });
88
+ return dur == null ? undefined : dur;
89
+ });
90
+ return found == null ? null : found;
91
+ }
92
+
93
+ function _parseMvhd(buf) {
94
+ if (buf.length < 20) return null;
95
+ const version = buf[0];
96
+ let timescale;
97
+ let duration;
98
+ if (version === 1) {
99
+ if (buf.length < 32) return null;
100
+ timescale = buf.readUInt32BE(20);
101
+ duration = Number(buf.readBigUInt64BE(24));
102
+ } else {
103
+ timescale = buf.readUInt32BE(12);
104
+ duration = buf.readUInt32BE(16);
105
+ }
106
+ if (!timescale) return null;
107
+ return duration / timescale;
108
+ }
109
+
110
+ function _durationFromFfprobe(filePath) {
111
+ let res;
112
+ try {
113
+ res = spawnSync(
114
+ 'ffprobe',
115
+ ['-v', 'error', '-show_entries', 'format=duration',
116
+ '-of', 'default=noprint_wrappers=1:nokey=1', filePath],
117
+ { encoding: 'utf8', timeout: 30000 },
118
+ );
119
+ } catch {
120
+ return null;
121
+ }
122
+ if (res.error || res.status !== 0) return null;
123
+ const value = parseFloat((res.stdout || '').trim());
124
+ return Number.isFinite(value) && value > 0 ? value : null;
125
+ }
@@ -0,0 +1,56 @@
1
+ export const YOUTUBE_FATAL_CODES = new Set([
2
+ "YOUTUBE_VIDEO_TOO_LONG",
3
+ "YOUTUBE_VIDEO_UNAVAILABLE",
4
+ "YOUTUBE_VIDEO_PRIVATE",
5
+ "YOUTUBE_VIDEO_AGE_RESTRICTED",
6
+ "YOUTUBE_VIDEO_GEO_BLOCKED",
7
+ "YOUTUBE_VIDEO_BLOCKED",
8
+ "YOUTUBE_AUTH_REQUIRED",
9
+ "YOUTUBE_AUTH_EXPIRED",
10
+ "YOUTUBE_AUTH_MISSING",
11
+ "YOUTUBE_AUTH_INVALID",
12
+ "YOUTUBE_IMPORT_FAILED",
13
+ ]);
14
+
15
+ export class ZyrisError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = this.constructor.name;
19
+ }
20
+ }
21
+
22
+ export class APIError extends ZyrisError {
23
+ constructor(message, statusCode = null, responseBody = null) {
24
+ super(message);
25
+ this.statusCode = statusCode;
26
+ this.responseBody = responseBody;
27
+ }
28
+ }
29
+
30
+ export class BadRequestError extends APIError {}
31
+
32
+ export class YouTubeImportError extends BadRequestError {
33
+ constructor(message, statusCode = null, responseBody = null, code = null) {
34
+ super(message, statusCode, responseBody);
35
+ this.code = code;
36
+ }
37
+ }
38
+
39
+ export class AuthenticationError extends APIError {}
40
+ export class InsufficientCreditsError extends APIError {}
41
+ export class PermissionDeniedError extends APIError {}
42
+ export class NotFoundError extends APIError {}
43
+ export class ConflictError extends APIError {}
44
+ export class PayloadTooLargeError extends APIError {}
45
+ export class UnprocessableEntityError extends APIError {}
46
+
47
+ export class RateLimitError extends APIError {
48
+ constructor(message, statusCode = null, responseBody = null, retryAfter = null) {
49
+ super(message, statusCode, responseBody);
50
+ this.retryAfter = retryAfter;
51
+ }
52
+ }
53
+
54
+ export class InternalServerError extends APIError {}
55
+ export class BadGatewayError extends APIError {}
56
+ export class ServiceUnavailableError extends APIError {}
package/src/index.js ADDED
@@ -0,0 +1,12 @@
1
+ export { Client } from './client.js';
2
+ export { VERSION } from './_version.js';
3
+ export { probeDuration } from './duration.js';
4
+ export {
5
+ UPLOAD_BYTES_PER_CREDIT,
6
+ estimateUploadCredits,
7
+ derivedUploadStatus,
8
+ isPipelineDone,
9
+ isPipelineFailed,
10
+ isPipelineTerminal,
11
+ } from './models.js';
12
+ export * from './exceptions.js';
package/src/models.js ADDED
@@ -0,0 +1,48 @@
1
+ export const UPLOAD_BYTES_PER_CREDIT = 25 * 1024 * 1024;
2
+
3
+ const PIPELINE_DONE_STATES = new Set(["done", "success", "completed"]);
4
+ const PIPELINE_FAILED_STATES = new Set(["failed", "error"]);
5
+
6
+ /**
7
+ * Estimate the storage credits that will be charged for an upload of
8
+ * `sizeBytes`. Mirrors the server-side formula: 1 credit per 25 MiB,
9
+ * rounded up, minimum 1.
10
+ */
11
+ export function estimateUploadCredits(sizeBytes) {
12
+ if (sizeBytes <= 0) return 0;
13
+ return Math.max(1, Math.ceil(sizeBytes / UPLOAD_BYTES_PER_CREDIT));
14
+ }
15
+
16
+ /**
17
+ * Roll the three upload-pipeline statuses into one of
18
+ * `pending` / `in_progress` / `completed` / `failed`.
19
+ *
20
+ * Accepts the raw object returned by `client.uploads.getStatus()`.
21
+ */
22
+ export function derivedUploadStatus(uploadStatus) {
23
+ const pipelines = [
24
+ uploadStatus?.thumbnail_generation_status,
25
+ uploadStatus?.transcoding_status,
26
+ uploadStatus?.hls_generation_status,
27
+ ];
28
+ const statuses = pipelines
29
+ .filter((p) => p != null)
30
+ .map((p) => p.status);
31
+ if (statuses.length === 0) return "pending";
32
+ if (statuses.some((s) => s === "failed")) return "failed";
33
+ if (statuses.every((s) => s === "completed")) return "completed";
34
+ if (statuses.some((s) => s === "in_progress" || s === "completed")) return "in_progress";
35
+ return "pending";
36
+ }
37
+
38
+ export function isPipelineDone(pipelineStatus) {
39
+ return PIPELINE_DONE_STATES.has(pipelineStatus?.status);
40
+ }
41
+
42
+ export function isPipelineFailed(pipelineStatus) {
43
+ return PIPELINE_FAILED_STATES.has(pipelineStatus?.status);
44
+ }
45
+
46
+ export function isPipelineTerminal(pipelineStatus) {
47
+ return isPipelineDone(pipelineStatus) || isPipelineFailed(pipelineStatus);
48
+ }
@@ -0,0 +1,68 @@
1
+ function serializeHistory(history) {
2
+ if (history == null) return null;
3
+ return history.map((msg) => ({ role: msg.role, content: msg.content }));
4
+ }
5
+
6
+ function buildPayload(question, videoId, topK, history, stream) {
7
+ const payload = {
8
+ question,
9
+ video_id: videoId.toString(),
10
+ };
11
+ if (topK != null) payload.top_k = topK;
12
+ const serialized = serializeHistory(history);
13
+ if (serialized != null) payload.history = serialized;
14
+ if (stream) payload.stream = true;
15
+ return payload;
16
+ }
17
+
18
+ export class ChatResource {
19
+ constructor(client) {
20
+ this._client = client;
21
+ }
22
+
23
+ /**
24
+ * Ask a question about a video (stateless, video-only RAG).
25
+ *
26
+ * The server is stateless — to maintain a multi-turn conversation,
27
+ * resend prior turns each call as
28
+ * `history: [{ role: "user", content: "..." }, { role: "assistant", content: "..." }, ...]`.
29
+ *
30
+ * Costs 1 credit per call. Refunded automatically on 5xx.
31
+ *
32
+ * POST /chat-service/v1/developer/chat/ask
33
+ */
34
+ async ask(question, { videoId, topK = null, history = null } = {}) {
35
+ if (videoId == null) throw new Error("videoId is required");
36
+ const payload = buildPayload(question, videoId, topK, history, false);
37
+ return await this._client._request("POST", "/chat-service/v1/developer/chat/ask", {
38
+ data: payload,
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Stream an answer as Server-Sent Events. Returns an async iterator that
44
+ * yields `{ event, data }` objects.
45
+ *
46
+ * - `{ event: "token", data: { content } }` per LLM chunk
47
+ * - terminates with one of:
48
+ * - `{ event: "done", data: { answer } }` (success)
49
+ * - `{ event: "error", data: { detail } }` (mid-stream failure; credit refunded)
50
+ *
51
+ * POST /chat-service/v1/developer/chat/ask (stream=true)
52
+ */
53
+ async *askStream(question, { videoId, topK = null, history = null } = {}) {
54
+ if (videoId == null) throw new Error("videoId is required");
55
+ const payload = buildPayload(question, videoId, topK, history, true);
56
+ for await (const ev of this._client._streamSse("POST", "/chat-service/v1/developer/chat/ask", { data: payload })) {
57
+ if (ev.event === "token") {
58
+ yield ev;
59
+ } else if (ev.event === "done") {
60
+ yield ev;
61
+ return;
62
+ } else if (ev.event === "error") {
63
+ yield ev;
64
+ return;
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,117 @@
1
+ export class CollectionsResource {
2
+ constructor(client) {
3
+ this._client = client;
4
+ }
5
+
6
+ /**
7
+ * Create a new collection.
8
+ *
9
+ * POST /backend-service/v1/developer/collections/create
10
+ */
11
+ async create(name, description = null) {
12
+ const data = { name };
13
+ if (description != null) data.description = description;
14
+ return await this._client._request(
15
+ "POST",
16
+ "/backend-service/v1/developer/collections/create",
17
+ { data },
18
+ );
19
+ }
20
+
21
+ /**
22
+ * Update a collection's name and/or description. Fields left as `undefined`
23
+ * are not sent. Server rejects with 400 if nothing is provided.
24
+ *
25
+ * PATCH /backend-service/v1/developer/collections/update/{collection_id}
26
+ */
27
+ async update(collectionId, { name, description } = {}) {
28
+ const data = {};
29
+ if (name !== undefined) data.name = name;
30
+ if (description !== undefined) data.description = description;
31
+ return await this._client._request(
32
+ "PATCH",
33
+ `/backend-service/v1/developer/collections/update/${collectionId}`,
34
+ { data },
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Delete a collection. Videos linked to it are unaffected.
40
+ *
41
+ * DELETE /backend-service/v1/developer/collections/delete/{collection_id}
42
+ */
43
+ async delete(collectionId) {
44
+ return await this._client._request(
45
+ "DELETE",
46
+ `/backend-service/v1/developer/collections/delete/${collectionId}`,
47
+ );
48
+ }
49
+
50
+ /**
51
+ * List the calling user's collections.
52
+ *
53
+ * `page` >= 1, `pageSize` 1-100. Response carries `data`, `page`,
54
+ * `page_size`, `total`.
55
+ *
56
+ * GET /backend-service/v1/developer/collections/list
57
+ */
58
+ async list({ page = 1, pageSize = 10 } = {}) {
59
+ return await this._client._request(
60
+ "GET",
61
+ "/backend-service/v1/developer/collections/list",
62
+ { params: { page, page_size: pageSize } },
63
+ );
64
+ }
65
+
66
+ /**
67
+ * List videos in a collection (paginated, sortable).
68
+ *
69
+ * GET /backend-service/v1/developer/collections/{collection_id}/videos
70
+ */
71
+ async listVideos(collectionId, {
72
+ page = 1,
73
+ pageSize = 10,
74
+ sortBy = "created_at",
75
+ sortOrder = "desc",
76
+ } = {}) {
77
+ return await this._client._request(
78
+ "GET",
79
+ `/backend-service/v1/developer/collections/${collectionId}/videos`,
80
+ {
81
+ params: {
82
+ page,
83
+ page_size: pageSize,
84
+ sort_by: sortBy,
85
+ sort_order: sortOrder,
86
+ },
87
+ },
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Add a fully-processed video to a collection. Throws `ConflictError`
93
+ * (409) if the video is already in the collection.
94
+ *
95
+ * POST /backend-service/v1/developer/collections/{collection_id}/videos
96
+ */
97
+ async addVideo(collectionId, videoId) {
98
+ return await this._client._request(
99
+ "POST",
100
+ `/backend-service/v1/developer/collections/${collectionId}/videos`,
101
+ { data: { video_id: videoId.toString() } },
102
+ );
103
+ }
104
+
105
+ /**
106
+ * Remove a video from a collection. Removes only the link — the video
107
+ * record stays intact.
108
+ *
109
+ * DELETE /backend-service/v1/developer/collections/{collection_id}/videos/{video_id}
110
+ */
111
+ async removeVideo(collectionId, videoId) {
112
+ return await this._client._request(
113
+ "DELETE",
114
+ `/backend-service/v1/developer/collections/${collectionId}/videos/${videoId}`,
115
+ );
116
+ }
117
+ }
@@ -0,0 +1,38 @@
1
+ export class EmbeddingsResource {
2
+ constructor(client) {
3
+ this._client = client;
4
+ }
5
+
6
+ /**
7
+ * Return stored per-segment embedding vectors for a video.
8
+ *
9
+ * Reads directly from the vector store; no recompute. Costs 1 credit per
10
+ * call (refunded on 404/408/502/5xx). Throws `ConflictError` (409) if the
11
+ * video's search-process pipeline hasn't finished yet — wait and retry
12
+ * once `search_process_status === "done"`.
13
+ *
14
+ * GET /chat-service/v1/developer/embeddings/video/{video_id}
15
+ */
16
+ async video(videoId) {
17
+ return await this._client._request(
18
+ "GET",
19
+ `/chat-service/v1/developer/embeddings/video/${videoId}`,
20
+ );
21
+ }
22
+
23
+ /**
24
+ * Embed an arbitrary text string with the active backend.
25
+ *
26
+ * `text` must be 1–4096 characters. Costs 1 credit per call (refunded on
27
+ * 408/502/5xx).
28
+ *
29
+ * POST /chat-service/v1/developer/embeddings/text
30
+ */
31
+ async text(text) {
32
+ return await this._client._request(
33
+ "POST",
34
+ "/chat-service/v1/developer/embeddings/text",
35
+ { data: { text } },
36
+ );
37
+ }
38
+ }
@@ -0,0 +1,50 @@
1
+ export class SearchResource {
2
+ constructor(client) {
3
+ this._client = client;
4
+ }
5
+
6
+ /**
7
+ * Vector-search across the developer's videos.
8
+ *
9
+ * Provide at least one of `videoIds` or `collectionId`. Costs 1 credit per
10
+ * call (refunded on failure). Returns the array of hits from `results`.
11
+ *
12
+ * POST /chat-service/v1/developer/search
13
+ */
14
+ async search(query, { videoIds = null, collectionId = null, topK = 5 } = {}) {
15
+ const payload = {
16
+ query,
17
+ video_ids: (videoIds || []).map((v) => v.toString()),
18
+ top_k: topK,
19
+ };
20
+ if (collectionId != null) payload.collection_id = collectionId.toString();
21
+
22
+ const data = await this._client._request(
23
+ "POST",
24
+ "/chat-service/v1/developer/search",
25
+ { data: payload },
26
+ );
27
+ return data.results;
28
+ }
29
+
30
+ /**
31
+ * Return a 2-D UMAP projection of every segment across the selection.
32
+ *
33
+ * Provide at least one of `videoIds` or `collectionId`. Free; no credit
34
+ * deduction.
35
+ *
36
+ * POST /chat-service/v1/developer/visualize
37
+ */
38
+ async visualize({ videoIds = null, collectionId = null } = {}) {
39
+ const payload = {
40
+ video_ids: (videoIds || []).map((v) => v.toString()),
41
+ };
42
+ if (collectionId != null) payload.collection_id = collectionId.toString();
43
+
44
+ return await this._client._request(
45
+ "POST",
46
+ "/chat-service/v1/developer/visualize",
47
+ { data: payload },
48
+ );
49
+ }
50
+ }
@@ -0,0 +1,316 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import axios from 'axios';
4
+ import {
5
+ BadRequestError,
6
+ InsufficientCreditsError,
7
+ YOUTUBE_FATAL_CODES,
8
+ YouTubeImportError,
9
+ } from '../exceptions.js';
10
+ import { probeDuration } from '../duration.js';
11
+
12
+ const DEFAULT_CHUNK_SIZE = 10 * 1024 * 1024;
13
+ const S3_PART_PUT_TIMEOUT = 300000;
14
+
15
+ // Minimal extension → MIME map for common video types. Falls back to
16
+ // application/octet-stream when unknown.
17
+ const MIME_BY_EXT = {
18
+ '.mp4': 'video/mp4',
19
+ '.m4v': 'video/x-m4v',
20
+ '.mov': 'video/quicktime',
21
+ '.mkv': 'video/x-matroska',
22
+ '.webm': 'video/webm',
23
+ '.avi': 'video/x-msvideo',
24
+ '.wmv': 'video/x-ms-wmv',
25
+ '.flv': 'video/x-flv',
26
+ '.ogv': 'video/ogg',
27
+ '.3gp': 'video/3gpp',
28
+ };
29
+
30
+ function guessContentType(filePath) {
31
+ const ext = path.extname(filePath).toLowerCase();
32
+ return MIME_BY_EXT[ext] || 'application/octet-stream';
33
+ }
34
+
35
+ export class UploadsResource {
36
+ constructor(client) {
37
+ this._client = client;
38
+ }
39
+
40
+ // --- Multipart ---------------------------------------------------------
41
+
42
+ /**
43
+ * Start an S3 multipart upload.
44
+ *
45
+ * `videoDurationSeconds` (required, > 0) is the client-measured video
46
+ * length. The server rejects values that exceed `MAX_VIDEO_DURATION_SECONDS`
47
+ * with a 400 `BadRequestError`.
48
+ *
49
+ * POST /backend-service/v1/developer/uploads/multipart/initiate
50
+ */
51
+ async initiateMultipart(filename, contentType, videoDurationSeconds) {
52
+ return await this._client._request(
53
+ "POST",
54
+ "/backend-service/v1/developer/uploads/multipart/initiate",
55
+ {
56
+ data: {
57
+ filename,
58
+ content_type: contentType,
59
+ video_duration_seconds: videoDurationSeconds,
60
+ },
61
+ },
62
+ );
63
+ }
64
+
65
+ /**
66
+ * Get a pre-signed URL for one chunk. `partNumber` must be 1-10000.
67
+ *
68
+ * POST /backend-service/v1/developer/uploads/multipart/part-url
69
+ */
70
+ async getPartUrl(s3ObjectName, uploadId, partNumber) {
71
+ return await this._client._request(
72
+ "POST",
73
+ "/backend-service/v1/developer/uploads/multipart/part-url",
74
+ {
75
+ data: {
76
+ s3_object_name: s3ObjectName,
77
+ upload_id: uploadId,
78
+ part_number: partNumber,
79
+ },
80
+ },
81
+ );
82
+ }
83
+
84
+ /**
85
+ * List parts already uploaded for an in-progress multipart upload.
86
+ *
87
+ * POST /backend-service/v1/developer/uploads/multipart/list-parts
88
+ */
89
+ async listParts(s3ObjectName, uploadId) {
90
+ const data = await this._client._request(
91
+ "POST",
92
+ "/backend-service/v1/developer/uploads/multipart/list-parts",
93
+ { data: { s3_object_name: s3ObjectName, upload_id: uploadId } },
94
+ );
95
+ return data.parts;
96
+ }
97
+
98
+ /**
99
+ * Finalize a multipart upload. `parts` is an array of
100
+ * `{ part_number, etag }`.
101
+ *
102
+ * Storage credits (1 per 25 MiB rounded up) are deducted here based on the
103
+ * actual S3 object size. Throws `InsufficientCreditsError` (402) when the
104
+ * user can't cover the charge — in that case the server has already
105
+ * aborted the S3 upload, so do not call `abortMultipart`.
106
+ *
107
+ * POST /backend-service/v1/developer/uploads/multipart/complete
108
+ */
109
+ async completeMultipart(s3ObjectName, uploadId, parts) {
110
+ return await this._client._request(
111
+ "POST",
112
+ "/backend-service/v1/developer/uploads/multipart/complete",
113
+ {
114
+ data: {
115
+ s3_object_name: s3ObjectName,
116
+ upload_id: uploadId,
117
+ parts,
118
+ },
119
+ },
120
+ );
121
+ }
122
+
123
+ /**
124
+ * Abort an in-progress multipart upload.
125
+ *
126
+ * POST /backend-service/v1/developer/uploads/multipart/abort
127
+ */
128
+ async abortMultipart(s3ObjectName, uploadId) {
129
+ return await this._client._request(
130
+ "POST",
131
+ "/backend-service/v1/developer/uploads/multipart/abort",
132
+ { data: { s3_object_name: s3ObjectName, upload_id: uploadId } },
133
+ );
134
+ }
135
+
136
+ // --- Finalization -----------------------------------------------------
137
+
138
+ /**
139
+ * Create the video record and enqueue processing after multipart completes.
140
+ *
141
+ * POST /backend-service/v1/developer/uploads/preprocess
142
+ */
143
+ async preprocess(s3ObjectName, videoId, name, useDynamicHls = false) {
144
+ return await this._client._request(
145
+ "POST",
146
+ "/backend-service/v1/developer/uploads/preprocess",
147
+ {
148
+ data: {
149
+ s3_object_name: s3ObjectName,
150
+ video_id: videoId.toString(),
151
+ name,
152
+ use_dynamic_hls: useDynamicHls,
153
+ },
154
+ },
155
+ );
156
+ }
157
+
158
+ /**
159
+ * Upload a small file (<= 100 MB) in a single request. Throws
160
+ * `PayloadTooLargeError` on 413 — use the multipart flow for larger files.
161
+ *
162
+ * `videoDurationSeconds` (> 0) is the client-measured video length. When
163
+ * omitted it's auto-detected from the file via `probeDuration` (MP4/MOV
164
+ * header, or ffprobe if installed). The server rejects values that exceed
165
+ * `MAX_VIDEO_DURATION_SECONDS` with a 400 `BadRequestError`.
166
+ *
167
+ * Storage credits (1 per 25 MiB rounded up) are deducted server-side after
168
+ * the file body is received but before S3 upload begins. Throws
169
+ * `InsufficientCreditsError` (402) when the user can't cover the charge.
170
+ *
171
+ * POST /backend-service/v1/developer/uploads/direct
172
+ */
173
+ async direct(filePath, { name, videoDurationSeconds, useDynamicHls = false, contentType = null, filename = null } = {}) {
174
+ if (videoDurationSeconds == null) videoDurationSeconds = probeDuration(filePath);
175
+ const resolvedName = filename || path.basename(filePath);
176
+ const resolvedType = contentType || guessContentType(filePath);
177
+ const fileBuffer = fs.readFileSync(filePath);
178
+
179
+ const form = new FormData();
180
+ const blob = new Blob([fileBuffer], { type: resolvedType });
181
+ form.append('file', blob, resolvedName);
182
+ form.append('name', name);
183
+ form.append('video_duration_seconds', String(videoDurationSeconds));
184
+ form.append('use_dynamic_hls', useDynamicHls ? 'true' : 'false');
185
+
186
+ return await this._client._request(
187
+ "POST",
188
+ "/backend-service/v1/developer/uploads/direct",
189
+ { data: form },
190
+ );
191
+ }
192
+
193
+ /**
194
+ * Import a YouTube video.
195
+ *
196
+ * Throws `YouTubeImportError` (a subclass of `BadRequestError`) on 400
197
+ * responses carrying a known YouTube error code — inspect `.code`
198
+ * (e.g. `"YOUTUBE_VIDEO_PRIVATE"`) to branch. The full set of known
199
+ * codes is exported as `YOUTUBE_FATAL_CODES`.
200
+ *
201
+ * POST /backend-service/v1/developer/uploads/youtube
202
+ */
203
+ async importYoutube(url, name = null) {
204
+ const payload = { url };
205
+ if (name != null) payload.name = name;
206
+ try {
207
+ return await this._client._request(
208
+ "POST",
209
+ "/backend-service/v1/developer/uploads/youtube",
210
+ { data: payload },
211
+ );
212
+ } catch (e) {
213
+ if (e instanceof BadRequestError) {
214
+ const body = e.responseBody && typeof e.responseBody === 'object' ? e.responseBody : {};
215
+ const code = body.message || body.detail;
216
+ if (code && YOUTUBE_FATAL_CODES.has(code)) {
217
+ throw new YouTubeImportError(e.message, e.statusCode, e.responseBody, code);
218
+ }
219
+ }
220
+ throw e;
221
+ }
222
+ }
223
+
224
+ // --- Status -----------------------------------------------------------
225
+
226
+ /**
227
+ * Poll per-pipeline processing status for a video. Use `derivedUploadStatus`
228
+ * from `models.js` to collapse the three pipelines into a single state.
229
+ *
230
+ * GET /backend-service/v1/developer/uploads/status/{video_id}
231
+ */
232
+ async getStatus(videoId) {
233
+ return await this._client._request(
234
+ "GET",
235
+ `/backend-service/v1/developer/uploads/status/${videoId}`,
236
+ );
237
+ }
238
+
239
+ // --- high-level helper ------------------------------------------------
240
+
241
+ /**
242
+ * Upload a local file via the multipart flow and kick off processing.
243
+ *
244
+ * `videoDurationSeconds` (> 0) is the client-measured video length, forwarded
245
+ * to `initiateMultipart`. When omitted it's auto-detected from `filePath` via
246
+ * `probeDuration`. The server rejects values that exceed
247
+ * `MAX_VIDEO_DURATION_SECONDS` with a 400 `BadRequestError` before any chunk
248
+ * is uploaded.
249
+ *
250
+ * Sequential chunks, no retry. On any failure before `completeMultipart`
251
+ * succeeds, calls `abortMultipart` so the partial S3 object isn't left
252
+ * behind. Preprocess failures propagate without aborting (the upload is
253
+ * already durable at that point — retry `preprocess` separately if you
254
+ * stash `s3_object_name`).
255
+ *
256
+ * Charges 1 storage credit per 25 MiB (rounded up) at `completeMultipart`.
257
+ * On `InsufficientCreditsError` from that step the server has already
258
+ * aborted the S3 upload, so this helper skips the redundant client-side
259
+ * abort.
260
+ */
261
+ async uploadFile(filePath, {
262
+ name,
263
+ videoDurationSeconds,
264
+ useDynamicHls = false,
265
+ chunkSize = DEFAULT_CHUNK_SIZE,
266
+ contentType = null,
267
+ onProgress = null,
268
+ } = {}) {
269
+ if (videoDurationSeconds == null) videoDurationSeconds = probeDuration(filePath);
270
+ const stats = fs.statSync(filePath);
271
+ const totalBytes = stats.size;
272
+ const resolvedType = contentType || guessContentType(filePath);
273
+
274
+ const init = await this.initiateMultipart(path.basename(filePath), resolvedType, videoDurationSeconds);
275
+
276
+ const parts = [];
277
+ let fd = null;
278
+ try {
279
+ fd = fs.openSync(filePath, 'r');
280
+ let uploaded = 0;
281
+ let partNumber = 1;
282
+ const buffer = Buffer.alloc(chunkSize);
283
+ while (uploaded < totalBytes) {
284
+ const bytesRead = fs.readSync(fd, buffer, 0, chunkSize, uploaded);
285
+ if (bytesRead === 0) break;
286
+ const chunk = bytesRead === chunkSize ? buffer : buffer.subarray(0, bytesRead);
287
+ const presigned = await this.getPartUrl(init.s3_object_name, init.upload_id, partNumber);
288
+ const response = await axios.put(presigned.url, chunk, { timeout: S3_PART_PUT_TIMEOUT });
289
+ parts.push({
290
+ part_number: partNumber,
291
+ etag: response.headers.etag.replace(/"/g, ''),
292
+ });
293
+ uploaded += bytesRead;
294
+ if (onProgress) onProgress(uploaded, totalBytes);
295
+ partNumber++;
296
+ }
297
+
298
+ await this.completeMultipart(init.s3_object_name, init.upload_id, parts);
299
+ } catch (error) {
300
+ if (!(error instanceof InsufficientCreditsError)) {
301
+ try {
302
+ await this.abortMultipart(init.s3_object_name, init.upload_id);
303
+ } catch {
304
+ // swallow — best-effort cleanup
305
+ }
306
+ }
307
+ throw error;
308
+ } finally {
309
+ if (fd != null) {
310
+ try { fs.closeSync(fd); } catch { /* ignore */ }
311
+ }
312
+ }
313
+
314
+ return await this.preprocess(init.s3_object_name, init.video_id, name, useDynamicHls);
315
+ }
316
+ }
@@ -0,0 +1,100 @@
1
+ export class VideosResource {
2
+ constructor(client) {
3
+ this._client = client;
4
+ }
5
+
6
+ // --- list / get / delete ---------------------------------------------
7
+
8
+ /**
9
+ * List videos owned by the calling developer user.
10
+ *
11
+ * `status` is one of `uploaded`, `analysed`, `search_processed`,
12
+ * `processed`. `processed` requires both pipelines done; the middle two
13
+ * filter on a single pipeline.
14
+ *
15
+ * GET /backend-service/v1/developer/videos/list
16
+ */
17
+ async list({
18
+ status,
19
+ startTime,
20
+ endTime,
21
+ notInCollection,
22
+ skip,
23
+ limit,
24
+ sortBy,
25
+ sortOrder,
26
+ } = {}) {
27
+ const params = {
28
+ status,
29
+ start_time: startTime instanceof Date ? startTime.toISOString() : startTime,
30
+ end_time: endTime instanceof Date ? endTime.toISOString() : endTime,
31
+ not_in_collection: notInCollection,
32
+ skip,
33
+ limit,
34
+ sort_by: sortBy,
35
+ sort_order: sortOrder,
36
+ };
37
+ const cleanParams = Object.fromEntries(
38
+ Object.entries(params).filter(([, v]) => v != null),
39
+ );
40
+ return await this._client._request(
41
+ "GET",
42
+ "/backend-service/v1/developer/videos/list",
43
+ { params: cleanParams },
44
+ );
45
+ }
46
+
47
+ /**
48
+ * Fetch a single video with metadata.
49
+ *
50
+ * GET /backend-service/v1/developer/videos/get-video-details
51
+ */
52
+ async get(videoId) {
53
+ return await this._client._request(
54
+ "GET",
55
+ "/backend-service/v1/developer/videos/get-video-details",
56
+ { params: { video_id: videoId.toString() } },
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Permanently delete a video and all derived assets.
62
+ *
63
+ * DELETE /backend-service/v1/developer/videos/delete
64
+ */
65
+ async delete(videoId) {
66
+ return await this._client._request(
67
+ "DELETE",
68
+ "/backend-service/v1/developer/videos/delete",
69
+ { params: { video_id: videoId.toString() } },
70
+ );
71
+ }
72
+
73
+ // --- Pipelines: search-process / analyse ----------------------------
74
+
75
+ /** POST /backend-service/v1/developer/videos/search-process/initiate */
76
+ async initiateSearchProcess(videoId) { return await this._initiate("search-process", videoId); }
77
+ /** GET /backend-service/v1/developer/videos/search-process/status/{video_id} */
78
+ async getSearchProcessStatus(videoId) { return await this._pipelineStatus("search-process", videoId); }
79
+ /** POST /backend-service/v1/developer/videos/analyse/initiate */
80
+ async initiateAnalyse(videoId) { return await this._initiate("analyse", videoId); }
81
+ /** GET /backend-service/v1/developer/videos/analyse/status/{video_id} */
82
+ async getAnalyseStatus(videoId) { return await this._pipelineStatus("analyse", videoId); }
83
+
84
+ // --- internals --------------------------------------------------------
85
+
86
+ async _initiate(pipeline, videoId) {
87
+ return await this._client._request(
88
+ "POST",
89
+ `/backend-service/v1/developer/videos/${pipeline}/initiate`,
90
+ { data: { video_id: videoId.toString() } },
91
+ );
92
+ }
93
+
94
+ async _pipelineStatus(pipeline, videoId) {
95
+ return await this._client._request(
96
+ "GET",
97
+ `/backend-service/v1/developer/videos/${pipeline}/status/${videoId}`,
98
+ );
99
+ }
100
+ }