Skip to content

Commit 9a6c6b3

Browse files
authored
Merge pull request #95 from nebulabroadcast/backend-rundown-enhancements
Backend rundown enhancements
2 parents c94c0ed + d4db899 commit 9a6c6b3

File tree

5 files changed

+120
-54
lines changed

5 files changed

+120
-54
lines changed

backend/api/delete.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncpg
12
from fastapi import Response
23
from pydantic import Field
34

@@ -46,8 +47,13 @@ async def handle(
4647
query = "DELETE FROM items WHERE id = ANY($1) RETURNING id, id_bin"
4748
affected_bins = set()
4849
nebula.log.debug(f"Deleted items: {request.ids}", user=user.name)
49-
async for row in nebula.db.iterate(query, request.ids):
50-
affected_bins.add(row["id_bin"])
50+
try:
51+
async for row in nebula.db.iterate(query, request.ids):
52+
affected_bins.add(row["id_bin"])
53+
except asyncpg.exceptions.ForeignKeyViolationError as e:
54+
raise nebula.ConflictException(
55+
"Cannot delete item because it was already aired"
56+
) from e
5157
await bin_refresh(list(affected_bins), initiator=initiator)
5258
return Response(status_code=204)
5359

backend/api/rundown/get_rundown.py

Lines changed: 67 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
1+
import time
2+
13
import nebula
24
from nebula.enum import ObjectStatus, RunMode
3-
from nebula.helpers.scheduling import (
4-
get_item_runs,
5-
get_pending_assets,
6-
parse_rundown_date,
7-
)
5+
from nebula.helpers.scheduling import get_pending_assets, parse_rundown_date
86

97
from .models import RundownRequestModel, RundownResponseModel, RundownRow
108

@@ -14,9 +12,9 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel:
1412
if not (channel := nebula.settings.get_playout_channel(request.id_channel)):
1513
raise nebula.BadRequestException(f"No such channel: {request.id_channel}")
1614

15+
request_start_time = time.monotonic()
1716
start_time = parse_rundown_date(request.date, channel)
1817
end_time = start_time + (3600 * 24)
19-
item_runs = await get_item_runs(request.id_channel, start_time, end_time)
2018
pending_assets = await get_pending_assets(channel.send_action)
2119
pskey = f"playout_status/{request.id_channel}"
2220

@@ -27,19 +25,45 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel:
2725
e.id_magic AS id_bin,
2826
i.id AS id_item,
2927
i.meta AS imeta,
30-
a.meta AS ameta
31-
FROM
32-
events AS e
33-
LEFT JOIN
34-
items AS i
35-
ON
36-
e.id_magic = i.id_bin
37-
LEFT JOIN
38-
assets AS a
39-
ON
40-
i.id_asset = a.id
28+
a.meta AS ameta,
29+
ar.latest_start AS as_start,
30+
ar.latest_stop AS as_stop
31+
FROM events AS e
32+
33+
LEFT JOIN items AS i
34+
ON e.id_magic = i.id_bin
35+
36+
LEFT JOIN assets AS a
37+
ON i.id_asset = a.id
38+
39+
LEFT JOIN (
40+
SELECT
41+
id_item,
42+
start AS latest_start,
43+
stop AS latest_stop
44+
FROM (
45+
SELECT
46+
id_item,
47+
start,
48+
stop,
49+
ROW_NUMBER() OVER
50+
(PARTITION BY id_item ORDER BY start DESC) AS rn
51+
FROM asrun
52+
WHERE
53+
id_channel = $1
54+
AND start >= $2 - 604800
55+
AND start < $3 + 604800
56+
) AS ranked
57+
WHERE rn = 1
58+
) AS ar
59+
60+
ON i.id = ar.id_item
61+
4162
WHERE
42-
e.id_channel = $1 AND e.start >= $2 AND e.start < $3
63+
e.id_channel = $1
64+
AND e.start >= $2
65+
AND e.start < $3
66+
4367
ORDER BY
4468
e.start ASC,
4569
i.position ASC,
@@ -102,10 +126,12 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel:
102126
# TODO: append empty row?
103127
continue
104128

105-
airstatus = 0
106-
if (runs := item_runs.get(id_item)) is not None:
107-
as_start, as_stop = runs
108-
ts_broadcast = as_start
129+
airstatus: ObjectStatus | None = None
130+
131+
if (as_start := record["as_start"]) is not None:
132+
if as_start > ts_broadcast:
133+
ts_broadcast = as_start
134+
as_stop = record["as_stop"]
109135
airstatus = ObjectStatus.AIRED if as_stop else ObjectStatus.ONAIR
110136

111137
# TODO
@@ -114,19 +140,27 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel:
114140

115141
# Row status
116142

117-
istatus = 0
143+
istatus: ObjectStatus
118144
if not ameta:
145+
# virtual item. consider it online
119146
istatus = ObjectStatus.ONLINE
120-
elif airstatus:
121-
istatus = airstatus
122147
elif ameta.get("status") == ObjectStatus.OFFLINE:
148+
# media is not on the production storage
123149
istatus = ObjectStatus.OFFLINE
124150
elif pskey not in ameta or ameta[pskey]["status"] == ObjectStatus.OFFLINE:
151+
# media is not on the playout storage
125152
istatus = ObjectStatus.REMOTE
126-
elif ameta[pskey]["status"] == ObjectStatus.ONLINE:
127-
istatus = ObjectStatus.ONLINE
128153
elif ameta[pskey]["status"] == ObjectStatus.CORRUPTED:
154+
# media is on the playout storage but corrupted
129155
istatus = ObjectStatus.CORRUPTED
156+
elif ameta[pskey]["status"] == ObjectStatus.ONLINE:
157+
if airstatus is not None:
158+
# media is on the playout storage and aired
159+
istatus = airstatus
160+
last_air = as_start
161+
else:
162+
# media is on the playout storage but not aired
163+
istatus = ObjectStatus.ONLINE
130164
else:
131165
istatus = ObjectStatus.UNKNOWN
132166

@@ -192,8 +226,12 @@ async def get_rundown(request: RundownRequestModel) -> RundownResponseModel:
192226
if not last_event.duration:
193227
last_event.broadcast_time = ts_broadcast
194228
ts_scheduled += duration
195-
ts_broadcast += duration
229+
if row.item_role not in ["placeholder", "lead_in", "lead_out"]:
230+
ts_broadcast += duration
196231
last_event.duration += duration
197232
last_event.is_empty = False
198233

199-
return RundownResponseModel(rows=rows)
234+
elapsed = time.monotonic() - request_start_time
235+
msg = f"Rundown generated in {elapsed:.3f} seconds"
236+
237+
return RundownResponseModel(rows=rows, detail=msg)

backend/api/rundown/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ class RundownRow(ResponseModel):
4343

4444
class RundownResponseModel(ResponseModel):
4545
rows: list[RundownRow] = Field(default_factory=list)
46+
detail: str | None = Field(None)

backend/api/scheduler/utils.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import asyncpg
12
from nxtools import format_time
23

34
import nebula
@@ -45,8 +46,12 @@ async def delete_events(ids: list[int], user: nebula.User | None = None) -> list
4546
"DELETE FROM items WHERE id_bin = $1",
4647
id_bin,
4748
)
49+
except asyncpg.exceptions.ForeignKeyViolationError as e:
50+
raise nebula.ConflictException(
51+
"Cannot delete event containing aired items"
52+
) from e
4853
except Exception:
49-
nebula.log.error(f"Failed to delete items of {event}")
54+
nebula.log.traceback(f"Failed to delete items of {event}")
5055
continue
5156

