pax_global_header00006660000000000000000000000064146177741020014523gustar00rootroot0000000000000052 comment=46110a744017526aea98eb0d880654a93c4255c4 allenporter-rtsp-to-webrtc-client-46110a7/000077500000000000000000000000001461777410200204655ustar00rootroot00000000000000allenporter-rtsp-to-webrtc-client-46110a7/.coveragerc000066400000000000000000000000341461777410200226030ustar00rootroot00000000000000[run] source=rtsp_to_webrtc allenporter-rtsp-to-webrtc-client-46110a7/.cruft.json000066400000000000000000000010431461777410200225570ustar00rootroot00000000000000{ "template": "https://github.com/allenporter/cookiecutter-python", "commit": "8fcd09030f24f62ffe30aab1d9f0cde5c3ee3de5", "checkout": null, "context": { "cookiecutter": { "full_name": "Allen Porter", "email": "allen.porter@gmail.com", "github_username": "allenporter", "project_name": "rtsp_to_webrtc", "description": "Python client library for RTSPtoWeb and RTSPtoWebRTC", "version": "0.5.1", "_template": "https://github.com/allenporter/cookiecutter-python" } }, "directory": null } allenporter-rtsp-to-webrtc-client-46110a7/.github/000077500000000000000000000000001461777410200220255ustar00rootroot00000000000000allenporter-rtsp-to-webrtc-client-46110a7/.github/workflows/000077500000000000000000000000001461777410200240625ustar00rootroot00000000000000allenporter-rtsp-to-webrtc-client-46110a7/.github/workflows/cruft.yaml000066400000000000000000000043361461777410200260770ustar00rootroot00000000000000--- name: Update repository with Cruft permissions: contents: write pull-requests: write on: schedule: - cron: "0 2 * * 1" # Every Monday at 2am jobs: update: runs-on: ubuntu-latest strategy: fail-fast: true matrix: include: - add-paths: . body: Use this to merge the changes to this repository. branch: cruft/update commit-message: "chore: accept new Cruft update" title: New updates detected with Cruft - add-paths: .cruft.json body: Use this to reject the changes in this repository. branch: cruft/reject commit-message: "chore: reject new Cruft update" title: Reject new updates detected with Cruft steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: "3.11" - name: Install Cruft run: pip3 install cruft - name: Check if update is available continue-on-error: false id: check run: | CHANGES=0 if [ -f .cruft.json ]; then if ! cruft check; then CHANGES=1 fi else echo "No .cruft.json file" fi echo "has_changes=$CHANGES" >> "$GITHUB_OUTPUT" - name: Run update if available if: steps.check.outputs.has_changes == '1' run: | git config --global user.email "allen.porter@gmail.com" git config --global user.name "Allen Porter" cruft update --skip-apply-ask --refresh-private-variables git restore --staged . - name: Create pull request if: steps.check.outputs.has_changes == '1' uses: peter-evans/create-pull-request@v4 with: token: ${{ secrets.GITHUB_TOKEN }} add-paths: ${{ matrix.add-paths }} commit-message: ${{ matrix.commit-message }} branch: ${{ matrix.branch }} delete-branch: true branch-suffix: timestamp title: ${{ matrix.title }} body: | This is an autogenerated PR. ${{ matrix.body }} [Cruft](https://cruft.github.io/cruft/) has detected updates from the Cookiecutter repository. allenporter-rtsp-to-webrtc-client-46110a7/.github/workflows/lint.yaml000066400000000000000000000013131461777410200257120ustar00rootroot00000000000000--- name: Lint on: push: branches: - main pull_request: branches: - main jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v4 - uses: chartboost/ruff-action@v1.0.0 - uses: codespell-project/actions-codespell@v2.0 with: check_hidden: false - name: Set up Python uses: actions/setup-python@v5 with: python-version: "3.12" - name: Install dependencies run: | python -m pip install --upgrade pip pip install -r requirements_dev.txt - name: Static typing with mypy run: | mypy --install-types --non-interactive --no-warn-unused-ignores . allenporter-rtsp-to-webrtc-client-46110a7/.github/workflows/pages.yaml000066400000000000000000000020411461777410200260420ustar00rootroot00000000000000--- name: Deploy static content to Pages on: push: branches: - main workflow_dispatch: permissions: contents: read pages: write id-token: write actions: read concurrency: group: "pages" cancel-in-progress: true jobs: deploy: environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest strategy: fail-fast: false steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: | pip install -r requirements.txt - run: pdoc ./rtsp_to_webrtc -o docs/ - name: Setup Pages uses: actions/configure-pages@v5 - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: # Upload entire repository path: 'docs/' - name: Deploy to GitHub Pages id: deployment uses: actions/deploy-pages@v4 allenporter-rtsp-to-webrtc-client-46110a7/.github/workflows/publish.yaml000066400000000000000000000011571461777410200264200ustar00rootroot00000000000000--- name: Upload Python Package on: release: types: [published] permissions: contents: read jobs: deploy: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Python uses: actions/setup-python@v5 with: python-version: '3.x' - name: Install dependencies run: | python -m pip install --upgrade pip pip install build - name: Build package run: python -m build - name: Publish package uses: pypa/gh-action-pypi-publish@v1.8.14 with: user: __token__ password: ${{ secrets.PYPI_API_TOKEN }} allenporter-rtsp-to-webrtc-client-46110a7/.github/workflows/test.yaml000066400000000000000000000015651461777410200257340ustar00rootroot00000000000000--- name: Test on: push: branches: - main pull_request: branches: - main jobs: build: runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: ["3.11", "3.12"] steps: - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip if [ -f requirements_dev.txt ]; then pip install -r requirements_dev.txt; fi - name: Test with pytest run: | pytest --cov=rtsp_to_webrtc --cov-report=term-missing - uses: codecov/codecov-action@v4 with: token: ${{ secrets.CODECOV_TOKEN }} env_vars: OS,PYTHON fail_ci_if_error: true verbose: true allenporter-rtsp-to-webrtc-client-46110a7/.gitignore000066400000000000000000000034071461777410200224610ustar00rootroot00000000000000# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ pip-wheel-metadata/ share/python-wheels/ *.egg-info/ .installed.cfg *.egg MANIFEST # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *.cover *.py,cover .hypothesis/ .pytest_cache/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py db.sqlite3 db.sqlite3-journal # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # IPython profile_default/ ipython_config.py # pyenv .python-version # pipenv # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # However, in case of collaboration, if having platform-specific dependencies or dependencies # having no cross-platform support, pipenv may install dependencies that don't work, or not # install all needed dependencies. #Pipfile.lock # PEP 582; used by e.g. github.com/David-OConnor/pyflow __pypackages__/ # Celery stuff celerybeat-schedule celerybeat.pid # SageMath parsed files *.sage.py # Environments .env .venv env/ venv/ ENV/ env.bak/ venv.bak/ # Spyder project settings .spyderproject .spyproject # Rope project settings .ropeproject # mkdocs documentation /site # mypy .mypy_cache/ .dmypy.json dmypy.json # Pyre type checker .pyre/ allenporter-rtsp-to-webrtc-client-46110a7/.pre-commit-config.yaml000066400000000000000000000015511461777410200247500ustar00rootroot00000000000000--- repos: - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.6.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - id: check-yaml - id: check-added-large-files - repo: https://github.com/psf/black rev: 24.4.2 hooks: - id: black - repo: https://github.com/charliermarsh/ruff-pre-commit rev: v0.4.4 hooks: - id: ruff args: - --fix - --exit-non-zero-on-fix - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.10.0 hooks: - id: mypy - repo: https://github.com/codespell-project/codespell rev: v2.2.6 hooks: - id: codespell - repo: https://github.com/adrienverge/yamllint.git rev: v1.35.1 hooks: - id: yamllint exclude: '^tests/tool/testdata/.*\.yaml$' args: - -c - ".yaml-lint.yaml" - repo: https://github.com/asottile/setup-cfg-fmt rev: v2.5.0 hooks: - id: setup-cfg-fmt allenporter-rtsp-to-webrtc-client-46110a7/.pre-commit-config.yaml.rej000066400000000000000000000003651461777410200255310ustar00rootroot00000000000000diff a/.pre-commit-config.yaml b/.pre-commit-config.yaml (rejected hunks) @@ -34,3 +34,7 @@ repos: args: - -c - ".yaml-lint.yaml" +- repo: https://github.com/asottile/setup-cfg-fmt + rev: v1.20.0 + hooks: + - id: setup-cfg-fmt allenporter-rtsp-to-webrtc-client-46110a7/.readthedocs.yml000066400000000000000000000000421461777410200235470ustar00rootroot00000000000000python: setup_py_install: true allenporter-rtsp-to-webrtc-client-46110a7/.ruff.toml000066400000000000000000000000221461777410200223740ustar00rootroot00000000000000ignore = ["E501"] allenporter-rtsp-to-webrtc-client-46110a7/.yaml-lint.yaml000066400000000000000000000005651461777410200233430ustar00rootroot00000000000000--- ignore: | venv tests/testdata extends: default rules: truthy: allowed-values: ['true', 'false', 'on', 'yes'] comments: min-spaces-from-content: 1 line-length: disable braces: min-spaces-inside: 0 max-spaces-inside: 1 brackets: min-spaces-inside: 0 max-spaces-inside: 0 indentation: spaces: 2 indent-sequences: consistent allenporter-rtsp-to-webrtc-client-46110a7/LICENSE000066400000000000000000000261351461777410200215010ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS APPENDIX: How to apply the Apache License to your work. To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. Copyright [yyyy] [name of copyright owner] Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. allenporter-rtsp-to-webrtc-client-46110a7/MANIFEST.in000066400000000000000000000001701461777410200222210ustar00rootroot00000000000000include setup.py include MANIFEST.in include LICENSE include README.md graft tests graft examples graft docs graft src allenporter-rtsp-to-webrtc-client-46110a7/README.md000066400000000000000000000005631461777410200217500ustar00rootroot00000000000000# rtsp-to-webrtc-client Python client library for [RTSPtoWeb](https://github.com/deepch/RTSPtoWeb) and [RTSPtoWebRTC](https://github.com/deepch/RTSPtoWebRTC). ## Development ``` $ python3 -m venv venv $ source venv/bin/activate $ pip3 install -e . $ pip3 install -r requirements.txt # Running tests $ pytest # Formatting and linting $ pre-commit run --all-files ``` allenporter-rtsp-to-webrtc-client-46110a7/mypy.ini000066400000000000000000000006141461777410200221650ustar00rootroot00000000000000[mypy] ignore_missing_imports = True exclude = (venv|build) check_untyped_defs = True disallow_incomplete_defs = True disallow_subclassing_any = True disallow_untyped_calls = True disallow_untyped_decorators = True disallow_untyped_defs = True no_implicit_optional = True warn_return_any = True warn_unreachable = True warn_redundant_casts = True warn_unused_ignores = True warn_no_return = True allenporter-rtsp-to-webrtc-client-46110a7/pytest.ini000066400000000000000000000000571461777410200225200ustar00rootroot00000000000000[pytest] log_level = DEBUG asyncio_mode = auto allenporter-rtsp-to-webrtc-client-46110a7/renovate.json5000066400000000000000000000006651461777410200232770ustar00rootroot00000000000000{ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:base" ], "assignees": ["allenporter"], "packageRules": [ { "description": "Minor updates are automatic", "automerge": true, "automergeType": "branch", "matchUpdateTypes": ["minor", "patch"] } ], "pip_requirements": { "fileMatch": ["requirements_dev.txt"] }, "pre-commit": {"enabled": true} } allenporter-rtsp-to-webrtc-client-46110a7/requirements_dev.txt000066400000000000000000000004001461777410200246010ustar00rootroot00000000000000-e . aiohttp==3.9.5 coverage==6.4.2 cruft==2.15.0 pdoc==12.1.0 pip==22.1.2 pytest==7.1.2 pytest-aiohttp==1.0.5 pytest-benchmark==3.4.1 pytest-cov==3.0.0 types-python-dateutil==2.8.19 ruff==0.0.253 black==22.10.0 wheel==0.37.1 pre-commit==3.6.0 mypy==1.8.0 allenporter-rtsp-to-webrtc-client-46110a7/requirements_dev.txt.rej000066400000000000000000000006321461777410200253670ustar00rootroot00000000000000diff a/requirements_dev.txt b/requirements_dev.txt (rejected hunks) @@ -1,13 +1,11 @@ -e . -coverage==6.4.2 +black==24.4.2 +coverage==7.5.1 +mypy==1.10.0 pdoc==12.1.0 -pip==22.1.2 -pytest==7.1.2 +pip==23.0.1 +pre-commit==3.6.0 pytest-benchmark==3.4.1 pytest-cov==3.0.0 -types-python-dateutil==2.8.19 -ruff==0.0.253 -black==22.10.0 -wheel==0.37.1 -pre-commit==3.6.0 -mypy==1.8.0 +pytest==8.2.0 +ruff==0.4.4 allenporter-rtsp-to-webrtc-client-46110a7/rtsp_to_webrtc/000077500000000000000000000000001461777410200235255ustar00rootroot00000000000000allenporter-rtsp-to-webrtc-client-46110a7/rtsp_to_webrtc/__init__.py000066400000000000000000000000421461777410200256320ustar00rootroot00000000000000"RTSPtoWebRTC Client Library." "" allenporter-rtsp-to-webrtc-client-46110a7/rtsp_to_webrtc/client.py000066400000000000000000000036411461777410200253610ustar00rootroot00000000000000"""Client library for RTSPtoWebRTC server.""" from __future__ import annotations import logging from collections.abc import Mapping from typing import Any import aiohttp from . import diagnostics from .diagnostics import DISCOVERY_DIAGNOSTICS as DIAGNOSTICS from .exceptions import ClientError from .interface import WebRTCClientInterface from .web_client import WebClient from .webrtc_client import WebRTCClient _LOGGER = logging.getLogger(__name__) # For backwards compatibility. Deprecated and will be removed in the future. Client = WebRTCClient async def get_adaptive_client( websession: aiohttp.ClientSession, server_url: str | None = None ) -> WebRTCClientInterface: """Initialize Client that can auto-detect the appropriate client for the server.""" web_client = WebClient(websession, server_url) webrtc_client = WebRTCClient(websession, server_url) DIAGNOSTICS.increment("attempt") web_heartbeat = web_client.heartbeat() webrtc_heartbeat = webrtc_client.heartbeat() client: WebRTCClientInterface | None = None web_err: ClientError | None = None try: await web_heartbeat except ClientError as err: DIAGNOSTICS.increment("web.failure") _LOGGER.debug("Discovery of RTSPtoWeb server failed: %s", str(err)) web_err = err else: DIAGNOSTICS.increment("web.success") client = web_client try: await webrtc_heartbeat except ClientError as err: DIAGNOSTICS.increment("webrtc.failure") _LOGGER.debug("Discovery of RTSPtoWebRTC server failed: %s", str(err)) if not client: assert web_err raise web_err else: DIAGNOSTICS.increment("webrtc.success") if not client: client = webrtc_client return client def get_diagnostics() -> Mapping[str, Any]: """Return client library diagnostic debug information.""" return diagnostics.get_diagnostics() allenporter-rtsp-to-webrtc-client-46110a7/rtsp_to_webrtc/diagnostics.py000066400000000000000000000023211461777410200264040ustar00rootroot00000000000000"""Diagnostics for debugging.""" from __future__ import annotations from collections import Counter from collections.abc import Mapping from typing import Any class Diagnostics: """Information for RTSP to Web Client libraries.""" def __init__(self) -> None: """Initialize Diagnostics.""" self._counter: Counter = Counter() def increment(self, key: str) -> None: """Increment a counter for the specified key/event.""" self._counter.update(Counter({key: 1})) def as_dict(self) -> Mapping[str, Any]: """Return diagnostics as a debug dictionary.""" return {k: self._counter[k] for k in self._counter} def reset(self) -> None: """Clear all diagnostics, for testing.""" self._counter = Counter() DISCOVERY_DIAGNOSTICS = Diagnostics() WEB_DIAGNOSTICS = Diagnostics() WEBRTC_DIAGNOSTICS = Diagnostics() MAP = { "discovery": DISCOVERY_DIAGNOSTICS, "web": WEB_DIAGNOSTICS, "webrtc": WEBRTC_DIAGNOSTICS, } def reset() -> None: """Clear all diagnostics, for testing.""" for diagnostics in MAP.values(): diagnostics.reset() def get_diagnostics() -> dict[str, Any]: return { k: v.as_dict() for (k, v) in MAP.items() } allenporter-rtsp-to-webrtc-client-46110a7/rtsp_to_webrtc/exceptions.py000066400000000000000000000003541461777410200262620ustar00rootroot00000000000000"""Library for exceptions in RTSPtoWebRTC Client.""" class ClientError(Exception): """Exception communicating with the server.""" class ResponseError(ClientError): """Exception after receiving a response from the server.""" allenporter-rtsp-to-webrtc-client-46110a7/rtsp_to_webrtc/interface.py000066400000000000000000000014151461777410200260400ustar00rootroot00000000000000"""Interface for client library for RTSPtoWeb / RTSPtoWebRTC server.""" from __future__ import annotations from abc import ABC, abstractmethod from typing import Any class WebRTCClientInterface(ABC): """Client for RTSPtoWeb / RTSPtoWebRTC server.""" @abstractmethod async def offer(self, offer_sdp: str, rtsp_url: str) -> str: """Send the WebRTC offer to the server.""" @abstractmethod async def offer_stream_id( self, stream_id: str, offer_sdp: str, rtsp_url: str, channel_data: dict[str, Any] | None = None, ) -> str: """Send the WebRTC offer to the server.""" @abstractmethod async def heartbeat(self) -> None: """Send a request to the server to determine if it is alive.""" allenporter-rtsp-to-webrtc-client-46110a7/rtsp_to_webrtc/py.typed000066400000000000000000000000001461777410200252120ustar00rootroot00000000000000allenporter-rtsp-to-webrtc-client-46110a7/rtsp_to_webrtc/web_client.py000066400000000000000000000251301461777410200262130ustar00rootroot00000000000000"""Client library for RTSPtoWebserver.""" from __future__ import annotations import base64 import enum import hashlib import logging from typing import Any, Dict, List, Mapping, Optional, cast from urllib.parse import urljoin import aiohttp from .diagnostics import WEB_DIAGNOSTICS as DIAGNOSTICS from .exceptions import ClientError, ResponseError from .interface import WebRTCClientInterface _LOGGER = logging.getLogger(__name__) STREAMS_PATH = "/streams" ADD_STREAM_PATH = "/stream/{stream_id}/add" EDIT_STREAM_PATH = "/stream/{stream_id}/edit" RELOAD_STREAM_PATH = "/stream/{stream_id}/reload" STREAM_INFO_PATH = "/stream/{stream_id}/info" DELETE_STREAM_PATH = "/stream/{stream_id}/delete" ADD_CHANNEL_PATH = "/stream/{stream_id}/channel/{channel_id}/add" EDIT_CHANNEL_PATH = "/stream/{stream_id}/channel/{channel_id}/edit" RELOAD_CHANNEL_PATH = "/stream/{stream_id}/channel/{channel_id}/reload" CHANNEL_INFO_PATH = "/stream/{stream_id}/channel/{channel_id}/info" CODEC_INFO_PATH = "/stream/{stream_id}/channel/{channel_id}/codec" DELETE_CHANNEL_PATH = "/stream/{stream_id}/channel/{channel_id}/delete" WEBRTC_PATH = "/stream/{stream_id}/channel/{channel_id}/webrtc" DATA_STATUS = "status" DATA_PAYLOAD = "payload" class StatusCode(enum.Enum): FAILURE = "0" SUCCESS = "1" class WebClient(WebRTCClientInterface): """Client for RTSPtoWeb server.""" def __init__( self, websession: aiohttp.ClientSession, server_url: Optional[str] = None ) -> None: """Initialize Client.""" self._session = websession self._base_url = server_url async def list_streams(self) -> dict[str, Any]: """List streams registered with the server.""" resp = await self._request("get", STREAMS_PATH, label="list_streams") return await self._get_dict(resp) async def add_stream(self, stream_id: str, data: dict[str, Any]) -> None: """Add a stream.""" resp = await self._request( "post", ADD_STREAM_PATH.format(stream_id=stream_id), json=data, label="add_stream", ) await self._get_payload(resp) async def update_stream(self, stream_id: str, data: dict[str, Any]) -> None: """Update a stream.""" resp = await self._request( "post", EDIT_STREAM_PATH.format(stream_id=stream_id), json=data, label="update_stream", ) await self._get_payload(resp) async def reload_stream(self, stream_id: str) -> None: """Reload a stream.""" resp = await self._request( "get", RELOAD_STREAM_PATH.format(stream_id=stream_id), label="reload_stream", ) await self._get_payload(resp) async def get_stream_info(self, stream_id: str) -> dict[str, Any]: """Get information about a stream.""" resp = await self._request( "get", STREAM_INFO_PATH.format(stream_id=stream_id), label="get_stream_info" ) return await self._get_dict(resp) async def delete_stream(self, stream_id: str) -> None: """Delete a stream.""" resp = await self._request( "get", DELETE_STREAM_PATH.format(stream_id=stream_id), label="delete_stream", ) await self._get_payload(resp) async def add_channel( self, stream_id: str, channel_id: str, data: dict[str, Any] ) -> None: """Add a channel""" resp = await self._request( "post", ADD_CHANNEL_PATH.format(stream_id=stream_id, channel_id=channel_id), json=data, label="add_channel", ) await self._get_payload(resp) async def update_channel( self, stream_id: str, channel_id: str, data: dict[str, Any] ) -> None: """Update a channel.""" resp = await self._request( "post", EDIT_CHANNEL_PATH.format(stream_id=stream_id, channel_id=channel_id), json=data, label="update_channel", ) await self._get_payload(resp) async def reload_channel(self, stream_id: str, channel_id: str) -> None: """Reload a channel.""" resp = await self._request( "get", RELOAD_CHANNEL_PATH.format(stream_id=stream_id, channel_id=channel_id), label="reload_channel", ) await self._get_payload(resp) async def get_channel_info(self, stream_id: str, channel_id: str) -> dict[str, Any]: """Get information about a channel.""" resp = await self._request( "get", CHANNEL_INFO_PATH.format(stream_id=stream_id, channel_id=channel_id), label="get_channel_info", ) return await self._get_dict(resp) async def get_codec_info(self, stream_id: str, channel_id: str) -> dict[str, Any]: """Get information about a codecs.""" resp = await self._request( "get", CODEC_INFO_PATH.format(stream_id=stream_id, channel_id=channel_id), label="get_codec_info", ) return await self._get_dict(resp) async def delete_channel(self, stream_id: str, channel_id: str) -> None: """Delete a channel.""" resp = await self._request( "get", DELETE_CHANNEL_PATH.format(stream_id=stream_id, channel_id=channel_id), label="delete_channel", ) await self._get_payload(resp) async def webrtc(self, stream_id: str, channel_id: str, offer_sdp: str) -> str: """Send the WebRTC offer to the RTSPtoWeb server.""" sdp64 = base64.b64encode(offer_sdp.encode("utf-8")).decode("utf-8") data = { "data": sdp64, } resp = await self._request( "post", WEBRTC_PATH.format(stream_id=stream_id, channel_id=channel_id), data=data, label="webrtc", ) text = await resp.text() answer = base64.b64decode(text).decode("utf-8") return answer async def offer(self, offer_sdp: str, rtsp_url: str) -> str: """Send the WebRTC offer to the RTSPtoWeb server.""" # Generate a fake stream id to use until API is updated to pass a # client generated id digest = hashlib.md5(rtsp_url.encode("utf-8")).digest() stream_id = base64.b32encode(digest).decode("utf-8") return await self.offer_stream_id(stream_id, offer_sdp, rtsp_url) async def offer_stream_id( self, stream_id: str, offer_sdp: str, rtsp_url: str, channel_data: dict[str, Any] | None = None, ) -> str: """Send the WebRTC offer to the RTSPtoWeb server.""" # Generate a fake stream id to use until API is updated to pass a # client generated id streams = await self.list_streams() if channel_data is None: channel_data = {} stream_payload = { "name": stream_id, "channels": { "0": { "name": "ch1", "url": rtsp_url, **channel_data, }, }, } if stream_id in streams: if streams[stream_id].get("channels", {}).get("0", {}).get("url") == rtsp_url: _LOGGER.debug("Not updating stream url since already set") else: await self.update_stream(stream_id, stream_payload) else: await self.add_stream(stream_id, stream_payload) return await self.webrtc(stream_id, "0", offer_sdp) async def heartbeat(self) -> None: """Send a request to the server to determine if it is alive.""" # ignore result await self._request("get", STREAMS_PATH, label="heartbeat") async def _request( self, method: str, path: str, **kwargs: Optional[Mapping[str, Any]] | str, ) -> aiohttp.ClientResponse: label = kwargs["label"] kwargs.pop("label") url = self._request_url(path) _LOGGER.debug("request[%s] %s", method, url) DIAGNOSTICS.increment(f"{label}.request") try: resp = await self._session.request(method, url, **kwargs) except aiohttp.ClientError as err: DIAGNOSTICS.increment(f"{label}.client_error") raise ClientError(f"RTSPtoWeb server communication failure: {err}") from err error_detail = await WebClient._error_detail(resp) try: resp.raise_for_status() except aiohttp.ClientResponseError as err: DIAGNOSTICS.increment(f"{label}.response_error") error_detail.insert(0, "RTSPtoWeb server failure") error_detail.append(err.message) raise ResponseError(": ".join(error_detail)) from err DIAGNOSTICS.increment(f"{label}.success") _LOGGER.debug("response %s", resp) return resp async def _get_payload(self, resp: aiohttp.ClientResponse) -> Any: """Return payload from the response.""" try: result = await resp.json() except aiohttp.ClientResponseError as err: DIAGNOSTICS.increment("decode_failure") raise ResponseError("RTSPtoWeb server response decode error: ", str(err)) if DATA_STATUS not in result: raise ResponseError(f"RTSPtoWeb server missing status: {result}") if str(result[DATA_STATUS]) != StatusCode.SUCCESS.value: raise ResponseError(f"RTSPtoWeb server failure: {result}") if DATA_PAYLOAD not in result: raise ResponseError(f"RTSPtoWeb server missing payload: {result}") return result[DATA_PAYLOAD] async def _get_dict(self, resp: aiohttp.ClientResponse) -> dict[str, Any]: """Return payload from the response.""" payload = await self._get_payload(resp) if not isinstance(payload, dict): DIAGNOSTICS.increment("malformed_payload") raise ResponseError( f"RTSPtoWeb server returned malformed payload: {payload}" ) return cast(Dict[str, Any], payload) def _request_url(self, path: str) -> str: """Return a request url for the specific path.""" if not self._base_url: return path return urljoin(self._base_url, path) @staticmethod async def _error_detail(resp: aiohttp.ClientResponse) -> List[str]: """Returns an error message string from the API response.""" if resp.status < 400: return [] try: result = await resp.json() if DATA_PAYLOAD in result: return [result[DATA_PAYLOAD]] except aiohttp.ClientError: return [] return [] allenporter-rtsp-to-webrtc-client-46110a7/rtsp_to_webrtc/webrtc_client.py000066400000000000000000000077061461777410200267350ustar00rootroot00000000000000"""Client library for RTSPtoWebRTC server.""" from __future__ import annotations import base64 import logging from typing import Any, List, Mapping, Optional from urllib.parse import urljoin import aiohttp from .diagnostics import WEBRTC_DIAGNOSTICS as DIAGNOSTICS from .exceptions import ClientError, ResponseError from .interface import WebRTCClientInterface _LOGGER = logging.getLogger(__name__) STREAM_PATH = "/stream" HEARTBEAT_PATH = "/static" DATA_URL = "url" DATA_SDP64 = "sdp64" DATA_ERROR = "error" class WebRTCClient(WebRTCClientInterface): """Client for RTSPtoWebRTC server.""" def __init__( self, websession: aiohttp.ClientSession, server_url: Optional[str] = None ) -> None: """Initialize Client.""" self._session = websession self._base_url = "" if server_url: self._base_url = urljoin(server_url, STREAM_PATH) async def offer(self, offer_sdp: str, rtsp_url: str) -> str: """Send the WebRTC offer to the RTSPtoWebRTC server.""" return await self.offer_stream_id("ignored", offer_sdp, rtsp_url) async def offer_stream_id( self, stream_id: str, offer_sdp: str, rtsp_url: str, channel_data: dict[str, Any] | None = None, ) -> str: """Send the WebRTC offer to the RTSPtoWebRTC server.""" _LOGGER.debug("rtsp_url=%s, offer=%s", rtsp_url, offer_sdp) sdp64 = base64.b64encode(offer_sdp.encode("utf-8")).decode("utf-8") if channel_data is None: channel_data = {} resp = await self._request( "post", STREAM_PATH, data={ DATA_URL: rtsp_url, DATA_SDP64: sdp64, **channel_data, }, label="stream", ) data = await resp.json() if DATA_SDP64 not in data: raise ResponseError( f"RTSPtoWebRTC server response missing SDP Answer: {resp}" ) answer = base64.b64decode(data[DATA_SDP64]).decode("utf-8") _LOGGER.debug("answer=%s", answer) return answer async def heartbeat(self) -> None: """Send a request to the server to determine if it is alive.""" await self._request("get", HEARTBEAT_PATH, label="heartbeat") async def _request( self, method: str, path: str, **kwargs: Optional[Mapping[str, Any]] | str, ) -> aiohttp.ClientResponse: url = self._request_url(path) label = kwargs["label"] kwargs.pop("label") DIAGNOSTICS.increment(f"{label}.request") try: resp = await self._session.request(method, url, **kwargs) except aiohttp.ClientError as err: DIAGNOSTICS.increment(f"{label}.client_error") raise ClientError( f"RTSPtoWebRTC server communication failure: {err}" ) from err error_detail = await WebRTCClient._error_detail(resp) try: resp.raise_for_status() except aiohttp.ClientResponseError as err: DIAGNOSTICS.increment(f"{label}.response_error") error_detail.insert(0, "RTSPtoWebRTC server failure") error_detail.append(err.message) raise ResponseError(": ".join(error_detail)) from err DIAGNOSTICS.increment(f"{label}.success") return resp def _request_url(self, path: str) -> str: """Return a request url for the specific path.""" if not self._base_url: return path return urljoin(self._base_url, path) @staticmethod async def _error_detail(resp: aiohttp.ClientResponse) -> List[str]: """Returns an error message string from the APi response.""" if resp.status < 400: return [] try: result = await resp.json() if DATA_ERROR in result: return [result[DATA_ERROR]] except aiohttp.ClientError: return [] return [] allenporter-rtsp-to-webrtc-client-46110a7/setup.cfg000066400000000000000000000014561461777410200223140ustar00rootroot00000000000000[metadata] name = rtsp_to_webrtc version = 0.6.0 description = Python client library for RTSPtoWeb and RTSPtoWebRTC long_description = file: README.md long_description_content_type = text/markdown url = https://github.com/allenporter/rtsp-to-webrtc-client author = Allen Porter author_email = allen@thebends.org license = Apache-2.0 license_file = LICENSE classifiers = License :: OSI Approved :: Apache Software License Programming Language :: Python :: 3 Programming Language :: Python :: 3 :: Only Programming Language :: Python :: 3.10 [options] packages = find: install_requires = aiohttp>=3.7.3 python_requires = >=3.10 include_package_data = True package_dir = = . [options.packages.find] where = . exclude = tests tests.* [options.package_data] rtsp_to_webrtc = py.typed allenporter-rtsp-to-webrtc-client-46110a7/setup.py000066400000000000000000000000461461777410200221770ustar00rootroot00000000000000from setuptools import setup setup() allenporter-rtsp-to-webrtc-client-46110a7/tests/000077500000000000000000000000001461777410200216275ustar00rootroot00000000000000allenporter-rtsp-to-webrtc-client-46110a7/tests/__init__.py000066400000000000000000000000001461777410200237260ustar00rootroot00000000000000allenporter-rtsp-to-webrtc-client-46110a7/tests/conftest.py000066400000000000000000000025161461777410200240320ustar00rootroot00000000000000from __future__ import annotations import logging from collections.abc import Awaitable, Callable, Generator from json import JSONDecodeError from typing import Any, cast import aiohttp import pytest from aiohttp import web from rtsp_to_webrtc import diagnostics _LOGGER = logging.getLogger(__name__) @pytest.fixture def loop(event_loop: Any) -> Any: return event_loop async def handler(request: aiohttp.web.Request) -> aiohttp.web.Response: """Handles the request, inserting response prepared by tests.""" if request.method == "POST": try: request.app["request-json"].append(await request.json()) except JSONDecodeError: pass request.app["request-post"].append(await request.post()) response = request.app["response"].pop(0) request.app["request"].append(request.url.path) return cast(aiohttp.web.Response, response) @pytest.fixture async def request_handler() -> Callable[ [aiohttp.web.Request], Awaitable[aiohttp.web.Response] ]: return handler @pytest.fixture def app() -> web.Application: app = web.Application() app["response"] = [] app["request"] = [] app["request-json"] = [] app["request-post"] = [] return app @pytest.fixture(autouse=True) def reset_diagnostics() -> Generator[None, None, None]: yield diagnostics.reset() allenporter-rtsp-to-webrtc-client-46110a7/tests/test_client.py000066400000000000000000000147771461777410200245360ustar00rootroot00000000000000from __future__ import annotations import asyncio import base64 from collections.abc import Awaitable, Callable from typing import Any, cast import aiohttp import pytest from aiohttp import ClientSession, web from aiohttp.test_utils import TestClient, TestServer from rtsp_to_webrtc.client import get_adaptive_client, get_diagnostics from rtsp_to_webrtc.exceptions import ClientError OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 h.example.com\r\n..." ANSWER_PAYLOAD = base64.b64encode(ANSWER_SDP.encode("utf-8")).decode("utf-8") RTSP_URL = "rtsp://example" STREAM_1 = { "name": "test video", "channels": { "0": { "name": "ch1", "url": "rtsp://example", }, "1": { "name": "ch2", "url": "rtsp://example", }, }, } SUCCESS_RESPONSE = { "status": 1, "payload": "success", } @pytest.fixture def event_loop() -> Any: loop = asyncio.get_event_loop() yield loop @pytest.fixture def cli_cb( loop: Any, app: web.Application, aiohttp_client: Callable[[web.Application], Awaitable[TestClient]], ) -> Callable[[], Awaitable[TestClient]]: """Creates a fake aiohttp client.""" async def func() -> TestClient: return await aiohttp_client(app) return func async def test_adaptive_web_client( cli_cb: Callable[[], Awaitable[TestClient]], app: web.Application, request_handler: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], ) -> None: """Test adaptive client picks Web when both succeed.""" app.router.add_get("/streams", request_handler) app.router.add_get("/static", request_handler) app.router.add_post("/stream/{stream_id}/add", request_handler) app.router.add_post( "/stream/{stream_id}/channel/{channel_id}/webrtc", request_handler ) cli = await cli_cb() assert isinstance(cli.server, TestServer) # Web heartbeat cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": { "demo1": STREAM_1, }, } ) ) # WebRTC heartbeat cli.server.app["response"].append( aiohttp.web.Response(status=404), ) # List call cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": {}, } ) ) # Add stream cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) # Web Offer cli.server.app["response"].append(aiohttp.web.Response(body=ANSWER_PAYLOAD)) client = await get_adaptive_client(cast(ClientSession, cli)) answer_sdp = await client.offer(OFFER_SDP, RTSP_URL) assert answer_sdp == ANSWER_SDP assert get_diagnostics() == { "discovery": {"attempt": 1, "web.success": 1, "webrtc.failure": 1}, "web": { "add_stream.request": 1, "add_stream.success": 1, "heartbeat.request": 1, "heartbeat.success": 1, "list_streams.request": 1, "list_streams.success": 1, "webrtc.request": 1, "webrtc.success": 1, }, "webrtc": {"heartbeat.request": 1, "heartbeat.response_error": 1}, } async def test_adaptive_both_succeed_web_client( cli_cb: Callable[[], Awaitable[TestClient]], app: web.Application, request_handler: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], ) -> None: """Test adaptive client picks Web when both succeed.""" app.router.add_get("/streams", request_handler) app.router.add_get("/static", request_handler) app.router.add_post("/stream/{stream_id}/add", request_handler) app.router.add_post( "/stream/{stream_id}/channel/{channel_id}/webrtc", request_handler ) cli = await cli_cb() assert isinstance(cli.server, TestServer) # Web heartbeat cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": { "demo1": STREAM_1, }, } ) ) # WebRTC heartbeat cli.server.app["response"].append( aiohttp.web.Response(status=200), ) # List call cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": {}, } ) ) # Add stream cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) # Web Offer cli.server.app["response"].append(aiohttp.web.Response(body=ANSWER_PAYLOAD)) client = await get_adaptive_client(cast(ClientSession, cli)) answer_sdp = await client.offer(OFFER_SDP, RTSP_URL) assert answer_sdp == ANSWER_SDP async def test_adaptive_webrtc_client( cli_cb: Callable[[], Awaitable[TestClient]], app: web.Application, request_handler: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], ) -> None: """Test List Streams calls.""" app.router.add_get("/streams", request_handler) app.router.add_get("/static", request_handler) app.router.add_post("/stream", request_handler) cli = await cli_cb() assert isinstance(cli.server, TestServer) # Web heartbeat fails cli.server.app["response"].append(aiohttp.web.Response(status=404)) # WebRTC heartbeat succeeds cli.server.app["response"].append( aiohttp.web.Response(status=200), ) # WebRTC offer cli.server.app["response"].append( aiohttp.web.json_response({"sdp64": ANSWER_PAYLOAD}) ) client = await get_adaptive_client(cast(ClientSession, cli)) answer_sdp = await client.offer(OFFER_SDP, RTSP_URL) assert answer_sdp == ANSWER_SDP assert get_diagnostics() == { "discovery": {"attempt": 1, "webrtc.success": 1, "web.failure": 1}, "web": {"heartbeat.request": 1, "heartbeat.response_error": 1}, "webrtc": { "stream.request": 1, "stream.success": 1, "heartbeat.request": 1, "heartbeat.success": 1, }, } async def test_adaptive_both_fail( cli_cb: Callable[[], Awaitable[TestClient]], app: web.Application, ) -> None: """Test successful response from RTSPtoWebRTC server.""" cli = await cli_cb() assert isinstance(cli.server, TestServer) with pytest.raises(ClientError): await get_adaptive_client(cast(ClientSession, cli)) allenporter-rtsp-to-webrtc-client-46110a7/tests/test_web_client.py000066400000000000000000000364441461777410200253660ustar00rootroot00000000000000from __future__ import annotations import base64 from collections.abc import Awaitable, Callable from typing import Any, cast import aiohttp import pytest from aiohttp import ClientSession, web from aiohttp.test_utils import TestClient, TestServer from rtsp_to_webrtc.exceptions import ResponseError from rtsp_to_webrtc.web_client import WebClient OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 h.example.com\r\n..." ANSWER_PAYLOAD = base64.b64encode(ANSWER_SDP.encode("utf-8")).decode("utf-8") RTSP_URL = "rtsp://example" STREAM_1 = { "name": "test video", "channels": { "0": { "name": "ch1", "url": RTSP_URL, }, "1": { "name": "ch2", "url": RTSP_URL, }, }, } STREAM_2 = { "name": "test video #2", "channels": { "0": { "name": "ch1", "url": "rtsp://example.com", }, "1": { "name": "ch2", "url": "rtsp://example.biz", }, }, } CHANNEL = { "name": "ch1", "url": "rtsp://example", "on_demand": False, "debug": False, "status": 0, } SUCCESS_RESPONSE = { "status": 1, "payload": "success", } @pytest.fixture(autouse=True) def setup_handler( app: web.Application, request_handler: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], ) -> None: app.router.add_get("/streams", request_handler) app.router.add_post("/stream/{stream_id}/add", request_handler) app.router.add_post("/stream/{stream_id}/edit", request_handler) app.router.add_get("/stream/{stream_id}/reload", request_handler) app.router.add_get("/stream/{stream_id}/info", request_handler) app.router.add_get("/stream/{stream_id}/delete", request_handler) app.router.add_post("/stream/{stream_id}/channel/{channel_id}/add", request_handler) app.router.add_post( "/stream/{stream_id}/channel/{channel_id}/edit", request_handler ) app.router.add_get( "/stream/{stream_id}/channel/{channel_id}/reload", request_handler ) app.router.add_get("/stream/{stream_id}/channel/{channel_id}/info", request_handler) app.router.add_get( "/stream/{stream_id}/channel/{channel_id}/codec", request_handler ) app.router.add_get( "/stream/{stream_id}/channel/{channel_id}/delete", request_handler ) app.router.add_post( "/stream/{stream_id}/channel/{channel_id}/webrtc", request_handler ) @pytest.fixture def cli( loop: Any, app: web.Application, aiohttp_client: Callable[[web.Application], Awaitable[TestClient]], ) -> TestClient: """Creates a fake aiohttp client.""" client = loop.run_until_complete(aiohttp_client(app)) return cast(TestClient, client) async def test_list_streams( cli: TestClient, request_handler: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], ) -> None: """Test List Streams calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": { "demo1": STREAM_1, "demo2": STREAM_2, }, } ) ) client = WebClient(cast(ClientSession, cli)) streams = await client.list_streams() assert len(streams) == 2 assert streams == { "demo1": STREAM_1, "demo2": STREAM_2, } requests = cli.server.app["request"] assert requests == ["/streams"] async def test_list_streams_failure( cli: TestClient, request_handler: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], ) -> None: """Test List Streams calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.Response(status=502)) client = WebClient(cast(ClientSession, cli)) with pytest.raises(ResponseError, match=r"server failure.*"): await client.list_streams() async def test_list_streams_status_failure(cli: TestClient) -> None: """Test failure response from RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response({"status": 0, "payload": "a message"}) ) client = WebClient(cast(ClientSession, cli)) with pytest.raises(ResponseError, match=r"server failure:.*a message.*"): await client.list_streams() async def test_list_streams_missing_payload(cli: TestClient) -> None: """Test failure response from RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response({"status": 1})) client = WebClient(cast(ClientSession, cli)) with pytest.raises(ResponseError, match=r"server missing payload.*"): await client.list_streams() async def test_list_streams_malformed_payload(cli: TestClient) -> None: """Test failure response from RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response({"status": 1, "payload": ["list"]}) ) client = WebClient(cast(ClientSession, cli)) with pytest.raises(ResponseError, match=r"malformed payload.*"): await client.list_streams() async def test_add_stream(cli: TestClient) -> None: """Test Add Streams calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) client = WebClient(cast(ClientSession, cli)) await client.add_stream("demo1", data=STREAM_1) requests = cli.server.app["request"] assert requests == ["/stream/demo1/add"] async def test_update_stream(cli: TestClient) -> None: """Test Update Streams calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) client = WebClient(cast(ClientSession, cli)) await client.update_stream("demo1", data=STREAM_1) requests = cli.server.app["request"] assert requests == ["/stream/demo1/edit"] async def test_reload_stream(cli: TestClient) -> None: """Test Reload Streams calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) client = WebClient(cast(ClientSession, cli)) await client.reload_stream("demo1") requests = cli.server.app["request"] assert requests == ["/stream/demo1/reload"] async def test_get_stream_info( cli: TestClient, request_handler: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], ) -> None: """Test Get Stream Info calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": STREAM_1, } ) ) client = WebClient(cast(ClientSession, cli)) data = await client.get_stream_info("demo1") assert data == STREAM_1 requests = cli.server.app["request"] assert requests == ["/stream/demo1/info"] async def test_delete_stream(cli: TestClient) -> None: """Test Delete Streams calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) client = WebClient(cast(ClientSession, cli)) await client.delete_stream("demo1") requests = cli.server.app["request"] assert requests == ["/stream/demo1/delete"] async def test_add_channel(cli: TestClient) -> None: """Test Add channel calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) client = WebClient(cast(ClientSession, cli)) await client.add_channel("demo1", "0", CHANNEL) requests = cli.server.app["request"] assert len(requests) == 1 async def test_update_channel(cli: TestClient) -> None: """Test Update channel calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) client = WebClient(cast(ClientSession, cli)) await client.update_channel("demo1", "0", CHANNEL) requests = cli.server.app["request"] assert len(requests) == 1 async def test_reload_channel(cli: TestClient) -> None: """Test Reload channel calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) client = WebClient(cast(ClientSession, cli)) await client.reload_channel("demo1", "0") requests = cli.server.app["request"] assert len(requests) == 1 async def test_get_channel_info( cli: TestClient, request_handler: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], ) -> None: """Test Get Stream Info calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": CHANNEL, } ) ) client = WebClient(cast(ClientSession, cli)) data = await client.get_channel_info("demo1", "0") assert data == CHANNEL async def test_delete_channel(cli: TestClient) -> None: """Test Reload channel calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) client = WebClient(cast(ClientSession, cli)) await client.delete_channel("demo1", "0") requests = cli.server.app["request"] assert len(requests) == 1 async def test_webrtc(cli: TestClient) -> None: """Test List Streams calls.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.Response(body=ANSWER_PAYLOAD)) client = WebClient(cast(ClientSession, cli)) answer = await client.webrtc("demo1", "0", OFFER_SDP) assert answer == ANSWER_SDP requests = cli.server.app["request"] assert len(requests) == 1 async def test_webrtc_failure(cli: TestClient) -> None: """Test a failure talking to RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.Response(status=502)) client = WebClient(cast(ClientSession, cli)) with pytest.raises(ResponseError, match=r"server failure.*"): await client.webrtc("demo1", "0", OFFER_SDP) async def test_server_failure_with_error(cli: TestClient) -> None: """Test invalid response from RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response({"status": 1, "payload": "a message"}, status=502) ) client = WebClient(cast(ClientSession, cli)) with pytest.raises(ResponseError, match=r"server failure:.*a message.*"): await client.webrtc("demo1", "0", OFFER_SDP) async def test_heartbeat(cli: TestClient) -> None: """Test successful response from RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].extend( [ aiohttp.web.Response(status=200), aiohttp.web.Response(status=502), aiohttp.web.Response(status=404), aiohttp.web.Response(status=200), ] ) client = WebClient(cast(ClientSession, cli)) await client.heartbeat() with pytest.raises(ResponseError): await client.heartbeat() with pytest.raises(ResponseError): await client.heartbeat() await client.heartbeat() async def test_offer(cli: TestClient) -> None: """Test Offer call.""" assert isinstance(cli.server, TestServer) # List call cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": {}, } ) ) # Add stream cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) # Offer cli.server.app["response"].append(aiohttp.web.Response(body=ANSWER_PAYLOAD)) client = WebClient(cast(ClientSession, cli)) answer_sdp = await client.offer(OFFER_SDP, RTSP_URL) assert answer_sdp == ANSWER_SDP requests = cli.server.app["request"] assert requests == [ "/streams", "/stream/Y7L7SZDOZXHIYFHESPL7YPKXHI======/add", "/stream/Y7L7SZDOZXHIYFHESPL7YPKXHI======/channel/0/webrtc", ] async def test_offer_update_stream(cli: TestClient) -> None: """Test Offer updates an existing stream.""" assert isinstance(cli.server, TestServer) # List call cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": { "demo1": STREAM_1, }, } ) ) # Edit stream cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) # Offer cli.server.app["response"].append(aiohttp.web.Response(body=ANSWER_PAYLOAD)) client = WebClient(cast(ClientSession, cli)) answer_sdp = await client.offer_stream_id("demo1", OFFER_SDP, f"{RTSP_URL}?example") assert answer_sdp == ANSWER_SDP requests = cli.server.app["request"] assert requests == [ "/streams", "/stream/demo1/edit", "/stream/demo1/channel/0/webrtc", ] async def test_offer_channel_data(cli: TestClient) -> None: """Test Offer updates an existing stream.""" assert isinstance(cli.server, TestServer) # List call cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": {}, } ) ) # Add stream cli.server.app["response"].append(aiohttp.web.json_response(SUCCESS_RESPONSE)) # Offer cli.server.app["response"].append(aiohttp.web.Response(body=ANSWER_PAYLOAD)) client = WebClient(cast(ClientSession, cli)) answer_sdp = await client.offer_stream_id( "demo1", OFFER_SDP, RTSP_URL, channel_data={"insecure_skip_verify": True} ) assert answer_sdp == ANSWER_SDP requests = cli.server.app["request"] assert requests == [ "/streams", "/stream/demo1/add", "/stream/demo1/channel/0/webrtc", ] assert cli.server.app["request-json"] == [ { "channels": { "0": { "insecure_skip_verify": True, "name": "ch1", "url": "rtsp://example", } }, "name": "demo1", } ] async def test_offer_update_no_op(cli: TestClient) -> None: """Test that an offer is a no-up when stream matches.""" assert isinstance(cli.server, TestServer) # List call cli.server.app["response"].append( aiohttp.web.json_response( { "status": 1, "payload": { "demo1": STREAM_1, }, } ) ) # Offer cli.server.app["response"].append(aiohttp.web.Response(body=ANSWER_PAYLOAD)) client = WebClient(cast(ClientSession, cli)) answer_sdp = await client.offer_stream_id("demo1", OFFER_SDP, RTSP_URL) assert answer_sdp == ANSWER_SDP requests = cli.server.app["request"] assert requests == [ "/streams", "/stream/demo1/channel/0/webrtc", ] allenporter-rtsp-to-webrtc-client-46110a7/tests/test_webrtc_client.py000066400000000000000000000120261461777410200260650ustar00rootroot00000000000000from __future__ import annotations import base64 from collections.abc import Awaitable, Callable from typing import Any, cast import aiohttp import pytest from aiohttp import ClientSession, web from aiohttp.test_utils import TestClient, TestServer from rtsp_to_webrtc.client import Client from rtsp_to_webrtc.exceptions import ResponseError SERVER_URL = "https://example.com" RTSP_URL = "rtsps://example" OFFER_SDP = "v=0\r\no=carol 28908764872 28908764872 IN IP4 100.3.6.6\r\n..." ANSWER_SDP = "v=0\r\no=bob 2890844730 2890844730 IN IP4 h.example.com\r\n..." ANSWER_PAYLOAD = base64.b64encode(ANSWER_SDP.encode("utf-8")).decode("utf-8") @pytest.fixture(autouse=True) def setup_handler( app: web.Application, request_handler: Callable[[aiohttp.web.Request], Awaitable[aiohttp.web.Response]], ) -> None: app.router.add_get("/static", request_handler) app.router.add_post("/stream", request_handler) @pytest.fixture def cli( loop: Any, app: web.Application, aiohttp_client: Callable[[web.Application], Awaitable[TestClient]], ) -> TestClient: """Creates a fake aiohttp client.""" client = loop.run_until_complete(aiohttp_client(app)) return cast(TestClient, client) async def test_offer(cli: TestClient) -> None: """Test successful response from RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response({"sdp64": ANSWER_PAYLOAD}) ) client = Client(cast(ClientSession, cli)) answer_sdp = await client.offer(OFFER_SDP, RTSP_URL) assert answer_sdp == ANSWER_SDP async def test_response_missing_answer(cli: TestClient) -> None: """Test invalid response from RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.json_response({})) client = Client(cast(ClientSession, cli)) with pytest.raises(ResponseError, match=r".*missing SDP Answer.*"): await client.offer(OFFER_SDP, RTSP_URL) async def test_server_failure(cli: TestClient) -> None: """Test a failure talking to RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append(aiohttp.web.Response(status=502)) client = Client(cast(ClientSession, cli)) with pytest.raises(ResponseError, match=r"server failure.*"): await client.offer(OFFER_SDP, RTSP_URL) async def test_server_failure_with_error(cli: TestClient) -> None: """Test invalid response from RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response({"error": "a message"}, status=502) ) client = Client(cast(ClientSession, cli)) with pytest.raises(ResponseError, match=r"server failure:.*a message.*"): await client.offer(OFFER_SDP, RTSP_URL) async def test_heartbeat(cli: TestClient) -> None: """Test successful response from RTSPtoWebRTC server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].extend( [ aiohttp.web.Response(status=200), aiohttp.web.Response(status=502), aiohttp.web.Response(status=404), aiohttp.web.Response(status=200), ] ) client = Client(cast(ClientSession, cli)) await client.heartbeat() with pytest.raises(ResponseError): await client.heartbeat() with pytest.raises(ResponseError): await client.heartbeat() await client.heartbeat() async def test_offer_stream_id(cli: TestClient) -> None: """Test offer with explicit stream id API.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response({"sdp64": ANSWER_PAYLOAD}) ) client = Client(cast(ClientSession, cli)) answer_sdp = await client.offer_stream_id("stream_id", OFFER_SDP, RTSP_URL) assert answer_sdp == ANSWER_SDP requests = cli.server.app["request"] assert requests == [ "/stream", ] assert cli.server.app["request-post"] == [ { "url": "rtsps://example", "sdp64": "dj0wDQpvPWNhcm9sIDI4OTA4NzY0ODcyIDI4OTA4NzY0ODcyIElOIElQNCAxMDAuMy42LjYNCi4uLg==", } ] async def test_offer_with_channel_data(cli: TestClient) -> None: """Test that the channel data is passed, though likely ignored by server.""" assert isinstance(cli.server, TestServer) cli.server.app["response"].append( aiohttp.web.json_response({"sdp64": ANSWER_PAYLOAD}) ) client = Client(cast(ClientSession, cli)) answer_sdp = await client.offer_stream_id( "stream_id", OFFER_SDP, RTSP_URL, channel_data={"insecure_skip_verify": True} ) assert answer_sdp == ANSWER_SDP requests = cli.server.app["request"] assert requests == [ "/stream", ] assert cli.server.app["request-post"] == [ { "url": "rtsps://example", "sdp64": "dj0wDQpvPWNhcm9sIDI4OTA4NzY0ODcyIDI4OTA4NzY0ODcyIElOIElQNCAxMDAuMy42LjYNCi4uLg==", "insecure_skip_verify": "True", } ]