Sync media from your own system
Mirror a folder of your own system into the library and let an automatic playlist play it.
- For
- Developers who connect a product database, a DAM, or a marketing tool that exports posters and videos
- Prerequisite
- a key
ak_…of the class Media automation, and a folder in the library - Where
- command line or a scheduled job on your server
By the end, a script mirrors a folder of your own system into a folder of the library: new files are uploaded, files you removed disappear, and unchanged files stay untouched. An automatic playlist on that folder plays the result without anyone maintaining it.
How it works
Every file is identified by its SHA-256 checksum. The script compares the checksums of your export with those in the library folder and only acts on the difference:
| Situation | What the script does |
|---|---|
| A file is only in your export | Upload it: precheck, push the bytes, confirm. |
| A file is only in the library folder | Delete it. Deleted media stay restorable. |
| A file is in both | Nothing. A changed file has a new checksum, so it counts as new, and the old version is removed. |
The upload has three steps. POST /v1/assets/upload-precheck checks the type and the storage limit and creates the media record. If the same bytes already exist in your organization, the response carries no upload address, and the bytes are reused. Otherwise you push the file to the address in presignedUrl. POST /v1/assets/upload-complete then starts the processing, and the file is ready for displays a few seconds later, a long video after a few minutes.
Steps
-
Create the folder in the library, for example one for the posters from your PIM, and find its ID:
GET/v1/folderscurl -sS "$SZENERA_API/v1/folders" \ -H "Authorization: Bearer $SZENERA_API_KEY" \ | jq -r '.[] | "\(.id) \(.path)"'fld_01KJ7MWKW0MAPPE00000000001 /Plakate aus dem PIM -
Save the script:
TypeScript// sync-media.ts: node sync-media.ts ./export fld_…import { createHash } from 'node:crypto';import { readdir, readFile } from 'node:fs/promises';import { extname, join } from 'node:path'; const API = process.env.SZENERA_API;const KEY = process.env.SZENERA_API_KEY; // class Media automationconst [dir, folderId] = process.argv.slice(2); const TYPES: Record<string, string> = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.mp4': 'video/mp4', '.webm': 'video/webm', '.pdf': 'application/pdf',};const SINGLE_UPLOAD_LIMIT = 50 * 1024 * 1024; // Above this, the upload goes in parts async function call(method: string, path: string, body?: unknown) { const res = await fetch(`${API}${path}`, { method, headers: { Authorization: `Bearer ${KEY}`, 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); if (!res.ok) throw new Error(`${method} ${path}: ${res.status} ${await res.text()}`); return res.json();} // What the library folder holds, by checksumconst remote = new Map<string, { id: string; name: string }>();let cursor = '';do { const query = new URLSearchParams({ folderId, limit: '200' }); if (cursor) query.set('cursor', cursor); const page = await call('GET', `/v1/assets?${query}`); for (const asset of page.items) remote.set(asset.sha256, asset); cursor = page.nextCursor ?? '';} while (cursor); // Upload what is newconst local = new Set<string>();for (const file of await readdir(dir)) { const mimeType = TYPES[extname(file).toLowerCase()]; if (!mimeType) continue; const bytes = await readFile(join(dir, file)); const sha256 = createHash('sha256').update(bytes).digest('hex'); local.add(sha256); if (remote.has(sha256)) continue; if (bytes.length > SINGLE_UPLOAD_LIMIT) { console.log(`skipped, upload with szenera asset upload: ${file}`); continue; } const precheck = await call('POST', '/v1/assets/upload-precheck', { originalFileName: file, mimeType, sizeBytes: bytes.length, sha256, folderId, }); if (!precheck.exists) { const { url, headers } = precheck.presignedUrl; const put = await fetch(url, { method: 'PUT', headers, body: bytes }); if (!put.ok) throw new Error(`upload ${file}: ${put.status}`); } await call('POST', '/v1/assets/upload-complete', { assetId: precheck.assetId, sha256 }); console.log(`uploaded: ${file}`);} // Delete what is gone from your systemfor (const [sha256, asset] of remote) { if (local.has(sha256)) continue; const res = await fetch(`${API}/v1/assets/${asset.id}`, { method: 'DELETE', headers: { Authorization: `Bearer ${KEY}` }, }); console.log(res.ok ? `deleted: ${asset.name}` : `kept, used in a scene: ${asset.name}`);} -
Run it once by hand and look at the output:
Terminalnode sync-media.ts ./export fld_01KJ7MWKW0MAPPE00000000001uploaded: herbst-angebot.pnguploaded: neue-oeffnungszeiten.mp4deleted: sommer-angebot.png -
Create an automatic playlist on the folder in the portal and assign it to your displays, see Fill a playlist automatically from a folder or tags. New files join it by themselves once their processing is done.
-
Run the script on a schedule, for example every night after your export:
15 2 * * * cd /opt/szenera-sync && node sync-media.ts /exports/posters fld_01KJ7MWKW0MAPPE00000000001
Files above 50 MiB go up in parts. The CLI does that for you: szenera asset upload --file=<path> --folder=<fld_…> uploads, waits until the file is ready, and cleans up an interrupted upload.
Check the result
- The library folder holds exactly the files of your export.
- A second run right after the first prints nothing, because nothing has changed.
- The automatic playlist lists the new files under Sequence, marked From the source.
If something goes wrong
| Symptom | Meaning | Solution |
|---|---|---|
403 Permission.Denied |
The key lacks asset.upload, asset.list, or asset.delete. |
Use a key of the class Media automation. |
422 Upload.MimeTypeNotAllowed |
The file type isn't supported. | Use JPEG, PNG, WebP, SVG, MP4, WebM, or PDF. |
402 Upload.QuotaExceeded |
The storage of your package is used up. | Delete media you no longer need, or change the package. |
kept, used in a scene |
The delete answered 409 Asset.InUse: a scene uses the file directly. |
Take the file out of the scene, or let it stay. |
| A file stays in processing | Long videos take minutes. | Wait for media-library.asset.ready, or check with GET /v1/assets/{id}. |
| Every run uploads everything again | The script compares the wrong folder, or the export rewrites files with new bytes. | Check the folder ID, and export identical files byte for byte. |