Weather-driven playlists with the API
A predicate knows the time and the day, not the forecast. Here is a job that reads the weather each morning and rewrites which playlist is allowed to play.
A screen that promotes hot drinks on the coldest morning of the year is worth more than one showing the same rotation it showed in July. So is a yard board that puts an ice warning first when there has been a frost, and a cinema foyer that pushes the afternoon showing when it is going to rain all day.
None of that can come from the schedule itself. A playlist decides when it may play using a predicate, and a predicate knows three things: the time, the date and the day of week. It has never heard of rain. The weather has to arrive from your side, as a job that reads a forecast and rewrites the schedule to match.
This tutorial builds that job.
What you need
An API token and a screen already paired to your workspace. The forecast comes from Open-Meteo, which needs no key, so the whole thing runs without signing up for anything else.
Every example below is plain HTTP against the same four settings, and reads them from the environment whichever language you run it in. Set them in the shell you are working in:
export API_TOKEN=your-token-here
export API=https://api.screenlyapp.com/api/v4.1
export LAT=51.5072
export LON=-0.1276The same four lines without export make a .env file, which is what you will want once this runs on a cron rather than in a terminal you have open. Either way the token is a credential, so keep it out of the repository.
There is no Screenly SDK to install. The only thing that differs by language is what you need to parse JSON and make requests:
# curl is already there. jq reads the responses.
brew install jq # or: apt install jqpip install requests# Node 18 or newer, for a global fetch. Run the script as an ES module,
# since the examples await at the top level.
node --version# Node 18 or newer, plus a way to run TypeScript directly.
npm install -D typescript tsx @types/nodeThe shape of it
One playlist per condition, each holding the content for that kind of day: Hot day, Cold day, Wet day, Mild day. All four are assigned to the same screens and stay that way. Nothing about the assignment changes from one day to the next.
What changes is which of them is allowed to play. Each morning the job reads the forecast, gives the matching playlist a predicate covering today’s trading hours, and gives the other three FALSE. A playlist whose predicate is FALSE is never eligible, so the screen simply plays the one that is.
Building it this way means the content for a cold day is prepared once, by whoever prepares content, and the job only ever writes one short string per playlist.
Writing the predicate
Three variables are available, and they are evaluated on the player, in that screen’s local time:
| Variable | What it holds |
|---|---|
$TIME | Milliseconds since local midnight, so 8am is 28800000 |
$DATE | A calendar day as an epoch timestamp in milliseconds |
$WEEKDAY | Day of week, 0 for Monday through 6 for Sunday |
Because the job runs again every morning, the predicate does not need a date in it. Today’s decision only has to survive until tomorrow’s run replaces it.
# Trading hours, 8am to 5pm. Write the hours in 24-hour time: the hour is the
# number you multiply, so there is no am/pm to convert first.
start=$(( 8 * 3600000 ))
end=$(( 17 * 3600000 ))
open_today="TRUE AND (\$TIME BETWEEN {$start, $end})"
never="FALSE"def ms(hour, minute=0):
return (hour * 60 + minute) * 60_000
def time_window(start, end):
return f"TRUE AND ($TIME BETWEEN {{{ms(*start)}, {ms(*end)}}})"
NEVER = "FALSE"
# Trading hours, 8am to 5pm, written in 24-hour time so no am/pm has to be
# converted before it reaches ms().
OPEN_TODAY = time_window((8, 0), (17, 0))const ms = (hour, minute = 0) => (hour * 60 + minute) * 60000;
const timeWindow = ([startHour, startMin], [endHour, endMin]) =>
`TRUE AND ($TIME BETWEEN {${ms(startHour, startMin)}, ${ms(endHour, endMin)}})`;
const NEVER = 'FALSE';
// Trading hours, 8am to 5pm, written in 24-hour time so no am/pm has to be
// converted before it reaches ms().
const OPEN_TODAY = timeWindow([8, 0], [17, 0]);type Clock = [hour: number, minute: number];
const ms = (hour: number, minute = 0): number => (hour * 60 + minute) * 60000;
const timeWindow = (start: Clock, end: Clock): string =>
`TRUE AND ($TIME BETWEEN {${ms(...start)}, ${ms(...end)}})`;
const NEVER = 'FALSE';
// Trading hours, 8am to 5pm, written in 24-hour time so no am/pm has to be
// converted before it reaches ms().
const OPEN_TODAY: string = timeWindow([8, 0], [17, 0]);Milliseconds since midnight is an awkward number to write by hand and an easy one to generate, which is most of the argument for doing this over the API rather than in the rule editor. Full syntax, including $DATE and how several rules become an OR chain, is in Predicates.
Create the four playlists
This part runs once. predicate is a field on the playlist, so each one is born with a schedule, and FALSE is the right starting point: nothing plays until the first run of the job decides what should.
Prefer: return=representation makes the API hand back what it created, including the id you need to patch it later. Keep those four ids somewhere the job can read them.
for title in "Hot day" "Cold day" "Wet day" "Mild day"; do
curl -s -X POST "$API/playlists" \
-H "Authorization: Token $API_TOKEN" \
-H "Content-Type: application/json" \
-H "Prefer: return=representation" \
-d "{\"title\": \"$title\", \"predicate\": \"FALSE\"}" \
| jq -r '.[0] | "\(.title)\t\(.id)"'
doneimport os
import requests
API = os.environ["API"]
HEADERS = {
"Authorization": f"Token {os.environ['API_TOKEN']}",
"Content-Type": "application/json",
"Prefer": "return=representation",
}
playlists = {}
for condition, title in [
("hot", "Hot day"),
("cold", "Cold day"),
("wet", "Wet day"),
("mild", "Mild day"),
]:
response = requests.post(
f"{API}/playlists",
headers=HEADERS,
json={"title": title, "predicate": NEVER},
)
response.raise_for_status()
playlists[condition] = response.json()[0]["id"]const API = process.env.API;
const headers = {
Authorization: `Token ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
Prefer: 'return=representation',
};
const titles = {
hot: 'Hot day',
cold: 'Cold day',
wet: 'Wet day',
mild: 'Mild day',
};
const playlists = {};
for (const [condition, title] of Object.entries(titles)) {
const response = await fetch(`${API}/playlists`, {
method: 'POST',
headers,
body: JSON.stringify({ title, predicate: NEVER }),
});
if (!response.ok) throw new Error(await response.text());
const [playlist] = await response.json();
playlists[condition] = playlist.id;
}type Condition = 'hot' | 'cold' | 'wet' | 'mild';
interface Playlist {
id: string;
title: string;
predicate: string;
}
const API = process.env.API!;
const headers: Record<string, string> = {
Authorization: `Token ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
Prefer: 'return=representation',
};
const titles: Record<Condition, string> = {
hot: 'Hot day',
cold: 'Cold day',
wet: 'Wet day',
mild: 'Mild day',
};
const playlists = {} as Record<Condition, string>;
for (const [condition, title] of Object.entries(titles) as [Condition, string][]) {
const response = await fetch(`${API}/playlists`, {
method: 'POST',
headers,
body: JSON.stringify({ title, predicate: NEVER }),
});
if (!response.ok) throw new Error(await response.text());
const [playlist]: Playlist[] = await response.json();
playlists[condition] = playlist.id;
}Fill each one with content and assign all four to the same screens through a label, which is also a one-off. Get started covers uploading an asset, adding it to a playlist, and finding the label id.
Read the forecast and pick a condition
Open-Meteo returns a daily maximum temperature and a precipitation total for the site. The thresholds below are a starting point and the interesting part of the tuning: what counts as a hot day for a QSR promoting cold drinks is not what counts as one for a yard board warning about heat stress.
forecast="https://api.open-meteo.com/v1/forecast?latitude=$LAT&longitude=$LON&daily=temperature_2m_max,precipitation_sum&timezone=auto&forecast_days=1"
condition=$(curl -s "$forecast" | jq -r '
.daily |
if .precipitation_sum[0] >= 1 then "wet"
elif .temperature_2m_max[0] >= 24 then "hot"
elif .temperature_2m_max[0] <= 8 then "cold"
else "mild" end')FORECAST = "https://api.open-meteo.com/v1/forecast"
def condition_today(lat, lon):
daily = requests.get(
FORECAST,
params={
"latitude": lat,
"longitude": lon,
"daily": "temperature_2m_max,precipitation_sum",
"timezone": "auto",
"forecast_days": 1,
},
timeout=10,
).json()["daily"]
rain = daily["precipitation_sum"][0]
high = daily["temperature_2m_max"][0]
if rain >= 1:
return "wet"
if high >= 24:
return "hot"
if high <= 8:
return "cold"
return "mild"async function conditionToday(lat, lon) {
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.search = new URLSearchParams({
latitude: lat,
longitude: lon,
daily: 'temperature_2m_max,precipitation_sum',
timezone: 'auto',
forecast_days: 1,
});
const { daily } = await fetch(url).then((r) => r.json());
const rain = daily.precipitation_sum[0];
const high = daily.temperature_2m_max[0];
if (rain >= 1) return 'wet';
if (high >= 24) return 'hot';
if (high <= 8) return 'cold';
return 'mild';
}interface DailyForecast {
daily: {
temperature_2m_max: number[];
precipitation_sum: number[];
};
}
async function conditionToday(lat: number, lon: number): Promise<Condition> {
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.search = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
daily: 'temperature_2m_max,precipitation_sum',
timezone: 'auto',
forecast_days: '1',
}).toString();
const { daily }: DailyForecast = await fetch(url).then((r) => r.json());
const rain = daily.precipitation_sum[0];
const high = daily.temperature_2m_max[0];
if (rain >= 1) return 'wet';
if (high >= 24) return 'hot';
if (high <= 8) return 'cold';
return 'mild';
}timezone=auto matters. It makes the daily figures cover the site’s own calendar day, which is the same day the predicate is evaluated against on the player.
Rewrite the schedules
Every playlist gets a PATCH, including the three that are being switched off. Writing all four each morning means the state on the screen matches today’s forecast and not some mixture of today’s and last Thursday’s.
for entry in "hot:$HOT_ID" "cold:$COLD_ID" "wet:$WET_ID" "mild:$MILD_ID"; do
key=${entry%%:*}
id=${entry#*:}
if [ "$key" = "$condition" ]; then
predicate=$open_today
else
predicate=$never
fi
curl -s -X PATCH "$API/playlists?id=eq.$id" \
-H "Authorization: Token $API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"predicate\": \"$predicate\"}"
donetoday = condition_today(os.environ["LAT"], os.environ["LON"])
for condition, playlist_id in playlists.items():
requests.patch(
f"{API}/playlists",
headers=HEADERS,
params={"id": f"eq.{playlist_id}"},
json={"predicate": OPEN_TODAY if condition == today else NEVER},
).raise_for_status()const today = await conditionToday(process.env.LAT, process.env.LON);
for (const [condition, playlistId] of Object.entries(playlists)) {
await fetch(`${API}/playlists?id=eq.${playlistId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({
predicate: condition === today ? OPEN_TODAY : NEVER,
}),
});
}const today = await conditionToday(Number(process.env.LAT), Number(process.env.LON));
for (const [condition, playlistId] of Object.entries(playlists)) {
await fetch(`${API}/playlists?id=eq.${playlistId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({
predicate: condition === today ? OPEN_TODAY : NEVER,
}),
});
}The filter goes in the query string because the API is built on PostgREST. A PATCH without one updates every playlist in the workspace, so never send it without id=eq..
A successful PATCH answers 204 with no body, so there is nothing to read back. Add Prefer: return=representation if you want the updated playlist returned.
Put the whole thing on a morning cron, early enough that it has landed before the doors open.
The screen has to be online to hear about it
A predicate change reaches a player the same way any other change does, so this only works on screens that are actually connected.
That is the good case, and it is fast. A screen that is online usually holds an open connection, and a schedule change is a few dozen bytes with no files attached: nothing to download, nothing to transcode. Swapping which playlist is eligible lands almost immediately. If the connection has dropped and the screen is pinging instead, it waits for the next ping, which is a matter of minutes rather than seconds.
Adding content is the slower case. A playlist whose assets the screen has never seen has to fetch them first, and video takes considerably longer than images. This is the argument for creating the four playlists and filling them once, ahead of time, rather than uploading a wet-day video at 7am and hoping. By the time the weather job runs, every screen already holds all four; the only thing crossing the wire is the decision.
A screen that is offline keeps playing whatever it was last told, because schedules are held on the device. It will not switch to the cold-day playlist, and it will not go dark either. When it reconnects it catches up on its own. Status and sync has the check-in intervals, and alerts is how you find out a screen has been missing for a week rather than noticing in the spring.
Two things to know before you rely on this
A predicate written by hand is read-only in the dashboard. The rule editor displays schedules in the shape it generates, an OR chain of AND groups, and anything else shows without editing controls. The playlist plays exactly as written, but a colleague cannot fix it by clicking. Complex schedules covers what happens.
Nothing warns you about a predicate that never matches. FALSE is the point here, but $TIME BETWEEN {61200000, 28800000} is equally valid and equally silent, and the only symptom is a screen that quietly never plays the playlist. The same goes for a job that fails: yesterday’s decision stays on the screens until something replaces it, so the run is worth alerting on.
The whole thing in one file
The pieces above, assembled. This is the file that goes on the cron: it reads the forecast, works out today’s condition, and writes a predicate to all four playlists. Creating the playlists and assigning them to screens stays a one-off, so it is not in here.
It expects API_TOKEN, API, LAT, LON and the four playlist ids from the creation step in the environment.
#!/usr/bin/env bash
# weather-playlists.sh
set -euo pipefail
# Trading hours, 8am to 5pm, in 24-hour time.
start=$(( 8 * 3600000 ))
end=$(( 17 * 3600000 ))
open_today="TRUE AND (\$TIME BETWEEN {$start, $end})"
never="FALSE"
forecast="https://api.open-meteo.com/v1/forecast?latitude=$LAT&longitude=$LON"
forecast+="&daily=temperature_2m_max,precipitation_sum&timezone=auto&forecast_days=1"
condition=$(curl -fsS "$forecast" | jq -r '
.daily |
if .precipitation_sum[0] >= 1 then "wet"
elif .temperature_2m_max[0] >= 24 then "hot"
elif .temperature_2m_max[0] <= 8 then "cold"
else "mild" end')
echo "Forecast for today: $condition"
for entry in "hot:$HOT_ID" "cold:$COLD_ID" "wet:$WET_ID" "mild:$MILD_ID"; do
key=${entry%%:*}
id=${entry#*:}
if [ "$key" = "$condition" ]; then
predicate=$open_today
else
predicate=$never
fi
curl -fsS -o /dev/null -X PATCH "$API/playlists?id=eq.$id" \
-H "Authorization: Token $API_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"predicate\": \"$predicate\"}"
done#!/usr/bin/env python3
"""weather_playlists.py: allow one playlist to play, by today's forecast."""
import os
import requests
API = os.environ["API"]
HEADERS = {
"Authorization": f"Token {os.environ['API_TOKEN']}",
"Content-Type": "application/json",
}
PLAYLISTS = {
"hot": os.environ["HOT_ID"],
"cold": os.environ["COLD_ID"],
"wet": os.environ["WET_ID"],
"mild": os.environ["MILD_ID"],
}
def ms(hour, minute=0):
return (hour * 60 + minute) * 60_000
def time_window(start, end):
return f"TRUE AND ($TIME BETWEEN {{{ms(*start)}, {ms(*end)}}})"
NEVER = "FALSE"
# Trading hours, 8am to 5pm, in 24-hour time.
OPEN_TODAY = time_window((8, 0), (17, 0))
def condition_today(lat, lon):
daily = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": lat,
"longitude": lon,
"daily": "temperature_2m_max,precipitation_sum",
"timezone": "auto",
"forecast_days": 1,
},
timeout=10,
).json()["daily"]
rain = daily["precipitation_sum"][0]
high = daily["temperature_2m_max"][0]
if rain >= 1:
return "wet"
if high >= 24:
return "hot"
if high <= 8:
return "cold"
return "mild"
def main():
today = condition_today(os.environ["LAT"], os.environ["LON"])
print(f"Forecast for today: {today}")
for condition, playlist_id in PLAYLISTS.items():
requests.patch(
f"{API}/playlists",
headers=HEADERS,
params={"id": f"eq.{playlist_id}"},
json={"predicate": OPEN_TODAY if condition == today else NEVER},
timeout=10,
).raise_for_status()
if __name__ == "__main__":
main()#!/usr/bin/env node
// weather-playlists.mjs: allow one playlist to play, by today's forecast.
const API = process.env.API;
const headers = {
Authorization: `Token ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
};
const playlists = {
hot: process.env.HOT_ID,
cold: process.env.COLD_ID,
wet: process.env.WET_ID,
mild: process.env.MILD_ID,
};
const ms = (hour, minute = 0) => (hour * 60 + minute) * 60000;
const timeWindow = ([startHour, startMin], [endHour, endMin]) =>
`TRUE AND ($TIME BETWEEN {${ms(startHour, startMin)}, ${ms(endHour, endMin)}})`;
const NEVER = 'FALSE';
// Trading hours, 8am to 5pm, in 24-hour time.
const OPEN_TODAY = timeWindow([8, 0], [17, 0]);
async function conditionToday(lat, lon) {
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.search = new URLSearchParams({
latitude: lat,
longitude: lon,
daily: 'temperature_2m_max,precipitation_sum',
timezone: 'auto',
forecast_days: 1,
});
const response = await fetch(url);
if (!response.ok) throw new Error(`Forecast failed: ${response.status}`);
const { daily } = await response.json();
const rain = daily.precipitation_sum[0];
const high = daily.temperature_2m_max[0];
if (rain >= 1) return 'wet';
if (high >= 24) return 'hot';
if (high <= 8) return 'cold';
return 'mild';
}
async function main() {
const today = await conditionToday(process.env.LAT, process.env.LON);
console.log(`Forecast for today: ${today}`);
for (const [condition, playlistId] of Object.entries(playlists)) {
const response = await fetch(`${API}/playlists?id=eq.${playlistId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({
predicate: condition === today ? OPEN_TODAY : NEVER,
}),
});
if (!response.ok) throw new Error(await response.text());
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});#!/usr/bin/env tsx
// weather-playlists.ts: allow one playlist to play, by today's forecast.
type Condition = 'hot' | 'cold' | 'wet' | 'mild';
type Clock = [hour: number, minute: number];
interface DailyForecast {
daily: {
temperature_2m_max: number[];
precipitation_sum: number[];
};
}
const API = process.env.API!;
const headers: Record<string, string> = {
Authorization: `Token ${process.env.API_TOKEN}`,
'Content-Type': 'application/json',
};
const playlists: Record<Condition, string> = {
hot: process.env.HOT_ID!,
cold: process.env.COLD_ID!,
wet: process.env.WET_ID!,
mild: process.env.MILD_ID!,
};
const ms = (hour: number, minute = 0): number => (hour * 60 + minute) * 60000;
const timeWindow = (start: Clock, end: Clock): string =>
`TRUE AND ($TIME BETWEEN {${ms(...start)}, ${ms(...end)}})`;
const NEVER = 'FALSE';
// Trading hours, 8am to 5pm, in 24-hour time.
const OPEN_TODAY: string = timeWindow([8, 0], [17, 0]);
async function conditionToday(lat: number, lon: number): Promise<Condition> {
const url = new URL('https://api.open-meteo.com/v1/forecast');
url.search = new URLSearchParams({
latitude: String(lat),
longitude: String(lon),
daily: 'temperature_2m_max,precipitation_sum',
timezone: 'auto',
forecast_days: '1',
}).toString();
const response = await fetch(url);
if (!response.ok) throw new Error(`Forecast failed: ${response.status}`);
const { daily }: DailyForecast = await response.json();
const rain = daily.precipitation_sum[0];
const high = daily.temperature_2m_max[0];
if (rain >= 1) return 'wet';
if (high >= 24) return 'hot';
if (high <= 8) return 'cold';
return 'mild';
}
async function main(): Promise<void> {
const today = await conditionToday(Number(process.env.LAT), Number(process.env.LON));
console.log(`Forecast for today: ${today}`);
for (const [condition, playlistId] of Object.entries(playlists)) {
const response = await fetch(`${API}/playlists?id=eq.${playlistId}`, {
method: 'PATCH',
headers,
body: JSON.stringify({
predicate: condition === today ? OPEN_TODAY : NEVER,
}),
});
if (!response.ok) throw new Error(await response.text());
}
}
main().catch((error) => {
console.error(error);
process.exit(1);
});Where to go next
Predicates has the full expression syntax. Priority override is what you want if a severe weather notice should interrupt everything else rather than share the screen. The v4.1 API reference has every endpoint and field.
If what you actually want is the forecast on the screen rather than the forecast deciding what plays, that is the Weather app, which needs none of this.
If a screen is not switching the way the forecast says it should, contact support and we will take a look.