Node.js CLI that walks a filesystem folder tree and uploads everything through the official FrameComment TUS pipeline (so transcoding, thumbnailing, HLS — all of it kicks in automatically just like a browser upload would). Built for the case where browser drag-and-drop isn't a realistic option: thousands of files spread over hundreds of folders, multi-terabyte libraries, etc.
The browser has to enumerate every file in the dropped tree at once before any upload starts. At ~2 TB / 4000 files it runs out of heap, and if the tab is closed mid-upload the per-file TUS fingerprints are lost. This script does the same end-to-end thing one file at a time, persists progress to a JSON state file, and resumes cleanly after any kind of interruption (Ctrl-C, kill -9, network drop, laptop sleep).
- Node.js 18+ on the machine doing the uploading (Node 18 is the
earliest with
fetchbuilt in). tus-js-clientfrom this repo'snode_modules(already a dependency). Runnpm installonce at the repo root before launching the script.- Admin email + password for the FrameComment instance.
# Set creds once per terminal session. `read -s` echoes nothing
# so the password stays out of shell history.
export FRAMECOMMENT_BASE_URL=http://192.168.100.20:4321
export FRAMECOMMENT_EMAIL=admin@example.com
read -s FRAMECOMMENT_PASSWORD; export FRAMECOMMENT_PASSWORD
# Always run with --dry-run first.
node scripts/bulk-upload.mjs \
--source /Volumes/Windows/04_DFFR \
--project '04_DFFR' \
--dry-run
# Looks good? Drop --dry-run.
node scripts/bulk-upload.mjs \
--source /Volumes/Windows/04_DFFR \
--project '04_DFFR'Inline flags also work if you prefer (--base-url, --email,
--password). The flags override env vars when both are set.
| Flag | Required | Default | Notes |
|---|---|---|---|
--base-url |
yes | — | http://... or https://..., no trailing slash needed |
--email |
yes | — | Admin email |
--password |
yes | — | Admin password (quote it if it has shell-special chars) |
--source |
yes | — | Local folder to ingest (absolute path) |
--project |
yes | — | Target project title. Created if it doesn't exist; matched by exact title if it does |
--state |
no | <source>/.bulk-upload-state.json |
Where to persist done-files + folder-id cache |
--concurrency |
no | 2 |
Number of files in flight at once. Bump to 4–6 on gigabit LAN |
--dry-run |
no | off | Walk + report only |
--extensions |
no | mp4,mov,m4v,avi,mkv,webm,jpg,jpeg,png,gif,webp |
Comma list, no dots |
--skip-existing |
no | on | Skip files already in the state file |
--no-skip-existing |
no | off | Force re-upload (rare) |
--verbose |
no | off | Log every API call + folder creation |
- Log in at
POST /api/auth/login→ keeps the JWT access token in memory, refreshes it ~60 s before expiry. - Find or create the target project — exact-title match against
GET /api/projects; if none,POST /api/projectswithauthMode: 'NONE'. The project id is cached in the state file so re-runs don't re-search. - Enumerate the source tree — depth-first walk, filter by
extension, skip dotfiles and
Thumbs.db. Total file count + total size are printed up front. - Group files by their parent folder so we don't ping
/api/foldersonce per file when uploading hundreds in the same directory. - For each folder, walk down the relative path and ensure every
segment exists via
GET /api/folders?projectId=...&parentFolderId=...(match by name) andPOST /api/foldersfor the missing ones. The resolved folder id is cached. - For each file, create the
VideoDB row withPOST /api/videos(passingfolderId), then run the TUS upload pointing at/api/uploadswith{ filename, filetype, videoId }metadata. Same wire protocol the in-app upload uses. - Persist after every success — state file gets rewritten
atomically (
.tmp+ rename) every time a file finishes, so the process can die at any moment without losing more than the current in-flight upload.
state.completed[relativePath]skips done files.- TUS's own fingerprint store (
~/.bulk-upload-tus-store/) resumes a half-uploaded file from its last acknowledged offset. - Together they mean: kill the process, plug your laptop in for the night, run the exact same command tomorrow → it picks up where it was, no re-uploads of completed files, partial files resume from their last byte.
At gigabit LAN throughput a Mac → TrueNAS upload tops out around
80–100 MB/s sustained (HTTP overhead included). 2.8 TB at that rate
is in the 8–10 hour range for raw data. The per-file overhead
(create Video row, ensure folders, TUS handshake) adds ~0.5 s per
file, so 4000 files × 0.5 s ≈ 35 min extra. Realistic ETA: 9–11 h
for the largest project, ~45 min for 04_DFFR. Bump --concurrency
on slower-per-stream links (cloud, VPN).
- Smoke test with
04_DFFR(smallest, 4.7 GB). Verify videos appear in the dashboard, transcoding kicks in, the folder tree matches. - Then
02_DFT(78 GB) — most boring, fewest folders. - Then
03_FFN(85 GB). - Finally
01_VDA(2.6 TB) — overnight + the next morning.
- "Login failed: HTTP 401" — wrong email/password.
- "HTTP 413" during TUS —
settings.maxUploadSizeGBis too low. Bump it in Admin → Settings. - Network keeps dropping — lower
--concurrencyto 1 so retries only have to recover one file at a time. - Want to wipe state and start over — delete the state file at the path printed by the script on startup. The server-side videos stay; the next run will detect the title collision and either skip via the existing-name check inside POST /api/videos, or create a new version label if the server's dedup logic is more lenient.