diff --git a/video/src/vonage_video/_version.py b/video/src/vonage_video/_version.py index 96e3ce8d..51ed7c48 100644 --- a/video/src/vonage_video/_version.py +++ b/video/src/vonage_video/_version.py @@ -1 +1 @@ -__version__ = '1.4.0' +__version__ = '1.5.1' diff --git a/video/src/vonage_video/models/archive.py b/video/src/vonage_video/models/archive.py index 7745e900..800c7e61 100644 --- a/video/src/vonage_video/models/archive.py +++ b/video/src/vonage_video/models/archive.py @@ -29,10 +29,16 @@ class Transcription(BaseModel): Args: status (str, Optional): The status of the transcription. reason (str, Optional): May give a brief reason for the transcription status. + url (str, Optional): The URL of the transcription file. + primaryLanguageCode (str, Optional): The primary language code for transcription. + hasSummary (bool, Optional): Whether the transcription includes a summary. """ status: Optional[str] = None reason: Optional[str] = None + url: Optional[str] = None + primaryLanguageCode: Optional[str] = None + hasSummary: Optional[bool] = None class Archive(BaseModel): @@ -71,6 +77,9 @@ class Archive(BaseModel): transcription (Transcription, Optional): Transcription options for the archive. max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per second. This is only valid for composed archives. + quantization_parameter (int, Optional): Quantization parameter (QP) for video encoding, + smaller values generate higher quality and larger archives, larger values generate + lower quality and smaller archives. Range: 15-40. Only valid for composed archives. """ id: Optional[str] = None @@ -97,6 +106,9 @@ class Archive(BaseModel): url: Optional[str] = None transcription: Optional[Transcription] = None max_bitrate: Optional[int] = Field(None, validation_alias='maxBitrate') + quantization_parameter: Optional[int] = Field( + None, validation_alias='quantizationParameter' + ) class CreateArchiveRequest(BaseModel): @@ -119,9 +131,12 @@ class CreateArchiveRequest(BaseModel): automatically ("auto", the default) or manually ("manual"). max_bitrate (int, Optional): The maximum video bitrate of the archive, in bits per second. This is only valid for composed archives. + quantization_parameter (int, Optional): Quantization parameter (QP) for video encoding, + smaller values generate higher quality and larger archives, larger values generate + lower quality and smaller archives. Range: 15-40. Only valid for composed archives. Raises: NoAudioOrVideoError: If neither `has_audio` nor `has_video` is set. - IndividualArchivePropertyError: If `resolution` or `layout` is set for individual archives + IndividualArchivePropertyError: If `resolution`, `layout`, or `quantization_parameter` is set for individual archives or if `has_transcription` is set for composed archives. """ @@ -140,6 +155,9 @@ class CreateArchiveRequest(BaseModel): max_bitrate: Optional[int] = Field( None, ge=100_000, le=6_000_000, serialization_alias='maxBitrate' ) + quantization_parameter: Optional[int] = Field( + None, ge=15, le=40, serialization_alias='quantizationParameter' + ) @model_validator(mode='after') def validate_audio_or_video(self): @@ -159,6 +177,13 @@ def no_layout_or_resolution_for_individual_archives(self): raise IndividualArchivePropertyError( 'The `layout` property cannot be set for `archive_mode: \'individual\'`.' ) + if ( + self.output_mode == OutputMode.INDIVIDUAL + and self.quantization_parameter is not None + ): + raise IndividualArchivePropertyError( + 'The `quantization_parameter` property cannot be set for `archive_mode: \'individual\'`.' + ) return self @model_validator(mode='after') diff --git a/video/tests/data/archive.json b/video/tests/data/archive.json index ee1490cc..7871653a 100644 --- a/video/tests/data/archive.json +++ b/video/tests/data/archive.json @@ -20,5 +20,6 @@ "event": "archive", "resolution": "1280x720", "url": null, - "maxBitrate": 2000000 + "maxBitrate": 2000000, + "quantizationParameter": 25 } \ No newline at end of file diff --git a/video/tests/data/archive_with_transcription.json b/video/tests/data/archive_with_transcription.json new file mode 100644 index 00000000..1e207bb7 --- /dev/null +++ b/video/tests/data/archive_with_transcription.json @@ -0,0 +1,30 @@ +{ + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "available", + "name": "archive with transcription", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 1048576, + "duration": 180, + "outputMode": "individual", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": true, + "sha256sum": "abc123def456", + "password": "", + "updatedAt": 1727870634977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/archive.mp4", + "transcription": { + "status": "completed", + "reason": "transcription completed successfully", + "url": "https://example.com/transcription.json", + "primaryLanguageCode": "en-US", + "hasSummary": true + } +} \ No newline at end of file diff --git a/video/tests/data/list_archives_with_transcription.json b/video/tests/data/list_archives_with_transcription.json new file mode 100644 index 00000000..48eb3a07 --- /dev/null +++ b/video/tests/data/list_archives_with_transcription.json @@ -0,0 +1,82 @@ +{ + "count": 3, + "items": [ + { + "id": "5b1521e6-115f-4efd-bed9-e527b87f0699", + "status": "paused", + "name": "archive without transcription", + "reason": "", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 0, + "duration": 0, + "outputMode": "composed", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "", + "password": "", + "updatedAt": 1727870434977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": null + }, + { + "id": "a9cdeb69-f6cf-408b-9197-6f99e6eac5aa", + "status": "available", + "name": "completed archive", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 1024000, + "duration": 134, + "outputMode": "composed", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": false, + "sha256sum": "test_sha256_sum", + "password": "", + "updatedAt": 1727870634977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/archive.mp4", + "maxBitrate": 2000000 + }, + { + "id": "c1d2e3f4-g5h6-i7j8-k9l0-m1n2o3p4q5r6", + "status": "available", + "name": "transcribed archive", + "reason": "session ended", + "sessionId": "test_session_id", + "applicationId": "test_application_id", + "createdAt": 1727870434974, + "size": 2048000, + "duration": 240, + "outputMode": "individual", + "streamMode": "auto", + "hasAudio": true, + "hasVideo": true, + "hasTranscription": true, + "sha256sum": "test_transcription_sha256", + "password": "", + "updatedAt": 1727870734977, + "multiArchiveTag": "", + "event": "archive", + "resolution": "1280x720", + "url": "https://example.com/transcribed_archive.mp4", + "transcription": { + "status": "completed", + "reason": "transcription completed successfully", + "url": "https://example.com/transcriptions/c1d2e3f4.json", + "primaryLanguageCode": "es-ES", + "hasSummary": true + } + } + ] +} \ No newline at end of file diff --git a/video/tests/test_archive.py b/video/tests/test_archive.py index 65e9f18a..5d9f0089 100644 --- a/video/tests/test_archive.py +++ b/video/tests/test_archive.py @@ -21,6 +21,7 @@ LayoutStylesheetError, NoAudioOrVideoError, ) +from vonage_video.models.archive import Transcription from testutils import build_response, get_mock_jwt_auth @@ -89,6 +90,87 @@ def test_create_archive_request_composed_output_mode_with_transcription_error(): ) +def test_create_archive_request_valid_quantization_parameter(): + """Test that quantization_parameter is accepted for composed archives with valid + values.""" + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + output_mode=OutputMode.COMPOSED, + quantization_parameter=25, + ) + assert request.quantization_parameter == 25 + + +def test_create_archive_request_quantization_parameter_boundary_values(): + """Test that quantization_parameter accepts boundary values (15 and 40).""" + # Test minimum value + request_min = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=15, + ) + assert request_min.quantization_parameter == 15 + + # Test maximum value + request_max = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=40, + ) + assert request_max.quantization_parameter == 40 + + +def test_create_archive_request_quantization_parameter_invalid_low(): + """Test that quantization_parameter rejects values below 15.""" + with raises(ValueError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=14, + ) + + +def test_create_archive_request_quantization_parameter_invalid_high(): + """Test that quantization_parameter rejects values above 40.""" + with raises(ValueError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + quantization_parameter=41, + ) + + +def test_create_archive_request_individual_output_mode_with_quantization_parameter(): + """Test that quantization_parameter is rejected for individual archives.""" + with raises(IndividualArchivePropertyError): + CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + output_mode=OutputMode.INDIVIDUAL, + quantization_parameter=25, + ) + + +def test_create_archive_request_serialization_with_quantization_parameter(): + """Test that quantization_parameter is properly serialized with the correct alias.""" + request = CreateArchiveRequest( + session_id="1_MX40NTY3NjYzMn5-MTQ4MTY3NjYzMn5", + has_audio=True, + has_video=True, + output_mode=OutputMode.COMPOSED, + quantization_parameter=30, + ) + + serialized = request.model_dump(by_alias=True, exclude_unset=True) + assert 'quantizationParameter' in serialized + assert serialized['quantizationParameter'] == 30 + assert ( + 'quantization_parameter' not in serialized + ) # Ensure Python field name is not used + + def test_layout_custom_without_stylesheet(): with raises(LayoutStylesheetError): ComposedLayout(type=LayoutType.CUSTOM) @@ -194,6 +276,7 @@ def test_start_archive(): assert archive.name == 'first archive test' assert archive.resolution == '1280x720' assert archive.max_bitrate == 2_000_000 + assert archive.quantization_parameter == 25 @responses.activate @@ -215,6 +298,7 @@ def test_get_archive(): assert archive.status == 'started' assert archive.name == 'first archive test' assert archive.resolution == '1280x720' + assert archive.quantization_parameter == 25 @responses.activate @@ -326,3 +410,228 @@ def test_change_archive_layout(): assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' assert video.http_client.last_response.status_code == 200 + + +# Tests for new Transcription options +def test_transcription_model_with_all_options(): + """Test that the Transcription model can be created with all new options.""" + transcription = Transcription( + status="completed", + reason="transcription completed successfully", + url="https://example.com/transcription.json", + primaryLanguageCode="en-US", + hasSummary=True, + ) + + assert transcription.status == "completed" + assert transcription.reason == "transcription completed successfully" + assert transcription.url == "https://example.com/transcription.json" + assert transcription.primaryLanguageCode == "en-US" + assert transcription.hasSummary is True + + +def test_transcription_model_with_partial_options(): + """Test that the Transcription model can be created with only some new options.""" + transcription = Transcription( + status="processing", url="https://example.com/transcription.json" + ) + + assert transcription.status == "processing" + assert transcription.url == "https://example.com/transcription.json" + assert transcription.primaryLanguageCode is None + assert transcription.hasSummary is None + assert transcription.reason is None + + +def test_transcription_model_with_url_only(): + """Test that the Transcription model can be created with just the url option.""" + transcription = Transcription(url="https://example.com/transcription.json") + + assert transcription.url == "https://example.com/transcription.json" + assert transcription.status is None + assert transcription.reason is None + assert transcription.primaryLanguageCode is None + assert transcription.hasSummary is None + + +def test_transcription_model_with_primary_language_code_only(): + """Test that the Transcription model can be created with just the primaryLanguageCode + option.""" + transcription = Transcription(primaryLanguageCode="es-ES") + + assert transcription.primaryLanguageCode == "es-ES" + assert transcription.status is None + assert transcription.reason is None + assert transcription.url is None + assert transcription.hasSummary is None + + +def test_transcription_model_with_has_summary_only(): + """Test that the Transcription model can be created with just the hasSummary + option.""" + transcription = Transcription(hasSummary=False) + + assert transcription.hasSummary is False + assert transcription.status is None + assert transcription.reason is None + assert transcription.url is None + assert transcription.primaryLanguageCode is None + + +def test_transcription_model_empty(): + """Test that the Transcription model can be created with no options set.""" + transcription = Transcription() + + assert transcription.status is None + assert transcription.reason is None + assert transcription.url is None + assert transcription.primaryLanguageCode is None + assert transcription.hasSummary is None + + +def test_transcription_model_serialization(): + """Test that the Transcription model serializes correctly.""" + transcription = Transcription( + status="completed", + reason="success", + url="https://example.com/transcription.json", + primaryLanguageCode="en-US", + hasSummary=True, + ) + + serialized = transcription.model_dump() + expected = { + "status": "completed", + "reason": "success", + "url": "https://example.com/transcription.json", + "primaryLanguageCode": "en-US", + "hasSummary": True, + } + + assert serialized == expected + + +def test_transcription_model_serialization_exclude_unset(): + """Test that the Transcription model serializes correctly excluding unset values.""" + transcription = Transcription( + url="https://example.com/transcription.json", hasSummary=True + ) + + serialized = transcription.model_dump(exclude_unset=True) + expected = {"url": "https://example.com/transcription.json", "hasSummary": True} + + assert serialized == expected + assert "status" not in serialized + assert "reason" not in serialized + assert "primaryLanguageCode" not in serialized + + +def test_transcription_model_deserialization(): + """Test that the Transcription model can be created from dictionary data.""" + data = { + "status": "completed", + "reason": "transcription finished", + "url": "https://example.com/transcription.json", + "primaryLanguageCode": "fr-FR", + "hasSummary": True, + } + + transcription = Transcription(**data) + + assert transcription.status == "completed" + assert transcription.reason == "transcription finished" + assert transcription.url == "https://example.com/transcription.json" + assert transcription.primaryLanguageCode == "fr-FR" + assert transcription.hasSummary is True + + +def test_transcription_model_with_various_language_codes(): + """Test that the Transcription model accepts various language codes.""" + test_cases = ["en-US", "es-ES", "fr-FR", "de-DE", "ja-JP", "zh-CN", "pt-BR"] + + for lang_code in test_cases: + transcription = Transcription(primaryLanguageCode=lang_code) + assert transcription.primaryLanguageCode == lang_code + + +def test_transcription_model_with_various_urls(): + """Test that the Transcription model accepts various URL formats.""" + test_urls = [ + "https://example.com/transcription.json", + "https://storage.googleapis.com/bucket/file.json", + "https://s3.amazonaws.com/bucket/transcription.txt", + "http://example.org/path/to/transcription", + "https://vonage.example.com/transcriptions/12345", + ] + + for url in test_urls: + transcription = Transcription(url=url) + assert transcription.url == url + + +def test_transcription_model_boolean_has_summary(): + """Test that hasSummary properly handles boolean values.""" + # Test True + transcription_true = Transcription(hasSummary=True) + assert transcription_true.hasSummary is True + + # Test False + transcription_false = Transcription(hasSummary=False) + assert transcription_false.hasSummary is False + + # Test None (default) + transcription_none = Transcription() + assert transcription_none.hasSummary is None + + +@responses.activate +def test_archive_with_transcription_options(): + """Test that Archive model properly deserializes with transcription containing new + options.""" + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive/5b1521e6-115f-4efd-bed9-e527b87f0699', + 'archive_with_transcription.json', + ) + + archive = video.get_archive('5b1521e6-115f-4efd-bed9-e527b87f0699') + + assert archive.id == '5b1521e6-115f-4efd-bed9-e527b87f0699' + assert archive.has_transcription is True + + # Test transcription object and its new properties + assert archive.transcription is not None + assert archive.transcription.status == "completed" + assert archive.transcription.reason == "transcription completed successfully" + assert archive.transcription.url == "https://example.com/transcription.json" + assert archive.transcription.primaryLanguageCode == "en-US" + assert archive.transcription.hasSummary is True + + +@responses.activate +def test_list_archives_with_transcription_options(): + """Test that listing archives properly handles transcription with new options.""" + # Create a modified list response that includes transcription data + build_response( + path, + 'GET', + 'https://video.api.vonage.com/v2/project/test_application_id/archive', + 'list_archives_with_transcription.json', + ) + + filter = ListArchivesFilter(session_id='test_session_id') + archives, count, next_page = video.list_archives(filter) + + # Find the archive with transcription + transcribed_archive = None + for archive in archives: + if archive.has_transcription: + transcribed_archive = archive + break + + assert transcribed_archive is not None + assert transcribed_archive.transcription is not None + assert transcribed_archive.transcription.url is not None + assert transcribed_archive.transcription.primaryLanguageCode is not None + assert transcribed_archive.transcription.hasSummary is not None diff --git a/vonage/CHANGES.md b/vonage/CHANGES.md index f75f9a3b..4dbd2f58 100644 --- a/vonage/CHANGES.md +++ b/vonage/CHANGES.md @@ -1,3 +1,12 @@ +# 4.7.1 +- vonage-video: Added missing transcription values from archive responses + +# 4.7.0 +- vonage-video: Added quantization parameter for video archives + +# 4.6.0 +- vonage-video: Added bidirectional websocket flag + # 4.5.0 - vonage-messages: add an optional "failover" property to `vonage_messages.Messages.send` diff --git a/vonage/src/vonage/_version.py b/vonage/src/vonage/_version.py index 52fde385..3c9329b2 100644 --- a/vonage/src/vonage/_version.py +++ b/vonage/src/vonage/_version.py @@ -1 +1 @@ -__version__ = '4.6.0' +__version__ = '4.7.1'