diff --git a/api/tests/test_info_endpoints.py b/api/tests/test_info_endpoints.py index 14c8b8e42..c92e892ea 100644 --- a/api/tests/test_info_endpoints.py +++ b/api/tests/test_info_endpoints.py @@ -47,7 +47,7 @@ def test_info_v1_endpoint( # Assert response data expected_data = { - 'viewlog': 'Not yet implemented', + 'viewlog': 'No data', 'loadavg': 0.11, 'free_space': '15G', 'display_power': 'off', @@ -128,7 +128,7 @@ def test_info_v2_endpoint( # Assert response data expected_data = { - 'viewlog': 'Not yet implemented', + 'viewlog': 'No data', 'loadavg': 0.25, 'free_space': '20G', 'display_power': 'on', diff --git a/api/views/mixins.py b/api/views/mixins.py index e9f83d3b6..57bf57e6b 100644 --- a/api/views/mixins.py +++ b/api/views/mixins.py @@ -1,8 +1,10 @@ +import logging +import sqlite3 import uuid from base64 import b64encode from inspect import cleandoc from mimetypes import guess_extension, guess_type -from os import path, remove, statvfs +from os import getenv, path, remove, statvfs from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema from hurry.filesize import size @@ -284,20 +286,72 @@ def get(self, request, command): class InfoViewMixin(APIView): + @staticmethod + def _get_viewlog(): + """Read the last played asset from viewlog.db. + + Returns a dict with the last entry or 'No data' if empty. + """ + home = getenv('HOME', '') + db_path = path.join(home, '.screenly', 'viewlog.db') + + if not path.exists(db_path): + return 'No data' + + try: + conn = sqlite3.connect(db_path, timeout=3) + conn.row_factory = sqlite3.Row + row = conn.execute( + 'SELECT asset_id, asset_name, mimetype, started_at ' + 'FROM viewlog ORDER BY id DESC LIMIT 1' + ).fetchone() + conn.close() + except Exception as e: + logging.warning('Failed to read viewlog: %s', e) + return 'No data' + + if not row: + return 'No data' + + return { + 'asset_id': row['asset_id'], + 'asset_name': row['asset_name'], + 'mimetype': row['mimetype'], + 'started_at': row['started_at'], + } + @extend_schema( summary='Get system information', responses={ 200: { 'type': 'object', 'properties': { - 'viewlog': {'type': 'string'}, + 'viewlog': { + 'oneOf': [ + { + 'type': 'object', + 'properties': { + 'asset_id': {'type': 'string'}, + 'asset_name': {'type': 'string'}, + 'mimetype': {'type': 'string'}, + 'started_at': {'type': 'string'}, + }, + }, + {'type': 'string'}, + ], + }, 'loadavg': {'type': 'number'}, 'free_space': {'type': 'string'}, 'display_power': {'type': 'string'}, 'up_to_date': {'type': 'boolean'}, }, 'example': { - 'viewlog': 'Not yet implemented', + 'viewlog': { + 'asset_id': 'abc123', + 'asset_name': 'My Video', + 'mimetype': 'video', + 'started_at': '2024-01-01T12:00:00+00:00', + }, 'loadavg': 0.1, 'free_space': '10G', 'display_power': 'on', @@ -308,7 +362,7 @@ class InfoViewMixin(APIView): ) @authorized def get(self, request): - viewlog = 'Not yet implemented' + viewlog = self._get_viewlog() # Calculate disk space slash = statvfs('/') diff --git a/api/views/v2.py b/api/views/v2.py index 61dcd3c83..fe74296e4 100644 --- a/api/views/v2.py +++ b/api/views/v2.py @@ -426,7 +426,20 @@ def get_ip_addresses(self): 200: { 'type': 'object', 'properties': { - 'viewlog': {'type': 'string'}, + 'viewlog': { + 'oneOf': [ + { + 'type': 'object', + 'properties': { + 'asset_id': {'type': 'string'}, + 'asset_name': {'type': 'string'}, + 'mimetype': {'type': 'string'}, + 'started_at': {'type': 'string'}, + }, + }, + {'type': 'string'}, + ], + }, 'loadavg': {'type': 'number'}, 'free_space': {'type': 'string'}, 'display_power': {'type': ['string', 'null']}, @@ -463,7 +476,7 @@ def get_ip_addresses(self): ) @authorized def get(self, request): - viewlog = 'Not yet implemented' + viewlog = self._get_viewlog() # Calculate disk space slash = statvfs('/') diff --git a/viewer/__init__.py b/viewer/__init__.py index 478302abc..0aecfc21c 100644 --- a/viewer/__init__.py +++ b/viewer/__init__.py @@ -4,8 +4,10 @@ import json import logging +import sqlite3 import sys from builtins import range +from datetime import datetime, timezone from os import getenv, path from signal import SIGALRM, signal from time import sleep @@ -222,6 +224,62 @@ def load_settings(): ) +def _get_viewlog_db_path(): + """Return the path to the viewlog database.""" + return path.join(HOME or getenv('HOME', ''), '.screenly', 'viewlog.db') + + +def _init_viewlog_db(): + """Create the viewlog database and table if they don't exist.""" + db_path = _get_viewlog_db_path() + try: + conn = sqlite3.connect(db_path, timeout=5) + conn.execute(""" + CREATE TABLE IF NOT EXISTS viewlog ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + asset_id TEXT NOT NULL, + asset_name TEXT NOT NULL, + mimetype TEXT NOT NULL, + uri TEXT NOT NULL, + started_at TEXT NOT NULL, + duration INTEGER DEFAULT 0 + ) + """) + conn.commit() + conn.close() + except Exception as e: + logging.warning('Failed to init viewlog DB: %s', e) + + +def _log_playback(asset): + """Log an asset playback event to viewlog.db. + + Records what asset started playing and when. This data is used by + the /api/v2/info endpoint and the screenshot endpoint to know + what is currently displayed. + """ + db_path = _get_viewlog_db_path() + try: + conn = sqlite3.connect(db_path, timeout=5) + conn.execute( + 'INSERT INTO viewlog ' + '(asset_id, asset_name, mimetype, uri, started_at, duration) ' + 'VALUES (?, ?, ?, ?, ?, ?)', + ( + asset.get('asset_id', ''), + asset.get('name', ''), + asset.get('mimetype', ''), + asset.get('uri', ''), + datetime.now(timezone.utc).isoformat(), + int(asset.get('duration', 0)), + ), + ) + conn.commit() + conn.close() + except Exception as e: + logging.warning('Failed to log playback: %s', e) + + def asset_loop(scheduler): asset = scheduler.get_next_asset() @@ -248,6 +306,7 @@ def asset_loop(scheduler): logging.info('Showing asset %s (%s)', name, mime) logging.debug('Asset URI %s', uri) watchdog() + _log_playback(asset) if 'image' in mime: view_image(uri) @@ -302,6 +361,7 @@ def setup(): signal(SIGALRM, sigalrm) load_settings() + _init_viewlog_db() load_browser() bus = pydbus.SessionBus()