5257
await nebula.db.execute("DELETE FROM bins WHERE id = $1", id_bin)
@@ -75,35 +80,38 @@ async def get_events_in_range(
7580
f"from {format_time(int(start_time))} to {format_time(int(end_time))}",
7681
user=username,
7782
)
78-
7983
result = []
8084

81-
# Last event before start_time
85+
# Events between start_time and end_time
86+
# and the last event before end_time
8287
async for row in nebula.db.iterate(
8388
"""
84-
SELECT e.meta as emeta, o.meta as ometa FROM events AS e, bins AS o
85-
WHERE
86-
e.id_channel=$1
89+
(
90+
SELECT
91+
e.meta AS emeta,
92+
o.meta AS ometa,
93+
e.start
94+
FROM events AS e, bins AS o
95+
WHERE
96+
e.id_channel = $1
8797
AND e.start < $2
8898
AND e.id_magic = o.id
89-
ORDER BY start DESC LIMIT 1
90-
""",
91-
id_channel,
92-
start_time,
93-
):
94-
rec = row["emeta"]
95-
rec["duration"] = row["ometa"].get("duration")
96-
result.append(nebula.Event.from_meta(rec))
97-
98-
# Events between start_time and end_time
99-
async for row in nebula.db.iterate(
100-
"""
101-
SELECT e.meta as emeta, o.meta as ometa FROM events AS e, bins AS o
102-
WHERE
103-
e.id_channel=$1
99+
ORDER BY e.start DESC
100+
LIMIT 1
101+
)
102+
UNION ALL
103+
(
104+
SELECT
105+
e.meta AS emeta,
106+
o.meta AS ometa,
107+
e.start
108+
FROM events AS e, bins AS o
109+
WHERE
110+
e.id_channel = $1
104111
AND e.start >= $2
105112
AND e.start < $3
106113
AND e.id_magic = o.id
114+
)
107115
ORDER BY start ASC
108116
""",
109117
id_channel,

backend/api/solve.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from fastapi import Response
22
from pydantic import Field
33

4+
import nebula
45
from nebula.exceptions import BadRequestException
56
from nebula.plugins.library import plugin_library
67
from server.dependencies import CurrentUser
@@ -29,8 +30,20 @@ async def handle(
2930
request: SolveRequestModel,
3031
user: CurrentUser,
3132
) -> Response:
32-
# TODO: check permissions
33-
assert user is not None
33+
# Get the list of channels of the requested items
34+
35+
query = """
36+
SELECT DISTINCT id_channel
37+
FROM events e
38+
INNER JOIN items i
39+
ON e.id_magic = i.id_bin
40+
WHERE i.id = ANY($1)
41+
"""
42+
43+
err_msg = "You are not allowed to edit this rundown"
44+
async for row in nebula.db.iterate(query, request.items):
45+
if not user.can("rundown_edit", row["id_channel"]):
46+
raise nebula.ForbiddenException(err_msg)
3447

3548
try:
3649
solver = plugin_library.get("solver", request.solver)

0 commit comments

Comments
 (0)