feat: init project

This commit is contained in:
Justin Lin
2025-10-09 16:31:57 +11:00
commit ca0a880c80
13 changed files with 1262 additions and 0 deletions

55
Makefile Normal file
View File

@@ -0,0 +1,55 @@
.PHONY: install test lint format clean run-example
# Install dependencies
install:
poetry install
# Install with development dependencies
install-dev:
poetry install --with dev
# Run tests
test:
poetry run python test_vlchan.py
# Run example
run-example:
@echo "Usage: make run-example VIDEO=/path/to/video.mp4 [SYNCHAN=http://localhost:3000]"
poetry run python example.py $(VIDEO) $(SYNCHAN)
# Run player directly
run-player:
@echo "Usage: make run-player VIDEO=/path/to/video.mp4 [SYNCHAN=http://localhost:3000]"
poetry run python -m vlchan.player $(VIDEO) $(SYNCHAN)
# Lint code
lint:
poetry run ruff check .
# Format code
format:
poetry run ruff format .
# Type check
type-check:
poetry run mypy vlchan/
# Clean up
clean:
find . -type d -name "__pycache__" -exec rm -rf {} +
find . -type f -name "*.pyc" -delete
find . -type f -name "*.pyo" -delete
# Show help
help:
@echo "Available targets:"
@echo " install - Install dependencies"
@echo " install-dev - Install with development dependencies"
@echo " test - Run tests"
@echo " run-example - Run example script (requires VIDEO=path)"
@echo " run-player - Run player directly (requires VIDEO=path)"
@echo " lint - Run linter"
@echo " format - Format code"
@echo " type-check - Run type checker"
@echo " clean - Clean up cache files"
@echo " help - Show this help"

162
README.md Normal file
View File

