From 42f8738777d019599ac1ecf6064bf4c60721c424 Mon Sep 17 00:00:00 2001 From: Salman Faris Date: Mon, 6 Jul 2026 15:46:57 +0530 Subject: [PATCH 1/5] Fix cross-team asset 401 and stale label associations The QC script was failing with 401 when adding assets to playlists because GET /v4/assets returns cross-team assets that the token's RLS policy blocks on insert (42501 insufficient_privilege). Fixed by fetching the current team_id and filtering assets by team_id in the query. Also fixed delete_playlist() to explicitly remove labels/playlists associations before deleting a playlist, preventing stale rows that caused POST /v4/labels/playlists to fail on subsequent runs. Additional improvements: - Add resolution=ignore-duplicates to POST /v4/labels/playlists as a safety net against any remaining stale associations - Add progress logging in create_qc_playlist() to make it clear which step fails when errors occur - Rename error message from "Unable to create playlist" to "QC playlist setup failed" to better reflect that the failure may occur in asset-adding or label-linking steps, not just creation --- automated_quality_control.py | 53 ++++++++++++++++++++++++------------ 1 file changed, 36 insertions(+), 17 deletions(-) diff --git a/automated_quality_control.py b/automated_quality_control.py index ed9de8b..001c7f4 100644 --- a/automated_quality_control.py +++ b/automated_quality_control.py @@ -16,26 +16,36 @@ PLAYLIST_PREFIX = "QC" -def get_ten_random_assets(): +def get_team_id() -> str: """ - Return 10 random assets in the account. + Return the current account's team ID via the built-in all-screens label. """ - response = requests.get( - 'https://api.screenlyapp.com/v4/assets?select=id&type=in.("appweb","audio","edge-app","image","video","web")&status=in.("finished","processing")', + "https://api.screenlyapp.com/v4/labels?type=eq.all-screens", headers=REQUEST_HEADERS, ) response.raise_for_status() + labels = response.json() + if not labels: + raise ValueError("No 'all-screens' label found in the account") + return labels[0]["team_id"] + - asset_count = len(response.json()) +def get_ten_random_assets(team_id: str) -> List[str]: + """ + Return 10 random asset IDs that belong to the current team. + Filtering by team_id ensures the token has permission to add them to playlists. + """ + response = requests.get( + f'https://api.screenlyapp.com/v4/assets?select=id&team_id=eq.{team_id}&type=in.("appweb","audio","edge-app","image","video","web")&status=in.("finished","processing")', + headers=REQUEST_HEADERS, + ) + response.raise_for_status() - # Pick 10 random assets - asset_list = [] - for i in range(10): - random_index = random.randint(0, asset_count - 1) - asset_list.append(response.json()[random_index]["id"]) + assets = response.json() + asset_count = len(assets) - return asset_list + return [assets[random.randint(0, asset_count - 1)]["id"] for _ in range(10)] def get_screens() -> List[Dict[str, Any]]: @@ -102,9 +112,13 @@ def get_qc_playlist_ids(): def delete_playlist(playlist_id): """ - Delete a playlist and its items. In v4, playlist items must be - removed before the playlist itself can be deleted. + Delete a playlist and its items. In v4, label associations and playlist + items must be removed before the playlist itself can be deleted. """ + requests.delete( + f"https://api.screenlyapp.com/v4/labels/playlists?playlist_id=eq.{playlist_id}", + headers=REQUEST_HEADERS, + ) items_response = requests.delete( f"https://api.screenlyapp.com/v4/playlist-items?playlist_id=eq.{playlist_id}", headers=REQUEST_HEADERS, @@ -162,7 +176,7 @@ def assign_playlist_to_all_screens(playlist_id): } response = requests.post( "https://api.screenlyapp.com/v4/labels/playlists", - headers={**REQUEST_HEADERS, "Prefer": "return=representation"}, + headers={**REQUEST_HEADERS, "Prefer": "return=representation, resolution=ignore-duplicates"}, json=payload, ) response.raise_for_status() @@ -192,10 +206,15 @@ def create_qc_playlist(): data = response.json() playlist_id = data[0]["id"] if isinstance(data, list) else data["id"] + print(f"Playlist created: {playlist_id}") + + team_id = get_team_id() - for asset_id in get_ten_random_assets(): + print("Adding assets to playlist...") + for asset_id in get_ten_random_assets(team_id): add_asset_to_playlist(playlist_id, asset_id) + print("Assigning playlist to all screens...") assign_playlist_to_all_screens(playlist_id) @@ -230,10 +249,10 @@ def main(): try: create_qc_playlist() except requests.HTTPError as error: - print(f"Unable to create playlist: {error.response.status_code} {error.response.text}") + print(f"QC playlist setup failed: {error.response.status_code} {error.response.text}") sys.exit(1) except Exception as error: - print(f"Unable to create playlist: {error}") + print(f"QC playlist setup failed: {error}") sys.exit(1) print("Waiting for screens to sync...") From 655f6c8fec05c4bf4a6fac02d082e8ae3ac32c4b Mon Sep 17 00:00:00 2001 From: Salman Faris Date: Wed, 8 Jul 2026 14:25:02 +0530 Subject: [PATCH 2/5] Use /v4.1/teams?is_current=eq.true to get team_id --- automated_quality_control.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/automated_quality_control.py b/automated_quality_control.py index 001c7f4..89453bd 100644 --- a/automated_quality_control.py +++ b/automated_quality_control.py @@ -18,17 +18,17 @@ def get_team_id() -> str: """ - Return the current account's team ID via the built-in all-screens label. + Return the current account's team ID. """ response = requests.get( - "https://api.screenlyapp.com/v4/labels?type=eq.all-screens", + "https://api.screenlyapp.com/v4.1/teams?is_current=eq.true", headers=REQUEST_HEADERS, ) response.raise_for_status() - labels = response.json() - if not labels: - raise ValueError("No 'all-screens' label found in the account") - return labels[0]["team_id"] + teams = response.json() + if not teams: + raise ValueError("No current team found for this token") + return teams[0]["id"] def get_ten_random_assets(team_id: str) -> List[str]: From fcc806072e58d54368d28feb4c507fa8d4b9e7e1 Mon Sep 17 00:00:00 2001 From: Nico Miguelino Date: Thu, 9 Jul 2026 11:54:36 -0700 Subject: [PATCH 3/5] Address Copilot review feedback on PR #38 (#39) Stacked on #38. Resolves the two Copilot review comments: - Guard against an empty asset list in `get_ten_random_assets()` and use `random.choice()` for clarity. - Fail early in `delete_playlist()` when the label-association delete is not OK, consistent with the playlist-items delete check. --- automated_quality_control.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/automated_quality_control.py b/automated_quality_control.py index 89453bd..7ec179c 100644 --- a/automated_quality_control.py +++ b/automated_quality_control.py @@ -43,9 +43,10 @@ def get_ten_random_assets(team_id: str) -> List[str]: response.raise_for_status() assets = response.json() - asset_count = len(assets) + if not assets: + raise ValueError(f"No eligible assets found for team {team_id}") - return [assets[random.randint(0, asset_count - 1)]["id"] for _ in range(10)] + return [random.choice(assets)["id"] for _ in range(10)] def get_screens() -> List[Dict[str, Any]]: @@ -115,10 +116,12 @@ def delete_playlist(playlist_id): Delete a playlist and its items. In v4, label associations and playlist items must be removed before the playlist itself can be deleted. """ - requests.delete( + labels_response = requests.delete( f"https://api.screenlyapp.com/v4/labels/playlists?playlist_id=eq.{playlist_id}", headers=REQUEST_HEADERS, ) + if not labels_response.ok: + return False items_response = requests.delete( f"https://api.screenlyapp.com/v4/playlist-items?playlist_id=eq.{playlist_id}", headers=REQUEST_HEADERS, From ec97bb5b88d63cfce6c96f744f2b811cea2ade06 Mon Sep 17 00:00:00 2001 From: Nico Miguelino Date: Thu, 9 Jul 2026 16:21:03 -0700 Subject: [PATCH 4/5] Switch to v4.1 API endpoints (#40) - Stacked on #38. Switches the QC script from v4 to v4.1 Screenly API endpoints. - Starts with a `SCREENLY_API_BASE_URL` constant so the endpoint changes stay small and consistent. --- automated_quality_control.py | 40 +++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/automated_quality_control.py b/automated_quality_control.py index 7ec179c..0cf8af1 100644 --- a/automated_quality_control.py +++ b/automated_quality_control.py @@ -8,6 +8,7 @@ from retry import retry API_TOKEN = os.environ.get("SCREENLY_API_TOKEN") +SCREENLY_API_BASE_URL = "https://api.screenlyapp.com" REQUEST_HEADERS = { "Authorization": f"Token {API_TOKEN}", "Content-Type": "application/json", @@ -21,7 +22,7 @@ def get_team_id() -> str: Return the current account's team ID. """ response = requests.get( - "https://api.screenlyapp.com/v4.1/teams?is_current=eq.true", + f"{SCREENLY_API_BASE_URL}/v4.1/teams?is_current=eq.true", headers=REQUEST_HEADERS, ) response.raise_for_status() @@ -37,7 +38,7 @@ def get_ten_random_assets(team_id: str) -> List[str]: Filtering by team_id ensures the token has permission to add them to playlists. """ response = requests.get( - f'https://api.screenlyapp.com/v4/assets?select=id&team_id=eq.{team_id}&type=in.("appweb","audio","edge-app","image","video","web")&status=in.("finished","processing")', + f'{SCREENLY_API_BASE_URL}/v4.1/assets?select=id&team_id=eq.{team_id}&type=in.("appweb","audio","edge-app","image","video","web")&status=in.("finished","processing")', headers=REQUEST_HEADERS, ) response.raise_for_status() @@ -51,12 +52,23 @@ def get_ten_random_assets(team_id: str) -> List[str]: def get_screens() -> List[Dict[str, Any]]: """ - Return a list of screens in the account. + Return a list of screens in the account. In v4.1, status and in_sync + live in the screen_statuses view, so they are embedded and flattened + into each screen dict. """ - response = requests.get('https://api.screenlyapp.com/v4/screens?select=id,name,hostname,status,in_sync&type=eq.hardware&is_enabled=eq.true', headers=REQUEST_HEADERS) + response = requests.get( + f'{SCREENLY_API_BASE_URL}/v4.1/screens?select=id,name,hostname,screen_statuses(status,in_sync)&type=eq.hardware&is_enabled=eq.true', + headers=REQUEST_HEADERS, + ) response.raise_for_status() - return response.json() + + screens = response.json() + for screen in screens: + screen_status = screen.pop("screen_statuses") or {} + screen["status"] = screen_status.get("status", "offline") + screen["in_sync"] = screen_status.get("in_sync", False) + return screens @retry(AssertionError, tries=10, delay=SCREEN_SYNC_THRESHOLD / 10) @@ -100,7 +112,7 @@ def get_qc_playlist_ids(): Get all playlists starting with 'PLAYLIST_PREFIX'. """ - response = requests.get("https://api.screenlyapp.com/v4/playlists", headers=REQUEST_HEADERS) + response = requests.get(f"{SCREENLY_API_BASE_URL}/v4.1/playlists", headers=REQUEST_HEADERS) response.raise_for_status() qc_playlists = [] @@ -117,19 +129,19 @@ def delete_playlist(playlist_id): items must be removed before the playlist itself can be deleted. """ labels_response = requests.delete( - f"https://api.screenlyapp.com/v4/labels/playlists?playlist_id=eq.{playlist_id}", + f"{SCREENLY_API_BASE_URL}/v4.1/labels/playlists?playlist_id=eq.{playlist_id}", headers=REQUEST_HEADERS, ) if not labels_response.ok: return False items_response = requests.delete( - f"https://api.screenlyapp.com/v4/playlist-items?playlist_id=eq.{playlist_id}", + f"{SCREENLY_API_BASE_URL}/v4.1/playlist-items?playlist_id=eq.{playlist_id}", headers=REQUEST_HEADERS, ) if not items_response.ok: return False response = requests.delete( - f"https://api.screenlyapp.com/v4/playlists?id=eq.{playlist_id}", + f"{SCREENLY_API_BASE_URL}/v4.1/playlists?id=eq.{playlist_id}", headers=REQUEST_HEADERS, ) return response.ok @@ -140,7 +152,7 @@ def get_all_screens_label_id(): Return the ID of the built-in 'all-screens' label. """ response = requests.get( - "https://api.screenlyapp.com/v4/labels?type=eq.all-screens", + f"{SCREENLY_API_BASE_URL}/v4.1/labels?type=eq.all-screens", headers=REQUEST_HEADERS, ) response.raise_for_status() @@ -152,7 +164,7 @@ def get_all_screens_label_id(): def add_asset_to_playlist(playlist_id, asset_id): """ - Add a single asset to a playlist via the v4 playlist-items endpoint. + Add a single asset to a playlist via the v4.1 playlist-items endpoint. """ payload = { "playlist_id": playlist_id, @@ -160,7 +172,7 @@ def add_asset_to_playlist(playlist_id, asset_id): "duration": 10, } response = requests.post( - "https://api.screenlyapp.com/v4/playlist-items", + f"{SCREENLY_API_BASE_URL}/v4.1/playlist-items", headers={**REQUEST_HEADERS, "Prefer": "return=representation"}, json=payload, ) @@ -178,7 +190,7 @@ def assign_playlist_to_all_screens(playlist_id): "playlist_id": playlist_id, } response = requests.post( - "https://api.screenlyapp.com/v4/labels/playlists", + f"{SCREENLY_API_BASE_URL}/v4.1/labels/playlists", headers={**REQUEST_HEADERS, "Prefer": "return=representation, resolution=ignore-duplicates"}, json=payload, ) @@ -201,7 +213,7 @@ def create_qc_playlist(): } response = requests.post( - "https://api.screenlyapp.com/v4/playlists", + f"{SCREENLY_API_BASE_URL}/v4.1/playlists", headers={**REQUEST_HEADERS, "Prefer": "return=representation"}, json=payload, ) From ab6b3449b0d52c58daa521ea480b18d613be01e3 Mon Sep 17 00:00:00 2001 From: nicomiguelino Date: Thu, 9 Jul 2026 16:41:29 -0700 Subject: [PATCH 5/5] Address Copilot review feedback on PR #38 - Fix stale v4 reference in delete_playlist() docstring - Log response details when a delete_playlist() call fails --- automated_quality_control.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/automated_quality_control.py b/automated_quality_control.py index 0cf8af1..9cadfa8 100644 --- a/automated_quality_control.py +++ b/automated_quality_control.py @@ -125,7 +125,7 @@ def get_qc_playlist_ids(): def delete_playlist(playlist_id): """ - Delete a playlist and its items. In v4, label associations and playlist + Delete a playlist and its items. In v4.1, label associations and playlist items must be removed before the playlist itself can be deleted. """ labels_response = requests.delete( @@ -133,17 +133,21 @@ def delete_playlist(playlist_id): headers=REQUEST_HEADERS, ) if not labels_response.ok: + print(f"Failed to delete label associations for playlist {playlist_id}: {labels_response.status_code} {labels_response.text}") return False items_response = requests.delete( f"{SCREENLY_API_BASE_URL}/v4.1/playlist-items?playlist_id=eq.{playlist_id}", headers=REQUEST_HEADERS, ) if not items_response.ok: + print(f"Failed to delete items for playlist {playlist_id}: {items_response.status_code} {items_response.text}") return False response = requests.delete( f"{SCREENLY_API_BASE_URL}/v4.1/playlists?id=eq.{playlist_id}", headers=REQUEST_HEADERS, ) + if not response.ok: + print(f"Failed to delete playlist {playlist_id}: {response.status_code} {response.text}") return response.ok