Source code for reprostim.capture.metadata
# SPDX-FileCopyrightText: 2020-2026 ReproNim Team <info@repronim.org>
#
# SPDX-License-Identifier: MIT
"""
API to extract REPROSTIM-METADATA-JSON entries embedded in
reprostim-videocapture log files.
See .ai/capture/metadata-spec.md for the full specification.
"""
import json
import logging
import os
import re
from enum import Enum
from typing import Dict, Generator, Optional, Type, TypeVar
from pydantic import BaseModel, field_validator
# initialize the logger
# Note: all logs out to stderr
logger = logging.getLogger(__name__)
logger.debug(f"name={__name__}")
# Global REPROSTIM-METADATA-JSON regex pattern
JSON_PATTERN = re.compile(r"REPROSTIM-METADATA-JSON: (.*) :REPROSTIM-METADATA-JSON")
T = TypeVar("T", bound="MetadataBase")
[docs]
class MetadataType(str, Enum):
"""``type`` field values emitted via ``_METADATA_LOG`` in
``src/reprostim-capture/videocapture/src/VideoCapture.cpp``. Keep in sync
with that file — these are the only three values reprostim-videocapture
currently writes."""
SESSION_BEGIN = "session_begin"
"""Emitted once when a recording session starts; carries device/session
info (``serial``, ``vDev``, ``aDev``, ``cx``, ``cy``, ``frameRate``,
``autoRecovery``, ``cap_ts_start``/``cap_isotime_start``)."""
CAPTURE_STOP = "capture_stop"
"""Emitted when capture stops (``message`` explains why); carries
``cap_ts_start``/``cap_isotime_start`` and
``cap_ts_stop``/``cap_isotime_stop``."""
SESSION_END = "session_end"
"""Emitted once when the session logger closes, after the ffmpeg thread
has terminated; carries ``message`` and
``cap_ts_start``/``cap_isotime_start``."""
[docs]
class MetadataBase(BaseModel):
"""Fields common to every REPROSTIM-METADATA-JSON entry, regardless of
:class:`MetadataType`.
All fields are ``Optional[str]`` for now, mirroring the raw JSON as
written by ``_METADATA_LOG`` in
``src/reprostim-capture/videocapture/src/VideoCapture.cpp`` — no
semantic typing (e.g. parsed timestamps, numeric resolution) yet. Values
that aren't already JSON strings (``cx``/``cy`` are JSON numbers,
``autoRecovery`` is a JSON bool) are coerced to ``str`` on load.
"""
type: Optional[str] = None # MetadataType value, e.g. "session_begin"
version: Optional[str] = None # reprostim-videocapture version string
json_ts: Optional[str] = None # Entry write time (reprostim timestamp format)
json_isotime: Optional[str] = None # Entry write time (ISO 8601)
cap_ts_start: Optional[str] = (
None # Capture start time (reprostim timestamp format)
)
cap_isotime_start: Optional[str] = None # Capture start time (ISO 8601)
@field_validator("*", mode="before")
@classmethod
def _stringify(cls, v):
return None if v is None else str(v)
[docs]
class MetadataSessionBegin(MetadataBase):
"""``type: session_begin`` — written once when a recording session
starts."""
appName: Optional[str] = None # Capture application name
serial: Optional[str] = None # Video capture device serial number
vDev: Optional[str] = None # Video capture device name
aDev: Optional[str] = None # Audio-in device (ALSA device name)
cx: Optional[str] = None # Video frame width in pixels
cy: Optional[str] = None # Video frame height in pixels
frameRate: Optional[str] = None # Video frame rate
autoRecovery: Optional[str] = None # Whether auto-recovery is enabled
[docs]
class MetadataCaptureStop(MetadataBase):
"""``type: capture_stop`` — written when capture stops."""
message: Optional[str] = None # Reason capture stopped
cap_ts_stop: Optional[str] = None # Capture stop time (reprostim timestamp format)
cap_isotime_stop: Optional[str] = None # Capture stop time (ISO 8601)
[docs]
class MetadataSessionEnd(MetadataBase):
"""``type: session_end`` — written once the session logger closes,
after the ffmpeg thread has terminated."""
message: Optional[str] = None # Reason the session ended
# Maps each typed MetadataBase subclass to the MetadataType it corresponds
# to, so find_metadata_by_class can look up "type" without the caller
# having to pass it separately. Keep in sync with the MetadataType enum and
# the Metadata* classes above.
_METADATA_TYPE_BY_CLASS: Dict[Type[MetadataBase], MetadataType] = {
MetadataSessionBegin: MetadataType.SESSION_BEGIN,
MetadataCaptureStop: MetadataType.CAPTURE_STOP,
MetadataSessionEnd: MetadataType.SESSION_END,
}
def _find_metadata_type_by_class(cls: Type[MetadataBase]) -> Optional[MetadataType]:
"""Look up the :class:`MetadataType` a `MetadataBase` subclass
corresponds to.
:param cls: `MetadataBase` subclass to look up
:type cls: Type[MetadataBase]
:return: The corresponding `MetadataType`, or ``None`` if ``cls`` isn't
one of the known typed subclasses (e.g. `MetadataBase` itself)
:rtype: Optional[MetadataType]
"""
return _METADATA_TYPE_BY_CLASS.get(cls)
[docs]
def iter_metadata_json(log_path: str) -> Generator[Dict, None, None]:
"""
Iterate over all REPROSTIM-METADATA-JSON lines in the log file.
Yields parsed JSON dictionaries.
:param log_path: Path to the log file
:type log_path: str
:return: Generator of parsed JSON dictionaries
:rtype: Generator[Dict, None, None]
"""
if not os.path.exists(log_path):
logger.error(f"Log file does not exist: {log_path}")
return # file missing, generator yields nothing
with open(log_path, "r", encoding="utf-8") as f:
for line in f:
match = JSON_PATTERN.search(line)
if match:
try:
data = json.loads(match.group(1))
yield data
except json.JSONDecodeError:
logger.error(f"Failed to parse JSON in line: {line}")
continue
[docs]
def find_metadata_json(path: str, key: str, value) -> Optional[Dict]:
"""Find the first metadata JSON entry with a specific key-value pair.
:param path: Path to the log file
:type path: str
:param key: Key to search for
:type key: str
:param value: Value to match
:type value: Any
:return: The first matching dictionary or None if not found
:rtype: Optional[Dict]
"""
return next(
(msg for msg in iter_metadata_json(path) if msg.get(key) == value), None
)
[docs]
def find_metadata_by_class(path: str, cls: Type[T]) -> Optional[T]:
"""Find the first metadata entry for ``cls``'s :class:`MetadataType` and
parse it as ``cls``.
Looks up ``cls``'s corresponding `MetadataType` (via
:func:`_find_metadata_type_by_class`) and calls
``find_metadata_json(path, "type", metadata_type.value)``.
:param path: Path to the log file
:type path: str
:param cls: `MetadataBase` subclass to search for and parse the result
into, e.g. `MetadataSessionBegin`
:type cls: Type[T]
:return: An instance of ``cls`` for the first matching entry, or
``None`` if ``cls`` has no corresponding `MetadataType` (logged as
an error) or no matching metadata entry exists in the log
:rtype: Optional[T]
"""
metadata_type = _find_metadata_type_by_class(cls)
if metadata_type is None:
logger.error(f"No MetadataType found for class: {cls}")
return None
msg = find_metadata_json(path, "type", metadata_type.value)
return None if msg is None else cls(**msg)