@tomorrowos/sdk 0.4.0 → 0.4.2
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/dist/media-probe.d.ts +6 -0
- package/dist/media-probe.d.ts.map +1 -0
- package/dist/media-probe.js +73 -0
- package/dist/playlist-catalog.d.ts +2 -0
- package/dist/playlist-catalog.d.ts.map +1 -1
- package/dist/playlist-catalog.js +5 -0
- package/dist/tomorrowos.d.ts +12 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +102 -3
- package/package.json +1 -1
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/methods.js +133 -6
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort video duration from file bytes (no ffprobe).
|
|
3
|
+
* Supports common MP4/MOV (mvhd) and WebM (Duration element).
|
|
4
|
+
*/
|
|
5
|
+
export declare function probeVideoDurationMs(body: Buffer, filename: string): number | null;
|
|
6
|
+
//# sourceMappingURL=media-probe.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"media-probe.d.ts","sourceRoot":"","sources":["../src/media-probe.ts"],"names":[],"mappings":"AAAA;;;GAGG;AA+DH,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,GACf,MAAM,GAAG,IAAI,CAOf"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort video duration from file bytes (no ffprobe).
|
|
3
|
+
* Supports common MP4/MOV (mvhd) and WebM (Duration element).
|
|
4
|
+
*/
|
|
5
|
+
function isVideoFilename(name) {
|
|
6
|
+
const lower = String(name || "").toLowerCase();
|
|
7
|
+
return /\.(mp4|m4v|mov|webm|mkv|avi)$/.test(lower);
|
|
8
|
+
}
|
|
9
|
+
function probeMp4MvhdDurationMs(buf) {
|
|
10
|
+
let searchFrom = 0;
|
|
11
|
+
while (searchFrom < buf.length - 32) {
|
|
12
|
+
const idx = buf.indexOf("mvhd", searchFrom, "utf8");
|
|
13
|
+
if (idx < 8)
|
|
14
|
+
break;
|
|
15
|
+
const boxStart = idx - 4;
|
|
16
|
+
if (boxStart < 0) {
|
|
17
|
+
searchFrom = idx + 4;
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const version = buf[boxStart + 8];
|
|
21
|
+
if (version === 0 && boxStart + 28 <= buf.length) {
|
|
22
|
+
const timescale = buf.readUInt32BE(boxStart + 20);
|
|
23
|
+
const duration = buf.readUInt32BE(boxStart + 24);
|
|
24
|
+
if (timescale > 0 && duration > 0) {
|
|
25
|
+
return clampDurationMs((duration / timescale) * 1000);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
if (version === 1 && boxStart + 40 <= buf.length) {
|
|
29
|
+
const timescale = buf.readUInt32BE(boxStart + 28);
|
|
30
|
+
const duration = buf.readUInt32BE(boxStart + 32) * 2 ** 32 + buf.readUInt32BE(boxStart + 36);
|
|
31
|
+
if (timescale > 0 && duration > 0) {
|
|
32
|
+
return clampDurationMs((duration / timescale) * 1000);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
searchFrom = idx + 4;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
function probeWebmDurationMs(buf) {
|
|
40
|
+
const marker = Buffer.from([0x44, 0x89]);
|
|
41
|
+
let searchFrom = 0;
|
|
42
|
+
while (searchFrom < buf.length - 12) {
|
|
43
|
+
const idx = buf.indexOf(marker, searchFrom);
|
|
44
|
+
if (idx < 0)
|
|
45
|
+
break;
|
|
46
|
+
const sizePos = idx + 2;
|
|
47
|
+
if (sizePos + 9 > buf.length)
|
|
48
|
+
break;
|
|
49
|
+
const size = buf[sizePos];
|
|
50
|
+
if (size === 0x84 && sizePos + 9 <= buf.length) {
|
|
51
|
+
const duration = buf.readFloatBE(sizePos + 1);
|
|
52
|
+
if (Number.isFinite(duration) && duration > 0) {
|
|
53
|
+
return clampDurationMs(duration);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
searchFrom = idx + 2;
|
|
57
|
+
}
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function clampDurationMs(ms) {
|
|
61
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
62
|
+
return 0;
|
|
63
|
+
return Math.min(3600 * 1000, Math.max(1000, Math.round(ms)));
|
|
64
|
+
}
|
|
65
|
+
export function probeVideoDurationMs(body, filename) {
|
|
66
|
+
if (!body?.length || !isVideoFilename(filename))
|
|
67
|
+
return null;
|
|
68
|
+
const lower = filename.toLowerCase();
|
|
69
|
+
if (lower.endsWith(".webm")) {
|
|
70
|
+
return probeWebmDurationMs(body) ?? probeMp4MvhdDurationMs(body);
|
|
71
|
+
}
|
|
72
|
+
return probeMp4MvhdDurationMs(body) ?? probeWebmDurationMs(body);
|
|
73
|
+
}
|
|
@@ -30,6 +30,8 @@ export declare class PlaylistCatalog {
|
|
|
30
30
|
getDeviceAssignments(deviceId: string): Promise<DevicePlaylistAssignment[]>;
|
|
31
31
|
publishPlaylistsToDevice(deviceId: string, playlistIds: string[], options?: PolicyBuildOptions): Promise<BuiltDevicePolicy>;
|
|
32
32
|
removePlaylistFromDevice(deviceId: string, playlistId: string): Promise<BuiltDevicePolicy>;
|
|
33
|
+
/** Remove every playlist assignment from a device (CMS catalog + empty policy). */
|
|
34
|
+
clearAllAssignmentsFromDevice(deviceId: string): Promise<BuiltDevicePolicy>;
|
|
33
35
|
buildPolicyForDevice(deviceId: string, options?: PolicyBuildOptions): Promise<BuiltDevicePolicy>;
|
|
34
36
|
private buildPolicyFromAssignments;
|
|
35
37
|
canPublishPlaylistToNewDevice(playlistId: string): Promise<boolean>;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"playlist-catalog.d.ts","sourceRoot":"","sources":["../src/playlist-catalog.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,wBAAwB,EACxB,kBAAkB,EAClB,gBAAgB,EAChB,yBAAyB,EACzB,cAAc,EACd,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,KAAK,EAAE,kBAAkB,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE;QACN,SAAS,EAAE,yBAAyB,EAAE,CAAC;QACvC,QAAQ,EAAE;YAAE,IAAI,EAAE,OAAO,CAAA;SAAE,CAAC;QAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;KAClC,CAAC;CACH;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA+CD,qBAAa,eAAe;IACd,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,eAAe;IAE7C,aAAa,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAK1C,6BAA6B,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAI1D,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC;IAI5D,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC;IAgC/D,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAcnD,oBAAoB,CACxB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,wBAAwB,EAAE,CAAC;IAIhC,wBAAwB,CAC5B,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,iBAAiB,CAAC;IA2CvB,wBAAwB,CAC5B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"playlist-catalog.d.ts","sourceRoot":"","sources":["../src/playlist-catalog.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EACV,wBAAwB,EACxB,kBAAkB,EAClB,gBAAgB,EAChB,yBAAyB,EACzB,cAAc,EACd,eAAe,EAChB,MAAM,kBAAkB,CAAC;AAE1B,MAAM,WAAW,iBAAiB;IAChC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,CAAC,EAAE,gBAAgB,CAAC;IAC5B,KAAK,EAAE,kBAAkB,EAAE,CAAC;CAC7B;AAED,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE;QACN,SAAS,EAAE,yBAAyB,EAAE,CAAC;QACvC,QAAQ,EAAE;YAAE,IAAI,EAAE,OAAO,CAAA;SAAE,CAAC;QAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,QAAQ,CAAC,EAAE,QAAQ,GAAG,UAAU,CAAC;KAClC,CAAC;CACH;AAED,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AA+CD,qBAAa,eAAe;IACd,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,eAAe;IAE7C,aAAa,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAK1C,6BAA6B,IAAI,OAAO,CAAC,cAAc,EAAE,CAAC;IAI1D,WAAW,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,GAAG,SAAS,CAAC;IAI5D,YAAY,CAAC,KAAK,EAAE,iBAAiB,GAAG,OAAO,CAAC,cAAc,CAAC;IAgC/D,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,CAAC;IAcnD,oBAAoB,CACxB,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,wBAAwB,EAAE,CAAC;IAIhC,wBAAwB,CAC5B,QAAQ,EAAE,MAAM,EAChB,WAAW,EAAE,MAAM,EAAE,EACrB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,iBAAiB,CAAC;IA2CvB,wBAAwB,CAC5B,QAAQ,EAAE,MAAM,EAChB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,iBAAiB,CAAC;IAO7B,mFAAmF;IAC7E,6BAA6B,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC;IAK3E,oBAAoB,CACxB,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,iBAAiB,CAAC;YAKf,0BAA0B;IAgCxC,6BAA6B,CAAC,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;CAGpE"}
|
package/dist/playlist-catalog.js
CHANGED
|
@@ -143,6 +143,11 @@ export class PlaylistCatalog {
|
|
|
143
143
|
await this.store.setDeviceAssignments(deviceId, next);
|
|
144
144
|
return this.buildPolicyFromAssignments(next, { useLatest: false });
|
|
145
145
|
}
|
|
146
|
+
/** Remove every playlist assignment from a device (CMS catalog + empty policy). */
|
|
147
|
+
async clearAllAssignmentsFromDevice(deviceId) {
|
|
148
|
+
await this.store.setDeviceAssignments(deviceId, []);
|
|
149
|
+
return this.buildPolicyFromAssignments([], { useLatest: false });
|
|
150
|
+
}
|
|
146
151
|
async buildPolicyForDevice(deviceId, options = {}) {
|
|
147
152
|
const assignments = await this.store.getDeviceAssignments(deviceId);
|
|
148
153
|
return this.buildPolicyFromAssignments(assignments, options);
|
package/dist/tomorrowos.d.ts
CHANGED
|
@@ -52,6 +52,15 @@ export interface DeviceListItem {
|
|
|
52
52
|
version: number;
|
|
53
53
|
publishedAt: string;
|
|
54
54
|
}>;
|
|
55
|
+
latestErrorAt: string | null;
|
|
56
|
+
latestErrorMessage: string | null;
|
|
57
|
+
}
|
|
58
|
+
export interface DeviceLogEntry {
|
|
59
|
+
timestamp: string;
|
|
60
|
+
level: "info" | "warn" | "error";
|
|
61
|
+
message: string;
|
|
62
|
+
source?: string;
|
|
63
|
+
details?: unknown;
|
|
55
64
|
}
|
|
56
65
|
export declare class TomorrowOS extends EventEmitter {
|
|
57
66
|
readonly brand: TomorrowOSBrand;
|
|
@@ -59,6 +68,7 @@ export declare class TomorrowOS extends EventEmitter {
|
|
|
59
68
|
readonly playlists: PlaylistCatalog;
|
|
60
69
|
private readonly devices;
|
|
61
70
|
private readonly pendingDeviceMeta;
|
|
71
|
+
private readonly deviceLogs;
|
|
62
72
|
private httpServer;
|
|
63
73
|
private wss;
|
|
64
74
|
private staticRoot;
|
|
@@ -90,6 +100,8 @@ export declare class TomorrowOS extends EventEmitter {
|
|
|
90
100
|
};
|
|
91
101
|
/** List paired devices with live connection + timing fields for the CMS panel. */
|
|
92
102
|
listDevices(): Promise<DeviceListItem[]>;
|
|
103
|
+
private pushDeviceLog;
|
|
104
|
+
private getDeviceLogs;
|
|
93
105
|
private isDeviceConnected;
|
|
94
106
|
private captureHelloMeta;
|
|
95
107
|
private getOrCreatePermanentPairingCode;
|
package/dist/tomorrowos.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAC;AAEtC,OAAO,IAAI,MAAM,MAAM,CAAC;AAUxB,OAAO,EAAE,eAAe,EAAE,KAAK,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAChF,OAAO,KAAK,EAIV,eAAe,EAChB,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"tomorrowos.d.ts","sourceRoot":"","sources":["../src/tomorrowos.ts"],"names":[],"mappings":"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"}
|
package/dist/tomorrowos.js
CHANGED
|
@@ -8,6 +8,7 @@ import { generateRandomPairingCode, isValidPairingCodeFormat, normalizePairingCo
|
|
|
8
8
|
import { PlaylistCatalog } from "./playlist-catalog.js";
|
|
9
9
|
import { MemoryStore } from "./store/memory-store.js";
|
|
10
10
|
import { resolveBrandLogoPath, syncProjectAssetsToStaticRoot } from "./brand-assets.js";
|
|
11
|
+
import { probeVideoDurationMs } from "./media-probe.js";
|
|
11
12
|
function formatDurationMs(ms) {
|
|
12
13
|
if (!Number.isFinite(ms) || ms < 0)
|
|
13
14
|
return "0s";
|
|
@@ -131,6 +132,7 @@ export class TomorrowOS extends EventEmitter {
|
|
|
131
132
|
playlists;
|
|
132
133
|
devices = new Map();
|
|
133
134
|
pendingDeviceMeta = new Map();
|
|
135
|
+
deviceLogs = new Map();
|
|
134
136
|
httpServer = null;
|
|
135
137
|
wss = null;
|
|
136
138
|
staticRoot = null;
|
|
@@ -266,6 +268,8 @@ export class TomorrowOS extends EventEmitter {
|
|
|
266
268
|
}
|
|
267
269
|
}
|
|
268
270
|
const screenOnlineSince = connected && record.lastBootAt ? record.lastBootAt : null;
|
|
271
|
+
const logs = this.deviceLogs.get(deviceId) ?? [];
|
|
272
|
+
const latestError = [...logs].reverse().find((entry) => entry.level === "error");
|
|
269
273
|
return {
|
|
270
274
|
deviceId,
|
|
271
275
|
pairingCode: reg?.permanentPairingCode ?? null,
|
|
@@ -287,10 +291,28 @@ export class TomorrowOS extends EventEmitter {
|
|
|
287
291
|
lastPolicyPushAt: record.lastPolicyPushAt ?? null,
|
|
288
292
|
screenOnlineActive,
|
|
289
293
|
screenOnlineLabel,
|
|
290
|
-
screenOnlineSince
|
|
294
|
+
screenOnlineSince,
|
|
295
|
+
latestErrorAt: latestError?.timestamp ?? null,
|
|
296
|
+
latestErrorMessage: latestError?.message ?? null
|
|
291
297
|
};
|
|
292
298
|
}));
|
|
293
299
|
}
|
|
300
|
+
pushDeviceLog(deviceId, entry) {
|
|
301
|
+
const key = String(deviceId || "").trim();
|
|
302
|
+
if (!key)
|
|
303
|
+
return;
|
|
304
|
+
const existing = this.deviceLogs.get(key) ?? [];
|
|
305
|
+
existing.push(entry);
|
|
306
|
+
if (existing.length > 80)
|
|
307
|
+
existing.splice(0, existing.length - 80);
|
|
308
|
+
this.deviceLogs.set(key, existing);
|
|
309
|
+
}
|
|
310
|
+
getDeviceLogs(deviceId) {
|
|
311
|
+
const key = String(deviceId || "").trim();
|
|
312
|
+
if (!key)
|
|
313
|
+
return [];
|
|
314
|
+
return [...(this.deviceLogs.get(key) ?? [])].reverse();
|
|
315
|
+
}
|
|
294
316
|
isDeviceConnected(deviceId) {
|
|
295
317
|
const ws = this.devices.get(deviceId);
|
|
296
318
|
return !!ws && ws.readyState === WebSocket.OPEN;
|
|
@@ -548,12 +570,16 @@ export class TomorrowOS extends EventEmitter {
|
|
|
548
570
|
await fs.mkdir(uploadsDir, { recursive: true });
|
|
549
571
|
const filePath = path.join(uploadsDir, storedName);
|
|
550
572
|
await fs.writeFile(filePath, body);
|
|
551
|
-
|
|
573
|
+
const durationMs = probeVideoDurationMs(body, safeName);
|
|
574
|
+
const payload = {
|
|
552
575
|
status: "success",
|
|
553
576
|
url: `/uploads/${storedName}`,
|
|
554
577
|
filename: storedName,
|
|
555
578
|
size: body.length
|
|
556
|
-
}
|
|
579
|
+
};
|
|
580
|
+
if (durationMs != null)
|
|
581
|
+
payload.durationMs = durationMs;
|
|
582
|
+
sendJson(res, 200, payload);
|
|
557
583
|
}
|
|
558
584
|
catch (e) {
|
|
559
585
|
const msg = e instanceof Error ? e.message : "Upload failed";
|
|
@@ -705,6 +731,39 @@ export class TomorrowOS extends EventEmitter {
|
|
|
705
731
|
sendJson(res, 200, { status: "success", assignments });
|
|
706
732
|
return;
|
|
707
733
|
}
|
|
734
|
+
const deviceLogsGet = /^\/device\/([^/]+)\/logs$/.exec(pathname);
|
|
735
|
+
if (req.method === "GET" && deviceLogsGet) {
|
|
736
|
+
const deviceId = decodeURIComponent(deviceLogsGet[1]);
|
|
737
|
+
sendJson(res, 200, { status: "success", logs: this.getDeviceLogs(deviceId) });
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
if (req.method === "DELETE" && deviceAssignmentsGet) {
|
|
741
|
+
const deviceId = decodeURIComponent(deviceAssignmentsGet[1]);
|
|
742
|
+
try {
|
|
743
|
+
const built = await this.playlists.clearAllAssignmentsFromDevice(deviceId);
|
|
744
|
+
const ws = this.devices.get(deviceId);
|
|
745
|
+
let policyPushed = false;
|
|
746
|
+
let policyResult = null;
|
|
747
|
+
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
748
|
+
policyResult = await this.sendDeviceCommand(deviceId, "device.content.setPolicy", { policy: built.policy });
|
|
749
|
+
policyPushed = policyResult.status === "success";
|
|
750
|
+
if (policyPushed)
|
|
751
|
+
await this.recordPolicyPush(deviceId);
|
|
752
|
+
}
|
|
753
|
+
sendJson(res, 200, {
|
|
754
|
+
status: "success",
|
|
755
|
+
assignmentsCleared: true,
|
|
756
|
+
policyPushed,
|
|
757
|
+
policy: built.policy,
|
|
758
|
+
result: policyResult
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
catch (e) {
|
|
762
|
+
const msg = e instanceof Error ? e.message : "Clear assignments failed";
|
|
763
|
+
sendJson(res, 400, { status: "failed", error: msg });
|
|
764
|
+
}
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
708
767
|
const deviceAssignmentsPost = /^\/device\/([^/]+)\/assignments$/.exec(pathname);
|
|
709
768
|
if (req.method === "POST" && deviceAssignmentsPost) {
|
|
710
769
|
const deviceId = decodeURIComponent(deviceAssignmentsPost[1]);
|
|
@@ -834,6 +893,26 @@ export class TomorrowOS extends EventEmitter {
|
|
|
834
893
|
(result.status === "success" || result.status === "accepted")) {
|
|
835
894
|
this.forceDeviceOffline(deviceId);
|
|
836
895
|
}
|
|
896
|
+
if (action === "content/clear" &&
|
|
897
|
+
(result.status === "success" || result.status === "accepted")) {
|
|
898
|
+
const built = await this.playlists.clearAllAssignmentsFromDevice(deviceId);
|
|
899
|
+
let assignmentsCleared = true;
|
|
900
|
+
let policyPushed = false;
|
|
901
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
902
|
+
const policyResult = await this.sendDeviceCommand(deviceId, "device.content.setPolicy", { policy: built.policy });
|
|
903
|
+
policyPushed = policyResult.status === "success";
|
|
904
|
+
if (policyPushed)
|
|
905
|
+
await this.recordPolicyPush(deviceId);
|
|
906
|
+
}
|
|
907
|
+
sendJson(res, 200, {
|
|
908
|
+
status: result.status,
|
|
909
|
+
data: result.data,
|
|
910
|
+
assignmentsCleared,
|
|
911
|
+
policyPushed,
|
|
912
|
+
policy: built.policy
|
|
913
|
+
});
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
837
916
|
sendJson(res, 200, { status: result.status, data: result.data });
|
|
838
917
|
}
|
|
839
918
|
catch (e) {
|
|
@@ -919,6 +998,26 @@ export class TomorrowOS extends EventEmitter {
|
|
|
919
998
|
console.error("[TomorrowOS] pushLatestPolicy on resume failed:", err);
|
|
920
999
|
});
|
|
921
1000
|
})();
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
if (type === "device.log") {
|
|
1004
|
+
const deviceId = ws.deviceId;
|
|
1005
|
+
if (!deviceId)
|
|
1006
|
+
return;
|
|
1007
|
+
const rawLevel = String(msg.level ?? "info").toLowerCase();
|
|
1008
|
+
const level = rawLevel === "error" || rawLevel === "warn" ? rawLevel : "info";
|
|
1009
|
+
const message = String(msg.message ?? "").trim();
|
|
1010
|
+
if (!message)
|
|
1011
|
+
return;
|
|
1012
|
+
const source = typeof msg.source === "string" ? msg.source : undefined;
|
|
1013
|
+
const details = msg.details;
|
|
1014
|
+
const timestamp = typeof msg.timestamp === "string" && msg.timestamp.trim()
|
|
1015
|
+
? msg.timestamp
|
|
1016
|
+
: new Date().toISOString();
|
|
1017
|
+
this.pushDeviceLog(deviceId, { timestamp, level, message, source, details });
|
|
1018
|
+
if (level === "error") {
|
|
1019
|
+
console.error(`[TomorrowOS][device-log][${deviceId}] ${message}`);
|
|
1020
|
+
}
|
|
922
1021
|
}
|
|
923
1022
|
});
|
|
924
1023
|
ws.on("close", () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tomorrowos/sdk",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "TomorrowOS CMS server SDK — WebSocket transport, pairing, device commands, optional static CMS UI. Includes CLI (tomorrowos init / build) and starter templates.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "my-cms",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "CMS server on @tomorrowos/sdk. Add your UI (React, static files, etc.) alongside this server.",
|
|
5
5
|
"private": true,
|
|
6
6
|
"type": "module",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"build-player": "tomorrowos build --platform tizen"
|
|
11
11
|
},
|
|
12
12
|
"dependencies": {
|
|
13
|
-
"@tomorrowos/sdk": "^0.4.
|
|
13
|
+
"@tomorrowos/sdk": "^0.4.2"
|
|
14
14
|
},
|
|
15
15
|
"devDependencies": {
|
|
16
16
|
"@types/node": "^20.0.0",
|
|
@@ -168,6 +168,96 @@ function defaultDurationMs(type) {
|
|
|
168
168
|
return 10000;
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
+
function clampVideoDurationMs(ms) {
|
|
172
|
+
if (!Number.isFinite(ms) || ms <= 0) return null;
|
|
173
|
+
return Math.min(3600 * 1000, Math.max(1000, Math.round(ms)));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Probe duration from a local File or an absolute media URL. */
|
|
177
|
+
function probeVideoDurationInBrowser(fileOrUrl) {
|
|
178
|
+
if (!fileOrUrl) return Promise.resolve(null);
|
|
179
|
+
|
|
180
|
+
let objectUrl = null;
|
|
181
|
+
const src = typeof fileOrUrl === "string" ? fileOrUrl : null;
|
|
182
|
+
if (!src && fileOrUrl instanceof File) {
|
|
183
|
+
objectUrl = URL.createObjectURL(fileOrUrl);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
const video = document.createElement("video");
|
|
188
|
+
let settled = false;
|
|
189
|
+
const timer = setTimeout(() => done(null), 20000);
|
|
190
|
+
|
|
191
|
+
const cleanup = () => {
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
video.removeAttribute("src");
|
|
194
|
+
try {
|
|
195
|
+
video.load();
|
|
196
|
+
} catch (_) {}
|
|
197
|
+
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const done = (value) => {
|
|
201
|
+
if (settled) return;
|
|
202
|
+
settled = true;
|
|
203
|
+
cleanup();
|
|
204
|
+
resolve(clampVideoDurationMs(value));
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const readDuration = () => {
|
|
208
|
+
const seconds = Number(video.duration);
|
|
209
|
+
if (Number.isFinite(seconds) && seconds > 0 && seconds !== Infinity) {
|
|
210
|
+
done(seconds * 1000);
|
|
211
|
+
}
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
video.preload = "auto";
|
|
215
|
+
video.muted = true;
|
|
216
|
+
video.playsInline = true;
|
|
217
|
+
video.setAttribute("playsinline", "");
|
|
218
|
+
video.addEventListener("loadedmetadata", readDuration);
|
|
219
|
+
video.addEventListener("durationchange", readDuration);
|
|
220
|
+
video.addEventListener("loadeddata", readDuration);
|
|
221
|
+
video.addEventListener("canplay", readDuration);
|
|
222
|
+
video.addEventListener("error", () => done(null));
|
|
223
|
+
|
|
224
|
+
if (src) {
|
|
225
|
+
video.src = src;
|
|
226
|
+
} else if (objectUrl) {
|
|
227
|
+
video.src = objectUrl;
|
|
228
|
+
} else {
|
|
229
|
+
done(null);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
video.load();
|
|
234
|
+
} catch (_) {
|
|
235
|
+
done(null);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function resolveVideoDurationMs(file, type, uploadData) {
|
|
241
|
+
if (type !== "video") return defaultDurationMs(type);
|
|
242
|
+
|
|
243
|
+
const fromServer = Number(uploadData?.durationMs);
|
|
244
|
+
if (Number.isFinite(fromServer) && fromServer > 0) {
|
|
245
|
+
return clampVideoDurationMs(fromServer) ?? defaultDurationMs(type);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const fromFile = await probeVideoDurationInBrowser(file);
|
|
249
|
+
if (fromFile) return fromFile;
|
|
250
|
+
|
|
251
|
+
if (uploadData?.url) {
|
|
252
|
+
try {
|
|
253
|
+
const fromUrl = await probeVideoDurationInBrowser(absoluteMediaUrl(uploadData.url));
|
|
254
|
+
if (fromUrl) return fromUrl;
|
|
255
|
+
} catch (_) {}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return defaultDurationMs(type);
|
|
259
|
+
}
|
|
260
|
+
|
|
171
261
|
function normalizeDurationMs(item) {
|
|
172
262
|
const minMs = 1000;
|
|
173
263
|
const maxMs = 3600 * 1000;
|
|
@@ -567,7 +657,9 @@ function renderDeviceCards() {
|
|
|
567
657
|
["System", device.system || device.platform || "—"],
|
|
568
658
|
["Device online", formatDeviceOnlineLabel(device)],
|
|
569
659
|
["Last boot", formatDateTimeSeconds(device.lastBootAt)],
|
|
570
|
-
["Latest push", formatDateTimeSeconds(device.lastPolicyPushAt)]
|
|
660
|
+
["Latest push", formatDateTimeSeconds(device.lastPolicyPushAt)],
|
|
661
|
+
["Latest error", device.latestErrorMessage || "—"],
|
|
662
|
+
["Error at", formatDateTimeSeconds(device.latestErrorAt)]
|
|
571
663
|
];
|
|
572
664
|
for (const [label, value] of rows) {
|
|
573
665
|
const row = document.createElement("div");
|
|
@@ -612,6 +704,11 @@ function renderDeviceCards() {
|
|
|
612
704
|
clearBtn.textContent = "Clear";
|
|
613
705
|
clearBtn.addEventListener("click", () => deviceAction(device.deviceId, "content/clear"));
|
|
614
706
|
|
|
707
|
+
const logsBtn = document.createElement("button");
|
|
708
|
+
logsBtn.type = "button";
|
|
709
|
+
logsBtn.textContent = "Logs";
|
|
710
|
+
logsBtn.addEventListener("click", () => viewDeviceLogs(device.deviceId));
|
|
711
|
+
|
|
615
712
|
const unpairBtn = document.createElement("button");
|
|
616
713
|
unpairBtn.type = "button";
|
|
617
714
|
unpairBtn.className = "danger";
|
|
@@ -623,6 +720,7 @@ function renderDeviceCards() {
|
|
|
623
720
|
actions.appendChild(capBtn);
|
|
624
721
|
actions.appendChild(rebootBtn);
|
|
625
722
|
actions.appendChild(clearBtn);
|
|
723
|
+
actions.appendChild(logsBtn);
|
|
626
724
|
actions.appendChild(unpairBtn);
|
|
627
725
|
|
|
628
726
|
card.appendChild(header);
|
|
@@ -766,17 +864,23 @@ async function addAssetFromFile(file) {
|
|
|
766
864
|
if (!isPlaylistEditorOpen()) {
|
|
767
865
|
newPlaylistDraft();
|
|
768
866
|
}
|
|
769
|
-
const data = await uploadFile(file);
|
|
770
867
|
const type = inferMediaType(file.name, file.type);
|
|
868
|
+
const data = await uploadFile(file);
|
|
869
|
+
const durationMs = await resolveVideoDurationMs(file, type, data);
|
|
771
870
|
editorItems.push({
|
|
772
871
|
id: crypto.randomUUID(),
|
|
773
872
|
url: data.url,
|
|
774
873
|
name: file.name,
|
|
775
874
|
type,
|
|
776
|
-
durationMs
|
|
875
|
+
durationMs
|
|
777
876
|
});
|
|
778
877
|
renderEditorAssets();
|
|
779
|
-
showResult({
|
|
878
|
+
showResult({
|
|
879
|
+
status: "uploaded",
|
|
880
|
+
...data,
|
|
881
|
+
detectedDurationMs: durationMs,
|
|
882
|
+
detectedDurationSec: Math.round(durationMs / 1000)
|
|
883
|
+
});
|
|
780
884
|
}
|
|
781
885
|
|
|
782
886
|
async function verify() {
|
|
@@ -816,13 +920,36 @@ async function unpairDevice(deviceId) {
|
|
|
816
920
|
async function deviceAction(deviceId, action) {
|
|
817
921
|
if (!deviceId) return;
|
|
818
922
|
if (action === "reboot" && !confirm("Reboot this device?")) return;
|
|
819
|
-
if (
|
|
923
|
+
if (
|
|
924
|
+
action === "content/clear" &&
|
|
925
|
+
!confirm("Clear content on this device and remove all published playlists?")
|
|
926
|
+
) {
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
820
929
|
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/${action}`, {
|
|
821
930
|
method: "POST"
|
|
822
931
|
});
|
|
823
932
|
const data = await res.json();
|
|
824
933
|
showResult({ deviceId, action, ...data });
|
|
825
|
-
if (
|
|
934
|
+
if (!res.ok) {
|
|
935
|
+
alert(data.error || `${action} failed`);
|
|
936
|
+
return;
|
|
937
|
+
}
|
|
938
|
+
if (action === "content/clear" && data.assignmentsCleared !== true) {
|
|
939
|
+
alert("Content cleared on device, but playlist assignments were not removed. Restart CMS with latest SDK.");
|
|
940
|
+
}
|
|
941
|
+
if (action === "reboot" || action === "content/clear") await fetchDevices();
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
async function viewDeviceLogs(deviceId) {
|
|
945
|
+
if (!deviceId) return;
|
|
946
|
+
const res = await fetch(`/device/${encodeURIComponent(deviceId)}/logs`);
|
|
947
|
+
const data = await res.json();
|
|
948
|
+
showResult({ deviceId, logs: data.logs || [] });
|
|
949
|
+
if (!res.ok) {
|
|
950
|
+
alert(data.error || "Failed to load logs");
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
826
953
|
}
|
|
827
954
|
|
|
828
955
|
function startDevicePolling() {
|