Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/signage-manager/USER_STORIES.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,9 @@ These stories cover the current app workflows:
- Users can search schedules by display, zone, playlist, and source labels where applicable.
- Timeline rows link to the related display or zone detail page.
- Display rows show the same online, offline, or never seen status as the displays page.
- Timeline blocks show only the times that the player plays. Blocks stop at the playlist and schedule dates, and clock times that daylight saving skips do not show. Blocks of disabled playlists say "Disabled" in text.
- Empty and filtered states explain when no rows are available.
- Users must confirm a change that makes two takeover playlists play at the same time on a display in the next 14 days. The check runs when users save playlist schedules and when they assign a playlist to a display or zone. The warning names the display, the other playlist, and the start time of the overlap.
- Users must confirm a change that makes two takeover playlists play at the same time on a display in the next 14 days. The check runs when users save playlist schedules and when they assign a playlist to a display or zone. The warning names the display, the other playlist, and the start time of the overlap. Disabled playlists and overlaps that ended earlier today do not count. A single pass (play period 0) plays alone before timed takeovers, so it conflicts only with another single pass.

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from '@placeos/components';
import { MediaAnimation, SignagePlaylist } from '@placeos/ts-client';
import { SignageDisplayService } from '../displays/signage-display.service';
import { playlistSchedules } from '../schedules/signage-schedule.util';
import { SignageSharedWithComponent } from '../shared/signage-shared-with.component';
import { SignageContextService } from '../signage-context.service';
import { SignageInventoryService } from '../signage-inventory.service';
Expand All @@ -28,27 +29,6 @@ import {
import { SignageZoneService } from '../zones/signage-zone.service';
import { SignagePlaylistService } from './signage-playlist.service';

const DEFAULT_PLAY_PERIOD_MINUTES = 24 * 60;

function playlistSchedules(playlist: SignagePlaylist) {
const legacy_playlist = playlist as SignagePlaylist & {
play_at?: number;
play_cron?: string;
play_period?: number;
play_takeover?: boolean;
};
if (playlist.schedules?.length) return playlist.schedules;
return [
{
play_at: legacy_playlist.play_at,
play_cron: legacy_playlist.play_cron || '0 0 * * *',
play_period:
legacy_playlist.play_period ?? DEFAULT_PLAY_PERIOD_MINUTES,
play_takeover: !!legacy_playlist.play_takeover,
},
];
}

@Component({
selector: 'playlist-item-details',
template: `
Expand Down
233 changes: 159 additions & 74 deletions apps/signage-manager/src/app/schedules/schedule-conflicts.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export interface TakeoverConflictOptions {
playlists: SignagePlaylist[];
/** Only return conflicts that include this playlist */
playlist_id?: string;
/** First day to check. Defaults to today. */
/** Time to check from. Leaves out overlaps that end before it. Defaults to now. */
start?: Date;
days?: number;
}
Expand All @@ -36,14 +36,96 @@ export interface TakeoverConflictOptions {
* at midnight.
*/
function wallClockDate(first_day: Date, minutes: number) {
const date = addDays(first_day, Math.floor(minutes / MINUTES_PER_DAY));
date.setHours(0, minutes % MINUTES_PER_DAY, 0, 0);
const days = Math.floor(minutes / MINUTES_PER_DAY);
const date = addDays(first_day, days);
date.setHours(0, minutes - days * MINUTES_PER_DAY, 0, 0);
return date;
}

/** Minutes from the start of the first day in which a takeover plays */
interface TakeoverRun {
playlist: SignagePlaylist;
start: number;
end: number;
}

/** Overlap of two takeover playlists, in minutes from the first day */
interface TakeoverOverlap {
playlists: [SignagePlaylist, SignagePlaylist];
start: number;
end: number;
}

/**
* Timed runs and single passes (`play_period` 0) of a playlist, in start
* order. The player plays a single pass alone, ahead of timed runs, so the
* two kinds do not play at the same time.
*/
interface PlaylistRuns {
timed: TakeoverRun[];
single_pass: TakeoverRun[];
}

const byStart = (a: { start: number }, b: { start: number }) =>
a.start - b.start;

const pairKey = ([a, b]: [SignagePlaylist, SignagePlaylist]) =>
[a.id, b.id].sort().join('|');

/**
* Join touching or overlapping runs. Runs must be in start order. Keeps the
* sweep in `findOverlaps` short when a playlist plays every few minutes.
*/
function mergeRuns(runs: TakeoverRun[]) {
const merged: TakeoverRun[] = [];
for (const run of runs) {
const last = merged[merged.length - 1];
if (last && run.start <= last.end) {
last.end = Math.max(last.end, run.end);
} else {
merged.push({ ...run });
}
}
return merged;
}

/**
* Overlaps of runs of different playlists, from runs in start order. When
* `playlist_id` is set, only overlaps with that playlist.
*/
function findOverlaps(runs: TakeoverRun[], playlist_id?: string) {
const overlaps: TakeoverOverlap[] = [];
for (let i = 0; i < runs.length; i++) {
const first = runs[i];
for (let j = i + 1; j < runs.length && runs[j].start < first.end; j++) {
const second = runs[j];
if (
first.playlist.id === second.playlist.id ||
(playlist_id &&
first.playlist.id !== playlist_id &&
second.playlist.id !== playlist_id)
) {
continue;
}
overlaps.push({
playlists: [first.playlist, second.playlist],
start: second.start,
end: Math.min(first.end, second.end),
});
}
}
return overlaps;
}

/**
* Find takeover schedules that overlap on the same display. Returns the
* first overlap of each pair of playlists on each display.
* first overlap of each pair of playlists on each display that has not
* ended before `start`.
*
* Matches the player: only enabled playlists play, and a single pass plays
* alone ahead of timed runs. So a single pass conflicts only with another
* single pass, and a timed run only with another timed run. A single pass
* has no set length, so it counts as one minute.
*/
export function findTakeoverConflicts({
displays,
Expand All @@ -54,88 +136,91 @@ export function findTakeoverConflicts({
days = CONFLICT_WINDOW_DAYS,
}: TakeoverConflictOptions): TakeoverConflict[] {
const first_day = startOfDay(start);
const day_list = Array.from({ length: days }, (_, index) =>
addDays(first_day, index),
const now = start.getHours() * 60 + start.getMinutes();
// Start a day early, so a run from yesterday that still plays counts
const day_list = Array.from({ length: days + 1 }, (_, index) =>
addDays(first_day, index - 1),
);
// Only enabled takeover playlists can conflict
const takeover_playlists = playlists.filter(
(playlist) => playlist.enabled && hasTakeoverSchedule(playlist),
);
// Only takeover playlists can conflict
const takeover_playlists = playlists.filter(hasTakeoverSchedule);
// Blocks do not depend on the display, so build them once per playlist
const playlist_blocks = new Map<
string,
{ playlist: SignagePlaylist; start: number; end: number }[]
>();
const takeoverBlocks = (playlist: SignagePlaylist) => {
let blocks = playlist_blocks.get(playlist.id);
if (blocks) return blocks;
// Minutes from the first day, so blocks that run past midnight
// still overlap blocks on the next day.
blocks = buildScheduleBlocks([{ playlist }], day_list)
.filter((block) => block.takeover)
.map((block) => {
const block_start =
block.day_index * MINUTES_PER_DAY + block.start_minutes;
// A play period of 0 plays the playlist once, so treat it as 1 minute
const length = Math.max(1, block.duration_minutes);
return {
playlist: block.playlist,
start: block_start,
end: block_start + length,
};
// Runs do not depend on the display, so build them once per playlist
const playlist_runs = new Map<string, PlaylistRuns>();
const takeoverRuns = (playlist: SignagePlaylist) => {
const cached = playlist_runs.get(playlist.id);
if (cached) return cached;
const runs: PlaylistRuns = { timed: [], single_pass: [] };
for (const block of buildScheduleBlocks([{ playlist }], day_list)) {
if (!block.takeover) continue;
// Minutes from the first day, so runs that go past midnight
// still overlap runs on the next day
const run_start =
(block.day_index - 1) * MINUTES_PER_DAY + block.start_minutes;
const run_end = run_start + Math.max(1, block.duration_minutes);
if (run_end <= now) continue;
runs[block.duration_minutes ? 'timed' : 'single_pass'].push({
playlist,
start: run_start,
end: run_end,
});
playlist_blocks.set(playlist.id, blocks);
return blocks;
}
runs.timed = mergeRuns(runs.timed.sort(byStart));
runs.single_pass = mergeRuns(runs.single_pass.sort(byStart));
playlist_runs.set(playlist.id, runs);
return runs;
};
// Displays with the same takeover playlists have the same overlaps
const set_overlaps = new Map<string, TakeoverOverlap[]>();
const overlapsOf = (assigned: SignagePlaylist[]) => {
const key = assigned
.map(({ id }) => id)
.sort()
.join('|');
const cached = set_overlaps.get(key);
if (cached) return cached;
const runs = assigned.map(takeoverRuns);
const seen_pairs = new Set<string>();
const overlaps = [
...findOverlaps(
runs.flatMap(({ timed }) => timed).sort(byStart),
playlist_id,
),
...findOverlaps(
runs.flatMap(({ single_pass }) => single_pass).sort(byStart),
playlist_id,
),
]
.sort(byStart)
.filter(({ playlists }) => {
const pair = pairKey(playlists);
if (seen_pairs.has(pair)) return false;
seen_pairs.add(pair);
return true;
});
set_overlaps.set(key, overlaps);
return overlaps;
};
const conflicts: TakeoverConflict[] = [];
for (const display of displays) {
const assignments = buildDisplayScheduleAssignments(
const assigned = buildDisplayScheduleAssignments(
display,
zones,
takeover_playlists,
);
).map(({ playlist }) => playlist);
if (
assignments.length < 2 ||
(playlist_id &&
!assignments.some(
({ playlist }) => playlist.id === playlist_id,
))
assigned.length < 2 ||
(playlist_id && !assigned.some(({ id }) => id === playlist_id))
) {
continue;
}
const blocks = assignments
.flatMap(({ playlist }) => takeoverBlocks(playlist))
.sort((a, b) => a.start - b.start);
const seen_pairs = new Set<string>();
for (let i = 0; i < blocks.length; i++) {
const first = blocks[i];
for (
let j = i + 1;
j < blocks.length && blocks[j].start < first.end;
j++
) {
const second = blocks[j];
if (first.playlist.id === second.playlist.id) continue;
if (
playlist_id &&
first.playlist.id !== playlist_id &&
second.playlist.id !== playlist_id
) {
continue;
}
const pair = [first.playlist.id, second.playlist.id]
.sort()
.join('|');
if (seen_pairs.has(pair)) continue;
seen_pairs.add(pair);
conflicts.push({
display,
playlists: [first.playlist, second.playlist],
starts_at: wallClockDate(first_day, second.start),
ends_at: wallClockDate(
first_day,
Math.min(first.end, second.end),
),
});
}
for (const overlap of overlapsOf(assigned)) {
conflicts.push({
display,
playlists: overlap.playlists,
starts_at: wallClockDate(first_day, overlap.start),
ends_at: wallClockDate(first_day, overlap.end),
});
}
}
return conflicts;
Expand Down
Loading
Loading