250 lines
8.3 KiB
TypeScript
250 lines
8.3 KiB
TypeScript
import {
|
|
type NewStoredSelection,
|
|
type PlaybackChanges,
|
|
type SelectionChanges,
|
|
type SignagePlaybackStore,
|
|
SignageStoreConflict,
|
|
type StoredChannel,
|
|
type StoredChannelGroup,
|
|
type StoredChannelGroupDetail,
|
|
type StoredPlaybackState,
|
|
type StoredSelection,
|
|
} from "../../src/server/store/types.js";
|
|
|
|
/**
|
|
* The playback half of the in-memory store.
|
|
*
|
|
* It enforces the guarantees the database enforces — one default channel that cannot be removed, a
|
|
* sequence that is never handed out twice, variants replaced wholesale — so a decision proven here
|
|
* holds against PostgreSQL too.
|
|
*/
|
|
export interface MemoryPlaybackStore extends SignagePlaybackStore {
|
|
readonly groups: Map<string, StoredChannelGroup>;
|
|
readonly channels: Map<string, StoredChannel>;
|
|
readonly playback: Map<string, StoredPlaybackState>;
|
|
readonly selections: Map<string, StoredSelection>;
|
|
}
|
|
|
|
export function createMemoryPlaybackStore(
|
|
options: { readonly now?: () => Date } = {},
|
|
): MemoryPlaybackStore {
|
|
const now = options.now ?? (() => new Date());
|
|
const groups = new Map<string, StoredChannelGroup>();
|
|
const channels = new Map<string, StoredChannel>();
|
|
const playback = new Map<string, StoredPlaybackState>();
|
|
const selections = new Map<string, StoredSelection>();
|
|
|
|
function detail(groupId: string): StoredChannelGroupDetail | undefined {
|
|
const group = groups.get(groupId);
|
|
const state = playback.get(groupId);
|
|
if (!group || !state) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
channels: [...channels.values()]
|
|
.filter((channel) => channel.groupId === groupId)
|
|
.sort((left, right) =>
|
|
left.isDefault === right.isDefault
|
|
? left.key.localeCompare(right.key)
|
|
: left.isDefault
|
|
? -1
|
|
: 1,
|
|
),
|
|
group,
|
|
playback: state,
|
|
};
|
|
}
|
|
|
|
return {
|
|
addChannel(channel: StoredChannel): Promise<void> {
|
|
channels.set(channel.id, channel);
|
|
const group = groups.get(channel.groupId);
|
|
if (group) {
|
|
groups.set(group.id, { ...group, revision: group.revision + 1 });
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
|
|
channels,
|
|
|
|
createChannelGroup(input: {
|
|
readonly groupId: string;
|
|
readonly name: string;
|
|
readonly channelId: string;
|
|
readonly channelName: string;
|
|
}): Promise<void> {
|
|
groups.set(input.groupId, { id: input.groupId, name: input.name, revision: 1 });
|
|
channels.set(input.channelId, {
|
|
groupId: input.groupId,
|
|
id: input.channelId,
|
|
isDefault: true,
|
|
key: "default",
|
|
name: input.channelName,
|
|
});
|
|
playback.set(input.groupId, {
|
|
activeTimelineId: null,
|
|
blackout: false,
|
|
currentSelectionId: null,
|
|
currentSlotId: null,
|
|
groupId: input.groupId,
|
|
nextSequence: 1,
|
|
paused: false,
|
|
pausedRemainingMs: null,
|
|
pendingRequestedAt: null,
|
|
pendingTimelineId: null,
|
|
revision: 1,
|
|
scheduledActivationAt: null,
|
|
scheduledTimelineId: null,
|
|
slotDeadlineAt: null,
|
|
slotStartedAt: null,
|
|
staticAssetId: null,
|
|
updatedAt: now(),
|
|
});
|
|
return Promise.resolve();
|
|
},
|
|
|
|
deleteChannelGroup(groupId: string, _fallbackChannelId: string): Promise<void> {
|
|
groups.delete(groupId);
|
|
playback.delete(groupId);
|
|
for (const [id, channel] of channels) {
|
|
if (channel.groupId === groupId) {
|
|
channels.delete(id);
|
|
}
|
|
}
|
|
for (const [id, selection] of selections) {
|
|
if (selection.groupId === groupId) {
|
|
selections.delete(id);
|
|
}
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
|
|
findChannelGroup(groupId: string): Promise<StoredChannelGroupDetail | undefined> {
|
|
return Promise.resolve(detail(groupId));
|
|
},
|
|
|
|
findLatestSelectionForScope(
|
|
groupId: string,
|
|
selectionScope: string,
|
|
): Promise<StoredSelection | undefined> {
|
|
const matching = [...selections.values()]
|
|
.filter(
|
|
(selection) =>
|
|
selection.groupId === groupId && selection.selectionScope === selectionScope,
|
|
)
|
|
.sort((left, right) => right.sequence - left.sequence);
|
|
return Promise.resolve(matching[0]);
|
|
},
|
|
|
|
findSelection(selectionId: string): Promise<StoredSelection | undefined> {
|
|
return Promise.resolve(selections.get(selectionId));
|
|
},
|
|
|
|
groups,
|
|
|
|
insertSelection(selection: NewStoredSelection): Promise<void> {
|
|
selections.set(selection.id, { ...selection, resolvedAt: now(), revision: 1 });
|
|
return Promise.resolve();
|
|
},
|
|
|
|
listChannelGroups(): Promise<readonly StoredChannelGroupDetail[]> {
|
|
return Promise.resolve(
|
|
[...groups.values()]
|
|
.sort((left, right) => left.name.localeCompare(right.name))
|
|
.map((group) => detail(group.id))
|
|
.filter((value): value is StoredChannelGroupDetail => value !== undefined),
|
|
);
|
|
},
|
|
|
|
listSelections(groupId: string, limit: number): Promise<readonly StoredSelection[]> {
|
|
return Promise.resolve(
|
|
[...selections.values()]
|
|
.filter((selection) => selection.groupId === groupId)
|
|
.sort((left, right) => right.sequence - left.sequence)
|
|
.slice(0, limit),
|
|
);
|
|
},
|
|
|
|
playback,
|
|
|
|
pruneSelections(groupId: string, limit: number): Promise<void> {
|
|
const ordered = [...selections.values()]
|
|
.filter((selection) => selection.groupId === groupId)
|
|
.sort((left, right) => right.sequence - left.sequence);
|
|
for (const selection of ordered.slice(limit)) {
|
|
selections.delete(selection.id);
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
|
|
removeChannel(channelId: string): Promise<void> {
|
|
const channel = channels.get(channelId);
|
|
if (!channel) {
|
|
return Promise.resolve();
|
|
}
|
|
if (channel.isDefault) {
|
|
return Promise.reject(new SignageStoreConflict("channel.default"));
|
|
}
|
|
channels.delete(channelId);
|
|
const group = groups.get(channel.groupId);
|
|
if (group) {
|
|
groups.set(group.id, { ...group, revision: group.revision + 1 });
|
|
}
|
|
return Promise.resolve();
|
|
},
|
|
|
|
selections,
|
|
|
|
takeSelectionSequence(groupId: string): Promise<number> {
|
|
const state = playback.get(groupId);
|
|
if (!state) {
|
|
return Promise.reject(new SignageStoreConflict("playback.sequence"));
|
|
}
|
|
playback.set(groupId, { ...state, nextSequence: state.nextSequence + 1 });
|
|
return Promise.resolve(state.nextSequence);
|
|
},
|
|
|
|
updatePlayback(
|
|
groupId: string,
|
|
changes: PlaybackChanges,
|
|
updateOptions?: { readonly bumpRevision?: boolean },
|
|
): Promise<void> {
|
|
const state = playback.get(groupId);
|
|
if (!state) {
|
|
return Promise.resolve();
|
|
}
|
|
const next: Record<string, unknown> = { ...state };
|
|
for (const key of Object.keys(changes)) {
|
|
next[key] = (changes as Record<string, unknown>)[key] ?? null;
|
|
}
|
|
playback.set(groupId, {
|
|
...(next as unknown as StoredPlaybackState),
|
|
revision: updateOptions?.bumpRevision === false ? state.revision : state.revision + 1,
|
|
updatedAt: now(),
|
|
});
|
|
return Promise.resolve();
|
|
},
|
|
|
|
updateSelection(selectionId: string, changes: SelectionChanges): Promise<void> {
|
|
const selection = selections.get(selectionId);
|
|
if (!selection) {
|
|
return Promise.resolve();
|
|
}
|
|
selections.set(selectionId, {
|
|
...selection,
|
|
...(changes.availableUntil === undefined ? {} : { availableUntil: changes.availableUntil }),
|
|
...(changes.deadlineAt === undefined ? {} : { deadlineAt: changes.deadlineAt }),
|
|
...(changes.durationMs === undefined ? {} : { durationMs: changes.durationMs }),
|
|
...(changes.itemRevision === undefined ? {} : { itemRevision: changes.itemRevision }),
|
|
...(changes.presentation === undefined ? {} : { presentation: changes.presentation }),
|
|
...(changes.stale === undefined ? {} : { stale: changes.stale }),
|
|
...(changes.startedAt === undefined ? {} : { startedAt: changes.startedAt }),
|
|
...(changes.withdrawn === undefined ? {} : { withdrawn: changes.withdrawn }),
|
|
...(changes.variants === undefined ? {} : { variants: changes.variants }),
|
|
revision: changes.bumpRevision === true ? selection.revision + 1 : selection.revision,
|
|
});
|
|
return Promise.resolve();
|
|
},
|
|
};
|
|
}
|