├── tests ├── __init__.py ├── conftest.py ├── util.py ├── test_is_subset.py ├── test_transport.py ├── test_plugin.py └── public_test_videos.json ├── .gitignore ├── .flake8 ├── steps_to_publish_new_version.sh ├── .coveragerc ├── janus_client ├── __init__.py ├── experiments │ ├── README.md │ ├── media.py │ └── plugin_video_room_ffmpeg.py ├── message_transaction.py ├── transport_websocket.py ├── plugin_echotest.py ├── transport_http.py ├── plugin_base.py └── session.py ├── .devcontainer ├── devcontainer.json └── Dockerfile ├── LICENSE ├── .github └── workflows │ ├── docs.yml │ └── README.md ├── test_echotest.py ├── .clinerules ├── documentation-philosophy.md ├── memory-bank.md ├── documentation-building.md ├── hatch-tooling.md └── testing-guidelines.md ├── pyproject.toml ├── eg_videocall_out.py ├── test_ffmpeg.py ├── eg_videocall_in.py ├── mkdocs.yml ├── memory-bank ├── projectbrief.md ├── README.md └── productContext.md ├── python_janus_client_icon.svg ├── docs ├── _static │ └── python_janus_client_icon.svg ├── assets │ └── python_janus_client_icon.svg ├── reference.md ├── session.md └── index.md ├── test_gst_videoroom.py ├── CODE_OF_CONDUCT.md ├── README.md ├── python_janus_client_logo.svg └── plugin_textroom_bak.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from dotenv import load_dotenv 2 | 3 | 4 | def pytest_sessionstart(session): 5 | # This hook runs once at the beginning of the entire test session 6 | load_dotenv() 7 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | 4 | def async_test(coro): 5 | def wrapper(*args, **kwargs): 6 | loop = asyncio.new_event_loop() 7 | try: 8 | return loop.run_until_complete(coro(*args, **kwargs)) 9 | finally: 10 | loop.close() 11 | 12 | return wrapper 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | 3 | # VSCode 4 | .vscode 5 | 6 | # Python 7 | __pycache__ 8 | 9 | # PyPI 10 | build 11 | dist 12 | *.egg-info 13 | 14 | # MkDocs 15 | site/* 16 | 17 | # Code coverage 18 | .coverage 19 | *.mp4 20 | htmlcov 21 | 22 | # pyenv 23 | .python-version 24 | 25 | # MAC OS X 26 | .DS_Store 27 | 28 | # Config 29 | ice_server_config.py 30 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # Recommend matching the black line length (default 88), 3 | # rather than using the flake8 default of 79: 4 | max-line-length = 88 5 | extend-ignore = 6 | # See https://github.com/PyCQA/pycodestyle/issues/373 7 | E203, 8 | ignore = E501, E203, W503 9 | per-file-ignores = 10 | __init__.py:F401, F403 11 | exclude = 12 | .git 13 | __pycache__ 14 | .venv 15 | .env 16 | .vscode 17 | .github 18 | .dev 19 | poetry.lock 20 | pyproject.toml 21 | Dockerfile 22 | .dev 23 | migrations 24 | settings 25 | -------------------------------------------------------------------------------- /steps_to_publish_new_version.sh: -------------------------------------------------------------------------------- 1 | #/bin/bash 2 | 3 | echo "Don't run this script. It's used as reference for manual work only" 4 | exit 5 | 6 | hatch test -i py=3.8 -c # All tests must pass 7 | hatch env run -e py3.8 coverage report -- --format=total # Update total into README.md 8 | 9 | # Set new version number in pyproject.toml 10 | git add pyproject.toml README.md 11 | git commit -m "Release v$(hatch version)" 12 | git push 13 | git tag -a release-$(hatch version) -m "Release v$(hatch version)" 14 | git push origin release-$(hatch version) 15 | 16 | # Really build and release it 17 | hatch -e py3.8 build --clean 18 | hatch publish 19 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | concurrency = thread 4 | 5 | [report] 6 | include = */janus_client/* 7 | ; Regexes for lines to exclude from consideration 8 | exclude_also = 9 | ; Don't complain about missing debug-only code: 10 | def __repr__ 11 | if self\.debug 12 | 13 | ; Don't complain if tests don't hit defensive assertion code: 14 | raise AssertionError 15 | raise NotImplementedError 16 | 17 | ; Don't complain if non-runnable code isn't run: 18 | if 0: 19 | if __name__ == .__main__.: 20 | 21 | ; Don't complain about abstract methods, they aren't run: 22 | @(abc\.)?abstractmethod 23 | 24 | ignore_errors = False 25 | 26 | [html] 27 | directory = htmlcov 28 | -------------------------------------------------------------------------------- /janus_client/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | from .admin_monitor import JanusAdminMonitorClient 3 | from .session import JanusSession, PluginAttachFail 4 | 5 | from .plugin_base import JanusPlugin 6 | from .plugin_echotest import JanusEchoTestPlugin 7 | from .plugin_textroom import JanusTextRoomPlugin, TextRoomError, TextRoomEventType 8 | from .plugin_video_call import JanusVideoCallPlugin, VideoCallError, VideoCallEventType 9 | from .plugin_video_room import JanusVideoRoomPlugin, VideoRoomError, VideoRoomEventType, ParticipantType 10 | 11 | from .transport import JanusTransport 12 | from .transport_http import JanusTransportHTTP 13 | from .transport_websocket import JanusTransportWebsocket 14 | 15 | from .media import MediaKind, MediaStreamTrack, MediaPlayer 16 | 17 | import logging 18 | logging.getLogger("janus_client").addHandler(logging.NullHandler()) 19 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "poetry3-poetry-pyenv", 3 | "build": { 4 | "dockerfile": "Dockerfile" 5 | }, 6 | 7 | // 👇 Features to add to the Dev Container. More info: https://containers.dev/implementors/features. 8 | // "features": {}, 9 | 10 | // 👇 Use 'forwardPorts' to make a list of ports inside the container available locally. 11 | // "forwardPorts": [], 12 | 13 | // 👇 Use 'postCreateCommand' to run commands after the container is created. 14 | // "postCreateCommand": "", 15 | 16 | // 👇 Configure tool-specific properties. 17 | "customizations": { 18 | "vscode": { 19 | "extensions":["ms-python.python", "njpwerner.autodocstring"] 20 | } 21 | } 22 | 23 | // 👇 Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 24 | // "remoteUser": "root" 25 | } 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Lim Meng Kiat 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/base:jammy 2 | # FROM mcr.microsoft.com/devcontainers/base:jammy 3 | 4 | ARG DEBIAN_FRONTEND=noninteractive 5 | ARG USER=vscode 6 | 7 | RUN DEBIAN_FRONTEND=noninteractive \ 8 | && apt-get update \ 9 | && apt-get install -y build-essential --no-install-recommends make \ 10 | ca-certificates \ 11 | git \ 12 | libssl-dev \ 13 | zlib1g-dev \ 14 | libbz2-dev \ 15 | libreadline-dev \ 16 | libsqlite3-dev \ 17 | wget \ 18 | curl \ 19 | llvm \ 20 | libncurses5-dev \ 21 | xz-utils \ 22 | tk-dev \ 23 | libxml2-dev \ 24 | libxmlsec1-dev \ 25 | libffi-dev \ 26 | liblzma-dev 27 | 28 | # Python and poetry installation 29 | USER $USER 30 | ARG HOME="/home/$USER" 31 | ARG PYTHON_VERSION=3.8 32 | # ARG PYTHON_VERSION=3.10 33 | 34 | ENV PYENV_ROOT="${HOME}/.pyenv" 35 | ENV PATH="${PYENV_ROOT}/shims:${PYENV_ROOT}/bin:${HOME}/.local/bin:$PATH" 36 | 37 | RUN echo "done 0" \ 38 | && curl https://pyenv.run | bash \ 39 | && echo "done 1" \ 40 | && pyenv install ${PYTHON_VERSION} \ 41 | && echo "done 2" \ 42 | && pyenv global ${PYTHON_VERSION} \ 43 | && echo "done 3" \ 44 | && curl -sSL https://install.python-poetry.org | python3 - \ 45 | && poetry config virtualenvs.in-project true -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: Build and Deploy Documentation 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: read 14 | pages: write 15 | id-token: write 16 | 17 | concurrency: 18 | group: "pages" 19 | cancel-in-progress: false 20 | 21 | jobs: 22 | build: 23 | runs-on: ubuntu-latest 24 | steps: 25 | - name: Checkout repository 26 | uses: actions/checkout@v4 27 | 28 | - name: Set up Python 29 | uses: actions/setup-python@v4 30 | with: 31 | python-version: '3.11' 32 | 33 | - name: Install Hatch 34 | run: pip install hatch 35 | 36 | - name: Build documentation 37 | run: hatch run docs-build 38 | 39 | - name: Setup Pages 40 | if: github.ref == 'refs/heads/master' 41 | uses: actions/configure-pages@v5 42 | 43 | - name: Upload artifact 44 | if: github.ref == 'refs/heads/master' 45 | uses: actions/upload-pages-artifact@v4 46 | with: 47 | path: ./site 48 | 49 | deploy: 50 | if: github.ref == 'refs/heads/master' 51 | environment: 52 | name: github-pages 53 | url: ${{ steps.deployment.outputs.page_url }} 54 | runs-on: ubuntu-latest 55 | needs: build 56 | steps: 57 | - name: Deploy to GitHub Pages 58 | id: deployment 59 | uses: actions/deploy-pages@v4 60 | -------------------------------------------------------------------------------- /test_echotest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | from urllib.parse import urljoin 5 | 6 | from dotenv import load_dotenv 7 | 8 | load_dotenv() 9 | 10 | # from janus_client.transport import JanusTransportHTTP 11 | from janus_client import JanusSession, JanusEchoTestPlugin 12 | 13 | format = "%(asctime)s: %(message)s" 14 | logging.basicConfig(format=format, level=logging.INFO, datefmt="%H:%M:%S") 15 | logger = logging.getLogger() 16 | 17 | 18 | async def main(): 19 | # transport = JanusTransportHTTP( 20 | # uri="https://janusmy.josephgetmyip.com/janusbase/janus" 21 | # ) 22 | # session = JanusSession(base_url="wss://janusmy.josephgetmyip.com/janusbasews/janus") 23 | session = JanusSession( 24 | base_url=urljoin( 25 | os.getenv("JANUS_HTTP_URL", ""), 26 | os.getenv("JANUS_HTTP_BASE_PATH", ""), 27 | ), 28 | api_secret=os.getenv("JANUS_API_SECRET", ""), 29 | ) 30 | 31 | plugin_handle = JanusEchoTestPlugin() 32 | 33 | await plugin_handle.attach(session=session) 34 | 35 | if os.path.exists("./asdasd.mp4"): 36 | os.remove("./asdasd.mp4") 37 | 38 | await plugin_handle.start( 39 | play_from="./Into.the.Wild.2007.mp4", record_to="./asdasd.mp4" 40 | ) 41 | 42 | response = await session.transport.ping() 43 | logger.info(response) 44 | 45 | await asyncio.sleep(15) 46 | 47 | await plugin_handle.close_stream() 48 | 49 | await plugin_handle.destroy() 50 | 51 | await session.destroy() 52 | 53 | 54 | if __name__ == "__main__": 55 | try: 56 | # asyncio.run(main=main()) 57 | asyncio.get_event_loop().run_until_complete(main()) 58 | except KeyboardInterrupt: 59 | pass 60 | -------------------------------------------------------------------------------- /.clinerules/documentation-philosophy.md: -------------------------------------------------------------------------------- 1 | # Documentation Philosophy 2 | 3 | ## Keep It Simple 4 | 5 | This project focuses on being **simple** and easy to use. The documentation should reflect this philosophy. 6 | 7 | ## Documentation Guidelines 8 | 9 | ### What to Document 10 | - Basic usage examples 11 | - Core functionality 12 | - Essential setup instructions 13 | - Common use cases 14 | 15 | ### What NOT to Document 16 | - Advanced WebRTC configuration details 17 | - Complex technical configurations that advanced users can discover themselves 18 | - Implementation details that clutter the simple interface 19 | - Edge cases that are not part of the core workflow 20 | 21 | ## Rationale 22 | 23 | Advanced users who need complex configurations (like WebRTC peer connection settings, STUN/TURN server configuration, etc.) will: 24 | 1. Read the code and docstrings 25 | 2. Understand the underlying aiortc library 26 | 3. Find the configuration options through exploration 27 | 4. Not need hand-holding in the documentation 28 | 29 | The documentation should serve the **majority use case** of users who want simple, straightforward WebRTC communication through Janus, not the minority who need advanced configuration. 30 | 31 | ## Examples 32 | 33 | ### ✅ Good Documentation 34 | ```python 35 | # Simple plugin creation 36 | plugin = JanusEchoTestPlugin() 37 | await plugin.attach(session) 38 | ``` 39 | 40 | ### ❌ Avoid in Documentation 41 | ```python 42 | # Complex WebRTC configuration examples 43 | config = RTCConfiguration(iceServers=[...]) 44 | plugin = JanusEchoTestPlugin(pc_config=config) 45 | ``` 46 | 47 | The advanced configuration capability exists in the code and is documented in docstrings, but doesn't need to be prominently featured in user-facing documentation. 48 | -------------------------------------------------------------------------------- /janus_client/experiments/README.md: -------------------------------------------------------------------------------- 1 | # Experiments 2 | 3 | This is a place for rapid prototyping, ensuring production quality of code while avoiding Git process overhead. 4 | 5 | ## FFmpeg 6 | 7 | The VideoRoom plugin implemented in [plugin_video_room_ffmpeg.py](./janus_client/plugin_video_room_ffmpeg.py) uses FFmpeg. It depends on the ffmpeg-cli, and that is required to be installed separately. 8 | 9 | ### FFmpeg Stream To WebRTC (:warning: **WARNING !!!**) 10 | 11 | This FFmpeg stream to WebRTC solution is a hack. The fact is that FFmpeg doesn't support WebRTC and aiortc is implemented using PyAV. PyAV has much less features than a full fledged installed FFmpeg, so to support more features and keep things simple, I hacked about a solution without the use of neither WHIP server nor UDP nor RTMP. 12 | 13 | First the ffmpeg input part should be constructed by the user, before passing it to `JanusVideoRoomPlugin.publish`. When the media player needs to stream the video, the following happens: 14 | 1. A thread will be created and a ffmpeg process will be created. Output of ffmpeg is hardcoded to be `rawvideo rgb24`. 15 | 2. Thread reads output of ffmpeg process. 16 | 3. Coverts the output data to numpy array and then to `av.VideoFrame` frame. 17 | 4. Hack the `pts` and `time_base` parameter of the frame. I don't know what it is and just found a value that works. 18 | 5. Put the frame into video track queue to be received and sent by `aiortc.mediastreams.MediaStreamTrack`. 19 | 20 | References: 21 | - [Aiortc Janus](https://github.com/aiortc/aiortc/tree/main/examples/janus). 22 | - [FFmpeg webrtc](https://github.com/ossrs/ffmpeg-webrtc/pull/1). 23 | 24 | ## Support for GStreamer VideoRoom plugin has been deprecated since v0.2.5 25 | 26 | Contributions to migrate the [plugin](./janus_client/plugin_video_room.py) to latest `JanusPlugin` API would be greatly appreciated. 27 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "janus-client" 3 | version = "0.9.0" 4 | description = "Janus WebRTC gateway Python async client." 5 | authors = [ 6 | {name = "Joseph Lim", email = "josephlim_94@hotmail.co.uk"}, 7 | ] 8 | license = {text = "MIT"} 9 | readme = "README.md" 10 | requires-python = ">=3.8,<3.14" 11 | classifiers = [ 12 | "Topic :: Software Development :: Libraries :: Python Modules", 13 | "Development Status :: 4 - Beta", 14 | "Programming Language :: Python :: 3", 15 | "Programming Language :: Python :: 3.8", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | "Programming Language :: Python :: 3.13", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent" 22 | ] 23 | dependencies = [ 24 | "websockets>=11.0.3", 25 | "aiortc>=1.5.0", 26 | "aiohttp>=3.8.5", 27 | ] 28 | 29 | [project.urls] 30 | Repository = "https://github.com/josephlim94/janus_gst_client_py" 31 | 32 | [build-system] 33 | requires = ["hatchling"] 34 | build-backend = "hatchling.build" 35 | 36 | [tool.hatch.build.targets.wheel] 37 | packages = ["janus_client"] 38 | 39 | [tool.hatch.envs.default] 40 | dependencies = [ 41 | "coverage>=7.2.7", 42 | "mkdocs-material>=9.6.20", 43 | "mkdocstrings>=0.23.0", 44 | "mkdocstrings-python>=1.7.5", 45 | "griffe>=0.38.0", 46 | "python-dotenv>=1.0.0", 47 | ] 48 | 49 | [[tool.hatch.envs.default.matrix]] 50 | python = ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"] 51 | 52 | [tool.hatch.envs.default.scripts] 53 | docs-build = "python -W ignore::DeprecationWarning:mkdocs_autorefs -m mkdocs build --clean --strict" 54 | docs-serve = "mkdocs serve" 55 | test = "python -m pytest" 56 | test-cov = "coverage run -m pytest" 57 | test-cov-report = "coverage report" 58 | 59 | [tool.hatch.envs.hatch-test] 60 | extra-dependencies = [ 61 | "python-dotenv", 62 | ] 63 | -------------------------------------------------------------------------------- /eg_videocall_out.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from janus_client import JanusSession, JanusVideoCallPlugin 5 | from aiortc.contrib.media import MediaPlayer, MediaRecorder 6 | 7 | format = "%(asctime)s: %(message)s" 8 | logging.basicConfig(format=format, level=logging.INFO, datefmt="%H:%M:%S") 9 | logger = logging.getLogger() 10 | 11 | 12 | async def main(): 13 | # Create session 14 | session = JanusSession( 15 | base_url="wss://janusmy.josephgetmyip.com/janusbasews/janus", 16 | ) 17 | 18 | # Create plugin 19 | plugin_handle = JanusVideoCallPlugin() 20 | 21 | # Attach to Janus session 22 | await plugin_handle.attach(session=session) 23 | logger.info("plugin created") 24 | 25 | username = "testusernamein" 26 | username_out = "testusernameout" 27 | # player = MediaPlayer("./Into.the.Wild.2007.mp4") 28 | # player = MediaPlayer("http://download.tsi.telecom-paristech.fr/gpac/dataset/dash/uhd/mux_sources/hevcds_720p30_2M.mp4") 29 | player = MediaPlayer( 30 | "desktop", 31 | format="gdigrab", 32 | options={ 33 | "video_size": "640x480", 34 | "framerate": "30", 35 | "offset_x": "20", 36 | "offset_y": "30", 37 | }, 38 | ) 39 | recorder = MediaRecorder("./videocall_record_out.mp4") 40 | 41 | result = await plugin_handle.register(username=username_out) 42 | logger.info(result) 43 | 44 | result = await plugin_handle.call( 45 | username=username, player=player, recorder=recorder 46 | ) 47 | logger.info(result) 48 | 49 | await asyncio.sleep(30) 50 | 51 | result = await plugin_handle.hangup() 52 | logger.info(result) 53 | 54 | # Destroy plugin 55 | await plugin_handle.destroy() 56 | 57 | # Destroy session 58 | await session.destroy() 59 | 60 | 61 | if __name__ == "__main__": 62 | try: 63 | asyncio.run(main()) 64 | except KeyboardInterrupt: 65 | pass 66 | -------------------------------------------------------------------------------- /test_ffmpeg.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from janus_client import JanusSession 5 | from janus_client.experiments.plugin_video_room_ffmpeg import JanusVideoRoomPlugin 6 | import ffmpeg 7 | 8 | format = "%(asctime)s: %(message)s" 9 | logging.basicConfig(format=format, level=logging.INFO, datefmt="%H:%M:%S") 10 | logger = logging.getLogger() 11 | 12 | room_id = 1234 13 | publisher_id = 333 14 | display_name = "qweqwe" 15 | 16 | width = 640 17 | height = 480 18 | # Specify the input part of ffmpeg 19 | ffmpeg_input = ffmpeg.input( 20 | "desktop", 21 | format="gdigrab", 22 | framerate=30, 23 | offset_x=20, 24 | offset_y=30, 25 | # s=f"{width}x{height}", 26 | video_size=[ 27 | width, 28 | height, 29 | ], # Using this video_size=[] or s="" is the same 30 | show_region=1, 31 | ) 32 | 33 | 34 | async def main(): 35 | # Create session 36 | # session = JanusSession( 37 | # base_url="wss://janusmy.josephgetmyip.com/janusbasews/janus", 38 | # ) 39 | session = JanusSession( 40 | base_url="https://janusmy.josephgetmyip.com/janusbase/janus", 41 | ) 42 | 43 | # Create plugin 44 | plugin_handle = JanusVideoRoomPlugin() 45 | 46 | # Attach to Janus session 47 | await plugin_handle.attach(session=session) 48 | logger.info("plugin created") 49 | 50 | await plugin_handle.join(room_id, publisher_id, display_name) 51 | logger.info("room joined") 52 | 53 | await plugin_handle.publish(ffmpeg_input=ffmpeg_input, width=width, height=height) 54 | logger.info("Let it stream for 60 seconds") 55 | await asyncio.sleep(60) 56 | logger.info("Stop streaming") 57 | await plugin_handle.unpublish() 58 | logger.info("Stream unpublished") 59 | 60 | # Destroy plugin 61 | await plugin_handle.destroy() 62 | 63 | 64 | if __name__ == "__main__": 65 | try: 66 | asyncio.get_event_loop().run_until_complete(main()) 67 | except KeyboardInterrupt: 68 | pass 69 | -------------------------------------------------------------------------------- /.github/workflows/README.md: -------------------------------------------------------------------------------- 1 | # GitHub Actions Workflows 2 | 3 | ## Documentation Deployment 4 | 5 | The `docs.yml` workflow automatically builds and deploys the project documentation to GitHub Pages. 6 | 7 | ### Workflow Details 8 | 9 | - **Trigger**: Runs on pushes to `master` branch, pull requests to master, and manual dispatch 10 | - **Build**: Uses Poetry to install dependencies and MkDocs to build the documentation 11 | - **Deploy**: Automatically deploys to GitHub Pages on pushes to master branch 12 | 13 | ### Setup Requirements 14 | 15 | 1. **GitHub Pages**: Enable GitHub Pages in your repository settings 16 | - Go to Settings → Pages 17 | - Set Source to "GitHub Actions" 18 | 19 | 2. **Dependencies**: The workflow uses: 20 | - Python 3.11 21 | - Poetry for dependency management 22 | - MkDocs Material theme 23 | - GitHub Actions for deployment 24 | 25 | ### Workflow Steps 26 | 27 | 1. **Build Job**: 28 | - Checkout repository 29 | - Set up Python and Poetry 30 | - Cache dependencies for faster builds 31 | - Install development dependencies 32 | - Build documentation with MkDocs 33 | - Upload build artifacts 34 | 35 | 2. **Deploy Job** (only on master): 36 | - Deploy artifacts to GitHub Pages 37 | - Set up custom domain if configured 38 | 39 | ### Local Testing 40 | 41 | To test the documentation build locally: 42 | 43 | ```bash 44 | # Install dependencies 45 | poetry install --only=dev 46 | 47 | # Build documentation 48 | poetry run mkdocs build --clean --strict 49 | 50 | # Serve locally for development 51 | poetry run mkdocs serve 52 | ``` 53 | 54 | ### Configuration 55 | 56 | The documentation is configured in: 57 | - `mkdocs.yml` - MkDocs configuration 58 | - `docs/` - Documentation source files 59 | - `site/` - Generated documentation (auto-generated, don't commit) 60 | 61 | ### Site URL 62 | 63 | The documentation will be available at: 64 | `https://josephlim94.github.io/python_janus_client/` 65 | 66 | ### Branch Configuration 67 | 68 | This workflow is configured to work with the `master` branch only: 69 | - Builds on pushes to master 70 | - Builds on pull requests targeting master 71 | - Deploys only from master branch 72 | -------------------------------------------------------------------------------- /eg_videocall_in.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | 4 | from janus_client import JanusSession, JanusVideoCallPlugin 5 | from aiortc.contrib.media import MediaPlayer, MediaRecorder 6 | 7 | format = "%(asctime)s: %(message)s" 8 | logging.basicConfig(format=format, level=logging.INFO, datefmt="%H:%M:%S") 9 | logger = logging.getLogger() 10 | 11 | 12 | async def on_incoming_call(plugin: JanusVideoCallPlugin, jsep: dict): 13 | # self.__player = MediaPlayer("./Into.the.Wild.2007.mp4") 14 | player = MediaPlayer( 15 | "http://download.tsi.telecom-paristech.fr/gpac/dataset/dash/uhd/mux_sources/hevcds_720p30_2M.mp4" 16 | ) 17 | recorder = MediaRecorder("./videocall_record_in.mp4") 18 | pc = await plugin.create_pc( 19 | player=player, 20 | recorder=recorder, 21 | jsep=jsep, 22 | ) 23 | 24 | await pc.setLocalDescription(await pc.createAnswer()) 25 | jsep = { 26 | "sdp": pc.localDescription.sdp, 27 | "trickle": False, 28 | "type": pc.localDescription.type, 29 | } 30 | await plugin.accept(jsep=jsep, pc=pc, player=player, recorder=recorder) 31 | 32 | 33 | async def main(): 34 | # Create session 35 | session = JanusSession( 36 | base_url="wss://janusmy.josephgetmyip.com/janusbasews/janus", 37 | ) 38 | 39 | # Create plugin 40 | plugin_handle = JanusVideoCallPlugin() 41 | 42 | # Attach to Janus session 43 | await plugin_handle.attach(session=session) 44 | logger.info("plugin created") 45 | 46 | # username = "testusername" 47 | username_in = "testusernamein" 48 | 49 | plugin_handle.on_incoming_call = on_incoming_call 50 | 51 | result = await plugin_handle.register(username=username_in) 52 | logger.info(result) 53 | 54 | # result = await plugin_handle.call( 55 | # username=username, player=player, recorder=recorder 56 | # ) 57 | # logger.info(result) 58 | 59 | if result: 60 | await asyncio.sleep(60) 61 | 62 | result = await plugin_handle.hangup() 63 | logger.info(result) 64 | 65 | # Destroy plugin 66 | await plugin_handle.destroy() 67 | 68 | # Destroy session 69 | await session.destroy() 70 | 71 | 72 | if __name__ == "__main__": 73 | try: 74 | asyncio.run(main()) 75 | except KeyboardInterrupt: 76 | pass 77 | -------------------------------------------------------------------------------- /tests/test_is_subset.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import logging 3 | 4 | from janus_client.message_transaction import is_subset 5 | 6 | format = "%(asctime)s: %(message)s" 7 | logging.basicConfig(format=format, level=logging.INFO, datefmt="%H:%M:%S") 8 | logger = logging.getLogger() 9 | 10 | 11 | class TestClass(unittest.TestCase): 12 | def test_sanity(self): 13 | """Sanity test""" 14 | self.assertTrue(is_subset(dict_1={"a": 1}, dict_2={"a": 1})) 15 | 16 | def test_empty_dict(self): 17 | self.assertTrue(is_subset(dict_1={"a": 1}, dict_2={})) 18 | self.assertTrue(is_subset(dict_1={}, dict_2={})) 19 | self.assertFalse(is_subset(dict_1={}, dict_2={"a": 1})) 20 | 21 | def test_ignored_types(self): 22 | self.assertTrue(is_subset(dict_1={"a": 1, "b": None}, dict_2={"b": None})) 23 | self.assertTrue(is_subset(dict_1={"a": 1, "b": 2}, dict_2={"b": None})) 24 | self.assertFalse(is_subset(dict_1={"a": 1, "b": 2}, dict_2={"b": 3})) 25 | self.assertFalse(is_subset(dict_1={"a": 1, "b": 2}, dict_2={"c": None})) 26 | 27 | def test_invalid_input(self): 28 | self.assertRaises(TypeError, is_subset, dict_1="", dict_2={}) 29 | self.assertRaises(TypeError, is_subset, dict_1={}, dict_2="") 30 | self.assertRaises(TypeError, is_subset, dict_1="", dict_2="") 31 | 32 | def test_recursive_check(self): 33 | self.assertTrue( 34 | is_subset( 35 | dict_1={"a": 1, "b": {"c": 2, "d": 3}}, dict_2={"a": 1, "b": {"c": 2}} 36 | ) 37 | ) 38 | self.assertTrue( 39 | is_subset( 40 | dict_1={"a": 1, "b": {"c": 2, "d": 3, "e": {"f": 4}}}, 41 | dict_2={"a": 1, "b": {"e": {}}}, 42 | ) 43 | ) 44 | self.assertTrue( 45 | is_subset( 46 | dict_1={"a": 1, "b": {"c": 2, "d": 3, "e": {"f": 4}}}, 47 | dict_2={"a": 1, "b": {"c": None, "e": {}}}, 48 | ) 49 | ) 50 | self.assertTrue( 51 | is_subset( 52 | dict_1={"a": 1, "b": {"c": 2, "d": 3, "e": {"f": 4}}}, 53 | dict_2={"a": 1, "b": {"e": None}}, 54 | ) 55 | ) 56 | self.assertTrue( 57 | is_subset( 58 | dict_1={"a": 1, "b": {"c": 2, "d": 3, "e": {"f": 4}}}, 59 | dict_2={"a": 1, "b": {"e": {"f": None}}}, 60 | ) 61 | ) 62 | self.assertFalse( 63 | is_subset( 64 | dict_1={"a": 1, "b": {"c": 2, "d": 3, "e": {"f": 4}}}, 65 | dict_2={"a": 1, "b": {"e": {"f": None, "g": None}}}, 66 | ) 67 | ) 68 | -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: Python Janus Client 2 | site_description: Easily send and share WebRTC media through Janus WebRTC server. 3 | site_url: https://josephlim94.github.io/python_janus_client/ 4 | repo_url: https://github.com/josephlim94/python_janus_client 5 | repo_name: josephlim94/python_janus_client 6 | 7 | theme: 8 | name: material 9 | logo: assets/python_janus_client_icon.svg 10 | favicon: assets/python_janus_client_icon.svg 11 | palette: 12 | - media: "(prefers-color-scheme)" 13 | toggle: 14 | icon: material/link 15 | name: Switch to light mode 16 | - media: "(prefers-color-scheme: light)" 17 | scheme: default 18 | primary: indigo 19 | accent: indigo 20 | toggle: 21 | icon: material/toggle-switch 22 | name: Switch to dark mode 23 | - media: "(prefers-color-scheme: dark)" 24 | scheme: slate 25 | primary: black 26 | accent: indigo 27 | toggle: 28 | icon: material/toggle-switch-off 29 | name: Switch to system preference 30 | font: 31 | text: Roboto 32 | code: Roboto Mono 33 | features: 34 | - content.action.edit 35 | - content.action.view 36 | - content.code.annotate 37 | - content.code.copy 38 | - content.tooltips 39 | - navigation.footer 40 | - navigation.indexes 41 | - navigation.sections 42 | - navigation.tabs 43 | - navigation.top 44 | - navigation.tracking 45 | - search.highlight 46 | - search.share 47 | - search.suggest 48 | - toc.follow 49 | 50 | plugins: 51 | - material/search 52 | - mkdocstrings: 53 | handlers: 54 | python: 55 | options: 56 | docstring_style: google 57 | 58 | markdown_extensions: 59 | - abbr 60 | - admonition 61 | - attr_list 62 | - def_list 63 | - footnotes 64 | - md_in_html 65 | - toc: 66 | permalink: true 67 | - pymdownx.arithmatex: 68 | generic: true 69 | - pymdownx.betterem: 70 | smart_enable: all 71 | - pymdownx.caret 72 | - pymdownx.details 73 | - pymdownx.emoji 74 | - pymdownx.highlight: 75 | anchor_linenums: true 76 | line_spans: __span 77 | pygments_lang_class: true 78 | - pymdownx.inlinehilite 79 | - pymdownx.keys 80 | - pymdownx.mark 81 | - pymdownx.smartsymbols 82 | - pymdownx.snippets 83 | - pymdownx.superfences 84 | - pymdownx.tabbed: 85 | alternate_style: true 86 | combine_header_slug: true 87 | - pymdownx.tasklist: 88 | custom_checkbox: true 89 | - pymdownx.tilde 90 | 91 | nav: 92 | - Home: index.md 93 | - Session: session.md 94 | - Plugins: plugins.md 95 | - Transport: transport.md 96 | - API Reference: reference.md 97 | 98 | extra: 99 | social: 100 | - icon: fontawesome/brands/github 101 | link: https://github.com/josephlim94/python_janus_client 102 | version: 103 | provider: mike 104 | 105 | copyright: Copyright © 2021 Lim Meng Kiat 106 | -------------------------------------------------------------------------------- /janus_client/message_transaction.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import uuid 3 | from typing import Dict, List, Union, Callable 4 | 5 | 6 | def is_subset(dict_1: Dict, dict_2: Dict) -> bool: 7 | """Check if dict_2 is subset of dict_1 recursively 8 | 9 | Only checks dict, str or int type in dict_2 10 | """ 11 | if not isinstance(dict_1, dict): 12 | raise TypeError(f"dict_1 must be a dictionary: {dict_1}") 13 | 14 | if not isinstance(dict_2, dict): 15 | raise TypeError(f"dict_2 must be a dictionary: {dict_2}") 16 | 17 | if not dict_2: 18 | return True 19 | 20 | for key_2, val_2 in dict_2.items(): 21 | if not ( 22 | isinstance(val_2, dict) or isinstance(val_2, str) or isinstance(val_2, int) 23 | ): 24 | # If not these few types, then only need 25 | # key_2 to be in dict_1 26 | if key_2 in dict_1: 27 | continue 28 | else: 29 | return False 30 | 31 | # Need to check values 32 | val_1 = dict_1.get(key_2, None) 33 | 34 | # Simple compare 35 | if val_1 == val_2: 36 | continue 37 | 38 | # Now val_2 can be another dict 39 | if isinstance(val_1, dict) and isinstance(val_2, dict): 40 | if is_subset(val_1, val_2): 41 | continue 42 | else: 43 | return False 44 | 45 | # key_2: val_2 is not in dict_1, so False 46 | return False 47 | 48 | # All of dict_2 is found in dict_1 49 | return True 50 | 51 | 52 | class MessageTransaction: 53 | __id: str 54 | __msg_all: List[Dict] 55 | __msg_in: asyncio.Queue 56 | 57 | def __init__(self) -> None: 58 | self.__id = uuid.uuid4().hex 59 | self.__msg_all = [] 60 | self.__msg_in = asyncio.Queue() 61 | 62 | @property 63 | def id(self) -> str: 64 | return self.__id 65 | 66 | def put_msg(self, message: Dict) -> None: 67 | # Queue is never full 68 | self.__msg_in.put_nowait(message) 69 | 70 | async def get( 71 | self, 72 | matcher: Union[Dict, Callable] = lambda *args, **kwargs: True, 73 | timeout: Union[float, None] = None, 74 | ) -> Dict: 75 | if not (isinstance(matcher, dict) or callable(matcher)): 76 | raise TypeError(f"matcher must be callable or dictionary: {matcher}") 77 | 78 | _matcher: Callable 79 | if callable(matcher): 80 | # matcher is a function 81 | _matcher = matcher 82 | else: 83 | # matcher is a dict 84 | def dict_matcher(msg: dict) -> bool: 85 | return is_subset(msg, matcher) 86 | 87 | _matcher = dict_matcher 88 | 89 | # Try to find message in saved messages 90 | for msg in self.__msg_all: 91 | if _matcher(msg): 92 | return msg 93 | 94 | # Wait in queue until a matching message is found 95 | msg = await asyncio.wait_for(self.__msg_in.get(), timeout=timeout) 96 | # Always save received messages 97 | self.__msg_all.append(msg) 98 | 99 | while not _matcher(msg): 100 | msg = await asyncio.wait_for(self.__msg_in.get(), timeout=timeout) 101 | self.__msg_all.append(msg) 102 | 103 | return msg 104 | 105 | async def on_done(self) -> None: 106 | pass 107 | 108 | async def done(self) -> None: 109 | """Must call this when finish using to release resources""" 110 | await self.on_done() 111 | -------------------------------------------------------------------------------- /tests/test_transport.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import logging 3 | import asyncio 4 | import os 5 | from urllib.parse import urljoin 6 | 7 | from janus_client import JanusTransport, JanusSession 8 | from tests.util import async_test 9 | 10 | format = "%(asctime)s: %(message)s" 11 | logging.basicConfig(format=format, level=logging.INFO, datefmt="%H:%M:%S") 12 | logger = logging.getLogger() 13 | 14 | 15 | class BaseTestClass: 16 | class TestClass(unittest.TestCase): 17 | server_url: str 18 | 19 | async def asyncSetUp(self) -> None: 20 | self.transport = JanusTransport.create_transport( 21 | base_url=self.server_url, api_secret=os.getenv("JANUS_API_SECRET", "") 22 | ) 23 | await self.transport.connect() 24 | 25 | async def asyncTearDown(self) -> None: 26 | await self.transport.disconnect() 27 | # https://docs.aiohttp.org/en/stable/client_advanced.html#graceful-shutdown 28 | # Working around to avoid "Exception ignored in: " 29 | await asyncio.sleep(0.250) 30 | 31 | @async_test 32 | async def test_sanity(self): 33 | await self.asyncSetUp() 34 | 35 | response = await self.transport.ping() 36 | self.assertEqual(response["janus"], "pong") 37 | 38 | await self.asyncTearDown() 39 | 40 | @async_test 41 | async def test_info(self): 42 | await self.asyncSetUp() 43 | 44 | response = await self.transport.info() 45 | self.assertEqual(response["janus"], "server_info") 46 | self.assertEqual(response["name"], "Janus WebRTC Server") 47 | 48 | await self.asyncTearDown() 49 | 50 | @async_test 51 | async def test_session(self): 52 | await self.asyncSetUp() 53 | 54 | session = JanusSession(transport=self.transport) 55 | 56 | message_transaction = await session.send( 57 | {"janus": "keepalive"}, 58 | ) 59 | response = await message_transaction.get({"janus": "ack"}) 60 | await message_transaction.done() 61 | self.assertEqual(response["janus"], "ack") 62 | 63 | await session.destroy() 64 | 65 | await self.asyncTearDown() 66 | 67 | @async_test 68 | async def test_session_fail_auth(self): 69 | session = JanusSession( 70 | base_url=self.server_url, 71 | ) 72 | with self.assertRaisesRegex(Exception, "Create session fail: {'code': 403"): 73 | await session.create() 74 | await session.transport.disconnect() 75 | 76 | session = JanusSession( 77 | base_url=self.server_url, 78 | api_secret="asdewqzxc", 79 | ) 80 | with self.assertRaisesRegex(Exception, "Create session fail: {'code': 403"): 81 | await session.create() 82 | await session.transport.disconnect() 83 | 84 | session = JanusSession( 85 | base_url=self.server_url, 86 | api_secret=os.getenv("JANUS_API_SECRET", ""), 87 | ) 88 | await session.create() 89 | await session.destroy() 90 | 91 | 92 | class TestTransportHttp(BaseTestClass.TestClass): 93 | server_url = urljoin( 94 | os.getenv("JANUS_HTTP_URL", ""), 95 | os.getenv("JANUS_HTTP_BASE_PATH", ""), 96 | ) 97 | 98 | 99 | class TestTransportWebsocket(BaseTestClass.TestClass): 100 | server_url = os.getenv("JANUS_WS_URL", "") 101 | -------------------------------------------------------------------------------- /.clinerules/memory-bank.md: -------------------------------------------------------------------------------- 1 | # Cline's Memory Bank 2 | 3 | I am Cline, an expert software engineer with a unique characteristic: my memory resets completely between sessions. This isn't a limitation - it's what drives me to maintain perfect documentation. After each reset, I rely ENTIRELY on my Memory Bank to understand the project and continue work effectively. I MUST read ALL memory bank files at the start of EVERY task - this is not optional. 4 | 5 | ## Memory Bank Structure 6 | 7 | The Memory Bank consists of core files and optional context files, all in Markdown format. Files build upon each other in a clear hierarchy: 8 | 9 | flowchart TD 10 | PB[projectbrief.md] --> PC[productContext.md] 11 | PB --> SP[systemPatterns.md] 12 | PB --> TC[techContext.md] 13 | 14 | PC --> AC[activeContext.md] 15 | SP --> AC 16 | TC --> AC 17 | 18 | AC --> P[progress.md] 19 | 20 | ### Core Files (Required) 21 | 1. `projectbrief.md` 22 | - Foundation document that shapes all other files 23 | - Created at project start if it doesn't exist 24 | - Defines core requirements and goals 25 | - Source of truth for project scope 26 | 27 | 2. `productContext.md` 28 | - Why this project exists 29 | - Problems it solves 30 | - How it should work 31 | - User experience goals 32 | 33 | 3. `activeContext.md` 34 | - Current work focus 35 | - Recent changes 36 | - Next steps 37 | - Active decisions and considerations 38 | - Important patterns and preferences 39 | - Learnings and project insights 40 | 41 | 4. `systemPatterns.md` 42 | - System architecture 43 | - Key technical decisions 44 | - Design patterns in use 45 | - Component relationships 46 | - Critical implementation paths 47 | 48 | 5. `techContext.md` 49 | - Technologies used 50 | - Development setup 51 | - Technical constraints 52 | - Dependencies 53 | - Tool usage patterns 54 | 55 | 6. `progress.md` 56 | - What works 57 | - What's left to build 58 | - Current status 59 | - Known issues 60 | - Evolution of project decisions 61 | 62 | ### Additional Context 63 | Create additional files/folders within memory-bank/ when they help organize: 64 | - Complex feature documentation 65 | - Integration specifications 66 | - API documentation 67 | - Testing strategies 68 | - Deployment procedures 69 | 70 | ## Core Workflows 71 | 72 | ### Plan Mode 73 | flowchart TD 74 | Start[Start] --> ReadFiles[Read Memory Bank] 75 | ReadFiles --> CheckFiles{Files Complete?} 76 | 77 | CheckFiles -->|No| Plan[Create Plan] 78 | Plan --> Document[Document in Chat] 79 | 80 | CheckFiles -->|Yes| Verify[Verify Context] 81 | Verify --> Strategy[Develop Strategy] 82 | Strategy --> Present[Present Approach] 83 | 84 | ### Act Mode 85 | flowchart TD 86 | Start[Start] --> Context[Check Memory Bank] 87 | Context --> Update[Update Documentation] 88 | Update --> Execute[Execute Task] 89 | Execute --> Document[Document Changes] 90 | 91 | ## Documentation Updates 92 | 93 | Memory Bank updates occur when: 94 | 1. Discovering new project patterns 95 | 2. After implementing significant changes 96 | 3. When user requests with **update memory bank** (MUST review ALL files) 97 | 4. When context needs clarification 98 | 99 | flowchart TD 100 | Start[Update Process] 101 | 102 | subgraph Process 103 | P1[Review ALL Files] 104 | P2[Document Current State] 105 | P3[Clarify Next Steps] 106 | P4[Document Insights & Patterns] 107 | 108 | P1 --> P2 --> P3 --> P4 109 | end 110 | 111 | Start --> Process 112 | 113 | Note: When triggered by **update memory bank**, I MUST review every memory bank file, even if some don't require updates. Focus particularly on activeContext.md and progress.md as they track current state. 114 | 115 | REMEMBER: After every memory reset, I begin completely fresh. The Memory Bank is my only link to previous work. It must be maintained with precision and clarity, as my effectiveness depends entirely on its accuracy. -------------------------------------------------------------------------------- /janus_client/transport_websocket.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import Any 3 | import asyncio 4 | import json 5 | import traceback 6 | 7 | import websockets 8 | 9 | from .transport import JanusTransport 10 | 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class JanusTransportWebsocket(JanusTransport): 16 | """Janus transport through HTTP 17 | 18 | Manage Sessions and Transactions 19 | """ 20 | 21 | ws: websockets.WebSocketClientProtocol 22 | subprotocol: str 23 | connected: bool 24 | receiving_message: bool 25 | receive_message_task: asyncio.Task 26 | receive_message_task_started: asyncio.Event 27 | 28 | def __init__(self, **kwargs: dict): 29 | super().__init__(**kwargs) 30 | 31 | self.connected = False 32 | self.receiving_message = False 33 | self.receive_message_task = None 34 | self.receive_message_task_started = asyncio.Event() 35 | 36 | if "subprotocol" in kwargs: 37 | self.subprotocol = kwargs["subprotocol"] 38 | else: 39 | self.subprotocol = "janus-protocol" 40 | 41 | async def _connect(self, **kwargs: Any) -> None: 42 | """Connect to server 43 | 44 | All extra keyword arguments will be passed to websockets.connect 45 | """ 46 | 47 | logger.info(f"Connecting to: {self.base_url}") 48 | 49 | self.ws = await websockets.connect( 50 | self.base_url, 51 | subprotocols=[websockets.Subprotocol(self.subprotocol)], 52 | **kwargs, 53 | ) 54 | self.receive_message_task = asyncio.create_task(self.receive_message()) 55 | self.receive_message_task.add_done_callback(self.receive_message_done_cb) 56 | await self.receive_message_task_started.wait() 57 | 58 | self.connected = True 59 | logger.info("Connected") 60 | 61 | async def _disconnect(self) -> None: 62 | logger.info("Disconnecting") 63 | self.receive_message_task.cancel() 64 | await asyncio.wait([self.receive_message_task]) 65 | await self.ws.close() 66 | self.connected = False 67 | logger.info("Disconnected") 68 | 69 | def receive_message_done_cb(self, task: asyncio.Task, context=None) -> None: 70 | self.receiving_message = False 71 | try: 72 | # Check if any exceptions are raised 73 | # If it's CancelledError or InvalidStateError exception then they will be raised 74 | # else the exception in task will be returned 75 | exception = task.exception() 76 | if exception: 77 | logger.error( 78 | "".join( 79 | traceback.format_exception( 80 | type(exception), 81 | value=exception, 82 | tb=exception.__traceback__, 83 | ) 84 | ) 85 | ) 86 | except asyncio.CancelledError: 87 | logger.info("Receive message task ended") 88 | except asyncio.InvalidStateError: 89 | logger.info("receive_message_done_cb called with invalid state") 90 | 91 | self.connected = False 92 | 93 | async def receive_message(self) -> None: 94 | self.receiving_message = True 95 | self.receive_message_task_started.set() 96 | 97 | if not self.ws: 98 | raise Exception("Not connected to server.") 99 | 100 | async for message_raw in self.ws: 101 | response = json.loads(message_raw) 102 | 103 | await self.receive(response) 104 | 105 | async def _send( 106 | self, 107 | message: dict, 108 | ) -> None: 109 | if not self.connected: 110 | raise Exception("Must connect before any communication.") 111 | 112 | if not self.receiving_message: 113 | raise Exception("Websocket not receiving message") 114 | 115 | await self.ws.send(json.dumps(message)) 116 | 117 | 118 | def protocol_matcher(base_url: str): 119 | return base_url.startswith(("ws://", "wss://")) 120 | 121 | 122 | JanusTransport.register_transport( 123 | protocol_matcher=protocol_matcher, transport_cls=JanusTransportWebsocket 124 | ) 125 | -------------------------------------------------------------------------------- /tests/test_plugin.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import logging 3 | import asyncio 4 | import os 5 | from urllib.parse import urljoin 6 | import json 7 | 8 | from aiortc import RTCConfiguration, RTCIceServer 9 | 10 | from janus_client import ( 11 | JanusTransport, 12 | JanusSession, 13 | PluginAttachFail, 14 | JanusEchoTestPlugin, 15 | ) 16 | from tests.util import async_test 17 | 18 | format = "%(asctime)s: %(message)s" 19 | logging.basicConfig(format=format, level=logging.INFO, datefmt="%H:%M:%S") 20 | logger = logging.getLogger() 21 | 22 | 23 | class BaseTestClass: 24 | class TestClass(unittest.TestCase): 25 | server_url: str 26 | config = RTCConfiguration( 27 | iceServers=[ 28 | RTCIceServer(urls="stun:stun.l.google.com:19302"), 29 | ] 30 | ) 31 | public_test_videos: dict 32 | 33 | @classmethod 34 | def setUpClass(cls): 35 | with open("./tests/public_test_videos.json", "r", encoding="utf-8") as file: 36 | cls.public_test_videos = json.load(file) 37 | 38 | def getVideoUrlByIndex(self, index: int): 39 | return self.public_test_videos["categories"][0]["videos"][index]["sources"][ 40 | 0 41 | ] 42 | 43 | async def asyncSetUp(self) -> None: 44 | self.transport = JanusTransport.create_transport( 45 | base_url=self.server_url, api_secret=os.getenv("JANUS_API_SECRET", "") 46 | ) 47 | await self.transport.connect() 48 | 49 | async def asyncTearDown(self) -> None: 50 | await self.transport.disconnect() 51 | # https://docs.aiohttp.org/en/stable/client_advanced.html#graceful-shutdown 52 | # Working around to avoid "Exception ignored in: " 53 | await asyncio.sleep(0.250) 54 | 55 | @async_test 56 | async def test_plugin_create_fail(self): 57 | await self.asyncSetUp() 58 | 59 | session = JanusSession(transport=self.transport) 60 | 61 | plugin = JanusEchoTestPlugin() 62 | 63 | # Give it a dummy plugin name 64 | plugin.name = "dummy_name" 65 | 66 | with self.assertRaises(PluginAttachFail): 67 | await plugin.attach(session=session) 68 | 69 | await session.destroy() 70 | 71 | await self.asyncTearDown() 72 | 73 | @async_test 74 | async def test_plugin_echotest_create(self): 75 | await self.asyncSetUp() 76 | logger.info("Start") 77 | print("ewq") 78 | 79 | session = JanusSession(transport=self.transport) 80 | 81 | plugin_handle = JanusEchoTestPlugin(pc_config=self.config) 82 | 83 | await plugin_handle.attach(session=session) 84 | 85 | output_filename = "./asdasd.mp4" 86 | 87 | if os.path.exists(output_filename): 88 | os.remove(output_filename) 89 | 90 | # await plugin_handle.start( 91 | # play_from="./Into.the.Wild.2007.mp4", record_to=output_filename 92 | # ) 93 | await plugin_handle.start( 94 | play_from=self.getVideoUrlByIndex(0), 95 | record_to=output_filename, 96 | ) 97 | 98 | await plugin_handle.wait_webrtcup() 99 | 100 | response = await session.transport.ping() 101 | self.assertEqual(response["janus"], "pong") 102 | 103 | await asyncio.sleep(15) 104 | 105 | await plugin_handle.close_stream() 106 | 107 | if not os.path.exists(output_filename): 108 | self.fail(f"Stream record file ({output_filename}) is not created.") 109 | 110 | await plugin_handle.destroy() 111 | 112 | await session.destroy() 113 | 114 | await self.asyncTearDown() 115 | 116 | 117 | class TestTransportHttp(BaseTestClass.TestClass): 118 | server_url = urljoin( 119 | os.getenv("JANUS_HTTP_URL", ""), 120 | os.getenv("JANUS_HTTP_BASE_PATH", ""), 121 | ) 122 | 123 | 124 | class TestTransportWebsocket(BaseTestClass.TestClass): 125 | server_url = os.getenv("JANUS_WS_URL", "") 126 | -------------------------------------------------------------------------------- /memory-bank/projectbrief.md: -------------------------------------------------------------------------------- 1 | # Project Brief: Python Janus Client 2 | 3 | ## Project Identity 4 | **Name:** python_janus_client (PyPI: janus-client) 5 | **Version:** 0.8.1 6 | **Type:** Python async client library 7 | **License:** MIT 8 | **Repository:** https://github.com/josephlim94/janus_gst_client_py 9 | 10 | ## Core Purpose 11 | Provide a Python async client library for interacting with the Janus WebRTC gateway, enabling developers to easily send and share WebRTC media through Janus server. 12 | 13 | ## Key Requirements 14 | 15 | ### Functional Requirements 16 | 1. **WebRTC Communication** 17 | - Full WebRTC support using aiortc library 18 | - Media streaming (audio/video) capabilities 19 | - Peer connection management 20 | - JSEP (JavaScript Session Establishment Protocol) handling 21 | 22 | 2. **Plugin Support** 23 | - EchoTest plugin (media echo/loopback testing) 24 | - VideoCall plugin (peer-to-peer video calls) 25 | - VideoRoom plugin (multi-party video conferencing) 26 | - TextRoom plugin (text-based chat rooms with data channels) 27 | - Extensible plugin architecture for custom plugins 28 | 29 | 3. **Transport Protocols** 30 | - WebSocket transport 31 | - HTTP long-polling transport 32 | - Automatic protocol detection from URL 33 | - Extensible transport layer 34 | 35 | 4. **Authentication & Security** 36 | - Shared static secret (API key) support 37 | - Stored token authentication 38 | - Admin/Monitor API access 39 | 40 | 5. **Session Management** 41 | - Async context manager support 42 | - Automatic connection/disconnection 43 | - Session keepalive mechanisms 44 | - Plugin attachment/detachment 45 | 46 | ### Non-Functional Requirements 47 | 1. **Simplicity** 48 | - Simple, intuitive API 49 | - Minimal boilerplate code 50 | - Clear examples and documentation 51 | 52 | 2. **Performance** 53 | - Async/await throughout (no blocking operations) 54 | - Efficient media handling with PyAV 55 | - Proper resource cleanup 56 | 57 | 3. **Compatibility** 58 | - Python 3.8 through 3.13 support 59 | - Cross-platform (Windows, Linux, macOS) 60 | - Modern Python packaging standards 61 | 62 | 4. **Maintainability** 63 | - Clean code architecture 64 | - Comprehensive test coverage (target: >80%) 65 | - Type hints throughout 66 | - Clear documentation 67 | 68 | 5. **Extensibility** 69 | - Plugin base class for custom plugins 70 | - Transport base class for custom transports 71 | - Hook points for customization 72 | 73 | ## Project Scope 74 | 75 | ### In Scope 76 | - Client-side Janus gateway interaction 77 | - Core Janus plugins (EchoTest, VideoCall, VideoRoom, TextRoom) 78 | - WebSocket and HTTP transports 79 | - Admin/Monitor API client 80 | - Media streaming utilities 81 | - Comprehensive documentation 82 | - Unit and integration tests 83 | 84 | ### Out of Scope 85 | - Janus server implementation 86 | - Browser-based client (this is Python-only) 87 | - Custom codec implementations (relies on aiortc/PyAV) 88 | - GUI/UI components 89 | - Production-ready signaling server 90 | 91 | ### Experimental (Separate Folder) 92 | - FFmpeg-based VideoRoom implementation 93 | - GStreamer-based VideoRoom implementation 94 | - Alternative media handling approaches 95 | 96 | ## Success Criteria 97 | 1. Successfully connect to Janus server via WebSocket or HTTP 98 | 2. Attach and use all core plugins 99 | 3. Send and receive WebRTC media streams 100 | 4. Maintain >80% test coverage 101 | 5. Clear, comprehensive documentation 102 | 6. Active PyPI package with regular updates 103 | 7. Stable API for production use 104 | 105 | ## Target Users 106 | - Python developers building WebRTC applications 107 | - Developers integrating with existing Janus deployments 108 | - Teams building video conferencing solutions 109 | - Researchers working with WebRTC technology 110 | - IoT developers needing WebRTC capabilities 111 | 112 | ## Development Principles 113 | 1. **Async First:** All I/O operations use async/await 114 | 2. **Type Safety:** Comprehensive type hints 115 | 3. **Clean Code:** Follow Python best practices 116 | 4. **Test Coverage:** Maintain high test coverage 117 | 5. **Documentation:** Keep docs in sync with code 118 | 6. **Backward Compatibility:** Semantic versioning 119 | 7. **Modern Tooling:** Use Hatch for development workflow 120 | -------------------------------------------------------------------------------- /python_janus_client_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/python_janus_client_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/assets/python_janus_client_icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/reference.md: -------------------------------------------------------------------------------- 1 | # API Reference 2 | 3 | Complete API reference for all classes, methods, and types in the Python Janus Client library. 4 | 5 | ## Session Classes 6 | 7 | ### JanusSession 8 | 9 | ::: janus_client.session.JanusSession 10 | options: 11 | show_root_heading: true 12 | show_source: false 13 | members_order: source 14 | docstring_section_style: table 15 | separate_signature: true 16 | show_signature_annotations: true 17 | 18 | ### PluginAttachFail Exception 19 | 20 | ::: janus_client.session.PluginAttachFail 21 | options: 22 | show_root_heading: true 23 | show_source: false 24 | docstring_section_style: table 25 | 26 | ## Plugin Classes 27 | 28 | ### Base Plugin Class 29 | 30 | ::: janus_client.plugin_base.JanusPlugin 31 | options: 32 | show_root_heading: true 33 | show_source: false 34 | members_order: source 35 | docstring_section_style: table 36 | separate_signature: true 37 | show_signature_annotations: true 38 | 39 | ### EchoTest Plugin 40 | 41 | ::: janus_client.plugin_echotest.JanusEchoTestPlugin 42 | options: 43 | show_root_heading: true 44 | show_source: false 45 | members_order: source 46 | docstring_section_style: table 47 | separate_signature: true 48 | show_signature_annotations: true 49 | 50 | ### VideoCall Plugin 51 | 52 | ::: janus_client.plugin_video_call.JanusVideoCallPlugin 53 | options: 54 | show_root_heading: true 55 | show_source: false 56 | members_order: source 57 | docstring_section_style: table 58 | separate_signature: true 59 | show_signature_annotations: true 60 | 61 | #### VideoCallError Exception 62 | 63 | ::: janus_client.plugin_video_call.VideoCallError 64 | options: 65 | show_root_heading: true 66 | show_source: false 67 | docstring_section_style: table 68 | 69 | #### VideoCallEventType Enum 70 | 71 | ::: janus_client.plugin_video_call.VideoCallEventType 72 | options: 73 | show_root_heading: true 74 | show_source: false 75 | docstring_section_style: table 76 | 77 | ### VideoRoom Plugin 78 | 79 | ::: janus_client.plugin_video_room.JanusVideoRoomPlugin 80 | options: 81 | show_root_heading: true 82 | show_source: false 83 | members_order: source 84 | docstring_section_style: table 85 | separate_signature: true 86 | show_signature_annotations: true 87 | 88 | #### VideoRoomError Exception 89 | 90 | ::: janus_client.plugin_video_room.VideoRoomError 91 | options: 92 | show_root_heading: true 93 | show_source: false 94 | docstring_section_style: table 95 | 96 | #### VideoRoomEventType Enum 97 | 98 | ::: janus_client.plugin_video_room.VideoRoomEventType 99 | options: 100 | show_root_heading: true 101 | show_source: false 102 | docstring_section_style: table 103 | 104 | #### ParticipantType Enum 105 | 106 | ::: janus_client.plugin_video_room.ParticipantType 107 | options: 108 | show_root_heading: true 109 | show_source: false 110 | docstring_section_style: table 111 | 112 | ### TextRoom Plugin 113 | 114 | ::: janus_client.plugin_textroom.JanusTextRoomPlugin 115 | options: 116 | show_root_heading: true 117 | show_source: false 118 | members_order: source 119 | docstring_section_style: table 120 | separate_signature: true 121 | show_signature_annotations: true 122 | 123 | #### TextRoomError Exception 124 | 125 | ::: janus_client.plugin_textroom.TextRoomError 126 | options: 127 | show_root_heading: true 128 | show_source: false 129 | docstring_section_style: table 130 | 131 | #### TextRoomEventType Enum 132 | 133 | ::: janus_client.plugin_textroom.TextRoomEventType 134 | options: 135 | show_root_heading: true 136 | show_source: false 137 | docstring_section_style: table 138 | 139 | ## Transport Classes 140 | 141 | ### Base Transport Class 142 | 143 | ::: janus_client.transport.JanusTransport 144 | options: 145 | show_root_heading: true 146 | show_source: false 147 | members_order: source 148 | docstring_section_style: table 149 | separate_signature: true 150 | show_signature_annotations: true 151 | 152 | ### HTTP Transport 153 | 154 | ::: janus_client.transport_http.JanusTransportHTTP 155 | options: 156 | show_root_heading: true 157 | show_source: false 158 | members_order: source 159 | docstring_section_style: table 160 | separate_signature: true 161 | show_signature_annotations: true 162 | 163 | ### WebSocket Transport 164 | 165 | ::: janus_client.transport_websocket.JanusTransportWebsocket 166 | options: 167 | show_root_heading: true 168 | show_source: false 169 | members_order: source 170 | docstring_section_style: table 171 | separate_signature: true 172 | show_signature_annotations: true 173 | -------------------------------------------------------------------------------- /test_gst_videoroom.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | import ssl 3 | import asyncio 4 | import pathlib 5 | from concurrent.futures import TimeoutError 6 | 7 | from janus_client import JanusClient, JanusAdminMonitorClient 8 | from janus_client.experiments.plugin_video_room_gst import JanusVideoRoomPlugin 9 | from typing import TYPE_CHECKING, Type 10 | 11 | if TYPE_CHECKING: 12 | from janus_client import JanusSession 13 | 14 | import gi 15 | 16 | gi.require_version("GLib", "2.0") 17 | gi.require_version("GObject", "2.0") 18 | gi.require_version("Gst", "1.0") 19 | from gi.repository import Gst 20 | 21 | ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) 22 | localhost_pem = pathlib.Path(__file__).with_name("lt_limmengkiat_name_my.crt") 23 | ssl_context.load_verify_locations(localhost_pem) 24 | # ssl_context.check_hostname = False 25 | # ssl_context.verify_mode = ssl.CERT_NONE 26 | 27 | 28 | async def publish_some_video(session: JanusSession): 29 | # Create plugin 30 | plugin_handle: JanusVideoRoomPlugin = await session.create_plugin_handle( 31 | JanusVideoRoomPlugin 32 | ) 33 | 34 | await plugin_handle.join(1234, 333, "qweqwe") 35 | await plugin_handle.publish() 36 | print("Let it stream for 60 seconds") 37 | await asyncio.sleep(60) 38 | print("Stop streaming") 39 | await plugin_handle.unpublish() 40 | print("Stream unpublished") 41 | 42 | # Destroy plugin 43 | await plugin_handle.destroy() 44 | 45 | 46 | async def subscribe_to_a_feed(session: JanusSession): 47 | # Create plugin 48 | plugin_handle: JanusVideoRoomPlugin = await session.create_plugin_handle( 49 | JanusVideoRoomPlugin 50 | ) 51 | 52 | participants = await plugin_handle.list_participants(1234) 53 | print(participants) 54 | if len(participants) > 0: 55 | # Publishers available 56 | participants_data_1 = participants[0] 57 | participant_id = participants_data_1["id"] 58 | 59 | await plugin_handle.subscribe(1234, participant_id) 60 | await asyncio.sleep(30) 61 | await plugin_handle.unsubscribe() 62 | # await plugin_handle.join(1234, 333, "qweqwe") 63 | # await asyncio.sleep(5) 64 | # await plugin_handle.unsubscribe() 65 | 66 | # Destroy plugin 67 | await plugin_handle.destroy() 68 | 69 | 70 | # API secret is used when you're communicating with Janus as a server, 71 | # such as when wrapping Janus requests with another server 72 | api_secret = "janusrocks" 73 | 74 | 75 | async def main(): 76 | # Start connection 77 | client = JanusClient( 78 | uri="wss://lt.limmengkiat.name.my:8989/", api_secret=api_secret, token="111" 79 | ) 80 | await client.connect(ssl=ssl_context) 81 | adminClient = JanusAdminMonitorClient( 82 | "wss://lt.limmengkiat.name.my:7989/", "janusoverlord" 83 | ) 84 | await adminClient.connect(ssl=ssl_context) 85 | 86 | # Authentication 87 | token = "ccc" 88 | # The following statements are not documented 89 | # It's fine to add a token when it already exists 90 | # The plugin access scope will be limited to the unified set of existing access scope 91 | # and new access scope when adding the token again. Thus, it is better to be explicit 92 | # for security purposes. 93 | await adminClient.add_token(token, ["janus.plugin.videoroom"]) 94 | client.token = token 95 | 96 | # Create session 97 | session = await client.create_session() 98 | 99 | # await subscribe_to_a_feed(session) 100 | await publish_some_video(session) 101 | 102 | # Destroy session 103 | await session.destroy() 104 | 105 | # Delete token 106 | await adminClient.remove_token(client.token) 107 | client.token = None 108 | 109 | # Destroy connection 110 | await adminClient.disconnect() 111 | await client.disconnect() 112 | print("End of main") 113 | 114 | 115 | async def main2(): 116 | adminClient = JanusAdminMonitorClient( 117 | "wss://lt.limmengkiat.name.my:7989/", "janusoverlord" 118 | ) 119 | await adminClient.connect(ssl=ssl_context) 120 | 121 | # print(await adminClient.info()) 122 | # print(await adminClient.ping()) 123 | print(await adminClient.list_tokens()) 124 | 125 | token = "cccs" 126 | await adminClient.add_token( 127 | token, ["janus.plugin.voicemail", "janus.plugin.audiobridge"] 128 | ) 129 | await adminClient.list_tokens() 130 | await adminClient.remove_token(token) 131 | 132 | # Destroy connection 133 | await adminClient.disconnect() 134 | print("End of main") 135 | 136 | 137 | def check_plugins(): 138 | needed = [ 139 | "opus", 140 | "vpx", 141 | "nice", 142 | "webrtc", 143 | "dtls", 144 | "srtp", 145 | "rtp", 146 | "rtpmanager", 147 | "videotestsrc", 148 | "audiotestsrc", 149 | ] 150 | missing = list(filter(lambda p: Gst.Registry.get().find_plugin(p) is None, needed)) 151 | if len(missing): 152 | print("Missing gstreamer plugins:", missing) 153 | return False 154 | return True 155 | 156 | 157 | Gst.init(None) 158 | check_plugins() 159 | asyncio.run(main()) 160 | -------------------------------------------------------------------------------- /.clinerules/documentation-building.md: -------------------------------------------------------------------------------- 1 | # Documentation Building Guidelines 2 | 3 | ## Building Documentation with Strict Mode 4 | 5 | The project uses MkDocs with Material theme for documentation. All documentation builds **MUST** use strict mode to catch warnings as errors and ensure documentation quality. 6 | 7 | ### Correct Build Command 8 | 9 | The documentation build command is defined in `pyproject.toml` and includes: 10 | - `--strict` flag to treat warnings as errors 11 | - Suppression of known deprecation warnings from mkdocs-autorefs 12 | 13 | **Always use:** 14 | ```bash 15 | hatch run docs-build 16 | ``` 17 | 18 | This executes: 19 | ```bash 20 | python -W ignore::DeprecationWarning:mkdocs_autorefs -m mkdocs build --clean --strict 21 | ``` 22 | 23 | ### Why Strict Mode? 24 | 25 | Strict mode ensures: 26 | - All documentation links are valid 27 | - All API references resolve correctly 28 | - Type annotations are complete 29 | - No broken cross-references 30 | - Documentation quality is maintained 31 | 32 | ### Common Issues and Solutions 33 | 34 | #### Issue: Missing Type Annotations for **kwargs 35 | 36 | **Error:** 37 | ``` 38 | WARNING - griffe: janus_client\plugin_name.py:123: No type or annotation for parameter '**kwargs' 39 | ``` 40 | 41 | **Solution:** 42 | Add `Any` type annotation to `**kwargs`: 43 | ```python 44 | from typing import Any 45 | 46 | def __init__(self, **kwargs: Any) -> None: 47 | """Initialize the plugin.""" 48 | super().__init__(**kwargs) 49 | ``` 50 | 51 | #### Issue: Broken API References 52 | 53 | **Error:** 54 | ``` 55 | ERROR - mkdocstrings: janus_client.SomeClass could not be found 56 | ``` 57 | 58 | **Solution:** 59 | - Verify the class/function exists in the codebase 60 | - Check the import path is correct 61 | - Ensure the class is exported in `__init__.py` if needed 62 | - Remove references to non-existent classes from `docs/reference.md` 63 | 64 | #### Issue: Deprecation Warnings 65 | 66 | **Error:** 67 | ``` 68 | DeprecationWarning: ... from mkdocs_autorefs 69 | ``` 70 | 71 | **Solution:** 72 | These are suppressed in the build command. If new deprecation warnings appear: 73 | 1. Check if they're from mkdocs-autorefs (can be suppressed) 74 | 2. If from our code, fix the deprecated usage 75 | 3. Update the build command in `pyproject.toml` if needed 76 | 77 | ### Local Development 78 | 79 | For local development without strict mode (faster iteration): 80 | ```bash 81 | hatch run mkdocs serve 82 | ``` 83 | 84 | Or build without strict mode: 85 | ```bash 86 | hatch run mkdocs build 87 | ``` 88 | 89 | **Note:** Always verify with strict mode before committing! 90 | 91 | ### Serving Documentation Locally 92 | 93 | To preview documentation with live reload: 94 | ```bash 95 | hatch run docs-serve 96 | ``` 97 | 98 | This will start a local server at http://127.0.0.1:8000/ 99 | 100 | ### Documentation Structure 101 | 102 | ``` 103 | docs/ 104 | ├── index.md # Home page with getting started 105 | ├── plugins.md # Plugin usage examples 106 | ├── reference.md # Auto-generated API reference 107 | ├── session.md # Session management guide 108 | ├── transport.md # Transport layer guide 109 | └── assets/ # Images and static files 110 | ``` 111 | 112 | ### Best Practices 113 | 114 | 1. **Always build with strict mode before committing** 115 | ```bash 116 | hatch run docs-build 117 | ``` 118 | 119 | 2. **Test documentation locally** 120 | ```bash 121 | hatch run docs-serve 122 | ``` 123 | 124 | 3. **Keep type annotations complete** 125 | - All public functions must have type hints 126 | - Use `Any` for `**kwargs` parameters 127 | - Use `Optional` for optional parameters 128 | 129 | 4. **Verify API references** 130 | - Check that all classes/functions referenced in `docs/reference.md` exist 131 | - Remove references to deleted or renamed classes 132 | - Update references when refactoring 133 | 134 | 5. **Follow documentation philosophy** 135 | - Keep it simple and concise 136 | - Focus on common use cases 137 | - Don't clutter with advanced configuration details 138 | - See `.clinerules/documentation-philosophy.md` 139 | 140 | ### CI/CD Integration 141 | 142 | The documentation build should be part of CI/CD pipeline: 143 | ```yaml 144 | - name: Build documentation 145 | run: hatch run docs-build 146 | ``` 147 | 148 | This ensures documentation quality is maintained across all contributions. 149 | 150 | ### Troubleshooting 151 | 152 | If documentation build fails: 153 | 154 | 1. **Read the error message carefully** - it usually points to the exact issue 155 | 2. **Check the file and line number** mentioned in the error 156 | 3. **Verify type annotations** are complete 157 | 4. **Check API references** in `docs/reference.md` 158 | 5. **Test locally** with `hatch run docs-serve` to see the issue in context 159 | 160 | ### Summary 161 | 162 | - ✅ Always use `hatch run docs-build` for production builds 163 | - ✅ Strict mode is mandatory for quality assurance 164 | - ✅ Complete type annotations for all `**kwargs` 165 | - ✅ Verify all API references resolve correctly 166 | - ✅ Test locally before committing 167 | - ✅ Follow the documentation philosophy 168 | -------------------------------------------------------------------------------- /docs/session.md: -------------------------------------------------------------------------------- 1 | # Session 2 | 3 | Create a session object that can be shared between plugin handles. 4 | 5 | The session is the main entry point for communicating with a Janus WebRTC Gateway server. It manages the connection, handles message routing, and provides lifecycle management for plugins. 6 | 7 | For detailed API documentation, see the [API Reference](reference.md#session-classes). 8 | 9 | ## Overview 10 | 11 | A `JanusSession` represents a connection to a Janus WebRTC Gateway server. It: 12 | 13 | - Manages the underlying transport (HTTP or WebSocket) 14 | - Routes messages between plugins and the server 15 | - Handles session lifecycle (creation, keepalive, destruction) 16 | - Provides automatic cleanup through async context managers 17 | 18 | ## Usage Examples 19 | 20 | ### Basic Session Usage 21 | 22 | ```python 23 | import asyncio 24 | from janus_client import JanusSession, JanusEchoTestPlugin 25 | 26 | async def main(): 27 | # Create session 28 | session = JanusSession(base_url="wss://example.com/janus") 29 | 30 | try: 31 | # Use session as async context manager for automatic cleanup 32 | async with session: 33 | # Create and attach plugin 34 | plugin = JanusEchoTestPlugin() 35 | await plugin.attach(session) 36 | 37 | # Use plugin... 38 | await plugin.start("input.mp4", "output.mp4") 39 | await asyncio.sleep(10) 40 | 41 | # Plugin will be automatically destroyed when session closes 42 | 43 | except Exception as e: 44 | print(f"Error: {e}") 45 | 46 | if __name__ == "__main__": 47 | asyncio.run(main()) 48 | ``` 49 | 50 | ### Manual Session Management 51 | 52 | ```python 53 | import asyncio 54 | from janus_client import JanusSession, JanusVideoRoomPlugin 55 | 56 | async def main(): 57 | session = JanusSession(base_url="https://example.com/janus") 58 | 59 | try: 60 | # Manually create session 61 | await session.create() 62 | 63 | # Attach plugin 64 | plugin = JanusVideoRoomPlugin() 65 | await plugin.attach(session) 66 | 67 | # Use plugin 68 | await plugin.join(room_id=1234, username="user1") 69 | 70 | # Manual cleanup 71 | await plugin.destroy() 72 | await session.destroy() 73 | 74 | except Exception as e: 75 | print(f"Error: {e}") 76 | # Ensure cleanup on error 77 | try: 78 | await session.destroy() 79 | except: 80 | pass 81 | 82 | if __name__ == "__main__": 83 | asyncio.run(main()) 84 | ``` 85 | 86 | ### Session with Custom Transport Options 87 | 88 | ```python 89 | import asyncio 90 | from janus_client import JanusSession 91 | 92 | async def main(): 93 | # Session with custom transport configuration 94 | session = JanusSession( 95 | base_url="wss://example.com/janus", 96 | timeout=30.0, # Request timeout 97 | max_retries=3, # Maximum retry attempts 98 | retry_delay=1.0, # Initial retry delay 99 | keepalive_interval=30 # Keepalive ping interval 100 | ) 101 | 102 | async with session: 103 | # Get server information 104 | info = await session.transport.info() 105 | print(f"Server info: {info}") 106 | 107 | # Send keepalive ping 108 | await session.keepalive() 109 | 110 | if __name__ == "__main__": 111 | asyncio.run(main()) 112 | ``` 113 | 114 | ## Best Practices 115 | 116 | ### Always Use Context Managers 117 | 118 | The recommended way to use sessions is with async context managers (`async with`), which ensures proper cleanup: 119 | 120 | ```python 121 | async with session: 122 | # Your code here 123 | pass 124 | # Session is automatically destroyed here 125 | ``` 126 | 127 | ### Error Handling 128 | 129 | Always wrap session operations in try-except blocks to handle connection failures: 130 | 131 | ```python 132 | try: 133 | async with session: 134 | # Session operations 135 | pass 136 | except ConnectionError: 137 | print("Failed to connect to Janus server") 138 | except TimeoutError: 139 | print("Operation timed out") 140 | except Exception as e: 141 | print(f"Unexpected error: {e}") 142 | ``` 143 | 144 | ### Plugin Lifecycle 145 | 146 | Plugins attached to a session should be properly destroyed: 147 | 148 | ```python 149 | async with session: 150 | plugin = JanusEchoTestPlugin() 151 | try: 152 | await plugin.attach(session) 153 | # Use plugin 154 | finally: 155 | await plugin.destroy() # Explicit cleanup 156 | ``` 157 | 158 | ### Connection Reuse 159 | 160 | Sessions can be reused for multiple operations, but should not be shared across different async tasks without proper synchronization: 161 | 162 | ```python 163 | # Good: Sequential operations 164 | async with session: 165 | plugin1 = JanusEchoTestPlugin() 166 | await plugin1.attach(session) 167 | await plugin1.start("input1.mp4") 168 | await plugin1.destroy() 169 | 170 | plugin2 = JanusVideoCallPlugin() 171 | await plugin2.attach(session) 172 | await plugin2.register("user1") 173 | await plugin2.destroy() 174 | 175 | # Avoid: Concurrent access without synchronization 176 | # Multiple plugins using the same session concurrently 177 | # requires careful message handling 178 | ``` 179 | -------------------------------------------------------------------------------- /janus_client/plugin_echotest.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Any 4 | 5 | from .plugin_base import JanusPlugin 6 | from aiortc import VideoStreamTrack 7 | from aiortc.contrib.media import MediaPlayer, MediaRecorder 8 | 9 | logger = logging.getLogger(__name__) 10 | 11 | 12 | class JanusEchoTestPlugin(JanusPlugin): 13 | """Janus EchoTest plugin implementation.""" 14 | 15 | name = "janus.plugin.echotest" 16 | __recorder: MediaRecorder 17 | __webrtcup_event: asyncio.Event 18 | 19 | def __init__(self, **kwargs) -> None: 20 | super().__init__(**kwargs) 21 | 22 | self.__webrtcup_event = asyncio.Event() 23 | 24 | async def on_receive(self, response: dict): 25 | if "jsep" in response: 26 | await self.on_receive_jsep(jsep=response["jsep"]) 27 | 28 | janus_code = response["janus"] 29 | 30 | if janus_code == "media": 31 | if response["receiving"]: 32 | # It's ok to start multiple times, only the track that 33 | # has not been started will start 34 | await self.__recorder.start() 35 | 36 | if janus_code == "webrtcup": 37 | self.__webrtcup_event.set() 38 | 39 | if janus_code == "event": 40 | plugin_data = response["plugindata"]["data"] 41 | 42 | if plugin_data["echotest"] != "event": 43 | # This plugin will only get events 44 | logger.error(f"Invalid response: {response}") 45 | return 46 | 47 | if "result" in plugin_data: 48 | if plugin_data["result"] == "ok": 49 | # Successful start stream request. Do nothing. 50 | pass 51 | 52 | if plugin_data["result"] == "done": 53 | # Stream ended. Ok to close PC multiple times. 54 | if self.pc.signalingState != "closed": 55 | await self.pc.close() 56 | # Ok to stop recording multiple times. 57 | if self.__recorder: 58 | await self.__recorder.stop() 59 | 60 | if "errorcode" in plugin_data: 61 | logger.error(f"Plugin Error: {response}") 62 | 63 | async def wait_webrtcup(self) -> None: 64 | await self.__webrtcup_event.wait() 65 | self.__webrtcup_event.clear() 66 | 67 | async def start(self, play_from: str, record_to: str = ""): 68 | # Reset the peer connection to start fresh 69 | await self.reset_connection() 70 | 71 | player = MediaPlayer(play_from) 72 | 73 | # configure media 74 | if player and player.audio: 75 | self.pc.addTrack(player.audio) 76 | 77 | if player and player.video: 78 | self.pc.addTrack(player.video) 79 | else: 80 | self.pc.addTrack(VideoStreamTrack()) 81 | 82 | if record_to: 83 | self.__recorder = MediaRecorder(record_to) 84 | 85 | @self.pc.on("track") 86 | async def on_track(track): 87 | logger.info("Track %s received" % track.kind) 88 | if track.kind == "video": 89 | self.__recorder.addTrack(track) 90 | if track.kind == "audio": 91 | self.__recorder.addTrack(track) 92 | 93 | # send offer 94 | await self.pc.setLocalDescription(await self.pc.createOffer()) 95 | 96 | message: dict[str, Any] = {"janus": "message"} 97 | body = { 98 | "audio": bool(player.audio), 99 | # "audiocodec" : "", 100 | "video": bool(player.video), 101 | # "videocodec" : "", 102 | # "videoprofile" : "", 103 | # "bitrate" : , 104 | # "record" : true|false, 105 | # "filename" : , 106 | # "substream" : , 107 | # "temporal" : , 108 | # "svc" : true|false, 109 | # "spatial_layer" : , 110 | # "temporal_layer" : 111 | } 112 | message["body"] = body 113 | message["jsep"] = { 114 | "sdp": self.pc.localDescription.sdp, 115 | "trickle": False, 116 | "type": self.pc.localDescription.type, 117 | } 118 | 119 | message_transaction = await self.send(message) 120 | response = await message_transaction.get() 121 | await message_transaction.done() 122 | 123 | # Immediately apply answer if it's found 124 | if "jsep" in response: 125 | await self.on_receive_jsep(jsep=response["jsep"]) 126 | 127 | async def close_stream(self): 128 | """Close stream 129 | 130 | This should cause the stream to stop and a done event to be received. 131 | """ 132 | if self.pc.signalingState != "closed": 133 | await self.pc.close() 134 | 135 | if self.__recorder: 136 | await self.__recorder.stop() 137 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | josephlim_94@hotmail.co.uk. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # Python Janus Client 2 | 3 | Easily send and share WebRTC media through [Janus](https://github.com/meetecho/janus-gateway) WebRTC server. 4 | 5 | ## Key Features 6 | 7 | - Supports HTTP/s and WebSockets communication with Janus. 8 | - Support Admin/Monitor API: 9 | - Generic requests 10 | - Configuration related requests 11 | - Token related requests 12 | - Supports Janus plugin: 13 | - EchoTest Plugin 14 | - VideoCall Plugin 15 | - VideoRoom Plugin 16 | - TextRoom Plugin 17 | - Extendable Transport class and Plugin class 18 | 19 | ## Library Installation 20 | 21 | ```bash 22 | pip install janus-client 23 | ``` 24 | 25 | ## Getting Started 26 | 27 | ### Simple Connect And Disconnect 28 | 29 | ```python 30 | import asyncio 31 | from janus_client import JanusSession, JanusEchoTestPlugin, JanusVideoRoomPlugin 32 | 33 | # Protocol will be derived from base_url 34 | base_url = "wss://janusmy.josephgetmyip.com/janusbasews/janus" 35 | # OR 36 | base_url = "https://janusmy.josephgetmyip.com/janusbase/janus" 37 | 38 | session = JanusSession(base_url=base_url) 39 | 40 | plugin_handle = JanusEchoTestPlugin() 41 | 42 | # Attach to Janus session 43 | await plugin_handle.attach(session=session) 44 | 45 | # Destroy plugin handle 46 | await plugin_handle.destroy() 47 | ``` 48 | 49 | This will create a plugin handle and then destroy it. 50 | 51 | Notice that we don't need to call connect or disconnect explicitly. It's managed internally. 52 | 53 | ### Make Video Calls (Outgoing) 54 | 55 | ```python 56 | import asyncio 57 | from janus_client import JanusSession, JanusVideoCallPlugin 58 | from aiortc.contrib.media import MediaPlayer, MediaRecorder 59 | from aiortc import RTCConfiguration, RTCIceServer 60 | 61 | async def main(): 62 | # Create session 63 | session = JanusSession( 64 | base_url="wss://janusmy.josephgetmyip.com/janusbasews/janus", 65 | ) 66 | 67 | # Create plugin (optionally with WebRTC configuration) 68 | config = RTCConfiguration(iceServers=[ 69 | RTCIceServer(urls='stun:stun.l.google.com:19302') 70 | ]) 71 | plugin_handle = JanusVideoCallPlugin(pc_config=config) 72 | 73 | # Attach to Janus session 74 | await plugin_handle.attach(session=session) 75 | 76 | # Prepare username and media stream 77 | username = "testusernamein" 78 | username_out = "testusernameout" 79 | 80 | player = MediaPlayer( 81 | "desktop", 82 | format="gdigrab", 83 | options={ 84 | "video_size": "640x480", 85 | "framerate": "30", 86 | "offset_x": "20", 87 | "offset_y": "30", 88 | }, 89 | ) 90 | recorder = MediaRecorder("./videocall_record_out.mp4") 91 | 92 | # Register myself as testusernameout 93 | result = await plugin_handle.register(username=username_out) 94 | 95 | # Call testusernamein 96 | result = await plugin_handle.call( 97 | username=username, player=player, recorder=recorder 98 | ) 99 | 100 | # Wait awhile then hangup 101 | await asyncio.sleep(30) 102 | 103 | result = await plugin_handle.hangup() 104 | 105 | # Destroy plugin 106 | await plugin_handle.destroy() 107 | 108 | # Destroy session 109 | await session.destroy() 110 | 111 | 112 | if __name__ == "__main__": 113 | try: 114 | asyncio.run(main()) 115 | except KeyboardInterrupt: 116 | pass 117 | ``` 118 | 119 | This example will register to the VideoCall plugin using username `testusernameout`. It will then call the user registered using the username `testusernamein`. 120 | 121 | A portion of the screen will be captured and sent in the call media stream. 122 | The incoming media stream will be saved into `videocall_record_out.mp4` file. 123 | 124 | ### Receive Video Calls (Incoming) 125 | 126 | ```python 127 | import asyncio 128 | from janus_client import JanusSession, JanusVideoCallPlugin, VideoCallEventType 129 | from aiortc.contrib.media import MediaPlayer, MediaRecorder 130 | 131 | async def main(): 132 | # Create session 133 | session = JanusSession( 134 | base_url="wss://janusmy.josephgetmyip.com/janusbasews/janus", 135 | ) 136 | 137 | # Create plugin 138 | plugin_handle = JanusVideoCallPlugin() 139 | 140 | # Attach to Janus session 141 | await plugin_handle.attach(session=session) 142 | 143 | # Register username 144 | username = "testusernamein" 145 | await plugin_handle.register(username=username) 146 | 147 | # Set up event handler for incoming calls 148 | async def on_incoming_call(data): 149 | print(f"Incoming call from {data['username']}") 150 | 151 | # Get JSEP from event data 152 | jsep = data['jsep'] 153 | 154 | # Set up media 155 | player = MediaPlayer("input.mp4") 156 | recorder = MediaRecorder("./videocall_record_in.mp4") 157 | 158 | # Accept the call with JSEP 159 | await plugin_handle.accept(jsep, player, recorder) 160 | print("Call accepted") 161 | 162 | # Register the event handler 163 | plugin_handle.on_event(VideoCallEventType.INCOMINGCALL, on_incoming_call) 164 | 165 | # Wait for incoming calls 166 | print(f"Waiting for calls as '{username}'...") 167 | await asyncio.sleep(60) 168 | 169 | # Cleanup 170 | await plugin_handle.destroy() 171 | await session.destroy() 172 | 173 | 174 | if __name__ == "__main__": 175 | try: 176 | asyncio.run(main()) 177 | except KeyboardInterrupt: 178 | pass 179 | ``` 180 | 181 | This example demonstrates the event-driven API for handling incoming calls. The plugin uses callbacks to notify you of incoming calls, and you can accept them by calling `accept()` with the JSEP data from the event. 182 | -------------------------------------------------------------------------------- /memory-bank/README.md: -------------------------------------------------------------------------------- 1 | # Memory Bank: Python Janus Client 2 | 3 | This Memory Bank provides comprehensive context about the Python Janus Client project for Cline (AI assistant) to maintain continuity across sessions. 4 | 5 | ## Purpose 6 | 7 | After each session reset, Cline relies entirely on this Memory Bank to understand the project and continue work effectively. These files serve as the single source of truth for project context, decisions, and current state. 8 | 9 | ## File Structure 10 | 11 | ### Core Files (Read in Order) 12 | 13 | 1. **[projectbrief.md](projectbrief.md)** - Foundation Document 14 | - Project identity and purpose 15 | - Core requirements and scope 16 | - Success criteria 17 | - Development principles 18 | - **Read this first** - shapes all other files 19 | 20 | 2. **[productContext.md](productContext.md)** - Product Understanding 21 | - Why this project exists 22 | - Problems it solves 23 | - How it should work 24 | - User experience goals 25 | - User personas 26 | 27 | 3. **[systemPatterns.md](systemPatterns.md)** - Architecture & Design 28 | - System architecture 29 | - Core components 30 | - Design patterns 31 | - Implementation patterns 32 | - Component relationships 33 | 34 | 4. **[techContext.md](techContext.md)** - Technical Setup 35 | - Technology stack 36 | - Development setup 37 | - Build system (Hatch) 38 | - Testing strategy 39 | - Documentation system 40 | 41 | 5. **[activeContext.md](activeContext.md)** - Current State 42 | - Current work focus 43 | - Recent changes 44 | - Active decisions 45 | - Important patterns 46 | - Next steps 47 | - **Most frequently updated** 48 | 49 | 6. **[progress.md](progress.md)** - Project Status 50 | - What works 51 | - What's left to build 52 | - Known issues 53 | - Evolution of decisions 54 | - Roadmap 55 | 56 | ## How to Use This Memory Bank 57 | 58 | ### Starting a New Session 59 | 60 | 1. **Always read ALL Memory Bank files** at the start of every task 61 | 2. Start with `projectbrief.md` for foundation 62 | 3. Read files in the order listed above 63 | 4. Pay special attention to `activeContext.md` for current state 64 | 5. Check `progress.md` for what's completed and what's pending 65 | 66 | ### During Work 67 | 68 | - Reference relevant files as needed 69 | - Keep context in mind when making decisions 70 | - Follow established patterns and conventions 71 | - Document significant changes 72 | 73 | ### Updating the Memory Bank 74 | 75 | Update when: 76 | - Discovering new project patterns 77 | - After implementing significant changes 78 | - When user requests "update memory bank" 79 | - When context needs clarification 80 | 81 | **When updating:** 82 | 1. Review ALL files (even if some don't need updates) 83 | 2. Focus on `activeContext.md` and `progress.md` 84 | 3. Update cross-references if structure changes 85 | 4. Keep information accurate and current 86 | 5. Remove outdated information 87 | 88 | ## File Relationships 89 | 90 | ``` 91 | projectbrief.md (Foundation) 92 | ↓ 93 | ├─→ productContext.md (Why & How) 94 | ├─→ systemPatterns.md (Architecture) 95 | └─→ techContext.md (Technical) 96 | ↓ 97 | ├─→ activeContext.md (Current State) 98 | └─→ progress.md (Status) 99 | ``` 100 | 101 | ## Quick Reference 102 | 103 | ### Project Essentials 104 | - **Name:** python_janus_client (PyPI: janus-client) 105 | - **Version:** 0.8.1 106 | - **Type:** Python async WebRTC client library 107 | - **Build Tool:** Hatch 108 | - **Python:** 3.8-3.13 109 | - **Coverage:** 82% 110 | 111 | ### Key Commands 112 | ```bash 113 | hatch env create # Setup environment 114 | hatch shell # Activate environment 115 | hatch test # Run tests 116 | hatch run docs-build # Build documentation 117 | hatch build # Build package 118 | ``` 119 | 120 | ### Key Files 121 | - `janus_client/session.py` - Core session management 122 | - `janus_client/plugin_*.py` - Plugin implementations 123 | - `janus_client/transport*.py` - Transport layer 124 | - `tests/test_*.py` - Test suite 125 | - `pyproject.toml` - Project configuration 126 | 127 | ### Current Focus 128 | - Memory Bank initialization (this session) 129 | - WebSocket cleanup improvements (next priority) 130 | - Documentation enhancements (ongoing) 131 | 132 | ### Known Issues 133 | 1. WebSocket cleanup needs improvement 134 | 2. Deprecation warnings from dependencies 135 | 3. Occasional test flakiness (acceptable) 136 | 137 | ## Important Reminders 138 | 139 | ### Development Practices 140 | - **Always use Hatch** for development tasks 141 | - **Run tests** before committing 142 | - **Build docs with strict mode** to catch issues 143 | - **Maintain type hints** throughout 144 | - **Keep docstrings concise** but clear 145 | - **Test across Python versions** 146 | 147 | ### Code Standards 148 | - **Line length:** 88 characters 149 | - **Docstrings:** Google-style 150 | - **Type hints:** Required for public APIs 151 | - **Async-first:** All I/O operations 152 | - **Context managers:** For resource management 153 | 154 | ### Testing 155 | - **Target:** >80% coverage (currently 82%) 156 | - **Framework:** pytest with async support 157 | - **Run:** `hatch test -i py=3.8 -c` for coverage 158 | - **Integration tests:** May be flaky (acceptable) 159 | 160 | ## Memory Bank Maintenance 161 | 162 | ### Regular Updates 163 | - After significant code changes 164 | - When discovering new patterns 165 | - When project direction changes 166 | - When user requests update 167 | 168 | ### Quality Checks 169 | - Ensure all cross-references are valid 170 | - Remove outdated information 171 | - Keep current state accurate 172 | - Maintain consistency across files 173 | 174 | ### Version Control 175 | - Memory Bank files are version controlled 176 | - Track changes with meaningful commits 177 | - Review changes during code review 178 | - Keep in sync with code changes 179 | 180 | ## Contact & Support 181 | 182 | For questions about the Memory Bank structure or content: 183 | - Review this README 184 | - Check individual file headers 185 | - Refer to project documentation 186 | - Ask the user for clarification 187 | 188 | --- 189 | 190 | **Last Updated:** 2025-10-22 191 | **Memory Bank Version:** 1.0 192 | **Project Version:** 0.8.1 193 | -------------------------------------------------------------------------------- /janus_client/transport_http.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import asyncio 3 | from dataclasses import dataclass 4 | from typing import Dict 5 | 6 | import aiohttp 7 | 8 | from .transport import JanusTransport 9 | 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | @dataclass 15 | class ReceiverTask: 16 | task: asyncio.Task 17 | destroyed_event: asyncio.Event 18 | 19 | 20 | class JanusTransportHTTP(JanusTransport): 21 | """Janus transport through HTTP""" 22 | 23 | __receive_response_task_map: Dict[int, ReceiverTask] 24 | __api_secret: str 25 | __token: str 26 | 27 | def __init__( 28 | self, base_url: str, api_secret: str = None, token: str = None, **kwargs: dict 29 | ): 30 | super().__init__(base_url=base_url, api_secret=api_secret, token=token) 31 | 32 | self.__receive_response_task_map = dict() 33 | # HTTP transport needs these for long polling 34 | self.__api_secret = api_secret 35 | self.__token = token 36 | 37 | async def _connect(self): 38 | pass 39 | 40 | async def _disconnect(self): 41 | pass 42 | 43 | def __build_url(self, session_id: int = None, handle_id: int = None) -> str: 44 | url = f"{self.base_url}" 45 | 46 | if session_id: 47 | url = f"{url}/{session_id}" 48 | 49 | if handle_id: 50 | url = f"{url}/{handle_id}" 51 | 52 | return url 53 | 54 | async def info(self) -> Dict: 55 | async with aiohttp.ClientSession() as http_session: 56 | async with http_session.get(f"{self.base_url}/info") as response: 57 | return await response.json() 58 | 59 | async def _send( 60 | self, 61 | message: Dict, 62 | ) -> None: 63 | session_id = message.get("session_id") 64 | handle_id = message.get("handle_id") 65 | 66 | async with aiohttp.ClientSession() as http_session: 67 | async with http_session.post( 68 | url=self.__build_url(session_id=session_id, handle_id=handle_id), 69 | json=message, 70 | ) as response: 71 | response.raise_for_status() 72 | 73 | response_dict = await response.json() 74 | 75 | # if "error" in response_dict: 76 | # raise Exception(response_dict) 77 | 78 | # # There must be a transaction ID 79 | # response_transaction_id = response_dict["transaction"] 80 | 81 | # Fake receive 82 | # # We will immediately get a response in the HTTP response, so need 83 | # # to put this into the queue 84 | # await self.put_response( 85 | # transaction_id=response_transaction_id, response=response_dict 86 | # ) 87 | await self.receive(response=response_dict) 88 | 89 | def session_receive_response_done_cb( 90 | self, task: asyncio.Task, context=None 91 | ) -> None: 92 | try: 93 | # Check if any exceptions are raised 94 | task.exception() 95 | except asyncio.CancelledError: 96 | logger.info("Receive message task ended") 97 | except asyncio.InvalidStateError: 98 | logger.info("receive_message_done_cb called with invalid state") 99 | except Exception as err: 100 | logger.error(err) 101 | 102 | async def session_receive_response( 103 | self, session_id: int, destroyed_event: asyncio.Event 104 | ) -> None: 105 | url_params = {} 106 | if self.__api_secret: 107 | url_params["apisecret"] = self.__api_secret 108 | if self.__token: 109 | url_params["token"] = self.__token 110 | 111 | async with aiohttp.ClientSession() as http_session: 112 | while not destroyed_event.is_set(): 113 | async with http_session.get( 114 | url=self.__build_url(session_id=session_id), 115 | params=url_params, 116 | ) as response: 117 | # Maybe session is destroyed during http request 118 | if destroyed_event.is_set(): 119 | break 120 | 121 | response.raise_for_status() 122 | 123 | response_dict = await response.json() 124 | 125 | if "error" in response_dict: 126 | raise Exception(response_dict) 127 | 128 | if response_dict["janus"] == "keepalive": 129 | continue 130 | 131 | await self.receive(response=response_dict) 132 | 133 | async def dispatch_session_created(self, session_id: int) -> None: 134 | logger.info(f"Create session_receive_response task ({session_id})") 135 | destroyed_event = asyncio.Event() 136 | task = asyncio.create_task( 137 | self.session_receive_response( 138 | session_id=session_id, destroyed_event=destroyed_event 139 | ) 140 | ) 141 | task.add_done_callback(self.session_receive_response_done_cb) 142 | self.__receive_response_task_map[session_id] = ReceiverTask( 143 | task=task, destroyed_event=destroyed_event 144 | ) 145 | 146 | async def dispatch_session_destroyed(self, session_id: int) -> None: 147 | if session_id not in self.__receive_response_task_map: 148 | logger.warn(f"Session receive response task not found for {session_id}") 149 | 150 | logger.info(f"Destroy session_receive_response task ({session_id})") 151 | receiver_task = self.__receive_response_task_map[session_id] 152 | receiver_task.task.cancel() 153 | # I think the following problem was fixed when aiohttp version was updated 154 | # # Don't use task.cancel() to avoid 155 | # # Exception ignored in: 156 | # receiver_task.destroyed_event.set() 157 | 158 | # # Destroying sessions could cost some time because it needs to 159 | # # wait for the long-poll request to complete 160 | # await asyncio.wait([receiver_task.task]) 161 | 162 | 163 | def protocol_matcher(base_url: str): 164 | return base_url.startswith(("http://", "https://")) 165 | 166 | 167 | JanusTransport.register_transport( 168 | protocol_matcher=protocol_matcher, transport_cls=JanusTransportHTTP 169 | ) 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Python Janus Client 3 |

