@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 +21 -0
- package/README.md +32 -0
- package/package.json +49 -0
- package/src/_version.js +1 -0
- package/src/client.js +195 -0
- package/src/duration.js +125 -0
- package/src/exceptions.js +56 -0
- package/src/index.js +12 -0
- package/src/models.js +48 -0
- package/src/resources/chat.js +68 -0
- package/src/resources/collections.js +117 -0
- package/src/resources/embeddings.js +38 -0
- package/src/resources/search.js +50 -0
- package/src/resources/uploads.js +316 -0
- package/src/resources/videos.js +100 -0
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
|
+
}
|
package/src/_version.js
ADDED
|
@@ -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
|
+
}
|
package/src/duration.js
ADDED
|
@@ -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
|
+
}
|