@@ -0,0 +1,162 @@
# VLChan
A Python video player using VLC SDK with synchan server timecode synchronization.
## Features
- VLC-based video playback with full media support
- Real-time synchronization with synchan server
- Reactive programming with RxPY
- Automatic timecode correction
- Playback state synchronization
- Volume and fullscreen controls
## Installation
1. Install VLC media player on your system:
- Ubuntu/Debian: `sudo apt install vlc`
- macOS: `brew install vlc`
- Windows: Download from [videolan.org](https://www.videolan.org/vlc/)
2. Install the Python package:
```bash
cd vlchan
poetry install
```
## Usage
### Basic Usage
```python
from vlchan import VLChanPlayer
# Create player with video file
player = VLChanPlayer(
synchan_url="http://localhost:3000",
video_path="/path/to/video.mp4"
)
# Start synchronization
player.start_sync()
# Play video
player.play()
# Clean up when done
player.cleanup()
```
### Advanced Usage
```python
from vlchan import VLChanPlayer
def on_state_change(state):
print(f"Synchan state: playing={state.playing}, time={state.currentTime:.2f}s")
# Create player
player = VLChanPlayer("http://localhost:3000", "video.mp4")
# Set up state change callback
player.on_state_change = on_state_change
# Start synchronization
player.start_sync()
# Control playback
player.play()
player.pause()
player.seek(120.5) # Seek to 2 minutes 0.5 seconds
# Adjust settings
player.set_volume(80)
player.set_fullscreen(True)
# Get current state
print(f"Position: {player.get_position():.2f}s")
print(f"Duration: {player.get_duration():.2f}s")
print(f"Playing: {player.is_playing()}")
```
### Command Line Usage
```bash
# Play video with synchan sync
poetry run python -m vlchan.player video.mp4
# Use custom synchan server
poetry run python -m vlchan.player video.mp4 http://192.168.1.100:3000
```
## API Reference
### VLChanPlayer
#### Constructor
- `VLChanPlayer(synchan_url: str, video_path: Optional[str])`
- `synchan_url`: URL of the synchan server (default: "http://localhost:3000")
- `video_path`: Path to video file to load
#### Methods
- `load_video(video_path: str)`: Load a video file
- `start_sync()`: Start synchronization with synchan server
- `stop_sync()`: Stop synchronization
- `play()`: Start playback
- `pause()`: Pause playback
- `stop()`: Stop playback
- `seek(time_seconds: float)`: Seek to specific time
- `set_volume(volume: int)`: Set volume (0-100)
- `get_volume() -> int`: Get current volume
- `set_fullscreen(fullscreen: bool)`: Toggle fullscreen
- `get_position() -> float`: Get current position in seconds
- `get_duration() -> float`: Get video duration in seconds
- `is_playing() -> bool`: Check if playing
- `cleanup()`: Clean up resources
#### Properties
- `on_state_change`: Callback for synchan state changes
### SynchanState
Data class containing synchronization state:
- `playing: bool`: Whether video should be playing
- `currentTime: float`: Current time position in seconds
- `duration: float`: Total duration in seconds
- `loop: bool`: Whether video should loop
- `latency: float`: Network latency in seconds
## Synchronization
The player automatically synchronizes with the synchan server:
1. **Playback State**: Play/pause commands are synchronized
2. **Time Position**: Video seeks to match synchan timecode
3. **Threshold**: Only seeks if time difference exceeds 100ms
4. **Reconnection**: Automatically attempts to reconnect on connection loss
## Requirements
- Python 3.11+
- VLC media player
- Synchan server running
## Dependencies
- `python-vlc`: VLC Python bindings
- `reactivex`: Reactive programming
- `python-socketio`: WebSocket client for synchan
- `requests`: HTTP client for synchan API
## Development
```bash
# Install development dependencies
poetry install --with dev
# Run linting
poetry run ruff check .
# Run type checking
poetry run mypy vlchan/
```

120
example.py Normal file
View File

@@ -0,0 +1,120 @@
#!/usr/bin/env python3
"""Example usage of VLChan video player with synchan synchronization."""
import time
import sys
from vlchan import VLChanPlayer
def main():
"""Example demonstrating VLChan usage."""
# Check command line arguments
if len(sys.argv) < 2:
print("Usage: python example.py <video_path> [synchan_url]")
print("Example: python example.py /path/to/video.mp4 http://localhost:3000")
sys.exit(1)
video_path = sys.argv[1]
synchan_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:3000"
print(f"VLChan Example")
print(f"Video: {video_path}")
print(f"Synchan: {synchan_url}")
print("-" * 40)
# Create player
player = VLChanPlayer(synchan_url, video_path)
# Set up state change callback
def on_state_change(state):
print(f"Synchan update: playing={state.playing}, "
f"time={state.currentTime:.2f}s, "
f"duration={state.duration:.2f}s")
player.on_state_change = on_state_change
try:
# Start synchronization
print("Starting synchan synchronization...")
player.start_sync()
# Wait a moment for connection
time.sleep(1)
# Start playback
print("Starting video playback...")
player.play()
# Display controls
print("\nControls:")
print("- Press 'p' to pause/play")
print("- Press 's' to stop")
print("- Press 'f' to toggle fullscreen")
print("- Press 'v' to change volume")
print("- Press 'q' to quit")
print("- Press 'j' to seek forward 10s")
print("- Press 'k' to seek backward 10s")
print("\nPress 'q' to quit...")
# Interactive control loop
while True:
try:
# Check for keyboard input (non-blocking)
import select
import tty
import termios
if sys.stdin in select.select([sys.stdin], [], [], 0)[0]:
key = sys.stdin.read(1)
if key == 'q':
break
elif key == 'p':
if player.is_playing():
player.pause()
else:
player.play()
elif key == 's':
player.stop()
elif key == 'f':
player.set_fullscreen(not player.player.get_fullscreen())
elif key == 'v':
current_vol = player.get_volume()
new_vol = min(100, current_vol + 10)
player.set_volume(new_vol)
print(f"Volume: {new_vol}")
elif key == 'j':
current_pos = player.get_position()
player.seek(current_pos + 10)
print(f"Seeked forward to {current_pos + 10:.1f}s")
elif key == 'k':
current_pos = player.get_position()
player.seek(max(0, current_pos - 10))
print(f"Seeked backward to {max(0, current_pos - 10):.1f}s")
# Display current status
if player.is_playing():
pos = player.get_position()
dur = player.get_duration()
print(f"\rPlaying: {pos:.1f}s / {dur:.1f}s", end="", flush=True)
time.sleep(0.1)
except KeyboardInterrupt:
break
except Exception as e:
print(f"\nError: {e}")
break
except Exception as e:
print(f"Error: {e}")
finally:
print("\nCleaning up...")
player.cleanup()
print("Done!")
if __name__ == "__main__":
main()

413
poetry.lock generated Normal file
View File

@@ -0,0 +1,413 @@
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
[[package]]
name = "bidict"
version = "0.23.1"
description = "The bidirectional mapping library for Python."
optional = false
python-versions = ">=3.8"
files = [
{file = "bidict-0.23.1-py3-none-any.whl", hash = "sha256:5dae8d4d79b552a71cbabc7deb25dfe8ce710b17ff41711e13010ead2abfc3e5"},
{file = "bidict-0.23.1.tar.gz", hash = "sha256:03069d763bc387bbd20e7d49914e75fc4132a41937fa3405417e1a5a2d006d71"},
]
[[package]]
name = "certifi"
version = "2025.10.5"
description = "Python package for providing Mozilla's CA Bundle."
optional = false
python-versions = ">=3.7"
files = [
{file = "certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de"},
{file = "certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43"},
]
[[package]]
name = "charset-normalizer"
version = "3.4.3"
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
optional = false
python-versions = ">=3.7"
files = [
{file = "charset_normalizer-3.4.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:fb7f67a1bfa6e40b438170ebdc8158b78dc465a5a67b6dde178a46987b244a72"},
{file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc9370a2da1ac13f0153780040f465839e6cccb4a1e44810124b4e22483c93fe"},
{file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07a0eae9e2787b586e129fdcbe1af6997f8d0e5abaa0bc98c0e20e124d67e601"},
{file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:74d77e25adda8581ffc1c720f1c81ca082921329452eba58b16233ab1842141c"},
{file = "charset_normalizer-3.4.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0e909868420b7049dafd3a31d45125b31143eec59235311fc4c57ea26a4acd2"},
{file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:c6f162aabe9a91a309510d74eeb6507fab5fff92337a15acbe77753d88d9dcf0"},
{file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:4ca4c094de7771a98d7fbd67d9e5dbf1eb73efa4f744a730437d8a3a5cf994f0"},
{file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:02425242e96bcf29a49711b0ca9f37e451da7c70562bc10e8ed992a5a7a25cc0"},
{file = "charset_normalizer-3.4.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:78deba4d8f9590fe4dae384aeff04082510a709957e968753ff3c48399f6f92a"},
{file = "charset_normalizer-3.4.3-cp310-cp310-win32.whl", hash = "sha256:d79c198e27580c8e958906f803e63cddb77653731be08851c7df0b1a14a8fc0f"},
{file = "charset_normalizer-3.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:c6e490913a46fa054e03699c70019ab869e990270597018cef1d8562132c2669"},
{file = "charset_normalizer-3.4.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b256ee2e749283ef3ddcff51a675ff43798d92d746d1a6e4631bf8c707d22d0b"},
{file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13faeacfe61784e2559e690fc53fa4c5ae97c6fcedb8eb6fb8d0a15b475d2c64"},
{file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:00237675befef519d9af72169d8604a067d92755e84fe76492fef5441db05b91"},
{file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:585f3b2a80fbd26b048a0be90c5aae8f06605d3c92615911c3a2b03a8a3b796f"},
{file = "charset_normalizer-3.4.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e78314bdc32fa80696f72fa16dc61168fda4d6a0c014e0380f9d02f0e5d8a07"},
{file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:96b2b3d1a83ad55310de8c7b4a2d04d9277d5591f40761274856635acc5fcb30"},
{file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:939578d9d8fd4299220161fdd76e86c6a251987476f5243e8864a7844476ba14"},
{file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:fd10de089bcdcd1be95a2f73dbe6254798ec1bda9f450d5828c96f93e2536b9c"},
{file = "charset_normalizer-3.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e8ac75d72fa3775e0b7cb7e4629cec13b7514d928d15ef8ea06bca03ef01cae"},
{file = "charset_normalizer-3.4.3-cp311-cp311-win32.whl", hash = "sha256:6cf8fd4c04756b6b60146d98cd8a77d0cdae0e1ca20329da2ac85eed779b6849"},
{file = "charset_normalizer-3.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:31a9a6f775f9bcd865d88ee350f0ffb0e25936a7f930ca98995c05abf1faf21c"},
{file = "charset_normalizer-3.4.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e28e334d3ff134e88989d90ba04b47d84382a828c061d0d1027b1b12a62b39b1"},
{file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cacf8f7297b0c4fcb74227692ca46b4a5852f8f4f24b3c766dd94a1075c4884"},
{file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c6fd51128a41297f5409deab284fecbe5305ebd7e5a1f959bee1c054622b7018"},
{file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cfb2aad70f2c6debfbcb717f23b7eb55febc0bb23dcffc0f076009da10c6392"},
{file = "charset_normalizer-3.4.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1606f4a55c0fd363d754049cdf400175ee96c992b1f8018b993941f221221c5f"},
{file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:027b776c26d38b7f15b26a5da1044f376455fb3766df8fc38563b4efbc515154"},
{file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:42e5088973e56e31e4fa58eb6bd709e42fc03799c11c42929592889a2e54c491"},
{file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cc34f233c9e71701040d772aa7490318673aa7164a0efe3172b2981218c26d93"},
{file = "charset_normalizer-3.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:320e8e66157cc4e247d9ddca8e21f427efc7a04bbd0ac8a9faf56583fa543f9f"},
{file = "charset_normalizer-3.4.3-cp312-cp312-win32.whl", hash = "sha256:fb6fecfd65564f208cbf0fba07f107fb661bcd1a7c389edbced3f7a493f70e37"},
{file = "charset_normalizer-3.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:86df271bf921c2ee3818f0522e9a5b8092ca2ad8b065ece5d7d9d0e9f4849bcc"},
{file = "charset_normalizer-3.4.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:14c2a87c65b351109f6abfc424cab3927b3bdece6f706e4d12faaf3d52ee5efe"},
{file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41d1fc408ff5fdfb910200ec0e74abc40387bccb3252f3f27c0676731df2b2c8"},
{file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1bb60174149316da1c35fa5233681f7c0f9f514509b8e399ab70fea5f17e45c9"},
{file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30d006f98569de3459c2fc1f2acde170b7b2bd265dc1943e87e1a4efe1b67c31"},
{file = "charset_normalizer-3.4.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:416175faf02e4b0810f1f38bcb54682878a4af94059a1cd63b8747244420801f"},
{file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6aab0f181c486f973bc7262a97f5aca3ee7e1437011ef0c2ec04b5a11d16c927"},
{file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabf8315679312cfa71302f9bd509ded4f2f263fb5b765cf1433b39106c3cc9"},
{file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:bd28b817ea8c70215401f657edef3a8aa83c29d447fb0b622c35403780ba11d5"},
{file = "charset_normalizer-3.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:18343b2d246dc6761a249ba1fb13f9ee9a2bcd95decc767319506056ea4ad4dc"},
{file = "charset_normalizer-3.4.3-cp313-cp313-win32.whl", hash = "sha256:6fb70de56f1859a3f71261cbe41005f56a7842cc348d3aeb26237560bfa5e0ce"},
{file = "charset_normalizer-3.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:cf1ebb7d78e1ad8ec2a8c4732c7be2e736f6e5123a4146c5b89c9d1f585f8cef"},
{file = "charset_normalizer-3.4.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3cd35b7e8aedeb9e34c41385fda4f73ba609e561faedfae0a9e75e44ac558a15"},
{file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b89bc04de1d83006373429975f8ef9e7932534b8cc9ca582e4db7d20d91816db"},
{file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2001a39612b241dae17b4687898843f254f8748b796a2e16f1051a17078d991d"},
{file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8dcfc373f888e4fb39a7bc57e93e3b845e7f462dacc008d9749568b1c4ece096"},
{file = "charset_normalizer-3.4.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18b97b8404387b96cdbd30ad660f6407799126d26a39ca65729162fd810a99aa"},
{file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ccf600859c183d70eb47e05a44cd80a4ce77394d1ac0f79dbd2dd90a69a3a049"},
{file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:53cd68b185d98dde4ad8990e56a58dea83a4162161b1ea9272e5c9182ce415e0"},
{file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:30a96e1e1f865f78b030d65241c1ee850cdf422d869e9028e2fc1d5e4db73b92"},
{file = "charset_normalizer-3.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d716a916938e03231e86e43782ca7878fb602a125a91e7acb8b5112e2e96ac16"},
{file = "charset_normalizer-3.4.3-cp314-cp314-win32.whl", hash = "sha256:c6dbd0ccdda3a2ba7c2ecd9d77b37f3b5831687d8dc1b6ca5f56a4880cc7b7ce"},
{file = "charset_normalizer-3.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:73dc19b562516fc9bcf6e5d6e596df0b4eb98d87e4f79f3ae71840e6ed21361c"},
{file = "charset_normalizer-3.4.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:0f2be7e0cf7754b9a30eb01f4295cc3d4358a479843b31f328afd210e2c7598c"},
{file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c60e092517a73c632ec38e290eba714e9627abe9d301c8c8a12ec32c314a2a4b"},
{file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:252098c8c7a873e17dd696ed98bbe91dbacd571da4b87df3736768efa7a792e4"},
{file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3653fad4fe3ed447a596ae8638b437f827234f01a8cd801842e43f3d0a6b281b"},
{file = "charset_normalizer-3.4.3-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8999f965f922ae054125286faf9f11bc6932184b93011d138925a1773830bbe9"},
{file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d95bfb53c211b57198bb91c46dd5a2d8018b3af446583aab40074bf7988401cb"},
{file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:5b413b0b1bfd94dbf4023ad6945889f374cd24e3f62de58d6bb102c4d9ae534a"},
{file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:b5e3b2d152e74e100a9e9573837aba24aab611d39428ded46f4e4022ea7d1942"},
{file = "charset_normalizer-3.4.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a2d08ac246bb48479170408d6c19f6385fa743e7157d716e144cad849b2dd94b"},
{file = "charset_normalizer-3.4.3-cp38-cp38-win32.whl", hash = "sha256:ec557499516fc90fd374bf2e32349a2887a876fbf162c160e3c01b6849eaf557"},
{file = "charset_normalizer-3.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:5d8d01eac18c423815ed4f4a2ec3b439d654e55ee4ad610e153cf02faf67ea40"},
{file = "charset_normalizer-3.4.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:70bfc5f2c318afece2f5838ea5e4c3febada0be750fcf4775641052bbba14d05"},
{file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:23b6b24d74478dc833444cbd927c338349d6ae852ba53a0d02a2de1fce45b96e"},
{file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:34a7f768e3f985abdb42841e20e17b330ad3aaf4bb7e7aeeb73db2e70f077b99"},
{file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb731e5deb0c7ef82d698b0f4c5bb724633ee2a489401594c5c88b02e6cb15f7"},
{file = "charset_normalizer-3.4.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:257f26fed7d7ff59921b78244f3cd93ed2af1800ff048c33f624c87475819dd7"},
{file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1ef99f0456d3d46a50945c98de1774da86f8e992ab5c77865ea8b8195341fc19"},
{file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2c322db9c8c89009a990ef07c3bcc9f011a3269bc06782f916cd3d9eed7c9312"},
{file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:511729f456829ef86ac41ca78c63a5cb55240ed23b4b737faca0eb1abb1c41bc"},
{file = "charset_normalizer-3.4.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:88ab34806dea0671532d3f82d82b85e8fc23d7b2dd12fa837978dad9bb392a34"},
{file = "charset_normalizer-3.4.3-cp39-cp39-win32.whl", hash = "sha256:16a8770207946ac75703458e2c743631c79c59c5890c80011d536248f8eaa432"},
{file = "charset_normalizer-3.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:d22dbedd33326a4a5190dd4fe9e9e693ef12160c77382d9e87919bce54f3d4ca"},
{file = "charset_normalizer-3.4.3-py3-none-any.whl", hash = "sha256:ce571ab16d890d23b5c278547ba694193a45011ff86a9162a71307ed9f86759a"},
{file = "charset_normalizer-3.4.3.tar.gz", hash = "sha256:6fce4b8500244f6fcb71465d4a4930d132ba9ab8e71a7859e6a5d59851068d14"},
]
[[package]]
name = "h11"
version = "0.16.0"
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
optional = false
python-versions = ">=3.8"
files = [
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
]
[[package]]
name = "idna"
version = "3.10"
description = "Internationalized Domain Names in Applications (IDNA)"
optional = false
python-versions = ">=3.6"
files = [
{file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"},
{file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"},
]
[package.extras]
all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "mypy"
version = "1.18.2"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.9"
files = [
{file = "mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c"},
{file = "mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e"},
{file = "mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b"},
{file = "mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66"},
{file = "mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428"},
{file = "mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed"},
{file = "mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f"},
{file = "mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341"},
{file = "mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d"},
{file = "mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86"},
{file = "mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37"},
{file = "mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8"},
{file = "mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34"},
{file = "mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764"},
{file = "mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893"},
{file = "mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914"},
{file = "mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8"},
{file = "mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074"},
{file = "mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc"},
{file = "mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e"},
{file = "mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986"},
{file = "mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d"},
{file = "mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba"},
{file = "mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544"},
{file = "mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce"},
{file = "mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d"},
{file = "mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c"},
{file = "mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb"},
{file = "mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075"},
{file = "mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf"},
{file = "mypy-1.18.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:25a9c8fb67b00599f839cf472713f54249a62efd53a54b565eb61956a7e3296b"},
{file = "mypy-1.18.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c2b9c7e284ee20e7598d6f42e13ca40b4928e6957ed6813d1ab6348aa3f47133"},
{file = "mypy-1.18.2-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6985ed057513e344e43a26cc1cd815c7a94602fb6a3130a34798625bc2f07b6"},
{file = "mypy-1.18.2-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22f27105f1525ec024b5c630c0b9f36d5c1cc4d447d61fe51ff4bd60633f47ac"},
{file = "mypy-1.18.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:030c52d0ea8144e721e49b1f68391e39553d7451f0c3f8a7565b59e19fcb608b"},
{file = "mypy-1.18.2-cp39-cp39-win_amd64.whl", hash = "sha256:aa5e07ac1a60a253445797e42b8b2963c9675563a94f11291ab40718b016a7a0"},
{file = "mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e"},
{file = "mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b"},
]
[package.dependencies]
mypy_extensions = ">=1.0.0"
pathspec = ">=0.9.0"
typing_extensions = ">=4.6.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
faster-cache = ["orjson"]
install-types = ["pip"]
mypyc = ["setuptools (>=50)"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.1.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505"},
{file = "mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558"},
]
[[package]]
name = "pathspec"
version = "0.12.1"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.8"
files = [
{file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"},
{file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"},
]
[[package]]
name = "python-engineio"
version = "4.12.3"
description = "Engine.IO server and client for Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "python_engineio-4.12.3-py3-none-any.whl", hash = "sha256:7c099abb2a27ea7ab429c04da86ab2d82698cdd6c52406cb73766fe454feb7e1"},
{file = "python_engineio-4.12.3.tar.gz", hash = "sha256:35633e55ec30915e7fc8f7e34ca8d73ee0c080cec8a8cd04faf2d7396f0a7a7a"},
]
[package.dependencies]
simple-websocket = ">=0.10.0"
[package.extras]
asyncio-client = ["aiohttp (>=3.4)"]
client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
docs = ["sphinx"]
[[package]]
name = "python-socketio"
version = "5.14.1"
description = "Socket.IO server and client for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "python_socketio-5.14.1-py3-none-any.whl", hash = "sha256:3419f5917f0e3942317836a77146cb4caa23ad804c8fd1a7e3f44a6657a8406e"},
{file = "python_socketio-5.14.1.tar.gz", hash = "sha256:bf49657073b90ee09e4cbd6651044b46bb526694276621e807a1b8fcc0c1b25b"},
]
[package.dependencies]
bidict = ">=0.21.0"
python-engineio = ">=4.11.0"
requests = {version = ">=2.21.0", optional = true, markers = "extra == \"client\""}
websocket-client = {version = ">=0.54.0", optional = true, markers = "extra == \"client\""}
[package.extras]
asyncio-client = ["aiohttp (>=3.4)"]
client = ["requests (>=2.21.0)", "websocket-client (>=0.54.0)"]
docs = ["sphinx"]
[[package]]
name = "python-vlc"
version = "3.0.21203"
description = "VLC bindings for python."
optional = false
python-versions = "*"
files = [
{file = "python_vlc-3.0.21203-py3-none-any.whl", hash = "sha256:1613451a31b692ec276296ceeae0c0ba82bfc2d094dabf9aceb70f58944a6320"},
{file = "python_vlc-3.0.21203.tar.gz", hash = "sha256:52d0544b276b11e58b6c0b748c3e0518f94f74b1b4cd328c83a59eacabead1ec"},
]
[[package]]
name = "reactivex"
version = "4.0.4"
description = "ReactiveX (Rx) for Python"
optional = false
python-versions = ">=3.7,<4.0"
files = [
{file = "reactivex-4.0.4-py3-none-any.whl", hash = "sha256:0004796c420bd9e68aad8e65627d85a8e13f293de76656165dffbcb3a0e3fb6a"},
{file = "reactivex-4.0.4.tar.gz", hash = "sha256:e912e6591022ab9176df8348a653fe8c8fa7a301f26f9931c9d8c78a650e04e8"},
]
[package.dependencies]
typing-extensions = ">=4.1.1,<5.0.0"
[[package]]
name = "requests"
version = "2.32.5"
description = "Python HTTP for Humans."
optional = false
python-versions = ">=3.9"
files = [
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
]
[package.dependencies]
certifi = ">=2017.4.17"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
urllib3 = ">=1.21.1,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
[[package]]
name = "ruff"
version = "0.12.12"
description = "An extremely fast Python linter and code formatter, written in Rust."
optional = false
python-versions = ">=3.7"
files = [
{file = "ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc"},
{file = "ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727"},
{file = "ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb"},
{file = "ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577"},
{file = "ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e"},
{file = "ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e"},
{file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8"},
{file = "ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5"},
{file = "ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92"},
{file = "ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45"},
{file = "ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5"},
{file = "ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4"},
{file = "ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23"},
{file = "ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489"},
{file = "ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee"},
{file = "ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1"},
{file = "ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d"},
{file = "ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093"},
{file = "ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6"},
]
[[package]]
name = "simple-websocket"
version = "1.1.0"
description = "Simple WebSocket server and client for Python"
optional = false
python-versions = ">=3.6"
files = [
{file = "simple_websocket-1.1.0-py3-none-any.whl", hash = "sha256:4af6069630a38ed6c561010f0e11a5bc0d4ca569b36306eb257cd9a192497c8c"},
{file = "simple_websocket-1.1.0.tar.gz", hash = "sha256:7939234e7aa067c534abdab3a9ed933ec9ce4691b0713c78acb195560aa52ae4"},
]
[package.dependencies]
wsproto = "*"
[package.extras]
dev = ["flake8", "pytest", "pytest-cov", "tox"]
docs = ["sphinx"]
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[[package]]
name = "urllib3"
version = "2.5.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
python-versions = ">=3.9"
files = [
{file = "urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc"},
{file = "urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760"},
]
[package.extras]
brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
h2 = ["h2 (>=4,<5)"]
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
zstd = ["zstandard (>=0.18.0)"]
[[package]]
name = "websocket-client"
version = "1.9.0"
description = "WebSocket client for Python with low level API options"
optional = false
python-versions = ">=3.9"
files = [
{file = "websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef"},
{file = "websocket_client-1.9.0.tar.gz", hash = "sha256:9e813624b6eb619999a97dc7958469217c3176312b3a16a4bd1bc7e08a46ec98"},
]
[package.extras]
docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"]
optional = ["python-socks", "wsaccel"]
test = ["pytest", "websockets"]
[[package]]
name = "wsproto"
version = "1.2.0"
description = "WebSockets state-machine based protocol implementation"
optional = false
python-versions = ">=3.7.0"
files = [
{file = "wsproto-1.2.0-py3-none-any.whl", hash = "sha256:b9acddd652b585d75b20477888c56642fdade28bdfd3579aa24a4d2c037dd736"},
{file = "wsproto-1.2.0.tar.gz", hash = "sha256:ad565f26ecb92588a3e43bc3d96164de84cd9902482b130d0ddbaa9664a85065"},
]
[package.dependencies]
h11 = ">=0.9.0,<1"
[metadata]
lock-version = "2.0"
python-versions = ">=3.11,<4.0"
content-hash = "a80df18ad34d1fdfb77fe9a5cdaeeace2a61248f735f55fe3506825aef6c8e0d"

24
pyproject.toml Normal file
View File

@@ -0,0 +1,24 @@
[tool.poetry]
name = "vlchan"
version = "0.1.0"
description = "VLC video player with synchan timecode synchronization"
authors = ["Justin Lin <wancat@wancat.cc>"]
readme = "README.md"
packages = [
{ include = "vlchan" }
]
[tool.poetry.dependencies]
python = ">=3.11,<4.0"
python-vlc = "^3.0.20123"
reactivex = "^4.0.4"
python-socketio = {extras = ["client"], version = "^5.14.1"}
requests = "^2.32.5"
[tool.poetry.group.dev.dependencies]
mypy = "^1.17.1"
ruff = "^0.12.12"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

45
setup.py Normal file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env python3
"""Setup script for VLChan."""
from setuptools import setup, find_packages
with open("README.md", "r", encoding="utf-8") as fh:
long_description = fh.read()
setup(
name="vlchan",
version="0.1.0",
author="Justin Lin",
author_email="wancat@wancat.cc",
description="VLC video player with synchan timecode synchronization",
long_description=long_description,
long_description_content_type="text/markdown",
packages=find_packages(),
classifiers=[
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
],
python_requires=">=3.11",
install_requires=[
"python-vlc>=3.0.20123",
"reactivex>=4.0.4",
"python-socketio[client]>=5.14.1",
"requests>=2.32.5",
],
extras_require={
"dev": [
"mypy>=1.17.1",
"ruff>=0.12.12",
],
},
entry_points={
"console_scripts": [
"vlchan=vlchan.player:main",
],
},
)

112
test_vlchan.py Normal file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""Test script to verify VLChan installation and basic functionality."""
import sys
import time
from vlchan import VLChanPlayer, SynchanController, SynchanState
def test_imports():
"""Test that all modules can be imported."""
print("Testing imports...")
try:
from vlchan import VLChanPlayer, SynchanController, SynchanState
print("✓ All imports successful")
return True
except ImportError as e:
print(f"✗ Import error: {e}")
return False
def test_vlc_availability():
"""Test that VLC is available."""
print("Testing VLC availability...")
try:
import vlc
instance = vlc.Instance()
print("✓ VLC is available")
instance.release()
return True
except Exception as e:
print(f"✗ VLC error: {e}")
print(" Make sure VLC is installed on your system")
return False
def test_synchan_controller():
"""Test synchan controller creation."""
print("Testing synchan controller...")
try:
controller = SynchanController("http://localhost:3000")
print("✓ Synchan controller created")
return True
except Exception as e:
print(f"✗ Synchan controller error: {e}")
return False
def test_player_creation():
"""Test player creation without video file."""
print("Testing player creation...")
try:
player = VLChanPlayer("http://localhost:3000")
print("✓ Player created successfully")
player.cleanup()
return True
except Exception as e:
print(f"✗ Player creation error: {e}")
return False
def test_synchan_state():
"""Test synchan state dataclass."""
print("Testing synchan state...")
try:
state = SynchanState(
playing=True,
currentTime=10.5,
duration=120.0,
loop=False,
latency=0.05
)
print(f"✓ Synchan state created: {state}")
return True
except Exception as e:
print(f"✗ Synchan state error: {e}")
return False
def main():
"""Run all tests."""
print("VLChan Test Suite")
print("=" * 40)
tests = [
test_imports,
test_vlc_availability,
test_synchan_controller,
test_player_creation,
test_synchan_state,
]
passed = 0
total = len(tests)
for test in tests:
if test():
passed += 1
print()
print("=" * 40)
print(f"Tests passed: {passed}/{total}")
if passed == total:
print("✓ All tests passed! VLChan is ready to use.")
return 0
else:
print("✗ Some tests failed. Please check the errors above.")
return 1
if __name__ == "__main__":
sys.exit(main())

6
vlchan/__init__.py Normal file
View File

@@ -0,0 +1,6 @@
"""VLC video player with synchan timecode synchronization."""
from .player import VLChanPlayer
from .synchan import SynchanController, SynchanState
__all__ = ["VLChanPlayer", "SynchanController", "SynchanState"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

207
vlchan/player.py Normal file
View File

@@ -0,0 +1,207 @@
"""VLC video player with synchan timecode synchronization."""
import vlc
import time
import threading
from typing import Optional, Callable
from reactivex import Observable
from .synchan import SynchanState, SynchanController, create_synchan
class VLChanPlayer:
"""VLC video player synchronized with synchan server."""
def __init__(self, synchan_url: str = "http://localhost:3000",
video_path: Optional[str] = None):
"""Initialize the VLC player with synchan synchronization.
Args:
synchan_url: URL of the synchan server
video_path: Path to video file to play
"""
self.synchan_url = synchan_url
self.video_path = video_path
# Initialize VLC
self.instance = vlc.Instance()
self.player = self.instance.media_player_new()
# Initialize synchan
self.synchan_controller = SynchanController(synchan_url)
self.synchan_observable: Optional[Observable[SynchanState]] = None
self.synchan_subscription = None
# State tracking
self._is_playing = False
self.last_sync_time = 0
self.sync_threshold = 0.1 # 100ms threshold for sync
# Callbacks
self.on_state_change: Optional[Callable[[SynchanState], None]] = None
# Load video if provided
if video_path:
self.load_video(video_path)
def load_video(self, video_path: str):
"""Load a video file."""
self.video_path = video_path
media = self.instance.media_new(video_path)
self.player.set_media(media)
print(f"Loaded video: {video_path}")
def start_sync(self):
"""Start synchronization with synchan server."""
if self.synchan_observable is not None:
print("Sync already started")
return
self.synchan_observable = create_synchan(self.synchan_url)
self.synchan_subscription = self.synchan_observable.subscribe(
on_next=self._handle_synchan_state,
on_error=self._handle_synchan_error,
on_completed=self._handle_synchan_completed
)
print("Started synchan synchronization")
def stop_sync(self):
"""Stop synchronization with synchan server."""
if self.synchan_subscription:
self.synchan_subscription.dispose()
self.synchan_subscription = None
self.synchan_observable = None
print("Stopped synchan synchronization")
def _handle_synchan_state(self, state: SynchanState):
"""Handle state updates from synchan server."""
if self.on_state_change:
self.on_state_change(state)
# Sync playback state
if state.playing != self._is_playing:
if state.playing:
self.play()
else:
self.pause()
self._is_playing = state.playing
# Sync time position
current_time = self.player.get_time() / 1000.0 # Convert to seconds
time_diff = abs(current_time - state.currentTime)
if time_diff > self.sync_threshold:
# Seek to match synchan time
seek_time = int(state.currentTime * 1000) # Convert to milliseconds
self.player.set_time(seek_time)
self.last_sync_time = time.time()
print(f"Synced to time: {state.currentTime:.2f}s (diff: {time_diff:.2f}s)")
def _handle_synchan_error(self, error):
"""Handle synchan connection errors."""
print(f"Synchan error: {error}")
# Optionally attempt to reconnect
threading.Timer(5.0, self.start_sync).start()
def _handle_synchan_completed(self):
"""Handle synchan connection completion."""
print("Synchan connection completed")
def play(self):
"""Start video playback."""
if self.player.get_media():
self.player.play()
self._is_playing = True
print("Started playback")
else:
print("No media loaded")
def pause(self):
"""Pause video playback."""
self.player.pause()
self._is_playing = False
print("Paused playback")
def stop(self):
"""Stop video playback."""
self.player.stop()
self._is_playing = False
print("Stopped playback")
def seek(self, time_seconds: float):
"""Seek to a specific time position."""
time_ms = int(time_seconds * 1000)
self.player.set_time(time_ms)
print(f"Seeked to {time_seconds:.2f}s")
def get_position(self) -> float:
"""Get current playback position in seconds."""
return self.player.get_time() / 1000.0
def get_duration(self) -> float:
"""Get video duration in seconds."""
return self.player.get_length() / 1000.0
def is_playing(self) -> bool:
"""Check if video is currently playing."""
return self.player.is_playing()
def set_volume(self, volume: int):
"""Set volume (0-100)."""
self.player.audio_set_volume(int(volume))
def get_volume(self) -> int:
"""Get current volume (0-100)."""
return self.player.audio_get_volume()
def set_fullscreen(self, fullscreen: bool):
"""Set fullscreen mode."""
self.player.set_fullscreen(fullscreen)
def cleanup(self):
"""Clean up resources."""
self.stop_sync()
self.stop()
self.player.release()
self.instance.release()
print("Cleaned up VLC player")
def main():
"""Example usage of VLChanPlayer."""
import sys
if len(sys.argv) < 2:
print("Usage: python -m vlchan.player <video_path> [synchan_url]")
sys.exit(1)
video_path = sys.argv[1]
synchan_url = sys.argv[2] if len(sys.argv) > 2 else "http://localhost:3000"
# Create player
player = VLChanPlayer(synchan_url, video_path)
# Set up state change callback
def on_state_change(state: SynchanState):
print(f"Synchan state: playing={state.playing}, time={state.currentTime:.2f}s")
player.on_state_change = on_state_change
# Start synchronization
player.start_sync()
try:
# Start playback
player.play()
# Keep running until interrupted
while True:
time.sleep(1)
except KeyboardInterrupt:
print("\nShutting down...")
finally:
player.cleanup()
if __name__ == "__main__":
main()

118
vlchan/synchan.py Normal file
View File

@@ -0,0 +1,118 @@
"""Synchan server integration for timecode synchronization."""
from dataclasses import dataclass
import multiprocessing
import reactivex
from reactivex.abc.observer import ObserverBase
from reactivex.observable import Observable
import reactivex.operators as ops
from reactivex.scheduler import ThreadPoolScheduler
import requests
import socketio
import threading
from concurrent.futures import ThreadPoolExecutor
@dataclass
class SynchanState:
"""State information from synchan server."""
playing: bool
currentTime: float
duration: float
loop: bool
latency: float
class SynchanController:
"""Controller for synchan server communication."""
def __init__(self, synchan_url: str = "http://localhost:3000"):
self.synchan_url = synchan_url
self.headers = {"Content-Type": "application/json"}
def seek(self, to: int):
"""Seek to a specific time position."""
print(f"Seeking to {to}")
r = requests.post(
f"{self.synchan_url}/trpc/admin.seek",
headers=self.headers,
data=str(to)
)
print(r.text)
def play(self):
"""Start playback."""
requests.post(
f"{self.synchan_url}/trpc/admin.play",
headers=self.headers
)
def pause(self):
"""Pause playback."""
requests.post(
f"{self.synchan_url}/trpc/admin.pause",
headers=self.headers
)
def create_socket(observer: ObserverBase[SynchanState], scheduler, synchan_url: str):
"""Create socket connection to synchan server."""
sio = socketio.Client()
@sio.event
def connect():
print("Synchan connection established")
@sio.event
def connect_error(data):
print(f"Synchan connection failed: {data}")
observer.on_error(data)
observer.on_completed()
@sio.event
def control(data):
"""Handle control events from synchan server."""
nonce = data["nonce"]
observer.on_next(
SynchanState(
playing=data["playing"],
currentTime=data["currentTime"],
duration=data["duration"],
loop=data["loop"],
latency=data["latency"],
)
)
sio.emit("ping", nonce)
@sio.event
def disconnect():
print("Disconnected from synchan server")
observer.on_completed()
print(f"Connecting to synchan at {synchan_url}")
try:
sio.connect(synchan_url, wait_timeout=5)
sio.wait()
except Exception as e:
print(f"Failed to connect to synchan: {e}")
observer.on_error(e)
observer.on_completed()
def create_synchan(synchan_url: str = "http://localhost:3000") -> Observable[SynchanState]:
"""Create an observable stream of synchan state updates."""
# Create a thread pool for socket operations
thread_count = multiprocessing.cpu_count()
thread_pool_scheduler = ThreadPoolScheduler(thread_count)
return reactivex.create(
lambda observer, scheduler: create_socket(observer, scheduler, synchan_url)
).pipe(
ops.share(),
ops.subscribe_on(thread_pool_scheduler)
)
if __name__ == "__main__":
# Test the synchan connection
create_synchan().subscribe(print)