4 | 5 | 6 | [Janus WebRTC Server](https://github.com/meetecho/janus-gateway) Python async client. 7 | 8 | ![PyPI - License](https://img.shields.io/pypi/l/janus-client) 9 | ![PyPI - Status](https://img.shields.io/pypi/status/janus-client) 10 | ![PyPI - Downloads](https://img.shields.io/pypi/dm/janus-client) 11 | ![Code Coverage](https://img.shields.io/badge/coverage-75%25-yellow) 12 | 13 | --- 14 | 15 | ## Install 16 | 17 | ```bash 18 | pip install janus-client 19 | ``` 20 | 21 | Requires Python >=3.8 <3.14 22 | 23 | --- 24 | 25 | ## Description 26 | 27 | Easily send and share WebRTC media through Janus WebRTC server. 28 | 29 | This client is using `aiortc` for WebRTC communication and subsequently `PyAV` for media stack. 30 | 31 | ## ✅ Features ✅ 32 | 33 | - Connect to Janus server using: 34 | - Websocket 35 | - HTTP 36 | - Authentication with shared static secret (API key) and/or stored token 37 | - Support Admin/Monitor API: 38 | - Generic requests 39 | - Configuration related requests 40 | - Token related requests 41 | - Support Janus plugins: 42 | - EchoTest plugin 43 | - VideoCall plugin (Please refer to [eg_videocall_in.py](./eg_videocall_in.py) and [eg_videocall_out.py](./eg_videocall_out.py)) 44 | - VideoRoom plugin 45 | - TextRoom plugin 46 | - Simple interface 47 | - Minimum dependency 48 | - Extendable Janus transport 49 | 50 | --- 51 | 52 | ## Examples 53 | 54 | ### Simple Connect And Disconnect 55 | 56 | ```python 57 | import asyncio 58 | from janus_client import JanusSession, JanusEchoTestPlugin, JanusVideoRoomPlugin 59 | 60 | # Protocol will be derived from base_url 61 | base_url = "wss://janusmy.josephgetmyip.com/janusbasews/janus" 62 | # OR 63 | base_url = "https://janusmy.josephgetmyip.com/janusbase/janus" 64 | 65 | session = JanusSession(base_url=base_url) 66 | 67 | plugin_handle = JanusEchoTestPlugin() 68 | 69 | # Attach to Janus session 70 | await plugin_handle.attach(session=session) 71 | 72 | # Destroy plugin handle 73 | await plugin_handle.destroy() 74 | ``` 75 | 76 | This will create a plugin handle and then destroy it. 77 | 78 | Notice that we don't need to call connect or disconnect explicitly. It's managed internally. 79 | 80 | ### Make Video Calls 81 | 82 | ```python 83 | import asyncio 84 | from janus_client import JanusSession, JanusVideoCallPlugin 85 | from aiortc.contrib.media import MediaPlayer, MediaRecorder 86 | from aiortc import RTCConfiguration, RTCIceServer 87 | 88 | async def main(): 89 | # Create session 90 | session = JanusSession( 91 | base_url="wss://janusmy.josephgetmyip.com/janusbasews/janus", 92 | ) 93 | 94 | # Create plugin (optionally with WebRTC configuration) 95 | config = RTCConfiguration(iceServers=[ 96 | RTCIceServer(urls='stun:stun.l.google.com:19302') 97 | ]) 98 | plugin_handle = JanusVideoCallPlugin(pc_config=config) 99 | 100 | # Attach to Janus session 101 | await plugin_handle.attach(session=session) 102 | 103 | # Prepare username and media stream 104 | username = "testusernamein" 105 | username_out = "testusernameout" 106 | 107 | player = MediaPlayer( 108 | "desktop", 109 | format="gdigrab", 110 | options={ 111 | "video_size": "640x480", 112 | "framerate": "30", 113 | "offset_x": "20", 114 | "offset_y": "30", 115 | }, 116 | ) 117 | recorder = MediaRecorder("./videocall_record_out.mp4") 118 | 119 | # Register myself as testusernameout 120 | result = await plugin_handle.register(username=username_out) 121 | 122 | # Call testusernamein 123 | result = await plugin_handle.call( 124 | username=username, player=player, recorder=recorder 125 | ) 126 | 127 | # Wait awhile then hangup 128 | await asyncio.sleep(30) 129 | 130 | result = await plugin_handle.hangup() 131 | 132 | # Destroy plugin 133 | await plugin_handle.destroy() 134 | 135 | # Destroy session 136 | await session.destroy() 137 | 138 | 139 | if __name__ == "__main__": 140 | try: 141 | asyncio.run(main()) 142 | except KeyboardInterrupt: 143 | pass 144 | ``` 145 | 146 | This example will register to the VideoCall plugin using username `testusernameout`. It will then call the user registered using the username `testusernamein`. 147 | 148 | A portion of the screen will be captured and sent in the call media stream. 149 | The incoming media stream will be saved into `videocall_record_out.mp4` file. 150 | 151 | 164 | 165 | ## Documentation 166 | 167 | Link here: https://josephlim94.github.io/python_janus_client/ 168 | 169 | ## Dev 170 | 171 | ### Documentation Development 172 | 173 | The project documentation is built with [Material for MkDocs](https://squidfunk.github.io/mkdocs-material/) and deployed to GitHub Pages. 174 | 175 | #### Setup 176 | 177 | ```bash 178 | # Install development dependencies 179 | hatch env create 180 | 181 | # To serve the documentation locally with live reload 182 | # Should be available at http://127.0.0.1:8000/ 183 | hatch run docs-serve 184 | ``` 185 | 186 | #### Building Documentation 187 | 188 | To build the documentation for production: 189 | 190 | ```bash 191 | hatch run docs-build 192 | ``` 193 | 194 | The built documentation will be in the `site/` directory. 195 | 196 | **Important:** The documentation build uses the `--strict` flag to catch warnings as errors. This ensures documentation quality and prevents deployment of documentation with issues. 197 | 198 | For local development without strict mode: 199 | 200 | ```bash 201 | hatch run mkdocs build 202 | hatch run +py=3.8 mkdocs build # to build in a specific python environment only, not all 203 | ``` 204 | 205 | ### Run unit tests 206 | 207 | Use video url from https://gist.github.com/jsturgis/3b19447b304616f18657 208 | 209 | Use following command to run unit tests and see all logs: 210 | 211 | ```bash 212 | hatch test # Run all tests on all environments 213 | hatch test -- -s --log-cli-level=INFO --full-trace -- tests # Run all tests with all logs on a default environment 214 | hatch test .\tests\test_plugin.py::TestTransportHttp::test_plugin_echotest_create -- -s --log-cli-level=INFO --full-trace # Run a specific test with all logs on a default environment 215 | hatch test -i py=3.8 .\tests\test_plugin.py::TestTransportHttp::test_plugin_echotest_create -- -s --log-cli-level=INFO --full-trace # Run a specific test with all logs on a specific environment 216 | ``` 217 | 218 | Generate code coverage: 219 | 220 | ```bash 221 | # Not running it through all python environments because the webrtc connection might fail to setup. 222 | # That is a server configuration issue which naturally comes with integration tests like these. 223 | hatch test -i py=3.8 -c 224 | hatch env run -e py3.8 coverage html 225 | ``` 226 | 227 | ### Build and publish 228 | 229 | ```bash 230 | hatch -e py3.8 build --clean 231 | hatch publish 232 | ``` 233 | ## Experiments 234 | 235 | FFmpeg support for VideoRoom plugin has now been moved to `experiments` folder, together with GStreamer support. 236 | -------------------------------------------------------------------------------- /memory-bank/productContext.md: -------------------------------------------------------------------------------- 1 | # Product Context: Python Janus Client 2 | 3 | ## Why This Project Exists 4 | 5 | ### The Problem 6 | WebRTC is a powerful technology for real-time communication, but it requires complex signaling infrastructure. Janus is a popular open-source WebRTC gateway that handles this complexity, but Python developers lacked a modern, async-first client library to interact with it. 7 | 8 | **Specific Pain Points:** 9 | - Existing Python WebRTC solutions were synchronous or incomplete 10 | - No comprehensive Python client for Janus gateway 11 | - Difficult to integrate WebRTC into Python applications 12 | - Complex setup required for basic WebRTC functionality 13 | - Limited examples and documentation for Python + Janus 14 | 15 | ### The Solution 16 | python_janus_client provides a clean, async Python interface to Janus WebRTC gateway, leveraging modern Python features (async/await) and the robust aiortc library for WebRTC implementation. 17 | 18 | **Key Benefits:** 19 | - Simple API that hides WebRTC complexity 20 | - Async/await for efficient I/O operations 21 | - Built-in support for common Janus plugins 22 | - Extensible architecture for custom needs 23 | - Comprehensive examples and documentation 24 | 25 | ## How It Should Work 26 | 27 | ### User Experience Goals 28 | 29 | #### 1. Simple Connection 30 | Users should connect to Janus with minimal code: 31 | ```python 32 | session = JanusSession(base_url="wss://janus.example.com/janus") 33 | async with session: 34 | # Work with session 35 | pass 36 | ``` 37 | 38 | #### 2. Easy Plugin Usage 39 | Attaching and using plugins should be intuitive: 40 | ```python 41 | plugin = JanusEchoTestPlugin() 42 | await plugin.attach(session) 43 | await plugin.start("input.mp4", "output.mp4") 44 | await plugin.destroy() 45 | ``` 46 | 47 | #### 3. Automatic Resource Management 48 | - Connections auto-establish when needed 49 | - Resources auto-cleanup with context managers 50 | - No manual connection/disconnection required 51 | - Graceful error handling 52 | 53 | #### 4. Clear Media Handling 54 | Media streaming should be straightforward: 55 | ```python 56 | player = MediaPlayer("desktop", format="gdigrab") 57 | recorder = MediaRecorder("output.mp4") 58 | await plugin.call(username="user", player=player, recorder=recorder) 59 | ``` 60 | 61 | ### Core Workflows 62 | 63 | #### Workflow 1: Echo Test (Testing) 64 | **Purpose:** Test WebRTC connectivity and media handling 65 | **Steps:** 66 | 1. Create session 67 | 2. Attach EchoTest plugin 68 | 3. Start with input media source 69 | 4. Receive echoed media back 70 | 5. Record output 71 | 6. Cleanup 72 | 73 | **Use Case:** Verify Janus server connectivity, test media pipeline 74 | 75 | #### Workflow 2: Video Call (P2P Communication) 76 | **Purpose:** Enable peer-to-peer video calls 77 | **Steps:** 78 | 1. Create session 79 | 2. Attach VideoCall plugin 80 | 3. Register with username 81 | 4. Call another user OR wait for incoming call 82 | 5. Exchange media streams 83 | 6. Hangup when done 84 | 7. Cleanup 85 | 86 | **Use Case:** Video conferencing, remote assistance, telemedicine 87 | 88 | #### Workflow 3: Video Room (Multi-Party) 89 | **Purpose:** Multi-party video conferencing 90 | **Steps:** 91 | 1. Create session 92 | 2. Attach VideoRoom plugin 93 | 3. Join room (or create if needed) 94 | 4. Publish local media stream 95 | 5. Subscribe to other participants 96 | 6. Handle participant join/leave events 97 | 7. Leave room and cleanup 98 | 99 | **Use Case:** Group video calls, webinars, virtual classrooms 100 | 101 | #### Workflow 4: Text Room (Chat) 102 | **Purpose:** Text-based chat rooms with data channels 103 | **Steps:** 104 | 1. Create session 105 | 2. Attach TextRoom plugin 106 | 3. Setup WebRTC data channel 107 | 4. Join room 108 | 5. Send/receive text messages 109 | 6. Handle room events 110 | 7. Leave and cleanup 111 | 112 | **Use Case:** Chat applications, real-time collaboration, signaling 113 | 114 | ### Design Philosophy 115 | 116 | #### Async-First 117 | - All I/O operations are async 118 | - No blocking calls in the main path 119 | - Efficient resource utilization 120 | - Natural integration with async frameworks (FastAPI, aiohttp, etc.) 121 | 122 | #### Minimal Dependencies 123 | - Core dependencies: aiortc, websockets, aiohttp 124 | - No unnecessary bloat 125 | - Easy to install and deploy 126 | - Reduced security surface 127 | 128 | #### Extensibility 129 | - Plugin base class for custom plugins 130 | - Transport base class for custom protocols 131 | - Hook points for customization 132 | - Clear extension patterns 133 | 134 | #### Developer-Friendly 135 | - Type hints throughout 136 | - Clear error messages 137 | - Comprehensive examples 138 | - Detailed documentation 139 | - Follows Python conventions 140 | 141 | ## User Personas 142 | 143 | ### Persona 1: Application Developer 144 | **Background:** Building a Python web application that needs video chat 145 | **Needs:** 146 | - Easy integration with existing async framework 147 | - Reliable video/audio streaming 148 | - Minimal setup complexity 149 | - Good documentation 150 | 151 | **Goals:** 152 | - Add video chat feature quickly 153 | - Maintain application performance 154 | - Handle errors gracefully 155 | 156 | ### Persona 2: IoT Developer 157 | **Background:** Building IoT devices that need WebRTC capabilities 158 | **Needs:** 159 | - Lightweight client 160 | - Efficient resource usage 161 | - Cross-platform support 162 | - Stable API 163 | 164 | **Goals:** 165 | - Stream sensor data via WebRTC 166 | - Remote device monitoring 167 | - Low latency communication 168 | 169 | ### Persona 3: Researcher 170 | **Background:** Researching WebRTC protocols and implementations 171 | **Needs:** 172 | - Access to low-level WebRTC details 173 | - Ability to customize behavior 174 | - Clear code structure 175 | - Extensibility 176 | 177 | **Goals:** 178 | - Experiment with WebRTC features 179 | - Implement custom protocols 180 | - Analyze WebRTC performance 181 | 182 | ### Persona 4: Enterprise Developer 183 | **Background:** Integrating with existing Janus deployment 184 | **Needs:** 185 | - Production-ready client 186 | - Authentication support 187 | - Admin API access 188 | - Monitoring capabilities 189 | 190 | **Goals:** 191 | - Integrate with corporate infrastructure 192 | - Manage Janus server programmatically 193 | - Monitor system health 194 | 195 | ## Success Metrics 196 | 197 | ### Technical Metrics 198 | - **Test Coverage:** >80% (currently at 82%) 199 | - **API Stability:** Semantic versioning, backward compatibility 200 | - **Performance:** Low latency, efficient resource usage 201 | - **Reliability:** Graceful error handling, automatic recovery 202 | 203 | ### Adoption Metrics 204 | - **PyPI Downloads:** Growing monthly downloads 205 | - **GitHub Stars:** Community interest indicator 206 | - **Issues/PRs:** Active community engagement 207 | - **Documentation Views:** Usage indicator 208 | 209 | ### Quality Metrics 210 | - **Bug Reports:** Low and decreasing 211 | - **Response Time:** Quick issue resolution 212 | - **Code Quality:** Clean, maintainable code 213 | - **Documentation:** Complete and up-to-date 214 | 215 | ## Future Vision 216 | 217 | ### Short-Term (Next Release) 218 | - Improve WebSocket cleanup 219 | - Remove deprecation warnings 220 | - Enhanced error messages 221 | - More examples 222 | 223 | ### Medium-Term (6-12 months) 224 | - Optional WebRTC dependency (signaling-only mode) 225 | - Additional plugin support 226 | - Performance optimizations 227 | - Enhanced monitoring 228 | 229 | ### Long-Term (1+ years) 230 | - Broader Janus feature coverage 231 | - Advanced media processing 232 | - Production-grade reliability 233 | - Enterprise features 234 | -------------------------------------------------------------------------------- /python_janus_client_logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.clinerules/hatch-tooling.md: -------------------------------------------------------------------------------- 1 | # Hatch Tooling Requirements 2 | 3 | ## Primary Development Tool 4 | 5 | **CRITICAL:** This project uses **Hatch** as its primary development and build tool. All development workflows, dependency management, testing, and build operations MUST use Hatch unless technically impossible. 6 | 7 | ## Why Hatch? 8 | 9 | Hatch is the modern Python project manager that provides: 10 | - Unified environment management 11 | - Reproducible builds 12 | - Integrated testing workflows 13 | - Standardized project structure 14 | - PEP 517/518 compliance 15 | 16 | ## Hatch Command Reference 17 | 18 | ### Environment Management 19 | 20 | **Install dependencies and set up environment:** 21 | ```bash 22 | hatch env create 23 | ``` 24 | 25 | **Activate the default environment:** 26 | ```bash 27 | hatch shell 28 | ``` 29 | 30 | **Run commands in the environment without activation:** 31 | ```bash 32 | hatch run 33 | ``` 34 | 35 | **List all environments:** 36 | ```bash 37 | hatch env show 38 | ``` 39 | 40 | **Remove an environment:** 41 | ```bash 42 | hatch env remove 43 | ``` 44 | 45 | ### Dependency Management 46 | 47 | **Add a new dependency:** 48 | ```bash 49 | # Edit pyproject.toml [project.dependencies] section manually 50 | # Then sync the environment 51 | hatch env create 52 | ``` 53 | 54 | **Add a development dependency:** 55 | ```bash 56 | # Edit pyproject.toml [tool.hatch.envs.default] section manually 57 | # Then sync the environment 58 | hatch env create 59 | ``` 60 | 61 | **Update dependencies:** 62 | ```bash 63 | hatch env prune 64 | hatch env create 65 | ``` 66 | 67 | ### Testing 68 | 69 | **Run tests:** 70 | ```bash 71 | hatch run test 72 | ``` 73 | 74 | **Run tests with coverage:** 75 | ```bash 76 | hatch run cov 77 | ``` 78 | 79 | **Run tests for specific Python versions:** 80 | ```bash 81 | hatch run test:test 82 | ``` 83 | 84 | **Run a specific test file:** 85 | ```bash 86 | hatch run pytest tests/test_plugin_textroom.py 87 | ``` 88 | 89 | **Run a specific test:** 90 | ```bash 91 | hatch run pytest tests/test_plugin_textroom.py::TestTransportHttp::test_textroom_message_history 92 | ``` 93 | 94 | ### Code Quality 95 | 96 | **Run linting:** 97 | ```bash 98 | hatch run lint:check 99 | ``` 100 | 101 | **Auto-fix linting issues:** 102 | ```bash 103 | hatch run lint:fix 104 | ``` 105 | 106 | **Run type checking:** 107 | ```bash 108 | hatch run lint:typing 109 | ``` 110 | 111 | **Format code:** 112 | ```bash 113 | hatch run lint:fmt 114 | ``` 115 | 116 | ### Documentation 117 | 118 | **Build documentation:** 119 | ```bash 120 | hatch run docs:build 121 | ``` 122 | 123 | **Serve documentation locally:** 124 | ```bash 125 | hatch run docs:serve 126 | ``` 127 | 128 | **Build documentation with strict mode:** 129 | ```bash 130 | hatch run python -W ignore::DeprecationWarning:mkdocs_autorefs -m mkdocs build --clean --strict 131 | ``` 132 | 133 | ### Building and Publishing 134 | 135 | **Build the package:** 136 | ```bash 137 | hatch build 138 | ``` 139 | 140 | **Build wheel only:** 141 | ```bash 142 | hatch build -t wheel 143 | ``` 144 | 145 | **Build source distribution only:** 146 | ```bash 147 | hatch build -t sdist 148 | ``` 149 | 150 | **Publish to PyPI:** 151 | ```bash 152 | hatch publish 153 | ``` 154 | 155 | **Publish to Test PyPI:** 156 | ```bash 157 | hatch publish -r test 158 | ``` 159 | 160 | ### Version Management 161 | 162 | **Show current version:** 163 | ```bash 164 | hatch version 165 | ``` 166 | 167 | **Bump version (patch):** 168 | ```bash 169 | hatch version patch 170 | ``` 171 | 172 | **Bump version (minor):** 173 | ```bash 174 | hatch version minor 175 | ``` 176 | 177 | **Bump version (major):** 178 | ```bash 179 | hatch version major 180 | ``` 181 | 182 | **Set specific version:** 183 | ```bash 184 | hatch version 1.2.3 185 | ``` 186 | 187 | ## Development Workflow with Hatch 188 | 189 | ### Initial Setup 190 | ```bash 191 | # Clone the repository 192 | git clone 193 | cd python_janus_client 194 | 195 | # Create and activate environment 196 | hatch env create 197 | hatch shell 198 | 199 | # Verify installation 200 | hatch run python -c "import janus_client; print(janus_client.__version__)" 201 | ``` 202 | 203 | ### Daily Development 204 | ```bash 205 | # Activate environment 206 | hatch shell 207 | 208 | # Run tests during development 209 | hatch run pytest tests/ 210 | 211 | # Check code quality 212 | hatch run lint:check 213 | 214 | # Format code 215 | hatch run lint:fmt 216 | ``` 217 | 218 | ### Before Committing 219 | ```bash 220 | # Run full test suite 221 | hatch run test 222 | 223 | # Check coverage 224 | hatch run cov 225 | 226 | # Run linting 227 | hatch run lint:check 228 | 229 | # Run type checking 230 | hatch run lint:typing 231 | 232 | # Build documentation 233 | hatch run docs:build 234 | ``` 235 | 236 | ### Adding New Dependencies 237 | ```bash 238 | # 1. Edit pyproject.toml to add dependency 239 | # 2. Recreate environment 240 | hatch env prune 241 | hatch env create 242 | 243 | # 3. Verify dependency is installed 244 | hatch run python -c "import " 245 | ``` 246 | 247 | ## When NOT to Use Hatch 248 | 249 | Hatch should be used for all development tasks. However, in rare cases where Hatch cannot be used: 250 | 251 | 1. **CI/CD Constraints:** If the CI/CD platform doesn't support Hatch, use pip with requirements files generated from pyproject.toml 252 | 2. **Legacy System Compatibility:** If deploying to systems that cannot run Hatch 253 | 3. **Docker Builds:** In minimal Docker images, pip installation from wheel may be preferred 254 | 255 | In these cases, document the reason and provide alternative commands. 256 | 257 | ## Migration from Poetry 258 | 259 | This project previously used Poetry. If you encounter Poetry commands in documentation or scripts: 260 | 261 | | Poetry Command | Hatch Equivalent | 262 | |----------------|------------------| 263 | | `poetry install` | `hatch env create` | 264 | | `poetry shell` | `hatch shell` | 265 | | `poetry add ` | Edit pyproject.toml + `hatch env create` | 266 | | `poetry run ` | `hatch run ` | 267 | | `poetry build` | `hatch build` | 268 | | `poetry publish` | `hatch publish` | 269 | | `poetry version` | `hatch version` | 270 | | `poetry run pytest` | `hatch run pytest` | 271 | | `poetry run flake8` | `hatch run lint:check` | 272 | 273 | ## Environment Configuration 274 | 275 | Hatch environments are configured in `pyproject.toml` under `[tool.hatch.envs]`. The project uses: 276 | 277 | - **default:** Main development environment with all dependencies 278 | - **test:** Testing environment with pytest and coverage 279 | - **lint:** Linting environment with flake8, black, isort, mypy 280 | - **docs:** Documentation environment with mkdocs 281 | 282 | ## Best Practices 283 | 284 | 1. **Always use Hatch commands:** Don't use pip directly in the project directory 285 | 2. **Keep pyproject.toml updated:** All dependencies must be declared in pyproject.toml 286 | 3. **Use environment-specific commands:** Use `hatch run :