From 798ceff23dfbd4977002381eb18003fae37bd997 Mon Sep 17 00:00:00 2001 From: Bernard Gawor Date: Mon, 2 Nov 2026 12:48:43 +0100 Subject: [PATCH] Expose the HLS playlist link on recordings Regenerate the OpenAPI client so a recording carries its hls url once the playlist is uploaded, None until then. --- .../api/mo_q/create_moq_access.py | 5 ++ .../api/rooms/refresh_token.py | 6 ++ .../api/streamers/create_streamer.py | 6 ++ .../api/streamers/generate_streamer_token.py | 6 ++ .../api/viewers/create_viewer.py | 6 ++ .../api/viewers/generate_viewer_token.py | 6 ++ .../models/__init__.py | 2 + .../models/moq_access.py | 5 +- .../models/moq_access_config.py | 2 +- .../models/recording.py | 29 +++++++++ .../models/recording_hls_type_0.py | 61 +++++++++++++++++++ tests/test_recording_api.py | 26 ++++++++ 12 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 fishjam/_fishjam_openapi_client/models/recording_hls_type_0.py diff --git a/fishjam/_fishjam_openapi_client/api/mo_q/create_moq_access.py b/fishjam/_fishjam_openapi_client/api/mo_q/create_moq_access.py index be0b70d..cf7167c 100644 --- a/fishjam/_fishjam_openapi_client/api/mo_q/create_moq_access.py +++ b/fishjam/_fishjam_openapi_client/api/mo_q/create_moq_access.py @@ -49,6 +49,11 @@ def _parse_response( return response_401 + if response.status_code == 402: + response_402 = Error.from_dict(response.json()) + + return response_402 + if response.status_code == 503: response_503 = Error.from_dict(response.json()) diff --git a/fishjam/_fishjam_openapi_client/api/rooms/refresh_token.py b/fishjam/_fishjam_openapi_client/api/rooms/refresh_token.py index 8b7e75b..9b5eeab 100644 --- a/fishjam/_fishjam_openapi_client/api/rooms/refresh_token.py +++ b/fishjam/_fishjam_openapi_client/api/rooms/refresh_token.py @@ -15,6 +15,7 @@ def _get_kwargs( room_id: str, id: str, ) -> dict[str, Any]: + _kwargs: dict[str, Any] = { "method": "post", "url": "/room/{room_id}/peer/{id}/refresh_token".format( @@ -44,6 +45,11 @@ def _parse_response( return response_401 + if response.status_code == 402: + response_402 = Error.from_dict(response.json()) + + return response_402 + if response.status_code == 404: response_404 = Error.from_dict(response.json()) diff --git a/fishjam/_fishjam_openapi_client/api/streamers/create_streamer.py b/fishjam/_fishjam_openapi_client/api/streamers/create_streamer.py index facc641..0459aaa 100644 --- a/fishjam/_fishjam_openapi_client/api/streamers/create_streamer.py +++ b/fishjam/_fishjam_openapi_client/api/streamers/create_streamer.py @@ -14,6 +14,7 @@ def _get_kwargs( stream_id: str, ) -> dict[str, Any]: + _kwargs: dict[str, Any] = { "method": "post", "url": "/livestream/{stream_id}/streamer".format( @@ -37,6 +38,11 @@ def _parse_response( return response_401 + if response.status_code == 402: + response_402 = Error.from_dict(response.json()) + + return response_402 + if response.status_code == 404: response_404 = Error.from_dict(response.json()) diff --git a/fishjam/_fishjam_openapi_client/api/streamers/generate_streamer_token.py b/fishjam/_fishjam_openapi_client/api/streamers/generate_streamer_token.py index a0d2a09..466fb22 100644 --- a/fishjam/_fishjam_openapi_client/api/streamers/generate_streamer_token.py +++ b/fishjam/_fishjam_openapi_client/api/streamers/generate_streamer_token.py @@ -14,6 +14,7 @@ def _get_kwargs( room_id: str, ) -> dict[str, Any]: + _kwargs: dict[str, Any] = { "method": "post", "url": "/room/{room_id}/streamer".format( @@ -42,6 +43,11 @@ def _parse_response( return response_401 + if response.status_code == 402: + response_402 = Error.from_dict(response.json()) + + return response_402 + if response.status_code == 404: response_404 = Error.from_dict(response.json()) diff --git a/fishjam/_fishjam_openapi_client/api/viewers/create_viewer.py b/fishjam/_fishjam_openapi_client/api/viewers/create_viewer.py index 3a608c1..59ae604 100644 --- a/fishjam/_fishjam_openapi_client/api/viewers/create_viewer.py +++ b/fishjam/_fishjam_openapi_client/api/viewers/create_viewer.py @@ -14,6 +14,7 @@ def _get_kwargs( stream_id: str, ) -> dict[str, Any]: + _kwargs: dict[str, Any] = { "method": "post", "url": "/livestream/{stream_id}/viewer".format( @@ -37,6 +38,11 @@ def _parse_response( return response_401 + if response.status_code == 402: + response_402 = Error.from_dict(response.json()) + + return response_402 + if response.status_code == 404: response_404 = Error.from_dict(response.json()) diff --git a/fishjam/_fishjam_openapi_client/api/viewers/generate_viewer_token.py b/fishjam/_fishjam_openapi_client/api/viewers/generate_viewer_token.py index f38a5d8..73e72bc 100644 --- a/fishjam/_fishjam_openapi_client/api/viewers/generate_viewer_token.py +++ b/fishjam/_fishjam_openapi_client/api/viewers/generate_viewer_token.py @@ -14,6 +14,7 @@ def _get_kwargs( room_id: str, ) -> dict[str, Any]: + _kwargs: dict[str, Any] = { "method": "post", "url": "/room/{room_id}/viewer".format( @@ -42,6 +43,11 @@ def _parse_response( return response_401 + if response.status_code == 402: + response_402 = Error.from_dict(response.json()) + + return response_402 + if response.status_code == 404: response_404 = Error.from_dict(response.json()) diff --git a/fishjam/_fishjam_openapi_client/models/__init__.py b/fishjam/_fishjam_openapi_client/models/__init__.py index 1861304..d34bd7e 100644 --- a/fishjam/_fishjam_openapi_client/models/__init__.py +++ b/fishjam/_fishjam_openapi_client/models/__init__.py @@ -32,6 +32,7 @@ from .recording_config_metadata_type_0 import RecordingConfigMetadataType0 from .recording_details_response import RecordingDetailsResponse from .recording_file import RecordingFile +from .recording_hls_type_0 import RecordingHlsType0 from .recording_list_response import RecordingListResponse from .recording_metadata_type_0 import RecordingMetadataType0 from .recording_status import RecordingStatus @@ -99,6 +100,7 @@ "RecordingConfigMetadataType0", "RecordingDetailsResponse", "RecordingFile", + "RecordingHlsType0", "RecordingListResponse", "RecordingMetadataType0", "RecordingStatus", diff --git a/fishjam/_fishjam_openapi_client/models/moq_access.py b/fishjam/_fishjam_openapi_client/models/moq_access.py index bcdfda4..fb3b204 100644 --- a/fishjam/_fishjam_openapi_client/models/moq_access.py +++ b/fishjam/_fishjam_openapi_client/models/moq_access.py @@ -14,8 +14,9 @@ class MoqAccess: """Connection details for a MoQ relay client Attributes: - connection_url (str): Relay connection URL with the JWT embedded as a `?jwt=` query parameter. Pass directly to - a MoQ client SDK. Example: https://relay.fishjam.io/abc123?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.... + connection_url (str): Relay connection URL scoped to the tenant UUID, with the JWT embedded as a `?jwt=` query + parameter. Pass directly to a MoQ client SDK. Example: + https://relay.fishjam.io/abc123?jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.... token (str): JWT authorizing the MoQ relay connection, also embedded in `connection_url` Example: eyJhbGciOiJIUz I1NiIsInR5cCI6IkpXVCJ9.eyJyb290IjoiZmlzaGphbSIsInB1dCI6WyJteS1zdHJlYW0iXSwiZ2V0IjpbXSwiaWF0IjoxNzEzMzYwMDAwLCJle HAiOjE3MTMzNjM2MDB9.abc123. diff --git a/fishjam/_fishjam_openapi_client/models/moq_access_config.py b/fishjam/_fishjam_openapi_client/models/moq_access_config.py index 35ee25c..1d552f0 100644 --- a/fishjam/_fishjam_openapi_client/models/moq_access_config.py +++ b/fishjam/_fishjam_openapi_client/models/moq_access_config.py @@ -19,7 +19,7 @@ class MoqAccessConfig: subscribe_path (None | str | Unset): Path under the root the token grants subscribe access to Example: my- stream. ttl (int | None | Unset): Token time to live in seconds. Defaults to 3600 (1 hour), maximum is 604800 (7 days). - Example: 3600. + On the free tier the maximum is 3600 (1 hour). Example: 3600. """ publish_path: None | str | Unset = UNSET diff --git a/fishjam/_fishjam_openapi_client/models/recording.py b/fishjam/_fishjam_openapi_client/models/recording.py index da34422..0d5a3af 100644 --- a/fishjam/_fishjam_openapi_client/models/recording.py +++ b/fishjam/_fishjam_openapi_client/models/recording.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from ..models.composition_source import CompositionSource from ..models.recording_file import RecordingFile + from ..models.recording_hls_type_0 import RecordingHlsType0 from ..models.recording_metadata_type_0 import RecordingMetadataType0 from ..models.template_source import TemplateSource @@ -26,6 +27,8 @@ class Recording: Attributes: files (list[RecordingFile]): Media files of the recording, in playback order. Empty until the recording is `available`. + hls (None | RecordingHlsType0): HLS playlist of the recording, playable while it is still `active`. Null until + the playlist reaches the delivery bucket. id (str): Assigned recording id source (CompositionSource | TemplateSource): The source for the recording status (RecordingStatus): Lifecycle status of a recording @@ -34,6 +37,7 @@ class Recording: """ files: list[RecordingFile] + hls: None | RecordingHlsType0 id: str source: CompositionSource | TemplateSource status: RecordingStatus @@ -42,6 +46,7 @@ class Recording: def to_dict(self) -> dict[str, Any]: from ..models.composition_source import CompositionSource + from ..models.recording_hls_type_0 import RecordingHlsType0 from ..models.recording_metadata_type_0 import RecordingMetadataType0 files = [] @@ -49,6 +54,12 @@ def to_dict(self) -> dict[str, Any]: files_item = files_item_data.to_dict() files.append(files_item) + hls: dict[str, Any] | None + if isinstance(self.hls, RecordingHlsType0): + hls = self.hls.to_dict() + else: + hls = self.hls + id = self.id source: dict[str, Any] @@ -71,6 +82,7 @@ def to_dict(self) -> dict[str, Any]: field_dict.update(self.additional_properties) field_dict.update({ "files": files, + "hls": hls, "id": id, "source": source, "status": status, @@ -84,6 +96,7 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: from ..models.composition_source import CompositionSource from ..models.recording_file import RecordingFile + from ..models.recording_hls_type_0 import RecordingHlsType0 from ..models.recording_metadata_type_0 import RecordingMetadataType0 from ..models.template_source import TemplateSource @@ -95,6 +108,21 @@ def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: files.append(files_item) + def _parse_hls(data: object) -> None | RecordingHlsType0: + if data is None: + return data + try: + if not isinstance(data, dict): + raise TypeError() + hls_type_0 = RecordingHlsType0.from_dict(data) + + return hls_type_0 + except (TypeError, ValueError, AttributeError, KeyError): + pass + return cast(None | RecordingHlsType0, data) + + hls = _parse_hls(d.pop("hls")) + id = d.pop("id") def _parse_source(data: object) -> CompositionSource | TemplateSource: @@ -137,6 +165,7 @@ def _parse_metadata(data: object) -> None | RecordingMetadataType0 | Unset: recording = cls( files=files, + hls=hls, id=id, source=source, status=status, diff --git a/fishjam/_fishjam_openapi_client/models/recording_hls_type_0.py b/fishjam/_fishjam_openapi_client/models/recording_hls_type_0.py new file mode 100644 index 0000000..eca68b8 --- /dev/null +++ b/fishjam/_fishjam_openapi_client/models/recording_hls_type_0.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, TypeVar + +from attrs import define as _attrs_define +from attrs import field as _attrs_field + +T = TypeVar("T", bound="RecordingHlsType0") + + +@_attrs_define +class RecordingHlsType0: + """HLS playlist of the recording, playable while it is still `active`. Null until the playlist reaches the delivery + bucket. + + Attributes: + url (str): Public URL the playlist is served at + """ + + url: str + additional_properties: dict[str, Any] = _attrs_field(init=False, factory=dict) + + def to_dict(self) -> dict[str, Any]: + url = self.url + + field_dict: dict[str, Any] = {} + field_dict.update(self.additional_properties) + field_dict.update({ + "url": url, + }) + + return field_dict + + @classmethod + def from_dict(cls: type[T], src_dict: Mapping[str, Any]) -> T: + d = dict(src_dict) + url = d.pop("url") + + recording_hls_type_0 = cls( + url=url, + ) + + recording_hls_type_0.additional_properties = d + return recording_hls_type_0 + + @property + def additional_keys(self) -> list[str]: + return list(self.additional_properties.keys()) + + def __getitem__(self, key: str) -> Any: + return self.additional_properties[key] + + def __setitem__(self, key: str, value: Any) -> None: + self.additional_properties[key] = value + + def __delitem__(self, key: str) -> None: + del self.additional_properties[key] + + def __contains__(self, key: str) -> bool: + return key in self.additional_properties diff --git a/tests/test_recording_api.py b/tests/test_recording_api.py index c5d8c67..082456b 100644 --- a/tests/test_recording_api.py +++ b/tests/test_recording_api.py @@ -46,6 +46,7 @@ def make_recording_json(source: RecordingSource, status: str): return { "id": RECORDING_ID, "files": [], + "hls": None, "source": source.to_dict(), "status": status, } @@ -243,6 +244,31 @@ def test_returns_recording(self, recording_api: FishjamClient): assert request.method == "GET" assert request.url.path.endswith(f"/recordings/{RECORDING_ID}") + def test_exposes_the_hls_link_of_an_active_recording( + self, recording_api: FishjamClient + ): + hls_url = "https://media.example.com/tenant/composition/recording/index.m3u8" + recording_json = make_recording_json(make_composition_source(), "active") + recording_json["hls"] = {"url": hls_url} + _, request_patch = mock_request(200, {"data": recording_json}) + + with request_patch: + recording = recording_api.get_recording(RECORDING_ID) + + assert recording.hls is not None + assert recording.hls.url == hls_url + + def test_hls_is_none_until_the_playlist_is_uploaded( + self, recording_api: FishjamClient + ): + recording_json = make_recording_json(make_composition_source(), "active") + _, request_patch = mock_request(200, {"data": recording_json}) + + with request_patch: + recording = recording_api.get_recording(RECORDING_ID) + + assert recording.hls is None + def test_id_not_found(self, recording_api: FishjamClient): with pytest.raises(NotFoundError): recording_api.get_recording(NONEXISTENT_RECORDING_ID)