cutsio 0.0.0-stage → 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/README.md +126 -2
- package/dist/index.js +1008 -0
- package/package.json +34 -4
package/README.md
CHANGED
|
@@ -1,3 +1,127 @@
|
|
|
1
|
-
#
|
|
1
|
+
# cutsio
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Syncs Cutsio mezzanine masters to a local folder. Resumable, idempotent, and
|
|
4
|
+
safe to run repeatedly: it downloads only videos that are new or changed since
|
|
5
|
+
the last run.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install -g cutsio # then: cutsio sync …
|
|
11
|
+
# or run without installing
|
|
12
|
+
npx cutsio sync --out ~/Cutsio --collection vacation
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Requires Node 18+ and `curl` on `PATH` (ships with macOS).
|
|
16
|
+
|
|
17
|
+
Create an **organization** API key in Cutsio: Settings → Organization → API Keys.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cutsio login --url https://studio.cutsio.com --key ak_...
|
|
21
|
+
cutsio whoami
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Config is stored at `~/.cutsio/config.json` with mode `600`. You can also skip
|
|
25
|
+
the config file entirely:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
export CUTSIO_API_URL=https://studio.cutsio.com
|
|
29
|
+
export CUTSIO_API_KEY=ak_...
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Development
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install
|
|
36
|
+
npm run dev -- whoami # runs src/index.ts directly (Node 23.6+)
|
|
37
|
+
npm run typecheck
|
|
38
|
+
npm run build # bundles src -> dist/index.js
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## List and select
|
|
42
|
+
|
|
43
|
+
`sync` never downloads the whole workspace unless you ask for it. Start by
|
|
44
|
+
listing what's there:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
cutsio list # collections, folder paths, ids, and video counts
|
|
48
|
+
cutsio list --videos # every video with its id
|
|
49
|
+
cutsio list --search shot # title matches anywhere in the workspace
|
|
50
|
+
cutsio list --json # raw manifest
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Sync
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
cutsio sync --out ~/Cutsio --collection vacation
|
|
57
|
+
cutsio sync --out ~/Cutsio --folder test/sub1a
|
|
58
|
+
cutsio sync --out ~/Cutsio --all --dry-run
|
|
59
|
+
cutsio sync --out ~/Cutsio --interactive
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Selection flags are comma-separated and can be combined (the result is a union):
|
|
63
|
+
|
|
64
|
+
| Flag | Meaning |
|
|
65
|
+
|---|---|
|
|
66
|
+
| `--all` | Every video in the workspace. Required for a full sync. |
|
|
67
|
+
| `--folder <path\|id,...>` | A folder **and all of its subfolders**. |
|
|
68
|
+
| `--collection <title\|id,...>` | Videos in a collection. |
|
|
69
|
+
| `--video <id,...>` | Specific videos. |
|
|
70
|
+
| `--search <term,...>` | Videos whose title contains a term, anywhere in the workspace. |
|
|
71
|
+
| `--unfiled` | Videos that are in no folder. |
|
|
72
|
+
| `--interactive` | Choose from a numbered menu. |
|
|
73
|
+
|
|
74
|
+
Unknown or ambiguous selectors fail before anything is downloaded. Search also
|
|
75
|
+
covers videos that are in no folder or collection.
|
|
76
|
+
|
|
77
|
+
| Other flag | Default | Meaning |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| `--out <dir>` | `./cutsio` | Destination root. The folder tree is mirrored underneath. |
|
|
80
|
+
| `--concurrency <n>` | `3` | Simultaneous downloads. |
|
|
81
|
+
| `--max-wait <seconds>` | `600` | How long to wait for masters still preparing. |
|
|
82
|
+
| `--limit <n>` | all | Cap how many videos this run downloads. |
|
|
83
|
+
| `--dry-run` | off | Print what would download, change nothing. |
|
|
84
|
+
|
|
85
|
+
## How it behaves
|
|
86
|
+
|
|
87
|
+
- **Manifest diffing:** each video's `realtime_version` is stored in
|
|
88
|
+
`<out>/.cutsio-state.json`. A video is re-downloaded only when that version
|
|
89
|
+
changes or the local file is missing/short.
|
|
90
|
+
- **Resume:** downloads go to `<file>.part` and `curl -C -` continues from the
|
|
91
|
+
existing byte offset. A crashed run picks up where it stopped, even if the
|
|
92
|
+
signed URL was refreshed in between.
|
|
93
|
+
- **Expiring URLs:** Mux master URLs live 24h. On a failed transfer the CLI
|
|
94
|
+
re-prepares that single video once and retries.
|
|
95
|
+
- **Nothing is deleted.** Videos removed upstream stay on disk; the CLI never
|
|
96
|
+
deletes local files.
|
|
97
|
+
- **Filenames:** `<title> [<id8>].mp4` — the id suffix prevents collisions when
|
|
98
|
+
two videos share a title.
|
|
99
|
+
- **Not ready / restricted:** videos that are still processing, or whose
|
|
100
|
+
moderation failed, are skipped and reported.
|
|
101
|
+
|
|
102
|
+
## Build
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npm run build # esbuild bundle -> dist/index.js (published artifact)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`prepublishOnly` runs typecheck and build, so a stale `dist/` cannot be
|
|
109
|
+
published.
|
|
110
|
+
|
|
111
|
+
## Layout
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
src/index.ts CLI entry and flag parsing
|
|
115
|
+
src/commands/ login, whoami, list, sync, interactive picker
|
|
116
|
+
src/core/client.ts API client (manifest, prepare, whoami)
|
|
117
|
+
src/core/selection.ts folder / collection / search selection
|
|
118
|
+
src/core/sync.ts diffing and orchestration
|
|
119
|
+
src/core/download.ts resumable curl-backed transfer
|
|
120
|
+
src/core/paths.ts folder tree -> local paths
|
|
121
|
+
src/core/state.ts versioned state file
|
|
122
|
+
src/core/events.ts structured progress events (the Tauri seam)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`src/core/` has no CLI-specific code. A Tauri app can bundle the compiled
|
|
126
|
+
binary as a sidecar and render the same events, or the core can be ported
|
|
127
|
+
without touching the orchestration logic.
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,1008 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import process2 from "node:process";
|
|
5
|
+
|
|
6
|
+
// src/config.ts
|
|
7
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
import { dirname, join } from "node:path";
|
|
10
|
+
var DEFAULT_API_URL = "https://studio.cutsio.com";
|
|
11
|
+
function configPath() {
|
|
12
|
+
return join(homedir(), ".cutsio", "config.json");
|
|
13
|
+
}
|
|
14
|
+
function readConfig() {
|
|
15
|
+
const path = configPath();
|
|
16
|
+
if (!existsSync(path)) return null;
|
|
17
|
+
try {
|
|
18
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
19
|
+
if (typeof parsed.apiUrl !== "string" || typeof parsed.apiKey !== "string") return null;
|
|
20
|
+
return { apiUrl: parsed.apiUrl, apiKey: parsed.apiKey };
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function writeConfig(config) {
|
|
26
|
+
const path = configPath();
|
|
27
|
+
mkdirSync(dirname(path), { recursive: true, mode: 448 });
|
|
28
|
+
writeFileSync(path, JSON.stringify(config, null, 2), { mode: 384 });
|
|
29
|
+
chmodSync(path, 384);
|
|
30
|
+
}
|
|
31
|
+
function resolveApiUrl(flag) {
|
|
32
|
+
return flag || process.env.CUTSIO_API_URL || readConfig()?.apiUrl || DEFAULT_API_URL;
|
|
33
|
+
}
|
|
34
|
+
function resolveApiKey() {
|
|
35
|
+
const key = process.env.CUTSIO_API_KEY || readConfig()?.apiKey;
|
|
36
|
+
return key && key.length > 0 ? key : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// src/core/client.ts
|
|
40
|
+
var ApiError = class extends Error {
|
|
41
|
+
status;
|
|
42
|
+
constructor(message, status) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "ApiError";
|
|
45
|
+
this.status = status;
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
var CutsioClient = class {
|
|
49
|
+
base;
|
|
50
|
+
apiKey;
|
|
51
|
+
constructor(apiUrl, apiKey) {
|
|
52
|
+
this.base = apiUrl.replace(/\/+$/, "");
|
|
53
|
+
this.apiKey = apiKey;
|
|
54
|
+
}
|
|
55
|
+
async whoami() {
|
|
56
|
+
return await this.request("/api/desktop/whoami");
|
|
57
|
+
}
|
|
58
|
+
async fetchManifest() {
|
|
59
|
+
let orgId = "";
|
|
60
|
+
let folders = [];
|
|
61
|
+
let collections = [];
|
|
62
|
+
const videos = [];
|
|
63
|
+
let cursor = null;
|
|
64
|
+
for (; ; ) {
|
|
65
|
+
const params = new URLSearchParams({ limit: "100" });
|
|
66
|
+
if (cursor !== null) params.set("cursor", String(cursor));
|
|
67
|
+
const page = await this.request(
|
|
68
|
+
`/api/desktop/manifest?${params.toString()}`
|
|
69
|
+
);
|
|
70
|
+
orgId = page.orgId;
|
|
71
|
+
folders = page.folders;
|
|
72
|
+
collections = page.collections ?? [];
|
|
73
|
+
videos.push(...page.videos);
|
|
74
|
+
if (!page.hasMore || page.nextCursor === null) break;
|
|
75
|
+
cursor = page.nextCursor;
|
|
76
|
+
}
|
|
77
|
+
return { orgId, folders, collections, videos };
|
|
78
|
+
}
|
|
79
|
+
async prepare(videoIds) {
|
|
80
|
+
const data = await this.request("/api/desktop/prepare", {
|
|
81
|
+
method: "POST",
|
|
82
|
+
body: JSON.stringify({ videoIds })
|
|
83
|
+
});
|
|
84
|
+
return Array.isArray(data.results) ? data.results : [];
|
|
85
|
+
}
|
|
86
|
+
async request(path, init) {
|
|
87
|
+
const headers = {
|
|
88
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
89
|
+
...init?.body ? { "Content-Type": "application/json" } : {},
|
|
90
|
+
...init?.headers ?? {}
|
|
91
|
+
};
|
|
92
|
+
const response = await fetch(`${this.base}${path}`, { ...init, headers });
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
let message = `HTTP ${response.status}`;
|
|
95
|
+
try {
|
|
96
|
+
const body = await response.json();
|
|
97
|
+
if (typeof body.error === "string") message = body.error;
|
|
98
|
+
} catch {
|
|
99
|
+
}
|
|
100
|
+
throw new ApiError(message, response.status);
|
|
101
|
+
}
|
|
102
|
+
return response.json();
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// src/core/paths.ts
|
|
107
|
+
function sanitizeSegment(value) {
|
|
108
|
+
const cleaned = value.replace(/[/\\?%*:|"<>]/g, "-").replace(/\s+/g, " ").replace(/^[.\s]+|[.\s]+$/g, "").slice(0, 80).trim();
|
|
109
|
+
return cleaned.length > 0 ? cleaned : "untitled";
|
|
110
|
+
}
|
|
111
|
+
function buildFolderPaths(folders) {
|
|
112
|
+
const byId = new Map(folders.map((folder) => [folder.id, folder]));
|
|
113
|
+
const memo = /* @__PURE__ */ new Map();
|
|
114
|
+
const resolve = (id, seen) => {
|
|
115
|
+
const cached = memo.get(id);
|
|
116
|
+
if (cached !== void 0) return cached;
|
|
117
|
+
const folder = byId.get(id);
|
|
118
|
+
if (!folder || seen.has(id)) return "";
|
|
119
|
+
seen.add(id);
|
|
120
|
+
const parent = folder.parent_id ? resolve(folder.parent_id, seen) : "";
|
|
121
|
+
const path = parent ? `${parent}/${sanitizeSegment(folder.name)}` : sanitizeSegment(folder.name);
|
|
122
|
+
memo.set(id, path);
|
|
123
|
+
return path;
|
|
124
|
+
};
|
|
125
|
+
for (const folder of folders) resolve(folder.id, /* @__PURE__ */ new Set());
|
|
126
|
+
return memo;
|
|
127
|
+
}
|
|
128
|
+
function videoRelativePath(video, folderPaths) {
|
|
129
|
+
const directory = video.folder_id ? folderPaths.get(video.folder_id) ?? "" : "";
|
|
130
|
+
const name = `${sanitizeSegment(video.title)} [${video.id.slice(0, 8)}].mp4`;
|
|
131
|
+
return directory ? `${directory}/${name}` : name;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/core/selection.ts
|
|
135
|
+
function isEmptySelection(selection) {
|
|
136
|
+
return !selection.all && !selection.unfiled && selection.folderRefs.length === 0 && selection.collectionRefs.length === 0 && selection.videoIds.length === 0 && selection.searchTerms.length === 0;
|
|
137
|
+
}
|
|
138
|
+
function folderSubtreeIds(manifest) {
|
|
139
|
+
const children = /* @__PURE__ */ new Map();
|
|
140
|
+
for (const folder of manifest.folders) {
|
|
141
|
+
if (!folder.parent_id) continue;
|
|
142
|
+
const list = children.get(folder.parent_id) ?? [];
|
|
143
|
+
list.push(folder.id);
|
|
144
|
+
children.set(folder.parent_id, list);
|
|
145
|
+
}
|
|
146
|
+
const subtrees = /* @__PURE__ */ new Map();
|
|
147
|
+
for (const folder of manifest.folders) {
|
|
148
|
+
const ids = /* @__PURE__ */ new Set([folder.id]);
|
|
149
|
+
const stack = [folder.id];
|
|
150
|
+
for (; ; ) {
|
|
151
|
+
const id = stack.pop();
|
|
152
|
+
if (id === void 0) break;
|
|
153
|
+
for (const child of children.get(id) ?? []) {
|
|
154
|
+
if (ids.has(child)) continue;
|
|
155
|
+
ids.add(child);
|
|
156
|
+
stack.push(child);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
subtrees.set(folder.id, ids);
|
|
160
|
+
}
|
|
161
|
+
return subtrees;
|
|
162
|
+
}
|
|
163
|
+
function folderVideoCounts(manifest) {
|
|
164
|
+
const subtrees = folderSubtreeIds(manifest);
|
|
165
|
+
const direct = /* @__PURE__ */ new Map();
|
|
166
|
+
const subtree = /* @__PURE__ */ new Map();
|
|
167
|
+
for (const video of manifest.videos) {
|
|
168
|
+
if (!video.folder_id) continue;
|
|
169
|
+
direct.set(video.folder_id, (direct.get(video.folder_id) ?? 0) + 1);
|
|
170
|
+
}
|
|
171
|
+
for (const [folderId, ids] of subtrees) {
|
|
172
|
+
let count = 0;
|
|
173
|
+
for (const video of manifest.videos) {
|
|
174
|
+
if (video.folder_id && ids.has(video.folder_id)) count += 1;
|
|
175
|
+
}
|
|
176
|
+
subtree.set(folderId, count);
|
|
177
|
+
}
|
|
178
|
+
return { direct, subtree };
|
|
179
|
+
}
|
|
180
|
+
function resolveSelection(manifest, selection) {
|
|
181
|
+
if (selection.all) return { videos: manifest.videos, errors: [] };
|
|
182
|
+
const errors = [];
|
|
183
|
+
const selected = /* @__PURE__ */ new Map();
|
|
184
|
+
const folderPaths = buildFolderPaths(manifest.folders);
|
|
185
|
+
const pathIndex = /* @__PURE__ */ new Map();
|
|
186
|
+
for (const [id, path] of folderPaths) pathIndex.set(path.toLowerCase(), id);
|
|
187
|
+
const nameIndex = /* @__PURE__ */ new Map();
|
|
188
|
+
for (const folder of manifest.folders) {
|
|
189
|
+
const key = folder.name.toLowerCase();
|
|
190
|
+
nameIndex.set(key, [...nameIndex.get(key) ?? [], folder.id]);
|
|
191
|
+
}
|
|
192
|
+
const resolveFolder = (ref) => {
|
|
193
|
+
if (folderPaths.has(ref)) return ref;
|
|
194
|
+
const byPath = pathIndex.get(ref.toLowerCase());
|
|
195
|
+
if (byPath) return byPath;
|
|
196
|
+
const byName = nameIndex.get(ref.toLowerCase());
|
|
197
|
+
if (byName && byName.length === 1) return byName[0] ?? null;
|
|
198
|
+
return null;
|
|
199
|
+
};
|
|
200
|
+
const subtrees = folderSubtreeIds(manifest);
|
|
201
|
+
const folderIds = /* @__PURE__ */ new Set();
|
|
202
|
+
for (const ref of selection.folderRefs) {
|
|
203
|
+
const id = resolveFolder(ref);
|
|
204
|
+
if (!id) {
|
|
205
|
+
const byName = nameIndex.get(ref.toLowerCase());
|
|
206
|
+
errors.push(
|
|
207
|
+
byName && byName.length > 1 ? `Folder name is ambiguous: ${ref} (use the path or id)` : `Folder not found: ${ref}`
|
|
208
|
+
);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
for (const descendant of subtrees.get(id) ?? [id]) folderIds.add(descendant);
|
|
212
|
+
}
|
|
213
|
+
if (folderIds.size > 0) {
|
|
214
|
+
for (const video of manifest.videos) {
|
|
215
|
+
if (video.folder_id && folderIds.has(video.folder_id)) selected.set(video.id, video);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
const collectionIds = /* @__PURE__ */ new Set();
|
|
219
|
+
for (const ref of selection.collectionRefs) {
|
|
220
|
+
const matches = manifest.collections.filter(
|
|
221
|
+
(collection) => collection.id === ref || collection.title.toLowerCase() === ref.toLowerCase()
|
|
222
|
+
);
|
|
223
|
+
if (matches.length === 0) {
|
|
224
|
+
errors.push(`Collection not found: ${ref}`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
for (const collection of matches) collectionIds.add(collection.id);
|
|
228
|
+
}
|
|
229
|
+
if (collectionIds.size > 0) {
|
|
230
|
+
for (const video of manifest.videos) {
|
|
231
|
+
if (video.collection_id && collectionIds.has(video.collection_id)) selected.set(video.id, video);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const byId = new Map(manifest.videos.map((video) => [video.id, video]));
|
|
235
|
+
for (const id of selection.videoIds) {
|
|
236
|
+
const video = byId.get(id);
|
|
237
|
+
if (!video) {
|
|
238
|
+
errors.push(`Video not found: ${id}`);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
selected.set(video.id, video);
|
|
242
|
+
}
|
|
243
|
+
if (selection.unfiled) {
|
|
244
|
+
for (const video of manifest.videos) {
|
|
245
|
+
if (!video.folder_id) selected.set(video.id, video);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const term of selection.searchTerms) {
|
|
249
|
+
const needle = term.toLowerCase();
|
|
250
|
+
let matches = 0;
|
|
251
|
+
for (const video of manifest.videos) {
|
|
252
|
+
if (!video.title.toLowerCase().includes(needle)) continue;
|
|
253
|
+
selected.set(video.id, video);
|
|
254
|
+
matches += 1;
|
|
255
|
+
}
|
|
256
|
+
if (matches === 0) errors.push(`No video titles match: ${term}`);
|
|
257
|
+
}
|
|
258
|
+
return { videos: [...selected.values()], errors };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// src/commands/list.ts
|
|
262
|
+
async function listCommand(flags) {
|
|
263
|
+
const apiKey = resolveApiKey();
|
|
264
|
+
if (!apiKey) {
|
|
265
|
+
console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
|
|
266
|
+
return 1;
|
|
267
|
+
}
|
|
268
|
+
const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
|
|
269
|
+
let manifest;
|
|
270
|
+
try {
|
|
271
|
+
manifest = await client.fetchManifest();
|
|
272
|
+
} catch (error) {
|
|
273
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
274
|
+
return 1;
|
|
275
|
+
}
|
|
276
|
+
if (flags.json === "true") {
|
|
277
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
278
|
+
return 0;
|
|
279
|
+
}
|
|
280
|
+
const search = (flags.search ?? "").trim().toLowerCase();
|
|
281
|
+
if (search) {
|
|
282
|
+
const matches = manifest.videos.filter((video) => video.title.toLowerCase().includes(search));
|
|
283
|
+
console.log(`Title matches (${matches.length}) for "${flags.search}":`);
|
|
284
|
+
if (matches.length === 0) console.log(" (none)");
|
|
285
|
+
for (const video of matches) {
|
|
286
|
+
console.log(` [${video.id}] ${video.status.padEnd(10)} ${video.title}`);
|
|
287
|
+
}
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
const folderPaths = buildFolderPaths(manifest.folders);
|
|
291
|
+
const { subtree } = folderVideoCounts(manifest);
|
|
292
|
+
console.log(`Collections (${manifest.collections.length}) \u2014 select with --collection <name|id>`);
|
|
293
|
+
if (manifest.collections.length === 0) console.log(" (none)");
|
|
294
|
+
for (const collection of manifest.collections) {
|
|
295
|
+
const count = manifest.videos.filter((video) => video.collection_id === collection.id).length;
|
|
296
|
+
console.log(` ${pad(count)} ${collection.title} [${collection.id}]`);
|
|
297
|
+
}
|
|
298
|
+
console.log(`
|
|
299
|
+
Folders (${manifest.folders.length}) \u2014 select with --folder <path|id>`);
|
|
300
|
+
if (manifest.folders.length === 0) console.log(" (none)");
|
|
301
|
+
const rows = [...folderPaths.entries()].sort((a, b) => a[1].localeCompare(b[1]));
|
|
302
|
+
for (const [id, path] of rows) {
|
|
303
|
+
console.log(` ${pad(subtree.get(id) ?? 0)} ${path}/ [${id}]`);
|
|
304
|
+
}
|
|
305
|
+
const unfiled = manifest.videos.filter((video) => !video.folder_id).length;
|
|
306
|
+
if (unfiled > 0) console.log(` ${pad(unfiled)} (no folder)`);
|
|
307
|
+
if (flags.videos === "true") {
|
|
308
|
+
console.log(`
|
|
309
|
+
Videos (${manifest.videos.length}) \u2014 select with --video <id>`);
|
|
310
|
+
for (const video of manifest.videos) {
|
|
311
|
+
console.log(` [${video.id}] ${video.status.padEnd(10)} ${video.title}`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return 0;
|
|
315
|
+
}
|
|
316
|
+
function pad(count) {
|
|
317
|
+
return `${count} video${count === 1 ? " " : "s"}`.padStart(9);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/commands/login.ts
|
|
321
|
+
import { stdin, stdout } from "node:process";
|
|
322
|
+
var MAX_ATTEMPTS = 3;
|
|
323
|
+
var KEY_PATTERN = /^ak_[A-Za-z0-9]{20,}$/;
|
|
324
|
+
async function loginCommand(flags) {
|
|
325
|
+
const apiUrl = resolveApiUrl(flags.url);
|
|
326
|
+
const interactiveTty = Boolean(stdin.isTTY) && typeof stdin.setRawMode === "function";
|
|
327
|
+
console.log(`Signing in to ${apiUrl}`);
|
|
328
|
+
console.log("Create an organization API key in Cutsio (Account or Organization \u2192 API Keys).");
|
|
329
|
+
console.log("The secret is shown once, when you create the key.\n");
|
|
330
|
+
const pipedAnswers = interactiveTty ? null : await readPipedAnswers();
|
|
331
|
+
let pipedIndex = 0;
|
|
332
|
+
let fromFlag = flags.key;
|
|
333
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt += 1) {
|
|
334
|
+
let raw;
|
|
335
|
+
if (fromFlag !== void 0) {
|
|
336
|
+
raw = fromFlag;
|
|
337
|
+
fromFlag = void 0;
|
|
338
|
+
} else if (pipedAnswers) {
|
|
339
|
+
const next = pipedAnswers[pipedIndex];
|
|
340
|
+
pipedIndex += 1;
|
|
341
|
+
if (next === void 0) break;
|
|
342
|
+
raw = next;
|
|
343
|
+
} else {
|
|
344
|
+
raw = await promptSecret("Paste API key: ");
|
|
345
|
+
}
|
|
346
|
+
const apiKey = normalizeKey(raw);
|
|
347
|
+
if (!apiKey) {
|
|
348
|
+
console.error("No API key entered.\n");
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (!KEY_PATTERN.test(apiKey)) {
|
|
352
|
+
console.error('That does not look like a Cutsio API key (expected something starting with "ak_").\n');
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
try {
|
|
356
|
+
const who = await new CutsioClient(apiUrl, apiKey).whoami();
|
|
357
|
+
if (!who.orgId) {
|
|
358
|
+
console.error("That key is not attached to an organization. Create an organization API key.\n");
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
writeConfig({ apiUrl, apiKey });
|
|
362
|
+
console.log(`
|
|
363
|
+
Saved. Organization ${who.orgId}${who.name ? ` (key "${who.name}")` : ""}.`);
|
|
364
|
+
console.log(`Config: ${configPath()}
|
|
365
|
+
`);
|
|
366
|
+
console.log("Next:");
|
|
367
|
+
console.log(" cutsio list");
|
|
368
|
+
console.log(" cutsio sync --out ~/Cutsio --interactive");
|
|
369
|
+
return 0;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error(`${explain(error, apiUrl)}
|
|
372
|
+
`);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
console.error("Could not authenticate. Nothing was saved.");
|
|
376
|
+
return 1;
|
|
377
|
+
}
|
|
378
|
+
async function readPipedAnswers() {
|
|
379
|
+
const chunks = [];
|
|
380
|
+
for await (const chunk of stdin) {
|
|
381
|
+
chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
|
|
382
|
+
}
|
|
383
|
+
return Buffer.concat(chunks).toString("utf8").split(/\r?\n/);
|
|
384
|
+
}
|
|
385
|
+
function normalizeKey(raw) {
|
|
386
|
+
let value = raw.trim();
|
|
387
|
+
const quoted = value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'");
|
|
388
|
+
if (quoted && value.length > 1) {
|
|
389
|
+
value = value.slice(1, -1).trim();
|
|
390
|
+
}
|
|
391
|
+
return value.replace(/^bearer\s+/i, "").trim();
|
|
392
|
+
}
|
|
393
|
+
function explain(error, apiUrl) {
|
|
394
|
+
if (error instanceof ApiError) {
|
|
395
|
+
if (error.status === 401) {
|
|
396
|
+
return [
|
|
397
|
+
"That key was rejected.",
|
|
398
|
+
" - If you copied it from the key list, that is the key ID, not the secret.",
|
|
399
|
+
" The secret is only shown once at creation \u2014 create a new key.",
|
|
400
|
+
` - If it was created in a different environment than ${apiUrl}, it will not exist here.`
|
|
401
|
+
].join("\n");
|
|
402
|
+
}
|
|
403
|
+
if (error.status === 403) {
|
|
404
|
+
return `That key authenticated but has no organization. ${error.message}`;
|
|
405
|
+
}
|
|
406
|
+
return `The server rejected the key (HTTP ${error.status}): ${error.message}`;
|
|
407
|
+
}
|
|
408
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
409
|
+
return `Could not reach ${apiUrl}: ${message}`;
|
|
410
|
+
}
|
|
411
|
+
async function promptSecret(question) {
|
|
412
|
+
stdout.write(question);
|
|
413
|
+
stdin.setRawMode?.(true);
|
|
414
|
+
stdin.resume();
|
|
415
|
+
stdin.setEncoding("utf8");
|
|
416
|
+
return new Promise((resolve) => {
|
|
417
|
+
let value = "";
|
|
418
|
+
const finish = () => {
|
|
419
|
+
stdin.setRawMode?.(false);
|
|
420
|
+
stdin.pause();
|
|
421
|
+
stdin.off("data", onData);
|
|
422
|
+
stdout.write("\n");
|
|
423
|
+
resolve(value);
|
|
424
|
+
};
|
|
425
|
+
const onData = (chunk) => {
|
|
426
|
+
for (const char of chunk) {
|
|
427
|
+
if (char === "\r" || char === "\n" || char === "") {
|
|
428
|
+
finish();
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
if (char === "") {
|
|
432
|
+
stdin.setRawMode?.(false);
|
|
433
|
+
stdout.write("\n");
|
|
434
|
+
process.exit(130);
|
|
435
|
+
}
|
|
436
|
+
if (char === "\x7F" || char === "\b") {
|
|
437
|
+
if (value.length > 0) {
|
|
438
|
+
value = value.slice(0, -1);
|
|
439
|
+
stdout.write("\b \b");
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
if (char < " ") continue;
|
|
444
|
+
value += char;
|
|
445
|
+
stdout.write("*");
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
stdin.on("data", onData);
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// src/core/sync.ts
|
|
453
|
+
import { existsSync as existsSync4, statSync as statSync2 } from "node:fs";
|
|
454
|
+
import { join as join2 } from "node:path";
|
|
455
|
+
|
|
456
|
+
// src/core/download.ts
|
|
457
|
+
import { spawn } from "node:child_process";
|
|
458
|
+
import { existsSync as existsSync2, mkdirSync as mkdirSync2, renameSync, statSync } from "node:fs";
|
|
459
|
+
import { dirname as dirname2 } from "node:path";
|
|
460
|
+
function partPathFor(destPath) {
|
|
461
|
+
return `${destPath}.part`;
|
|
462
|
+
}
|
|
463
|
+
async function downloadResumable(url, destPath) {
|
|
464
|
+
mkdirSync2(dirname2(destPath), { recursive: true });
|
|
465
|
+
const part = partPathFor(destPath);
|
|
466
|
+
const resumedFrom = existsSync2(part) ? statSync(part).size : 0;
|
|
467
|
+
const { code, stderr } = await runCurl([
|
|
468
|
+
"-sS",
|
|
469
|
+
"--fail",
|
|
470
|
+
"--location",
|
|
471
|
+
"--retry",
|
|
472
|
+
"5",
|
|
473
|
+
"--retry-delay",
|
|
474
|
+
"2",
|
|
475
|
+
"--retry-all-errors",
|
|
476
|
+
"--connect-timeout",
|
|
477
|
+
"30",
|
|
478
|
+
"-C",
|
|
479
|
+
"-",
|
|
480
|
+
"-o",
|
|
481
|
+
part,
|
|
482
|
+
url
|
|
483
|
+
]);
|
|
484
|
+
if (code === 0) {
|
|
485
|
+
renameSync(part, destPath);
|
|
486
|
+
return { ok: true, bytes: statSync(destPath).size, resumedFrom };
|
|
487
|
+
}
|
|
488
|
+
const remote = await remoteSize(url);
|
|
489
|
+
if (remote !== null && existsSync2(part) && statSync(part).size === remote) {
|
|
490
|
+
renameSync(part, destPath);
|
|
491
|
+
return { ok: true, bytes: remote, resumedFrom };
|
|
492
|
+
}
|
|
493
|
+
const lastLine = stderr.trim().split("\n").filter(Boolean).at(-1);
|
|
494
|
+
return { ok: false, error: `curl exited ${code}${lastLine ? `: ${lastLine}` : ""}` };
|
|
495
|
+
}
|
|
496
|
+
function runCurl(args) {
|
|
497
|
+
return new Promise((resolve) => {
|
|
498
|
+
const child = spawn("curl", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
499
|
+
let stderr = "";
|
|
500
|
+
child.stderr.on("data", (chunk) => {
|
|
501
|
+
stderr += chunk.toString();
|
|
502
|
+
});
|
|
503
|
+
child.on("error", (error) => resolve({ code: -1, stderr: error.message }));
|
|
504
|
+
child.on("close", (code) => resolve({ code: code ?? -1, stderr }));
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
async function remoteSize(url) {
|
|
508
|
+
try {
|
|
509
|
+
const response = await fetch(url, { method: "HEAD" });
|
|
510
|
+
if (!response.ok) return null;
|
|
511
|
+
const header = response.headers.get("content-length");
|
|
512
|
+
const value = header ? Number(header) : Number.NaN;
|
|
513
|
+
return Number.isFinite(value) ? value : null;
|
|
514
|
+
} catch {
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// src/core/state.ts
|
|
520
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync2, renameSync as renameSync2, writeFileSync as writeFileSync2 } from "node:fs";
|
|
521
|
+
import { dirname as dirname3 } from "node:path";
|
|
522
|
+
var STATE_FILE = ".cutsio-state.json";
|
|
523
|
+
var StateOrgMismatchError = class extends Error {
|
|
524
|
+
constructor(message) {
|
|
525
|
+
super(message);
|
|
526
|
+
this.name = "StateOrgMismatchError";
|
|
527
|
+
}
|
|
528
|
+
};
|
|
529
|
+
function emptyState(orgId) {
|
|
530
|
+
return { schema: 1, orgId, updatedAt: Date.now(), videos: {} };
|
|
531
|
+
}
|
|
532
|
+
function loadState(path, orgId) {
|
|
533
|
+
if (!existsSync3(path)) return emptyState(orgId);
|
|
534
|
+
try {
|
|
535
|
+
const parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
536
|
+
if (parsed.schema !== 1 || typeof parsed.videos !== "object" || parsed.videos === null) {
|
|
537
|
+
return emptyState(orgId);
|
|
538
|
+
}
|
|
539
|
+
if (parsed.orgId && parsed.orgId !== orgId) {
|
|
540
|
+
throw new StateOrgMismatchError(
|
|
541
|
+
`This directory is already syncing ${parsed.orgId}. Use a different --out directory for ${orgId}.`
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
return { schema: 1, orgId, updatedAt: Date.now(), videos: parsed.videos };
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (error instanceof StateOrgMismatchError) throw error;
|
|
547
|
+
return emptyState(orgId);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function saveState(path, state) {
|
|
551
|
+
mkdirSync3(dirname3(path), { recursive: true });
|
|
552
|
+
state.updatedAt = Date.now();
|
|
553
|
+
const temp = `${path}.tmp`;
|
|
554
|
+
writeFileSync2(temp, JSON.stringify(state, null, 2));
|
|
555
|
+
renameSync2(temp, path);
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// src/core/sync.ts
|
|
559
|
+
var PREPARE_BATCH_SIZE = 50;
|
|
560
|
+
var INITIAL_PREPARE_DELAY_MS = 3e3;
|
|
561
|
+
var MAX_PREPARE_DELAY_MS = 3e4;
|
|
562
|
+
async function sync(client, options) {
|
|
563
|
+
const { onEvent, outDir } = options;
|
|
564
|
+
const manifest = options.manifest ?? await client.fetchManifest();
|
|
565
|
+
const resolved = resolveSelection(manifest, options.selection);
|
|
566
|
+
if (resolved.errors.length > 0) {
|
|
567
|
+
throw new Error(`Selection problems:
|
|
568
|
+
${resolved.errors.join("\n ")}`);
|
|
569
|
+
}
|
|
570
|
+
if (resolved.videos.length === 0) {
|
|
571
|
+
throw new Error("Selection matched no videos.");
|
|
572
|
+
}
|
|
573
|
+
const folderPaths = buildFolderPaths(manifest.folders);
|
|
574
|
+
const statePath = join2(outDir, STATE_FILE);
|
|
575
|
+
const state = loadState(statePath, manifest.orgId);
|
|
576
|
+
onEvent({
|
|
577
|
+
type: "manifest",
|
|
578
|
+
orgId: manifest.orgId,
|
|
579
|
+
folders: manifest.folders.length,
|
|
580
|
+
videos: manifest.videos.length
|
|
581
|
+
});
|
|
582
|
+
const pending = [];
|
|
583
|
+
let unchanged = 0;
|
|
584
|
+
let skipped = 0;
|
|
585
|
+
for (const video of resolved.videos) {
|
|
586
|
+
if (video.status !== "ready") {
|
|
587
|
+
skipped += 1;
|
|
588
|
+
onEvent({ type: "file-skipped", id: video.id, title: video.title, reason: `status: ${video.status}` });
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (video.moderation_status === "failed" || video.moderation_status === "errored") {
|
|
592
|
+
skipped += 1;
|
|
593
|
+
onEvent({ type: "file-skipped", id: video.id, title: video.title, reason: "restricted" });
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
const relPath = videoRelativePath(video, folderPaths);
|
|
597
|
+
const absPath = join2(outDir, relPath);
|
|
598
|
+
const stored = state.videos[video.id];
|
|
599
|
+
const isCurrent = stored !== void 0 && stored.version === video.version && stored.path === relPath && existsSync4(absPath) && (stored.sizeBytes === null || statSync2(absPath).size === stored.sizeBytes);
|
|
600
|
+
if (isCurrent) {
|
|
601
|
+
unchanged += 1;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
pending.push({ video, absPath, relPath, resuming: existsSync4(partPathFor(absPath)) });
|
|
605
|
+
}
|
|
606
|
+
const selected = options.limit && options.limit > 0 ? pending.slice(0, options.limit) : pending;
|
|
607
|
+
onEvent({ type: "planned", total: resolved.videos.length, pending: selected.length, unchanged });
|
|
608
|
+
if (options.dryRun) {
|
|
609
|
+
for (const item of selected) {
|
|
610
|
+
onEvent({
|
|
611
|
+
type: "file-start",
|
|
612
|
+
id: item.video.id,
|
|
613
|
+
title: item.video.title,
|
|
614
|
+
path: item.relPath,
|
|
615
|
+
resuming: item.resuming
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return { downloaded: 0, unchanged, skipped, failed: 0 };
|
|
619
|
+
}
|
|
620
|
+
let downloaded = 0;
|
|
621
|
+
let failed = 0;
|
|
622
|
+
let prepareDelayMs = INITIAL_PREPARE_DELAY_MS;
|
|
623
|
+
const deadline = Date.now() + options.maxWaitSeconds * 1e3;
|
|
624
|
+
const queue = new Map(selected.map((item) => [item.video.id, item]));
|
|
625
|
+
while (queue.size > 0) {
|
|
626
|
+
const ready = [];
|
|
627
|
+
const ids = [...queue.keys()];
|
|
628
|
+
for (let index = 0; index < ids.length; index += PREPARE_BATCH_SIZE) {
|
|
629
|
+
const batch = ids.slice(index, index + PREPARE_BATCH_SIZE);
|
|
630
|
+
const results = await client.prepare(batch);
|
|
631
|
+
for (const result of results) {
|
|
632
|
+
const item = queue.get(result.id);
|
|
633
|
+
if (!item) continue;
|
|
634
|
+
switch (result.status) {
|
|
635
|
+
case "ready":
|
|
636
|
+
if (result.url) {
|
|
637
|
+
ready.push({ id: result.id, url: result.url });
|
|
638
|
+
} else {
|
|
639
|
+
queue.delete(result.id);
|
|
640
|
+
failed += 1;
|
|
641
|
+
onEvent({ type: "file-failed", id: result.id, title: item.video.title, error: "no download URL returned" });
|
|
642
|
+
}
|
|
643
|
+
break;
|
|
644
|
+
case "preparing":
|
|
645
|
+
break;
|
|
646
|
+
case "not_found":
|
|
647
|
+
case "not_ready":
|
|
648
|
+
queue.delete(result.id);
|
|
649
|
+
skipped += 1;
|
|
650
|
+
onEvent({ type: "file-skipped", id: result.id, title: item.video.title, reason: result.status });
|
|
651
|
+
break;
|
|
652
|
+
case "forbidden":
|
|
653
|
+
queue.delete(result.id);
|
|
654
|
+
skipped += 1;
|
|
655
|
+
onEvent({ type: "file-skipped", id: result.id, title: item.video.title, reason: "restricted" });
|
|
656
|
+
break;
|
|
657
|
+
default:
|
|
658
|
+
queue.delete(result.id);
|
|
659
|
+
failed += 1;
|
|
660
|
+
onEvent({ type: "file-failed", id: result.id, title: item.video.title, error: `prepare: ${result.status}` });
|
|
661
|
+
break;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
if (ready.length > 0) {
|
|
666
|
+
prepareDelayMs = INITIAL_PREPARE_DELAY_MS;
|
|
667
|
+
await runPool(ready, options.concurrency, async ({ id, url }) => {
|
|
668
|
+
const item = queue.get(id);
|
|
669
|
+
if (!item) return;
|
|
670
|
+
onEvent({
|
|
671
|
+
type: "file-start",
|
|
672
|
+
id,
|
|
673
|
+
title: item.video.title,
|
|
674
|
+
path: item.relPath,
|
|
675
|
+
resuming: item.resuming
|
|
676
|
+
});
|
|
677
|
+
let result = await downloadResumable(url, item.absPath);
|
|
678
|
+
if (!result.ok) {
|
|
679
|
+
const [refreshed] = await client.prepare([id]);
|
|
680
|
+
if (refreshed?.status === "ready" && refreshed.url) {
|
|
681
|
+
result = await downloadResumable(refreshed.url, item.absPath);
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (result.ok) {
|
|
685
|
+
state.videos[id] = {
|
|
686
|
+
version: item.video.version,
|
|
687
|
+
sizeBytes: result.bytes,
|
|
688
|
+
path: item.relPath
|
|
689
|
+
};
|
|
690
|
+
saveState(statePath, state);
|
|
691
|
+
queue.delete(id);
|
|
692
|
+
downloaded += 1;
|
|
693
|
+
onEvent({ type: "file-done", id, title: item.video.title, path: item.relPath, bytes: result.bytes });
|
|
694
|
+
} else {
|
|
695
|
+
queue.delete(id);
|
|
696
|
+
failed += 1;
|
|
697
|
+
onEvent({ type: "file-failed", id, title: item.video.title, error: result.error });
|
|
698
|
+
}
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
if (queue.size === 0) break;
|
|
702
|
+
if (Date.now() > deadline) {
|
|
703
|
+
for (const item of queue.values()) {
|
|
704
|
+
failed += 1;
|
|
705
|
+
onEvent({
|
|
706
|
+
type: "file-failed",
|
|
707
|
+
id: item.video.id,
|
|
708
|
+
title: item.video.title,
|
|
709
|
+
error: "timed out waiting for the master"
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
break;
|
|
713
|
+
}
|
|
714
|
+
await sleep(prepareDelayMs);
|
|
715
|
+
prepareDelayMs = Math.min(prepareDelayMs * 2, MAX_PREPARE_DELAY_MS);
|
|
716
|
+
}
|
|
717
|
+
return { downloaded, unchanged, skipped, failed };
|
|
718
|
+
}
|
|
719
|
+
async function runPool(items, concurrency, worker) {
|
|
720
|
+
let index = 0;
|
|
721
|
+
const workerCount = Math.max(1, Math.min(concurrency, items.length));
|
|
722
|
+
await Promise.all(
|
|
723
|
+
Array.from({ length: workerCount }, async () => {
|
|
724
|
+
for (; ; ) {
|
|
725
|
+
const current = index;
|
|
726
|
+
index += 1;
|
|
727
|
+
const item = items[current];
|
|
728
|
+
if (item === void 0) return;
|
|
729
|
+
await worker(item);
|
|
730
|
+
}
|
|
731
|
+
})
|
|
732
|
+
);
|
|
733
|
+
}
|
|
734
|
+
function sleep(ms) {
|
|
735
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// src/commands/selection-prompt.ts
|
|
739
|
+
import { stdin as stdin2, stdout as stdout2 } from "node:process";
|
|
740
|
+
import { createInterface } from "node:readline/promises";
|
|
741
|
+
function countLabel(count) {
|
|
742
|
+
return `${count} video${count === 1 ? "" : "s"}`;
|
|
743
|
+
}
|
|
744
|
+
async function promptSelection(manifest) {
|
|
745
|
+
const { subtree } = folderVideoCounts(manifest);
|
|
746
|
+
const entries = [
|
|
747
|
+
...manifest.collections.map((collection) => ({
|
|
748
|
+
kind: "collection",
|
|
749
|
+
ref: collection.title,
|
|
750
|
+
label: `collection ${collection.title} \u2014 ${countLabel(
|
|
751
|
+
manifest.videos.filter((video) => video.collection_id === collection.id).length
|
|
752
|
+
)}`
|
|
753
|
+
})),
|
|
754
|
+
...[...buildFolderPaths(manifest.folders).entries()].sort((a, b) => a[1].localeCompare(b[1])).map(([id, path]) => ({
|
|
755
|
+
kind: "folder",
|
|
756
|
+
ref: path,
|
|
757
|
+
label: `folder ${path}/ \u2014 ${countLabel(subtree.get(id) ?? 0)}`
|
|
758
|
+
}))
|
|
759
|
+
];
|
|
760
|
+
const unfiledCount = manifest.videos.filter((video) => !video.folder_id).length;
|
|
761
|
+
if (unfiledCount > 0) {
|
|
762
|
+
entries.push({
|
|
763
|
+
kind: "unfiled",
|
|
764
|
+
ref: "",
|
|
765
|
+
label: `unfiled (no folder) \u2014 ${countLabel(unfiledCount)}`
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (entries.length === 0) {
|
|
769
|
+
console.error("Nothing to select: this workspace has no folders or collections.");
|
|
770
|
+
return null;
|
|
771
|
+
}
|
|
772
|
+
console.log("Select what to sync:");
|
|
773
|
+
entries.forEach((entry, index) => {
|
|
774
|
+
console.log(` ${String(index + 1).padStart(3)}) ${entry.label}`);
|
|
775
|
+
});
|
|
776
|
+
const rl = createInterface({ input: stdin2, output: stdout2 });
|
|
777
|
+
try {
|
|
778
|
+
for (; ; ) {
|
|
779
|
+
const answer = (await rl.question('Numbers separated by commas, "all", or blank to cancel: ')).trim();
|
|
780
|
+
if (answer === "") return null;
|
|
781
|
+
if (answer.toLowerCase() === "all") {
|
|
782
|
+
return { all: true, unfiled: false, folderRefs: [], collectionRefs: [], videoIds: [], searchTerms: [] };
|
|
783
|
+
}
|
|
784
|
+
const indexes = answer.split(/[,\s]+/).filter(Boolean).map(Number);
|
|
785
|
+
const invalid = indexes.some(
|
|
786
|
+
(value) => !Number.isInteger(value) || value < 1 || value > entries.length
|
|
787
|
+
);
|
|
788
|
+
if (invalid || indexes.length === 0) {
|
|
789
|
+
console.log(`Enter numbers between 1 and ${entries.length}.`);
|
|
790
|
+
continue;
|
|
791
|
+
}
|
|
792
|
+
const picked = indexes.map((index) => entries[index - 1]).filter((entry) => entry !== void 0);
|
|
793
|
+
return {
|
|
794
|
+
all: false,
|
|
795
|
+
unfiled: picked.some((entry) => entry.kind === "unfiled"),
|
|
796
|
+
folderRefs: picked.filter((entry) => entry.kind === "folder").map((entry) => entry.ref),
|
|
797
|
+
collectionRefs: picked.filter((entry) => entry.kind === "collection").map((entry) => entry.ref),
|
|
798
|
+
videoIds: [],
|
|
799
|
+
searchTerms: []
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
} finally {
|
|
803
|
+
rl.close();
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// src/commands/sync.ts
|
|
808
|
+
async function syncCommand(flags) {
|
|
809
|
+
const apiKey = resolveApiKey();
|
|
810
|
+
if (!apiKey) {
|
|
811
|
+
console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
|
|
812
|
+
return 1;
|
|
813
|
+
}
|
|
814
|
+
const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
|
|
815
|
+
const onEvent = (event) => {
|
|
816
|
+
switch (event.type) {
|
|
817
|
+
case "manifest":
|
|
818
|
+
console.log(`Manifest: ${event.videos} videos, ${event.folders} folders (${event.orgId})`);
|
|
819
|
+
break;
|
|
820
|
+
case "planned":
|
|
821
|
+
console.log(`Selected ${event.total}, pending ${event.pending}, unchanged ${event.unchanged}`);
|
|
822
|
+
break;
|
|
823
|
+
case "file-start":
|
|
824
|
+
console.log(` ${event.resuming ? "resume " : "fetch "} ${event.path}`);
|
|
825
|
+
break;
|
|
826
|
+
case "file-done":
|
|
827
|
+
console.log(` done ${event.path} (${formatBytes(event.bytes)})`);
|
|
828
|
+
break;
|
|
829
|
+
case "file-skipped":
|
|
830
|
+
console.log(` skip ${event.title} \u2014 ${event.reason}`);
|
|
831
|
+
break;
|
|
832
|
+
case "file-failed":
|
|
833
|
+
console.error(` FAILED ${event.title} \u2014 ${event.error}`);
|
|
834
|
+
break;
|
|
835
|
+
case "warning":
|
|
836
|
+
console.warn(`warning: ${event.message}`);
|
|
837
|
+
break;
|
|
838
|
+
}
|
|
839
|
+
};
|
|
840
|
+
let selection = {
|
|
841
|
+
all: flags.all === "true",
|
|
842
|
+
folderRefs: splitList(flags.folder),
|
|
843
|
+
collectionRefs: splitList(flags.collection),
|
|
844
|
+
videoIds: splitList(flags.video),
|
|
845
|
+
searchTerms: splitList(flags.search),
|
|
846
|
+
unfiled: flags.unfiled === "true"
|
|
847
|
+
};
|
|
848
|
+
let manifest;
|
|
849
|
+
try {
|
|
850
|
+
if (flags.interactive === "true") {
|
|
851
|
+
manifest = await client.fetchManifest();
|
|
852
|
+
const prompted = await promptSelection(manifest);
|
|
853
|
+
if (!prompted) {
|
|
854
|
+
console.log("Cancelled. Nothing downloaded.");
|
|
855
|
+
return 0;
|
|
856
|
+
}
|
|
857
|
+
selection = mergeSelections(selection, prompted);
|
|
858
|
+
}
|
|
859
|
+
if (isEmptySelection(selection)) {
|
|
860
|
+
console.error("Refusing to sync the whole workspace. Choose one of:\n");
|
|
861
|
+
console.error(" --all every video in the workspace");
|
|
862
|
+
console.error(" --folder <path|id,...> a folder and everything under it");
|
|
863
|
+
console.error(" --collection <name|id,...> a collection");
|
|
864
|
+
console.error(" --video <id,...> specific videos");
|
|
865
|
+
console.error(" --search <term,...> video titles containing a term");
|
|
866
|
+
console.error(" --unfiled videos that are in no folder");
|
|
867
|
+
console.error(" --interactive pick from a menu\n");
|
|
868
|
+
console.error("`cutsio list` prints names, paths, and ids.");
|
|
869
|
+
return 1;
|
|
870
|
+
}
|
|
871
|
+
const summary = await sync(client, {
|
|
872
|
+
outDir: flags.out ?? "./cutsio",
|
|
873
|
+
concurrency: positiveInt(flags.concurrency, 3),
|
|
874
|
+
dryRun: flags["dry-run"] === "true",
|
|
875
|
+
maxWaitSeconds: positiveInt(flags["max-wait"], 600),
|
|
876
|
+
limit: positiveInt(flags.limit, 0),
|
|
877
|
+
selection,
|
|
878
|
+
manifest,
|
|
879
|
+
onEvent
|
|
880
|
+
});
|
|
881
|
+
console.log(
|
|
882
|
+
`
|
|
883
|
+
Downloaded ${summary.downloaded}, unchanged ${summary.unchanged}, skipped ${summary.skipped}, failed ${summary.failed}.`
|
|
884
|
+
);
|
|
885
|
+
return summary.failed > 0 ? 1 : 0;
|
|
886
|
+
} catch (error) {
|
|
887
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
888
|
+
return 1;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
function splitList(value) {
|
|
892
|
+
if (!value) return [];
|
|
893
|
+
return [...new Set(value.split(",").map((entry) => entry.trim()).filter(Boolean))];
|
|
894
|
+
}
|
|
895
|
+
function mergeSelections(a, b) {
|
|
896
|
+
return {
|
|
897
|
+
all: a.all || b.all,
|
|
898
|
+
unfiled: a.unfiled || b.unfiled,
|
|
899
|
+
folderRefs: [.../* @__PURE__ */ new Set([...a.folderRefs, ...b.folderRefs])],
|
|
900
|
+
collectionRefs: [.../* @__PURE__ */ new Set([...a.collectionRefs, ...b.collectionRefs])],
|
|
901
|
+
videoIds: [.../* @__PURE__ */ new Set([...a.videoIds, ...b.videoIds])],
|
|
902
|
+
searchTerms: [.../* @__PURE__ */ new Set([...a.searchTerms, ...b.searchTerms])]
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
function positiveInt(value, fallback) {
|
|
906
|
+
const parsed = Number(value);
|
|
907
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
|
|
908
|
+
}
|
|
909
|
+
function formatBytes(bytes) {
|
|
910
|
+
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
911
|
+
let value = bytes;
|
|
912
|
+
let unit = 0;
|
|
913
|
+
while (value >= 1024 && unit < units.length - 1) {
|
|
914
|
+
value /= 1024;
|
|
915
|
+
unit += 1;
|
|
916
|
+
}
|
|
917
|
+
return `${value.toFixed(unit === 0 ? 0 : 1)} ${units[unit]}`;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// src/commands/whoami.ts
|
|
921
|
+
async function whoamiCommand(flags) {
|
|
922
|
+
const apiKey = resolveApiKey();
|
|
923
|
+
if (!apiKey) {
|
|
924
|
+
console.error("No API key. Run `cutsio login` or set CUTSIO_API_KEY.");
|
|
925
|
+
return 1;
|
|
926
|
+
}
|
|
927
|
+
const client = new CutsioClient(resolveApiUrl(flags.url), apiKey);
|
|
928
|
+
try {
|
|
929
|
+
console.log(JSON.stringify(await client.whoami(), null, 2));
|
|
930
|
+
return 0;
|
|
931
|
+
} catch (error) {
|
|
932
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
933
|
+
return 1;
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// src/index.ts
|
|
938
|
+
function parseFlags(args) {
|
|
939
|
+
const flags = {};
|
|
940
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
941
|
+
const arg = args[index];
|
|
942
|
+
if (!arg || !arg.startsWith("--")) continue;
|
|
943
|
+
const body = arg.slice(2);
|
|
944
|
+
const separator = body.indexOf("=");
|
|
945
|
+
const key = separator === -1 ? body : body.slice(0, separator);
|
|
946
|
+
const inline = separator === -1 ? void 0 : body.slice(separator + 1);
|
|
947
|
+
if (!key) continue;
|
|
948
|
+
if (inline !== void 0) {
|
|
949
|
+
flags[key] = inline;
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
const next = args[index + 1];
|
|
953
|
+
if (next !== void 0 && !next.startsWith("--")) {
|
|
954
|
+
flags[key] = next;
|
|
955
|
+
index += 1;
|
|
956
|
+
} else {
|
|
957
|
+
flags[key] = "true";
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
return flags;
|
|
961
|
+
}
|
|
962
|
+
function printHelp() {
|
|
963
|
+
console.log(`cutsio \u2014 sync Cutsio mezzanine masters to a local folder
|
|
964
|
+
|
|
965
|
+
Usage:
|
|
966
|
+
cutsio login [--url URL] [--key KEY]
|
|
967
|
+
cutsio whoami [--url URL]
|
|
968
|
+
cutsio list [--videos] [--search TERM] [--json] [--url URL]
|
|
969
|
+
cutsio sync [--all | --folder REFS | --collection REFS | --video IDS | --search TERMS | --unfiled | --interactive]
|
|
970
|
+
[--out DIR] [--concurrency N] [--max-wait SECONDS] [--limit N] [--dry-run] [--url URL]
|
|
971
|
+
|
|
972
|
+
Selection:
|
|
973
|
+
REFS and IDS are comma-separated. Folders accept a path (e.g. test/sub1a) or id;
|
|
974
|
+
collections accept a title or id. A folder selection includes its subfolders.
|
|
975
|
+
--search matches video titles anywhere in the workspace; --unfiled selects
|
|
976
|
+
videos that are in no folder.
|
|
977
|
+
|
|
978
|
+
Environment:
|
|
979
|
+
CUTSIO_API_URL API base URL (default ${DEFAULT_API_URL})
|
|
980
|
+
CUTSIO_API_KEY Organization API key`);
|
|
981
|
+
}
|
|
982
|
+
async function main() {
|
|
983
|
+
const [, , command, ...rest] = process2.argv;
|
|
984
|
+
const flags = parseFlags(rest);
|
|
985
|
+
switch (command) {
|
|
986
|
+
case "login":
|
|
987
|
+
return loginCommand(flags);
|
|
988
|
+
case "whoami":
|
|
989
|
+
return whoamiCommand(flags);
|
|
990
|
+
case "list":
|
|
991
|
+
return listCommand(flags);
|
|
992
|
+
case "sync":
|
|
993
|
+
return syncCommand(flags);
|
|
994
|
+
case void 0:
|
|
995
|
+
case "help":
|
|
996
|
+
case "--help":
|
|
997
|
+
case "-h":
|
|
998
|
+
printHelp();
|
|
999
|
+
return 0;
|
|
1000
|
+
default:
|
|
1001
|
+
console.error(`Unknown command: ${command}`);
|
|
1002
|
+
printHelp();
|
|
1003
|
+
return 1;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
main().then((code) => {
|
|
1007
|
+
process2.exitCode = code;
|
|
1008
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cutsio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sync Cutsio mezzanine masters to a local folder",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cutsio": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"license": "UNLICENSED",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"cutsio",
|
|
18
|
+
"cli",
|
|
19
|
+
"download",
|
|
20
|
+
"sync",
|
|
21
|
+
"mux",
|
|
22
|
+
"mezzanine"
|
|
23
|
+
],
|
|
24
|
+
"scripts": {
|
|
25
|
+
"dev": "node src/index.ts",
|
|
26
|
+
"start": "node dist/index.js",
|
|
27
|
+
"build": "esbuild src/index.ts --bundle --platform=node --format=esm --target=node18 --outfile=dist/index.js",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"prepublishOnly": "npm run typecheck && npm run build"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/node": "^22.10.2",
|
|
33
|
+
"esbuild": "^0.24.0",
|
|
34
|
+
"typescript": "^5.7.2"
|
|
35
|
+
}
|
|
36
|
+
}
|