@tomorrowos/sdk 1.1.5 → 1.1.6
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/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/playlist-catalog.d.ts +5 -0
- package/dist/playlist-catalog.d.ts.map +1 -1
- package/dist/playlist-catalog.js +22 -1
- package/dist/screen-tags.d.ts +95 -0
- package/dist/screen-tags.d.ts.map +1 -0
- package/dist/screen-tags.js +835 -0
- package/dist/store/memory-store.d.ts +2 -0
- package/dist/store/memory-store.d.ts.map +1 -1
- package/dist/store/memory-store.js +11 -0
- package/dist/store/postgres-store.d.ts +2 -0
- package/dist/store/postgres-store.d.ts.map +1 -1
- package/dist/store/postgres-store.js +9 -0
- package/dist/store/sqlite-store.d.ts +2 -0
- package/dist/store/sqlite-store.d.ts.map +1 -1
- package/dist/store/sqlite-store.js +12 -0
- package/dist/store/types.d.ts +20 -0
- package/dist/store/types.d.ts.map +1 -1
- package/dist/tomorrowos.d.ts +6 -0
- package/dist/tomorrowos.d.ts.map +1 -1
- package/dist/tomorrowos.js +44 -1
- package/package.json +1 -1
- package/templates/cms-starter/package-lock.json +3 -3
- package/templates/cms-starter/package.json +2 -2
- package/templates/cms-starter/public/index.html +86 -1
- package/templates/cms-starter/public/methods.js +782 -11
- package/templates/cms-starter/public/panel.css +356 -0
- package/templates/cms-starter-v0/cms-panel/index.html +86 -1
- package/templates/cms-starter-v0/cms-panel/methods.js +782 -11
- package/templates/cms-starter-v0/cms-panel/panel.css +356 -0
- package/templates/cms-starter-v0/package-lock.json +3 -3
- package/templates/cms-starter-v0/package.json +2 -2
|
@@ -0,0 +1,835 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Screen tags and per-item schedules.
|
|
3
|
+
*
|
|
4
|
+
* Screens store tags. Each playlist item may store a tagRule and a schedule.
|
|
5
|
+
* When a policy is pushed, items that do not match that screen right now are
|
|
6
|
+
* removed. Items with no rule play on every screen. Items with no schedule
|
|
7
|
+
* play all the time. tagRule and schedule are stripped before the player sees
|
|
8
|
+
* the policy.
|
|
9
|
+
*
|
|
10
|
+
* Screen tags live in screen-tags.json beside the SQLite database (or
|
|
11
|
+
* data/screen-tags.json). Playlist item rules ride along in items JSON.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdir, readFile, rename, writeFile } from "fs/promises";
|
|
14
|
+
import { dirname } from "path";
|
|
15
|
+
const TAG_PATTERN = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
16
|
+
const OP_ALIASES = {
|
|
17
|
+
any: "any",
|
|
18
|
+
all: "all",
|
|
19
|
+
notany: "notAny",
|
|
20
|
+
"not any": "notAny",
|
|
21
|
+
notall: "notAll",
|
|
22
|
+
"not all": "notAll",
|
|
23
|
+
notAny: "notAny",
|
|
24
|
+
notAll: "notAll"
|
|
25
|
+
};
|
|
26
|
+
export function normalizeTag(raw) {
|
|
27
|
+
const tag = String(raw ?? "").trim().toLowerCase();
|
|
28
|
+
if (!TAG_PATTERN.test(tag))
|
|
29
|
+
return null;
|
|
30
|
+
return tag;
|
|
31
|
+
}
|
|
32
|
+
export function normalizeTagList(raw) {
|
|
33
|
+
const parts = Array.isArray(raw) ? raw : String(raw ?? "").split(",");
|
|
34
|
+
const tags = [];
|
|
35
|
+
const seen = new Set();
|
|
36
|
+
for (const part of parts) {
|
|
37
|
+
const tag = normalizeTag(part);
|
|
38
|
+
if (!tag || seen.has(tag))
|
|
39
|
+
continue;
|
|
40
|
+
seen.add(tag);
|
|
41
|
+
tags.push(tag);
|
|
42
|
+
}
|
|
43
|
+
return tags;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Monday = 0 … Sunday = 6.
|
|
47
|
+
* [true, true, true, true, true, false, false] is Mon–Fri and does not play Sat or Sun.
|
|
48
|
+
* [false, false, false, false, false, true, true] is Sat and Sun.
|
|
49
|
+
*/
|
|
50
|
+
export function weekdayIndexMondayFirst(date) {
|
|
51
|
+
return (date.getDay() + 6) % 7;
|
|
52
|
+
}
|
|
53
|
+
function localYmd(date) {
|
|
54
|
+
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
55
|
+
const day = String(date.getDate()).padStart(2, "0");
|
|
56
|
+
return `${date.getFullYear()}-${month}-${day}`;
|
|
57
|
+
}
|
|
58
|
+
function isYmd(value) {
|
|
59
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(value))
|
|
60
|
+
return false;
|
|
61
|
+
const [year, month, day] = value.split("-").map(Number);
|
|
62
|
+
const date = new Date(year, month - 1, day);
|
|
63
|
+
return date.getFullYear() === year && date.getMonth() === month - 1 && date.getDate() === day;
|
|
64
|
+
}
|
|
65
|
+
function toMinutes(value) {
|
|
66
|
+
const match = /^(\d{2}):(\d{2})(?::\d{2})?$/.exec(value.trim());
|
|
67
|
+
if (!match)
|
|
68
|
+
return null;
|
|
69
|
+
const hour = Number(match[1]);
|
|
70
|
+
const minute = Number(match[2]);
|
|
71
|
+
if (hour > 23 || minute > 59)
|
|
72
|
+
return null;
|
|
73
|
+
return hour * 60 + minute;
|
|
74
|
+
}
|
|
75
|
+
function normalizeClock(raw) {
|
|
76
|
+
if (typeof raw !== "string")
|
|
77
|
+
return undefined;
|
|
78
|
+
const minutes = toMinutes(raw);
|
|
79
|
+
if (minutes === null)
|
|
80
|
+
return undefined;
|
|
81
|
+
const hour = String(Math.floor(minutes / 60)).padStart(2, "0");
|
|
82
|
+
const minute = String(minutes % 60).padStart(2, "0");
|
|
83
|
+
return `${hour}:${minute}`;
|
|
84
|
+
}
|
|
85
|
+
/** Missing or empty schedule means the item plays all the time. */
|
|
86
|
+
export function parseItemSchedule(raw) {
|
|
87
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
88
|
+
return null;
|
|
89
|
+
const record = raw;
|
|
90
|
+
const schedule = {};
|
|
91
|
+
const startDate = typeof record.startDate === "string" ? record.startDate.trim() : "";
|
|
92
|
+
const endDate = typeof record.endDate === "string" ? record.endDate.trim() : "";
|
|
93
|
+
if (startDate && isYmd(startDate))
|
|
94
|
+
schedule.startDate = startDate;
|
|
95
|
+
if (endDate && isYmd(endDate))
|
|
96
|
+
schedule.endDate = endDate;
|
|
97
|
+
const startTime = normalizeClock(record.startTime);
|
|
98
|
+
const endTime = normalizeClock(record.endTime);
|
|
99
|
+
if (startTime)
|
|
100
|
+
schedule.startTime = startTime;
|
|
101
|
+
if (endTime)
|
|
102
|
+
schedule.endTime = endTime;
|
|
103
|
+
if (Array.isArray(record.days) &&
|
|
104
|
+
record.days.length === 7 &&
|
|
105
|
+
record.days.every((day) => typeof day === "boolean")) {
|
|
106
|
+
schedule.days = record.days.slice();
|
|
107
|
+
}
|
|
108
|
+
if (!schedule.startDate &&
|
|
109
|
+
!schedule.endDate &&
|
|
110
|
+
!schedule.startTime &&
|
|
111
|
+
!schedule.endTime &&
|
|
112
|
+
!schedule.days) {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
return schedule;
|
|
116
|
+
}
|
|
117
|
+
function dateAllowed(schedule, ymd) {
|
|
118
|
+
if (schedule.startDate && ymd < schedule.startDate)
|
|
119
|
+
return false;
|
|
120
|
+
if (schedule.endDate && ymd > schedule.endDate)
|
|
121
|
+
return false;
|
|
122
|
+
return true;
|
|
123
|
+
}
|
|
124
|
+
function dayAllowed(schedule, date) {
|
|
125
|
+
if (!schedule.days)
|
|
126
|
+
return true;
|
|
127
|
+
return schedule.days[weekdayIndexMondayFirst(date)] === true;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* `now` is interpreted in its local timezone. End times are exclusive.
|
|
131
|
+
* A window whose end is earlier than its start continues past midnight,
|
|
132
|
+
* and that tail still belongs to the day the window started.
|
|
133
|
+
*/
|
|
134
|
+
export function itemScheduleActive(raw, now = new Date()) {
|
|
135
|
+
const schedule = parseItemSchedule(raw);
|
|
136
|
+
if (!schedule)
|
|
137
|
+
return true;
|
|
138
|
+
const today = localYmd(now);
|
|
139
|
+
const minutes = now.getHours() * 60 + now.getMinutes();
|
|
140
|
+
const startMinutes = schedule.startTime ? toMinutes(schedule.startTime) : null;
|
|
141
|
+
const endMinutes = schedule.endTime ? toMinutes(schedule.endTime) : null;
|
|
142
|
+
if (startMinutes === null && endMinutes === null) {
|
|
143
|
+
return dateAllowed(schedule, today) && dayAllowed(schedule, now);
|
|
144
|
+
}
|
|
145
|
+
const windowStart = startMinutes ?? 0;
|
|
146
|
+
const windowEnd = endMinutes ?? 24 * 60;
|
|
147
|
+
if (windowEnd > windowStart) {
|
|
148
|
+
if (!dateAllowed(schedule, today) || !dayAllowed(schedule, now))
|
|
149
|
+
return false;
|
|
150
|
+
return minutes >= windowStart && minutes < windowEnd;
|
|
151
|
+
}
|
|
152
|
+
if (windowEnd === windowStart)
|
|
153
|
+
return false;
|
|
154
|
+
if (minutes >= windowStart) {
|
|
155
|
+
return dateAllowed(schedule, today) && dayAllowed(schedule, now);
|
|
156
|
+
}
|
|
157
|
+
if (minutes < windowEnd) {
|
|
158
|
+
const yesterday = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, now.getHours(), now.getMinutes(), now.getSeconds(), now.getMilliseconds());
|
|
159
|
+
return dateAllowed(schedule, localYmd(yesterday)) && dayAllowed(schedule, yesterday);
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
/** Missing, empty, or invalid rules mean the item plays on every screen. */
|
|
164
|
+
export function parseTagRule(raw) {
|
|
165
|
+
if (!raw || typeof raw !== "object")
|
|
166
|
+
return null;
|
|
167
|
+
const record = raw;
|
|
168
|
+
const opKey = String(record.match ?? record.op ?? "").trim();
|
|
169
|
+
const op = OP_ALIASES[opKey] ?? OP_ALIASES[opKey.toLowerCase()];
|
|
170
|
+
if (!op)
|
|
171
|
+
return null;
|
|
172
|
+
const tags = normalizeTagList(record.tags);
|
|
173
|
+
if (tags.length === 0)
|
|
174
|
+
return null;
|
|
175
|
+
return { op, tags };
|
|
176
|
+
}
|
|
177
|
+
export function itemMatchesScreen(screenTags, item, now = new Date()) {
|
|
178
|
+
if (!item || typeof item !== "object")
|
|
179
|
+
return true;
|
|
180
|
+
const rule = parseTagRule(item.tagRule);
|
|
181
|
+
if (rule) {
|
|
182
|
+
const have = new Set(screenTags.map((tag) => tag.trim().toLowerCase()));
|
|
183
|
+
const has = (tag) => have.has(tag);
|
|
184
|
+
const tagsMatch = (() => {
|
|
185
|
+
switch (rule.op) {
|
|
186
|
+
case "any":
|
|
187
|
+
return rule.tags.some(has);
|
|
188
|
+
case "all":
|
|
189
|
+
return rule.tags.every(has);
|
|
190
|
+
case "notAny":
|
|
191
|
+
return rule.tags.every((tag) => !has(tag));
|
|
192
|
+
case "notAll":
|
|
193
|
+
return rule.tags.some((tag) => !has(tag));
|
|
194
|
+
default:
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
})();
|
|
198
|
+
if (!tagsMatch)
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return itemScheduleActive(item.schedule, now);
|
|
202
|
+
}
|
|
203
|
+
function stripPlaybackFields(item) {
|
|
204
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
205
|
+
return item;
|
|
206
|
+
const copy = { ...item };
|
|
207
|
+
delete copy.tagRule;
|
|
208
|
+
delete copy.schedule;
|
|
209
|
+
return copy;
|
|
210
|
+
}
|
|
211
|
+
/** Returns a new policy. Does not mutate the SDK snapshot objects. */
|
|
212
|
+
export function filterPolicyForScreen(policy, screenTags, now = new Date()) {
|
|
213
|
+
if (!policy || typeof policy !== "object")
|
|
214
|
+
return policy;
|
|
215
|
+
const source = policy;
|
|
216
|
+
if (!Array.isArray(source.playlists))
|
|
217
|
+
return policy;
|
|
218
|
+
return {
|
|
219
|
+
...policy,
|
|
220
|
+
playlists: source.playlists.map((playlist) => {
|
|
221
|
+
if (!playlist || typeof playlist !== "object")
|
|
222
|
+
return playlist;
|
|
223
|
+
const record = playlist;
|
|
224
|
+
const items = Array.isArray(record.items) ? record.items : [];
|
|
225
|
+
return {
|
|
226
|
+
...playlist,
|
|
227
|
+
items: items
|
|
228
|
+
.filter((item) => itemMatchesScreen(screenTags, item, now))
|
|
229
|
+
.map((item) => stripPlaybackFields(item))
|
|
230
|
+
};
|
|
231
|
+
})
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function policyItemSignature(policy) {
|
|
235
|
+
if (!policy || typeof policy !== "object")
|
|
236
|
+
return "";
|
|
237
|
+
const playlists = policy.playlists;
|
|
238
|
+
if (!Array.isArray(playlists))
|
|
239
|
+
return "";
|
|
240
|
+
return playlists
|
|
241
|
+
.map((playlist) => {
|
|
242
|
+
if (!playlist || typeof playlist !== "object")
|
|
243
|
+
return "";
|
|
244
|
+
const record = playlist;
|
|
245
|
+
const items = Array.isArray(record.items) ? record.items : [];
|
|
246
|
+
const urls = items.map((item) => item && typeof item === "object" ? String(item.url || "") : "");
|
|
247
|
+
return `${String(record.id || "")}:${urls.join("|")}`;
|
|
248
|
+
})
|
|
249
|
+
.join("\n");
|
|
250
|
+
}
|
|
251
|
+
function policyContainsItemSchedule(policy) {
|
|
252
|
+
if (!policy || typeof policy !== "object")
|
|
253
|
+
return false;
|
|
254
|
+
const playlists = policy.playlists;
|
|
255
|
+
if (!Array.isArray(playlists))
|
|
256
|
+
return false;
|
|
257
|
+
for (const playlist of playlists) {
|
|
258
|
+
if (!playlist || typeof playlist !== "object")
|
|
259
|
+
continue;
|
|
260
|
+
const items = playlist.items;
|
|
261
|
+
if (!Array.isArray(items))
|
|
262
|
+
continue;
|
|
263
|
+
for (const item of items) {
|
|
264
|
+
if (!item || typeof item !== "object")
|
|
265
|
+
continue;
|
|
266
|
+
if (parseItemSchedule(item.schedule))
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Re-push a screen when a schedule window opens or closes.
|
|
274
|
+
* Current players ignore item schedules, so the CMS removes inactive items
|
|
275
|
+
* before the policy is sent. The check uses this computer's local clock.
|
|
276
|
+
*/
|
|
277
|
+
export function startItemScheduleRefresher(deps) {
|
|
278
|
+
let running = false;
|
|
279
|
+
let timer;
|
|
280
|
+
const lastSignature = new Map();
|
|
281
|
+
const arm = () => {
|
|
282
|
+
if (timer)
|
|
283
|
+
clearTimeout(timer);
|
|
284
|
+
const delay = 60_000 - (Date.now() % 60_000) + 250;
|
|
285
|
+
timer = setTimeout(() => {
|
|
286
|
+
void refresh(true);
|
|
287
|
+
}, delay);
|
|
288
|
+
};
|
|
289
|
+
const refresh = async (push) => {
|
|
290
|
+
if (running) {
|
|
291
|
+
arm();
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
running = true;
|
|
295
|
+
try {
|
|
296
|
+
const devices = await deps.host.listDevices();
|
|
297
|
+
const now = new Date();
|
|
298
|
+
for (const device of devices) {
|
|
299
|
+
if (!device.connected)
|
|
300
|
+
continue;
|
|
301
|
+
let built;
|
|
302
|
+
try {
|
|
303
|
+
built = await deps.host.playlists.buildPolicyForDevice(device.deviceId, {
|
|
304
|
+
useLatest: true
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
if (!policyContainsItemSchedule(built.policy)) {
|
|
311
|
+
lastSignature.delete(device.deviceId);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
const tags = await deps.tags.get(device.deviceId);
|
|
315
|
+
const signature = policyItemSignature(filterPolicyForScreen(built.policy, tags, now));
|
|
316
|
+
const previous = lastSignature.get(device.deviceId);
|
|
317
|
+
lastSignature.set(device.deviceId, signature);
|
|
318
|
+
if (!push || previous === signature)
|
|
319
|
+
continue;
|
|
320
|
+
try {
|
|
321
|
+
await deps.host.pushLatestPolicyToDevice(device.deviceId);
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
console.error(`[TomorrowOS] item schedule refresh failed for ${device.deviceId}:`, err);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
console.error("[TomorrowOS] item schedule refresh failed:", err);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
running = false;
|
|
333
|
+
arm();
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
void refresh(true);
|
|
337
|
+
}
|
|
338
|
+
export class ScreenTagStore {
|
|
339
|
+
filePath;
|
|
340
|
+
chain = Promise.resolve();
|
|
341
|
+
constructor(filePath) {
|
|
342
|
+
this.filePath = filePath;
|
|
343
|
+
}
|
|
344
|
+
async get(deviceId) {
|
|
345
|
+
const all = await this.read();
|
|
346
|
+
return all.devices[deviceId]?.slice() ?? [];
|
|
347
|
+
}
|
|
348
|
+
async all() {
|
|
349
|
+
const file = await this.read();
|
|
350
|
+
const out = {};
|
|
351
|
+
for (const [deviceId, tags] of Object.entries(file.devices)) {
|
|
352
|
+
out[deviceId] = tags.slice();
|
|
353
|
+
}
|
|
354
|
+
return out;
|
|
355
|
+
}
|
|
356
|
+
async catalog() {
|
|
357
|
+
const file = await this.read();
|
|
358
|
+
return file.catalog.slice();
|
|
359
|
+
}
|
|
360
|
+
async remember(tag) {
|
|
361
|
+
return this.enqueue(async () => {
|
|
362
|
+
const file = await this.read();
|
|
363
|
+
this.rememberOn(file, tag);
|
|
364
|
+
await this.write(file);
|
|
365
|
+
return file.catalog.slice();
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
async rename(from, to) {
|
|
369
|
+
return this.enqueue(async () => {
|
|
370
|
+
const file = await this.read();
|
|
371
|
+
const touched = [];
|
|
372
|
+
this.rememberOn(file, from);
|
|
373
|
+
file.catalog = file.catalog
|
|
374
|
+
.map((tag) => (tag === from ? to : tag))
|
|
375
|
+
.filter((tag, index, all) => all.indexOf(tag) === index)
|
|
376
|
+
.sort();
|
|
377
|
+
for (const [deviceId, tags] of Object.entries(file.devices)) {
|
|
378
|
+
if (!tags.includes(from))
|
|
379
|
+
continue;
|
|
380
|
+
file.devices[deviceId] = tags
|
|
381
|
+
.map((tag) => (tag === from ? to : tag))
|
|
382
|
+
.filter((tag, index, all) => all.indexOf(tag) === index);
|
|
383
|
+
touched.push(deviceId);
|
|
384
|
+
}
|
|
385
|
+
await this.write(file);
|
|
386
|
+
return touched;
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async forget(tag) {
|
|
390
|
+
await this.enqueue(async () => {
|
|
391
|
+
const file = await this.read();
|
|
392
|
+
file.catalog = file.catalog.filter((item) => item !== tag);
|
|
393
|
+
await this.write(file);
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
async add(deviceId, tag) {
|
|
397
|
+
return this.enqueue(async () => {
|
|
398
|
+
const file = await this.read();
|
|
399
|
+
this.rememberOn(file, tag);
|
|
400
|
+
const current = file.devices[deviceId] ?? [];
|
|
401
|
+
if (!current.includes(tag))
|
|
402
|
+
current.push(tag);
|
|
403
|
+
file.devices[deviceId] = current;
|
|
404
|
+
await this.write(file);
|
|
405
|
+
return current.slice();
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
async remove(deviceId, tag) {
|
|
409
|
+
return this.enqueue(async () => {
|
|
410
|
+
const file = await this.read();
|
|
411
|
+
const current = (file.devices[deviceId] ?? []).filter((item) => item !== tag);
|
|
412
|
+
if (current.length === 0)
|
|
413
|
+
delete file.devices[deviceId];
|
|
414
|
+
else
|
|
415
|
+
file.devices[deviceId] = current;
|
|
416
|
+
await this.write(file);
|
|
417
|
+
return current.slice();
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
async deleteDevice(deviceId) {
|
|
421
|
+
await this.enqueue(async () => {
|
|
422
|
+
const file = await this.read();
|
|
423
|
+
if (!file.devices[deviceId])
|
|
424
|
+
return;
|
|
425
|
+
delete file.devices[deviceId];
|
|
426
|
+
await this.write(file);
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
rememberOn(file, tag) {
|
|
430
|
+
if (!file.catalog.includes(tag)) {
|
|
431
|
+
file.catalog.push(tag);
|
|
432
|
+
file.catalog.sort();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
enqueue(fn) {
|
|
436
|
+
const run = this.chain.then(fn, fn);
|
|
437
|
+
this.chain = run.then(() => undefined, () => undefined);
|
|
438
|
+
return run;
|
|
439
|
+
}
|
|
440
|
+
async read() {
|
|
441
|
+
try {
|
|
442
|
+
const raw = await readFile(this.filePath, "utf8");
|
|
443
|
+
const parsed = JSON.parse(raw);
|
|
444
|
+
const devices = {};
|
|
445
|
+
if (parsed.devices && typeof parsed.devices === "object") {
|
|
446
|
+
for (const [deviceId, tags] of Object.entries(parsed.devices)) {
|
|
447
|
+
devices[deviceId] = normalizeTagList(tags);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
const catalog = normalizeTagList(parsed.catalog);
|
|
451
|
+
for (const tags of Object.values(devices)) {
|
|
452
|
+
for (const tag of tags) {
|
|
453
|
+
if (!catalog.includes(tag))
|
|
454
|
+
catalog.push(tag);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catalog.sort();
|
|
458
|
+
return { devices, catalog };
|
|
459
|
+
}
|
|
460
|
+
catch (err) {
|
|
461
|
+
const code = err.code;
|
|
462
|
+
if (code === "ENOENT")
|
|
463
|
+
return { devices: {}, catalog: [] };
|
|
464
|
+
throw err;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
async write(file) {
|
|
468
|
+
await mkdir(dirname(this.filePath), { recursive: true });
|
|
469
|
+
const tmp = `${this.filePath}.${process.pid}.tmp`;
|
|
470
|
+
await writeFile(tmp, JSON.stringify(file, null, 2), "utf8");
|
|
471
|
+
await rename(tmp, this.filePath);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
function sendJson(res, status, body) {
|
|
475
|
+
res.writeHead(status, {
|
|
476
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
477
|
+
"Cache-Control": "no-store"
|
|
478
|
+
});
|
|
479
|
+
res.end(JSON.stringify(body));
|
|
480
|
+
}
|
|
481
|
+
function pathnameOf(req) {
|
|
482
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
483
|
+
let pathname = url.pathname || "/";
|
|
484
|
+
if (pathname.length > 1 && pathname.endsWith("/"))
|
|
485
|
+
pathname = pathname.slice(0, -1);
|
|
486
|
+
return pathname;
|
|
487
|
+
}
|
|
488
|
+
async function readJson(req) {
|
|
489
|
+
const chunks = [];
|
|
490
|
+
let size = 0;
|
|
491
|
+
for await (const chunk of req) {
|
|
492
|
+
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
493
|
+
size += buf.length;
|
|
494
|
+
if (size > 1_000_000)
|
|
495
|
+
throw new Error("Body too large");
|
|
496
|
+
chunks.push(buf);
|
|
497
|
+
}
|
|
498
|
+
if (chunks.length === 0)
|
|
499
|
+
return {};
|
|
500
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
501
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
502
|
+
throw new Error("JSON object required");
|
|
503
|
+
}
|
|
504
|
+
return parsed;
|
|
505
|
+
}
|
|
506
|
+
async function requirePaired(deps, deviceId, res) {
|
|
507
|
+
const paired = await deps.store.getPairedDevice(deviceId);
|
|
508
|
+
if (paired)
|
|
509
|
+
return true;
|
|
510
|
+
sendJson(res, 404, { status: "failed", error: "Device not paired" });
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
function replaceTagOnItems(items, from, to) {
|
|
514
|
+
let changed = false;
|
|
515
|
+
const next = items.map((item) => {
|
|
516
|
+
if (!item || typeof item !== "object")
|
|
517
|
+
return item;
|
|
518
|
+
const copy = { ...item };
|
|
519
|
+
const rule = copy.tagRule;
|
|
520
|
+
if (!rule || typeof rule !== "object")
|
|
521
|
+
return copy;
|
|
522
|
+
const tags = normalizeTagList(rule.tags);
|
|
523
|
+
if (!tags.includes(from))
|
|
524
|
+
return copy;
|
|
525
|
+
changed = true;
|
|
526
|
+
const replaced = tags
|
|
527
|
+
.map((tag) => (tag === from ? to : tag))
|
|
528
|
+
.filter((tag, index, all) => all.indexOf(tag) === index);
|
|
529
|
+
const ruleCopy = { ...rule };
|
|
530
|
+
if (replaced.length === 0)
|
|
531
|
+
delete copy.tagRule;
|
|
532
|
+
else {
|
|
533
|
+
ruleCopy.tags = replaced;
|
|
534
|
+
copy.tagRule = ruleCopy;
|
|
535
|
+
}
|
|
536
|
+
return copy;
|
|
537
|
+
});
|
|
538
|
+
return { items: next, changed };
|
|
539
|
+
}
|
|
540
|
+
async function listKnownTags(deps) {
|
|
541
|
+
const known = new Set(await deps.tags.catalog());
|
|
542
|
+
const playlists = await deps.store.listPlaylists();
|
|
543
|
+
for (const playlist of playlists) {
|
|
544
|
+
for (const item of playlist.items ?? []) {
|
|
545
|
+
for (const tag of normalizeTagList(item.tagRule?.tags)) {
|
|
546
|
+
known.add(tag);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return [...known].sort();
|
|
551
|
+
}
|
|
552
|
+
async function findTagUsage(deps, tag) {
|
|
553
|
+
const devices = await deps.host.listDevices();
|
|
554
|
+
const byDevice = await deps.tags.all();
|
|
555
|
+
const screens = devices
|
|
556
|
+
.filter((device) => (byDevice[device.deviceId] ?? []).includes(tag))
|
|
557
|
+
.map((device) => ({
|
|
558
|
+
deviceId: device.deviceId,
|
|
559
|
+
deviceName: device.deviceName ?? null
|
|
560
|
+
}));
|
|
561
|
+
const playlists = await deps.store.listPlaylists();
|
|
562
|
+
const assets = [];
|
|
563
|
+
for (const playlist of playlists) {
|
|
564
|
+
if (playlist.retired)
|
|
565
|
+
continue;
|
|
566
|
+
for (const item of playlist.items ?? []) {
|
|
567
|
+
const tags = normalizeTagList(item.tagRule?.tags);
|
|
568
|
+
if (!tags.includes(tag))
|
|
569
|
+
continue;
|
|
570
|
+
const url = typeof item.url === "string" ? item.url : "";
|
|
571
|
+
assets.push({
|
|
572
|
+
playlistName: playlist.name,
|
|
573
|
+
label: url.split("/").pop() || "asset"
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
return { screens, assets };
|
|
578
|
+
}
|
|
579
|
+
function tagInUseMessage(tag, usage) {
|
|
580
|
+
const parts = [];
|
|
581
|
+
if (usage.screens.length > 0) {
|
|
582
|
+
parts.push(`screens: ${usage.screens.map((screen) => screen.deviceName || screen.deviceId).join(", ")}`);
|
|
583
|
+
}
|
|
584
|
+
if (usage.assets.length > 0) {
|
|
585
|
+
parts.push(`assets: ${usage.assets.map((asset) => `${asset.label} (${asset.playlistName})`).join(", ")}`);
|
|
586
|
+
}
|
|
587
|
+
return `Remove “${tag}” from ${parts.join(" and ")} before deleting it.`;
|
|
588
|
+
}
|
|
589
|
+
async function renameTagEverywhere(deps, from, to) {
|
|
590
|
+
const playlists = await deps.store.listPlaylists();
|
|
591
|
+
for (const playlist of playlists) {
|
|
592
|
+
const replaced = replaceTagOnItems(playlist.items ?? [], from, to);
|
|
593
|
+
if (!replaced.changed)
|
|
594
|
+
continue;
|
|
595
|
+
const next = {
|
|
596
|
+
...playlist,
|
|
597
|
+
items: replaced.items
|
|
598
|
+
};
|
|
599
|
+
await deps.store.setPlaylist(next);
|
|
600
|
+
}
|
|
601
|
+
const paired = await deps.store.listPairedDevices();
|
|
602
|
+
const pushIds = new Set(await deps.tags.rename(from, to));
|
|
603
|
+
for (const { deviceId } of paired) {
|
|
604
|
+
const assignments = await deps.store.getDeviceAssignments(deviceId);
|
|
605
|
+
let changed = false;
|
|
606
|
+
const next = assignments.map((assignment) => {
|
|
607
|
+
const replaced = replaceTagOnItems(assignment.snapshot.items ?? [], from, to);
|
|
608
|
+
if (!replaced.changed)
|
|
609
|
+
return assignment;
|
|
610
|
+
changed = true;
|
|
611
|
+
pushIds.add(deviceId);
|
|
612
|
+
const updated = {
|
|
613
|
+
...assignment,
|
|
614
|
+
snapshot: {
|
|
615
|
+
...assignment.snapshot,
|
|
616
|
+
items: replaced.items
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
return updated;
|
|
620
|
+
});
|
|
621
|
+
if (changed)
|
|
622
|
+
await deps.store.setDeviceAssignments(deviceId, next);
|
|
623
|
+
}
|
|
624
|
+
for (const deviceId of pushIds) {
|
|
625
|
+
try {
|
|
626
|
+
await deps.host.pushLatestPolicyToDevice(deviceId);
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
// An offline screen picks up the renamed tag the next time it connects.
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
async function replyAfterPush(res, deps, deviceId, tags) {
|
|
634
|
+
try {
|
|
635
|
+
const result = await deps.host.pushLatestPolicyToDevice(deviceId);
|
|
636
|
+
sendJson(res, 200, {
|
|
637
|
+
status: "success",
|
|
638
|
+
deviceId,
|
|
639
|
+
tags,
|
|
640
|
+
pushed: result.pushed,
|
|
641
|
+
...(result.pushed
|
|
642
|
+
? {}
|
|
643
|
+
: {
|
|
644
|
+
note: "Tag saved. The screen updates when it is online and a playlist is assigned."
|
|
645
|
+
})
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
catch (err) {
|
|
649
|
+
const message = err instanceof Error ? err.message : "push failed";
|
|
650
|
+
sendJson(res, 200, {
|
|
651
|
+
status: "success",
|
|
652
|
+
deviceId,
|
|
653
|
+
tags,
|
|
654
|
+
pushed: false,
|
|
655
|
+
error: message
|
|
656
|
+
});
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
export async function handleScreenTagHttp(req, res, deps) {
|
|
660
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
661
|
+
const pathname = pathnameOf(req);
|
|
662
|
+
const url = new URL(req.url || "/", "http://localhost");
|
|
663
|
+
if (method === "GET" && pathname === "/tags") {
|
|
664
|
+
sendJson(res, 200, { status: "success", tags: await listKnownTags(deps) });
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
if (method === "POST" && pathname === "/tags") {
|
|
668
|
+
let body;
|
|
669
|
+
try {
|
|
670
|
+
body = await readJson(req);
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
sendJson(res, 400, {
|
|
674
|
+
status: "failed",
|
|
675
|
+
error: err instanceof Error ? err.message : "Invalid JSON"
|
|
676
|
+
});
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
const tag = normalizeTag(body.tag);
|
|
680
|
+
if (!tag) {
|
|
681
|
+
sendJson(res, 400, {
|
|
682
|
+
status: "failed",
|
|
683
|
+
error: "Tag must be letters, numbers, hyphens, or underscores (e.g. screen-a)"
|
|
684
|
+
});
|
|
685
|
+
return true;
|
|
686
|
+
}
|
|
687
|
+
const known = await listKnownTags(deps);
|
|
688
|
+
if (!known.includes(tag))
|
|
689
|
+
await deps.tags.remember(tag);
|
|
690
|
+
sendJson(res, 200, { status: "success", tags: await listKnownTags(deps) });
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
const catalogTag = /^\/tags\/([^/]+)$/.exec(pathname);
|
|
694
|
+
if (catalogTag && (method === "PATCH" || method === "DELETE")) {
|
|
695
|
+
const from = normalizeTag(decodeURIComponent(catalogTag[1]));
|
|
696
|
+
if (!from) {
|
|
697
|
+
sendJson(res, 400, { status: "failed", error: "Invalid tag" });
|
|
698
|
+
return true;
|
|
699
|
+
}
|
|
700
|
+
const known = await listKnownTags(deps);
|
|
701
|
+
if (!known.includes(from)) {
|
|
702
|
+
sendJson(res, 404, { status: "failed", error: "Tag not found" });
|
|
703
|
+
return true;
|
|
704
|
+
}
|
|
705
|
+
if (method === "DELETE") {
|
|
706
|
+
const usage = await findTagUsage(deps, from);
|
|
707
|
+
if (usage.screens.length > 0 || usage.assets.length > 0) {
|
|
708
|
+
sendJson(res, 409, {
|
|
709
|
+
status: "failed",
|
|
710
|
+
error: tagInUseMessage(from, usage),
|
|
711
|
+
screens: usage.screens,
|
|
712
|
+
assets: usage.assets
|
|
713
|
+
});
|
|
714
|
+
return true;
|
|
715
|
+
}
|
|
716
|
+
await deps.tags.forget(from);
|
|
717
|
+
sendJson(res, 200, { status: "success", tags: await listKnownTags(deps) });
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
let body;
|
|
721
|
+
try {
|
|
722
|
+
body = await readJson(req);
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
sendJson(res, 400, {
|
|
726
|
+
status: "failed",
|
|
727
|
+
error: err instanceof Error ? err.message : "Invalid JSON"
|
|
728
|
+
});
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
const to = normalizeTag(body.tag);
|
|
732
|
+
if (!to) {
|
|
733
|
+
sendJson(res, 400, {
|
|
734
|
+
status: "failed",
|
|
735
|
+
error: "Tag must be letters, numbers, hyphens, or underscores (e.g. screen-a)"
|
|
736
|
+
});
|
|
737
|
+
return true;
|
|
738
|
+
}
|
|
739
|
+
if (to !== from && known.includes(to)) {
|
|
740
|
+
sendJson(res, 409, { status: "failed", error: `Tag “${to}” already exists.` });
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
if (to !== from)
|
|
744
|
+
await renameTagEverywhere(deps, from, to);
|
|
745
|
+
sendJson(res, 200, { status: "success", tags: await listKnownTags(deps) });
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
if (method === "GET" && pathname === "/screen-tags") {
|
|
749
|
+
const tag = normalizeTag(url.searchParams.get("tag"));
|
|
750
|
+
if (url.searchParams.has("tag") && !tag) {
|
|
751
|
+
sendJson(res, 400, { status: "failed", error: "Invalid tag" });
|
|
752
|
+
return true;
|
|
753
|
+
}
|
|
754
|
+
const tagsByDevice = await deps.tags.all();
|
|
755
|
+
if (!tag) {
|
|
756
|
+
sendJson(res, 200, { status: "success", tagsByDevice });
|
|
757
|
+
return true;
|
|
758
|
+
}
|
|
759
|
+
const devices = await deps.host.listDevices();
|
|
760
|
+
const screens = devices
|
|
761
|
+
.filter((device) => (tagsByDevice[device.deviceId] ?? []).includes(tag))
|
|
762
|
+
.map((device) => ({
|
|
763
|
+
deviceId: device.deviceId,
|
|
764
|
+
deviceName: device.deviceName,
|
|
765
|
+
tags: tagsByDevice[device.deviceId] ?? []
|
|
766
|
+
}));
|
|
767
|
+
sendJson(res, 200, { status: "success", tag, screens });
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
const preview = /^\/device\/([^/]+)\/playback-preview$/.exec(pathname);
|
|
771
|
+
if (method === "GET" && preview) {
|
|
772
|
+
const deviceId = decodeURIComponent(preview[1]);
|
|
773
|
+
if (!(await requirePaired(deps, deviceId, res)))
|
|
774
|
+
return true;
|
|
775
|
+
const tags = await deps.tags.get(deviceId);
|
|
776
|
+
const built = await deps.host.playlists.buildPolicyForDevice(deviceId, {
|
|
777
|
+
useLatest: true
|
|
778
|
+
});
|
|
779
|
+
const policy = filterPolicyForScreen(built.policy, tags);
|
|
780
|
+
sendJson(res, 200, {
|
|
781
|
+
status: "success",
|
|
782
|
+
deviceId,
|
|
783
|
+
tags,
|
|
784
|
+
playlists: (policy.playlists ?? []).map((playlist) => ({
|
|
785
|
+
id: playlist.id,
|
|
786
|
+
name: playlist.name,
|
|
787
|
+
items: playlist.items ?? []
|
|
788
|
+
}))
|
|
789
|
+
});
|
|
790
|
+
return true;
|
|
791
|
+
}
|
|
792
|
+
const tagCollection = /^\/device\/([^/]+)\/tags$/.exec(pathname);
|
|
793
|
+
if (method === "POST" && tagCollection) {
|
|
794
|
+
const deviceId = decodeURIComponent(tagCollection[1]);
|
|
795
|
+
if (!(await requirePaired(deps, deviceId, res)))
|
|
796
|
+
return true;
|
|
797
|
+
let body;
|
|
798
|
+
try {
|
|
799
|
+
body = await readJson(req);
|
|
800
|
+
}
|
|
801
|
+
catch (err) {
|
|
802
|
+
sendJson(res, 400, {
|
|
803
|
+
status: "failed",
|
|
804
|
+
error: err instanceof Error ? err.message : "Invalid JSON"
|
|
805
|
+
});
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
const tag = normalizeTag(body.tag);
|
|
809
|
+
if (!tag) {
|
|
810
|
+
sendJson(res, 400, {
|
|
811
|
+
status: "failed",
|
|
812
|
+
error: "Tag must be letters, numbers, hyphens, or underscores (e.g. screen-a)"
|
|
813
|
+
});
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
const tags = await deps.tags.add(deviceId, tag);
|
|
817
|
+
await replyAfterPush(res, deps, deviceId, tags);
|
|
818
|
+
return true;
|
|
819
|
+
}
|
|
820
|
+
const oneTag = /^\/device\/([^/]+)\/tags\/([^/]+)$/.exec(pathname);
|
|
821
|
+
if (method === "DELETE" && oneTag) {
|
|
822
|
+
const deviceId = decodeURIComponent(oneTag[1]);
|
|
823
|
+
const tag = normalizeTag(decodeURIComponent(oneTag[2]));
|
|
824
|
+
if (!tag) {
|
|
825
|
+
sendJson(res, 400, { status: "failed", error: "Invalid tag" });
|
|
826
|
+
return true;
|
|
827
|
+
}
|
|
828
|
+
if (!(await requirePaired(deps, deviceId, res)))
|
|
829
|
+
return true;
|
|
830
|
+
const tags = await deps.tags.remove(deviceId, tag);
|
|
831
|
+
await replyAfterPush(res, deps, deviceId, tags);
|
|
832
|
+
return true;
|
|
833
|
+
}
|
|
834
|
+
return false;
|
|
835
|
+
}
|