Code Gen
This commit is contained in:
70
twitch-highlight/.gitignore
vendored
Normal file
70
twitch-highlight/.gitignore
vendored
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
72
twitch-highlight/README.md
Normal file
72
twitch-highlight/README.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# Twitch Highlight
|
||||||
|
|
||||||
|
A Twitch highlight extraction tool for extracting clips and segments from Twitch streams.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
This application requires Twitch API credentials to function. You need to set up a `.env` file in the root of the project with the following environment variables:
|
||||||
|
|
||||||
|
```
|
||||||
|
TWITCH_NICKNAME=your_twitch_username
|
||||||
|
TWITCH_OAUTH_TOKEN=oauth:your_oauth_token
|
||||||
|
TWITCH_CLIENT_ID=your_client_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Getting Twitch Credentials
|
||||||
|
|
||||||
|
You have a few options to get your OAuth token:
|
||||||
|
|
||||||
|
1. Go to [https://twitchtokengenerator.com/](https://twitchtokengenerator.com/)
|
||||||
|
2. Click "Connect with Twitch" to authorize
|
||||||
|
3. Select the scopes you need (e.g., "Chat: Read Chat", "Chat: Send Chat Messages")
|
||||||
|
4. Copy the generated token (it will start with `oauth:`)
|
||||||
|
|
||||||
|
#### 4. Your Twitch Nickname
|
||||||
|
|
||||||
|
This is simply your Twitch username (the one you use to log in). For example, if your Twitch channel is `https://twitch.tv/yourname`, your nickname is `yourname`.
|
||||||
|
|
||||||
|
### Setting Up the .env File
|
||||||
|
|
||||||
|
Create a `.env` file in the project root directory:
|
||||||
|
|
||||||
|
```env
|
||||||
|
TWITCH_NICKNAME=relicjamin1
|
||||||
|
TWITCH_OAUTH_TOKEN=oauth:your_actual_token_here
|
||||||
|
TWITCH_CLIENT_ID=your_actual_client_id_here
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important**: Never commit your `.env` file to version control. The `.env` file is already included in `.gitignore`.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e .
|
||||||
|
```
|
||||||
|
|
||||||
|
For development dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python ./src/main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run tests
|
||||||
|
pytest
|
||||||
|
|
||||||
|
# Format code
|
||||||
|
black .
|
||||||
|
|
||||||
|
# Lint
|
||||||
|
ruff check .
|
||||||
|
|
||||||
|
# Type check
|
||||||
|
mypy src/
|
||||||
|
```
|
||||||
69
twitch-highlight/pyproject.toml
Normal file
69
twitch-highlight/pyproject.toml
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=61.0", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "twitch-highlight"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Twitch highlight extraction tool"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.8"
|
||||||
|
license = {text = "MIT"}
|
||||||
|
authors = [
|
||||||
|
{name = "Collin Campbell", email = "collin@example.com"}
|
||||||
|
]
|
||||||
|
keywords = ["twitch", "highlight", "video"]
|
||||||
|
classifiers = [
|
||||||
|
"Development Status :: 3 - Alpha",
|
||||||
|
"Intended Audience :: Developers",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Programming Language :: Python :: 3",
|
||||||
|
"Programming Language :: Python :: 3.8",
|
||||||
|
"Programming Language :: Python :: 3.9",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
|
]
|
||||||
|
dependencies = ["python-dotenv>=1.0.0", "streamlink>=6.0.0", "textual>=7.5.0"]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
"pytest>=7.0",
|
||||||
|
"pytest-cov>=4.0",
|
||||||
|
"black>=23.0",
|
||||||
|
"ruff>=0.1.0",
|
||||||
|
"mypy>=1.0",
|
||||||
|
"textual-dev>=1.8.0"
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Homepage = "https://github.com/collincampbell/twitch-highlight"
|
||||||
|
Repository = "https://github.com/collincampbell/twitch-highlight"
|
||||||
|
Issues = "https://github.com/collincampbell/twitch-highlight/issues"
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 100
|
||||||
|
target-version = ['py38', 'py39', 'py310', 'py311', 'py312']
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 100
|
||||||
|
select = ["E", "F", "I", "N", "W"]
|
||||||
|
ignore = []
|
||||||
|
|
||||||
|
[tool.ruff.per-file-ignores]
|
||||||
|
"__init__.py" = ["F401"]
|
||||||
|
|
||||||
|
[tool.mypy]
|
||||||
|
python_version = "3.8"
|
||||||
|
warn_return_any = true
|
||||||
|
warn_unused_configs = true
|
||||||
|
disallow_untyped_defs = false
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
testpaths = ["tests"]
|
||||||
|
python_files = ["test_*.py"]
|
||||||
|
python_classes = ["Test*"]
|
||||||
|
python_functions = ["test_*"]
|
||||||
3
twitch-highlight/src/__init__.py
Normal file
3
twitch-highlight/src/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""Twitch Highlight Package."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
13
twitch-highlight/src/main.py
Normal file
13
twitch-highlight/src/main.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""Main module for twitch-highlight."""
|
||||||
|
|
||||||
|
from services.config import load_config
|
||||||
|
from tui.tui import TwitchHighlightTUI
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point."""
|
||||||
|
TwitchHighlightTUI(load_config()).run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
33
twitch-highlight/src/services/config.py
Normal file
33
twitch-highlight/src/services/config.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
env_path = Path(__file__).parent.parent.parent / ".env"
|
||||||
|
load_dotenv(env_path)
|
||||||
|
|
||||||
|
TWITCH_NICKNAME = os.getenv("TWITCH_NICKNAME", "")
|
||||||
|
TWITCH_OAUTH_TOKEN = os.getenv("TWITCH_OAUTH_TOKEN", "")
|
||||||
|
TWITCH_CLIENT_ID = os.getenv("TWITCH_CLIENT_ID", "")
|
||||||
|
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
def __init__(self, nickname: str, oauth_token: str, client_id: str):
|
||||||
|
self.nickname = nickname
|
||||||
|
self.oauth_token = oauth_token
|
||||||
|
self.client_id = client_id
|
||||||
|
|
||||||
|
|
||||||
|
def load_config() -> Config:
|
||||||
|
missing = []
|
||||||
|
if not TWITCH_NICKNAME:
|
||||||
|
missing.append("TWITCH_NICKNAME")
|
||||||
|
if not TWITCH_OAUTH_TOKEN:
|
||||||
|
missing.append("TWITCH_OAUTH_TOKEN")
|
||||||
|
if not TWITCH_CLIENT_ID:
|
||||||
|
missing.append("TWITCH_CLIENT_ID")
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise ValueError(f"Missing required environment variables: {', '.join(missing)}")
|
||||||
|
|
||||||
|
return Config(TWITCH_NICKNAME, TWITCH_OAUTH_TOKEN, TWITCH_CLIENT_ID)
|
||||||
115
twitch-highlight/src/services/twitch_client.py
Normal file
115
twitch-highlight/src/services/twitch_client.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import socket
|
||||||
|
import time
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from collections import deque
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChatRateStats:
|
||||||
|
current_rate: float
|
||||||
|
average_rate: float
|
||||||
|
total_messages: int
|
||||||
|
rate_of_change: float
|
||||||
|
|
||||||
|
|
||||||
|
class TwitchClient:
|
||||||
|
def __init__(self, oauth_token: str, nickname: str, window_seconds: int = 60):
|
||||||
|
self.oauth_token = oauth_token
|
||||||
|
self.nickname = nickname
|
||||||
|
self.window_seconds = window_seconds
|
||||||
|
self.socket: Optional[socket.socket] = None
|
||||||
|
self.message_timestamps: deque[float] = deque()
|
||||||
|
self.start_time: Optional[float] = None
|
||||||
|
self.total_messages = 0
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
self.channel = ""
|
||||||
|
|
||||||
|
def set_channel(self, channel: str):
|
||||||
|
self.channel = channel
|
||||||
|
|
||||||
|
def connect(self) -> None:
|
||||||
|
self.socket = socket.socket()
|
||||||
|
self.socket.connect(("irc.chat.twitch.tv", 6667))
|
||||||
|
self.socket.send(f"PASS {self.oauth_token}\n".encode("utf-8"))
|
||||||
|
self.socket.send(f"NICK {self.nickname}\n".encode("utf-8"))
|
||||||
|
self.socket.send(f"JOIN #{self.channel}\n".encode("utf-8"))
|
||||||
|
self.connected = True
|
||||||
|
self.start_time = time.time()
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
if self.socket:
|
||||||
|
self.socket.send("PART #{}\n".format(self.channel).encode("utf-8"))
|
||||||
|
self.socket.close()
|
||||||
|
self.connected = False
|
||||||
|
|
||||||
|
def _receive_messages(self) -> list[str]:
|
||||||
|
messages = []
|
||||||
|
if self.socket:
|
||||||
|
try:
|
||||||
|
data = self.socket.recv(2048).decode("utf-8")
|
||||||
|
print(data)
|
||||||
|
|
||||||
|
if data.find("PING") != -1:
|
||||||
|
print("sending back pong")
|
||||||
|
self.socket.send("PONG :tmi.twitch.tv\n".encode("utf-8"))
|
||||||
|
|
||||||
|
messages = [line for line in data.split("\r\n") if line.strip()]
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return messages
|
||||||
|
|
||||||
|
def _is_chat_message(self, line: str) -> bool:
|
||||||
|
return "PRIVMSG #{} :".format(self.channel) in line
|
||||||
|
|
||||||
|
def _cleanup_old_timestamps(self) -> None:
|
||||||
|
current_time = time.time()
|
||||||
|
while (
|
||||||
|
self.message_timestamps
|
||||||
|
and current_time - self.message_timestamps[0] > self.window_seconds
|
||||||
|
):
|
||||||
|
self.message_timestamps.popleft()
|
||||||
|
|
||||||
|
def read_messages(self) -> list[str]:
|
||||||
|
lines = self._receive_messages()
|
||||||
|
chat_messages = []
|
||||||
|
current_time = time.time()
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
if self._is_chat_message(line):
|
||||||
|
chat_messages.append(line)
|
||||||
|
self.message_timestamps.append(current_time)
|
||||||
|
self.total_messages += 1
|
||||||
|
|
||||||
|
self._cleanup_old_timestamps()
|
||||||
|
return chat_messages
|
||||||
|
|
||||||
|
def _calculate_current_rate(self) -> float:
|
||||||
|
if not self.message_timestamps:
|
||||||
|
return 0.0
|
||||||
|
return len(self.message_timestamps) / self.window_seconds
|
||||||
|
|
||||||
|
def _calculate_average_rate(self) -> float:
|
||||||
|
if not self.start_time:
|
||||||
|
return 0.0
|
||||||
|
elapsed_time = time.time() - self.start_time
|
||||||
|
if elapsed_time == 0:
|
||||||
|
return 0.0
|
||||||
|
return self.total_messages / elapsed_time
|
||||||
|
|
||||||
|
def get_rate_stats(self, old: ChatRateStats) -> ChatRateStats:
|
||||||
|
self._cleanup_old_timestamps()
|
||||||
|
current_rate = self._calculate_current_rate()
|
||||||
|
average_rate = self._calculate_average_rate()
|
||||||
|
|
||||||
|
roc = 0.0
|
||||||
|
if old.current_rate != 0.0:
|
||||||
|
roc = (current_rate - old.current_rate) / old.current_rate
|
||||||
|
return ChatRateStats(
|
||||||
|
current_rate=current_rate,
|
||||||
|
average_rate=average_rate,
|
||||||
|
total_messages=self.total_messages,
|
||||||
|
rate_of_change=roc,
|
||||||
|
)
|
||||||
119
twitch-highlight/src/services/twitch_video.py
Normal file
119
twitch-highlight/src/services/twitch_video.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import io
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
|
||||||
|
class TwitchVideoClient:
|
||||||
|
def __init__(self, oauth_token: str, client_id: str, buffer_seconds: int = 15):
|
||||||
|
self.oauth_token = oauth_token
|
||||||
|
self.client_id = client_id
|
||||||
|
self.base_url = "https://api.twitch.tv/helix"
|
||||||
|
self.buffer_seconds = buffer_seconds
|
||||||
|
self._buffer: io.BytesIO = io.BytesIO()
|
||||||
|
self._is_recording = False
|
||||||
|
self._process: Optional[subprocess.Popen] = None
|
||||||
|
|
||||||
|
self.channel = ""
|
||||||
|
|
||||||
|
def set_channel(self, channel: str):
|
||||||
|
self.channel = channel
|
||||||
|
|
||||||
|
def _make_request(self, endpoint: str, params: Optional[dict] = None) -> dict:
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
if params:
|
||||||
|
url += f"?{'&'.join(f'{k}={v}' for k, v in params.items())}"
|
||||||
|
|
||||||
|
req = urllib.request.Request(url)
|
||||||
|
req.add_header("Authorization", f"Bearer {self.oauth_token.removeprefix('oauth:')}")
|
||||||
|
req.add_header("Client-Id", self.client_id)
|
||||||
|
|
||||||
|
with urllib.request.urlopen(req) as response:
|
||||||
|
return json.loads(response.read().decode("utf-8"))
|
||||||
|
|
||||||
|
def _get_stream_url(self) -> str:
|
||||||
|
try:
|
||||||
|
import streamlink
|
||||||
|
|
||||||
|
streams = streamlink.streams(f"https://twitch.tv/{self.channel}")
|
||||||
|
if "best" not in streams:
|
||||||
|
raise ValueError(f"No stream available for channel {self.channel}")
|
||||||
|
return streams["best"].url
|
||||||
|
except ImportError:
|
||||||
|
raise ImportError("streamlink is required. Install with: pip install streamlink")
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to get stream URL: {e}")
|
||||||
|
|
||||||
|
def start_recording(self, on_chunk: Optional[Callable[[bytes], None]] = None) -> None:
|
||||||
|
if self._is_recording:
|
||||||
|
raise RuntimeError("Already recording")
|
||||||
|
|
||||||
|
try:
|
||||||
|
stream_url = self._get_stream_url()
|
||||||
|
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-i",
|
||||||
|
stream_url,
|
||||||
|
"-t",
|
||||||
|
str(self.buffer_seconds),
|
||||||
|
"-c",
|
||||||
|
"copy",
|
||||||
|
"-f",
|
||||||
|
"mpegts",
|
||||||
|
"-",
|
||||||
|
]
|
||||||
|
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
ffmpeg_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
self._is_recording = True
|
||||||
|
self._buffer = io.BytesIO()
|
||||||
|
|
||||||
|
def read_stream():
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
chunk = self._process.stdout.read(8192)
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
self._buffer.write(chunk)
|
||||||
|
if on_chunk:
|
||||||
|
on_chunk(chunk)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
import threading
|
||||||
|
|
||||||
|
self._read_thread = threading.Thread(target=read_stream)
|
||||||
|
self._read_thread.daemon = True
|
||||||
|
self._read_thread.start()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Failed to start recording: {e}")
|
||||||
|
|
||||||
|
def stop_recording(self) -> bytes:
|
||||||
|
if not self._is_recording:
|
||||||
|
raise RuntimeError("Not recording")
|
||||||
|
|
||||||
|
if self._process:
|
||||||
|
self._process.terminate()
|
||||||
|
self._process.wait()
|
||||||
|
|
||||||
|
self._is_recording = False
|
||||||
|
self._buffer.seek(0)
|
||||||
|
return self._buffer.getvalue()
|
||||||
|
|
||||||
|
def save_to_file(self, filepath: Path, data: Optional[bytes] = None) -> Path:
|
||||||
|
if data is None:
|
||||||
|
data = self._buffer.getvalue()
|
||||||
|
|
||||||
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
return filepath
|
||||||
|
|
||||||
|
def clear_buffer(self) -> None:
|
||||||
|
self._buffer = io.BytesIO()
|
||||||
114
twitch-highlight/src/tui/tui.py
Normal file
114
twitch-highlight/src/tui/tui.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from services.config import Config
|
||||||
|
from services.twitch_client import TwitchClient
|
||||||
|
from services.twitch_video import TwitchVideoClient
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.widgets import Input, Log, Button
|
||||||
|
import time
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from services.twitch_client import ChatRateStats
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
|
||||||
|
class TwitchHighlightTUI(App):
|
||||||
|
CSS = """
|
||||||
|
.header {
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
#channel-input {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
#connect-btn {
|
||||||
|
margin-left: 1;
|
||||||
|
}
|
||||||
|
#log {
|
||||||
|
height: 1fr;
|
||||||
|
border-top: solid $primary;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.twitch_chat = TwitchClient(config.oauth_token, config.nickname)
|
||||||
|
self.twitch_record = TwitchVideoClient(config.oauth_token, config.client_id)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(classes="main-content"):
|
||||||
|
with Horizontal(classes="header"):
|
||||||
|
yield Input(placeholder="Enter channel name...", id="channel-input")
|
||||||
|
yield Button("Connect", id="connect-btn")
|
||||||
|
yield Button("Disconnect", id="disconnect-btn")
|
||||||
|
yield Log(id="log")
|
||||||
|
|
||||||
|
async def update(self):
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
current_rate = ChatRateStats(
|
||||||
|
current_rate=0.0, average_rate=0.0, total_messages=0, rate_of_change=0.0
|
||||||
|
)
|
||||||
|
while self.twitch_chat.connected:
|
||||||
|
messages = self.twitch_chat.read_messages()
|
||||||
|
current_rate = self.twitch_chat.get_rate_stats(current_rate)
|
||||||
|
|
||||||
|
self.add_log(f"{current_rate}, highlights enabled: {time.time() - start_time > 120}")
|
||||||
|
for m in messages:
|
||||||
|
self.add_log(m)
|
||||||
|
if current_rate.rate_of_change > 0.8 and time.time() - start_time > 120:
|
||||||
|
self.add_log("POG CHAMP HIGHLIGH TIME")
|
||||||
|
self.twitch_record.stop_recording()
|
||||||
|
self.twitch_record.save_to_file(
|
||||||
|
Path(f"clips/{self.twitch_record.channel}-{datetime.datetime.now()}.mp4")
|
||||||
|
)
|
||||||
|
self.twitch_record.clear_buffer()
|
||||||
|
self.twitch_record.start_recording()
|
||||||
|
await asyncio.sleep(15)
|
||||||
|
|
||||||
|
self.add_log("disconnected worker")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "connect-btn":
|
||||||
|
channel_input = self.query_one("#channel-input", Input)
|
||||||
|
channel = channel_input.value.strip()
|
||||||
|
if channel:
|
||||||
|
self.log(f"Connecting to channel: {channel}")
|
||||||
|
self.twitch_chat.set_channel(channel)
|
||||||
|
self.twitch_record.set_channel(channel)
|
||||||
|
self.start_twitch()
|
||||||
|
else:
|
||||||
|
self.log("Please enter a channel name", "warning")
|
||||||
|
elif event.button.id == "disconnect-btn":
|
||||||
|
self.log("Disconnection from channel")
|
||||||
|
self.twitch_chat.set_channel("")
|
||||||
|
self.twitch_chat.set_channel("")
|
||||||
|
self.dissconnect_twitch()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
if event.input.id == "channel-input":
|
||||||
|
channel = event.input.value.strip()
|
||||||
|
if channel:
|
||||||
|
self.log(f"Connecting to channel: {channel}")
|
||||||
|
event.input.value = ""
|
||||||
|
else:
|
||||||
|
self.log("Please enter a channel name", "warning")
|
||||||
|
|
||||||
|
def start_twitch(self):
|
||||||
|
self.add_log("Connecting...")
|
||||||
|
self.twitch_chat.connect()
|
||||||
|
self.twitch_record.start_recording()
|
||||||
|
self.run_worker(self.update)
|
||||||
|
|
||||||
|
def dissconnect_twitch(self):
|
||||||
|
self.add_log("diconnecting, wait until worker reports its done")
|
||||||
|
self.twitch_chat.disconnect()
|
||||||
|
self.twitch_record.stop_recording()
|
||||||
|
|
||||||
|
def add_log(self, message: str, level: str = "info") -> None:
|
||||||
|
log_widget = self.query_one("#log", Log)
|
||||||
|
log_widget.write_line(f"[{level.upper()}] {message}")
|
||||||
6
twitch-highlight/tests/__init__.py
Normal file
6
twitch-highlight/tests/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
"""Tests package."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||||
8
twitch-highlight/tests/test_example.py
Normal file
8
twitch-highlight/tests/test_example.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
"""Example test module."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def test_example():
|
||||||
|
"""Example test function."""
|
||||||
|
assert True
|
||||||
10
twitch-vod/.env.example
Normal file
10
twitch-vod/.env.example
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# Twitch API credentials
|
||||||
|
TWITCH_CLIENT_ID=your-twitch-client-id
|
||||||
|
TWITCH_ACCESS_TOKEN=your-twitch-access-token
|
||||||
|
|
||||||
|
# Postgres configuration
|
||||||
|
POSTGRES_HOST=localhost
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_DB=twitch_vod
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=postgres
|
||||||
146
twitch-vod/.gitignore
vendored
Normal file
146
twitch-vod/.gitignore
vendored
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
wheels/
|
||||||
|
share/python-wheels/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
MANIFEST
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.nox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*.cover
|
||||||
|
*.py,cover
|
||||||
|
.hypothesis/
|
||||||
|
.pytest_cache/
|
||||||
|
cover/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# Django stuff:
|
||||||
|
*.log
|
||||||
|
local_settings.py
|
||||||
|
db.sqlite3
|
||||||
|
db.sqlite3-journal
|
||||||
|
|
||||||
|
# Flask stuff:
|
||||||
|
instance/
|
||||||
|
.webassets-cache
|
||||||
|
|
||||||
|
# Scrapy stuff:
|
||||||
|
.scrapy
|
||||||
|
|
||||||
|
# Sphinx documentation
|
||||||
|
docs/_build/
|
||||||
|
|
||||||
|
# PyBuilder
|
||||||
|
.pybuilder/
|
||||||
|
target/
|
||||||
|
|
||||||
|
# Jupyter Notebook
|
||||||
|
.ipynb_checkpoints
|
||||||
|
|
||||||
|
# IPython
|
||||||
|
profile_default/
|
||||||
|
ipython_config.py
|
||||||
|
|
||||||
|
# pyenv
|
||||||
|
.python-version
|
||||||
|
|
||||||
|
# pipenv
|
||||||
|
Pipfile.lock
|
||||||
|
|
||||||
|
# poetry
|
||||||
|
poetry.lock
|
||||||
|
|
||||||
|
# pdm
|
||||||
|
.pdm.toml
|
||||||
|
|
||||||
|
# PEP 582
|
||||||
|
__pypackages__/
|
||||||
|
|
||||||
|
# Celery stuff
|
||||||
|
celerybeat-schedule
|
||||||
|
celerybeat.pid
|
||||||
|
|
||||||
|
# SageMath parsed files
|
||||||
|
*.sage.py
|
||||||
|
|
||||||
|
# Environments
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
env/
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
env.bak/
|
||||||
|
venv.bak/
|
||||||
|
|
||||||
|
# Spyder project settings
|
||||||
|
.spyderproject
|
||||||
|
.spyproject
|
||||||
|
|
||||||
|
# Rope project settings
|
||||||
|
.ropeproject
|
||||||
|
|
||||||
|
# mkdocs documentation
|
||||||
|
/site
|
||||||
|
|
||||||
|
# mypy
|
||||||
|
.mypy_cache/
|
||||||
|
.dmypy.json
|
||||||
|
dmypy.json
|
||||||
|
|
||||||
|
# Pyre type checker
|
||||||
|
.pyre/
|
||||||
|
|
||||||
|
# pytype static type analyzer
|
||||||
|
.pytype/
|
||||||
|
|
||||||
|
# Cython debug symbols
|
||||||
|
cython_debug/
|
||||||
|
|
||||||
|
# IDEs
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# Project specific
|
||||||
|
vods/
|
||||||
8
twitch-vod/Makefile
Normal file
8
twitch-vod/Makefile
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.PHONY: tui
|
||||||
|
|
||||||
|
tui:
|
||||||
|
python ./src/twitch_vod/tui.py
|
||||||
|
|
||||||
|
.PHONY: run
|
||||||
|
run:
|
||||||
|
fastapi dev ./src/twitch_vod/main.py
|
||||||
0
twitch-vod/README.md
Normal file
0
twitch-vod/README.md
Normal file
47
twitch-vod/docker-compose.yml
Normal file
47
twitch-vod/docker-compose.yml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: twitch-vod-postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
POSTGRES_DB: twitch_vod
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
pgadmin:
|
||||||
|
image: dpage/pgadmin4:latest
|
||||||
|
container_name: twitch-vod-pgadmin
|
||||||
|
environment:
|
||||||
|
PGADMIN_DEFAULT_EMAIL: admin@admin.com
|
||||||
|
PGADMIN_DEFAULT_PASSWORD: admin
|
||||||
|
PGADMIN_LISTEN_PORT: 80
|
||||||
|
ports:
|
||||||
|
- "5050:80"
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
container_name: twitch-vod-minio
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- "9000:9000"
|
||||||
|
- "9001:9001"
|
||||||
|
volumes:
|
||||||
|
- minio_data:/data
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
minio_data:
|
||||||
29
twitch-vod/pyproject.toml
Normal file
29
twitch-vod/pyproject.toml
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
[project]
|
||||||
|
name = "twitch-vod"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = ""
|
||||||
|
authors = [
|
||||||
|
{name = "Collin Campbell",email = "collincampbell97@proton.me"}
|
||||||
|
]
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10,<4"
|
||||||
|
dependencies = [
|
||||||
|
"streamlink (>=8.1.2,<9.0.0)",
|
||||||
|
"twitchapi (>=4.5.0,<5.0.0)",
|
||||||
|
"dotenv (>=0.9.9,<0.10.0)",
|
||||||
|
"loguru (>=0.7.3,<0.8.0)",
|
||||||
|
"pydantic (>=2.12.5,<3.0.0)",
|
||||||
|
"textual (>=7.5.0,<8.0.0)",
|
||||||
|
"textual-dev (>=1.8.0,<2.0.0)",
|
||||||
|
"psycopg2-binary (>=2.9.9,<3.0.0)",
|
||||||
|
"boto3 (>=1.35.0,<2.0.0)",
|
||||||
|
"fastapi[standard] (>=0.128.5,<0.129.0)",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.poetry]
|
||||||
|
packages = [{include = "twitch_vod", from = "src"}]
|
||||||
|
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
0
twitch-vod/src/twitch_vod/__init__.py
Normal file
0
twitch-vod/src/twitch_vod/__init__.py
Normal file
61
twitch-vod/src/twitch_vod/config.py
Normal file
61
twitch-vod/src/twitch_vod/config.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
basedir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
|
||||||
|
class Config(object):
|
||||||
|
TWITCH_CLIENT_ID = os.getenv("TWITCH_CLIENT_ID")
|
||||||
|
TWITCH_SECRET = os.getenv("TWITCH_SECRET")
|
||||||
|
|
||||||
|
POSTGRES_HOST = os.getenv("POSTGRES_HOST")
|
||||||
|
POSTGRES_PORT = os.getenv("POSTGRES_PORT")
|
||||||
|
POSTGRES_DB = os.getenv("POSTGRES_DB")
|
||||||
|
POSTGRES_USER = os.getenv("POSTGRES_USER")
|
||||||
|
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
||||||
|
|
||||||
|
S3_ENDPOINT_URL = os.getenv("S3_ENDPOINT_URL")
|
||||||
|
S3_ACCESS_KEY = os.getenv("S3_ACCESS_KEY")
|
||||||
|
S3_SECRET_KEY = os.getenv("S3_SECRET_KEY")
|
||||||
|
S3_REGION = os.getenv("S3_REGION", "us-east-1")
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
required_vars = [
|
||||||
|
"TWITCH_CLIENT_ID",
|
||||||
|
"TWITCH_SECRET",
|
||||||
|
"POSTGRES_HOST",
|
||||||
|
"POSTGRES_PORT",
|
||||||
|
"POSTGRES_DB",
|
||||||
|
"POSTGRES_USER",
|
||||||
|
"POSTGRES_PASSWORD",
|
||||||
|
"S3_ENDPOINT_URL",
|
||||||
|
"S3_ACCESS_KEY",
|
||||||
|
"S3_SECRET_KEY",
|
||||||
|
]
|
||||||
|
missing = [var for var in required_vars if not os.getenv(var)]
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Missing required environment variables: {', '.join(missing)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DevelopmentConfig(Config):
|
||||||
|
DEBUG = True
|
||||||
|
LOG_BACKTRACE = True
|
||||||
|
LOG_LEVEL = "DEBUG"
|
||||||
|
|
||||||
|
|
||||||
|
class ProductionConfig(Config):
|
||||||
|
LOG_BACKTRACE = False
|
||||||
|
LOG_LEVEL = "INFO"
|
||||||
|
|
||||||
|
|
||||||
|
config_types = {
|
||||||
|
"development": DevelopmentConfig,
|
||||||
|
"production": ProductionConfig,
|
||||||
|
"default": DevelopmentConfig,
|
||||||
|
}
|
||||||
37
twitch-vod/src/twitch_vod/main.py
Normal file
37
twitch-vod/src/twitch_vod/main.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
# need to import the models to allow SQLModel to create the DB tables for us
|
||||||
|
from twitch_vod.services.db.models import * # noqa # pylint: disable=unused-import
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
|
from twitch_vod.services.db.postgres import postgres_engine
|
||||||
|
from twitch_vod.routers import channel
|
||||||
|
from twitch_vod.services.twitch.controller import TwitchController
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("application starting")
|
||||||
|
|
||||||
|
engine = postgres_engine()
|
||||||
|
twitch_controller = await TwitchController().create()
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
|
||||||
|
yield
|
||||||
|
|
||||||
|
logger.info("application is shutting down")
|
||||||
|
engine.dispose()
|
||||||
|
twitch_controller.cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(lifespan=lifespan)
|
||||||
|
app.include_router(channel.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
async def root():
|
||||||
|
return {"healthy"}
|
||||||
0
twitch-vod/src/twitch_vod/routers/__init__.py
Normal file
0
twitch-vod/src/twitch_vod/routers/__init__.py
Normal file
48
twitch-vod/src/twitch_vod/routers/channel.py
Normal file
48
twitch-vod/src/twitch_vod/routers/channel.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
|
||||||
|
from twitch_vod.services.db.repository.channel import (
|
||||||
|
create_channel,
|
||||||
|
delete_channel,
|
||||||
|
get_all_channels,
|
||||||
|
)
|
||||||
|
from twitch_vod.services.twitch.controller import TwitchController
|
||||||
|
from twitch_vod.services.twitch.models.errors import ChannelDoesNotExist
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/channels")
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{name}", status_code=201)
|
||||||
|
async def handle_create_channel(name: str):
|
||||||
|
try:
|
||||||
|
await TwitchController().add_client(name)
|
||||||
|
create_channel(name)
|
||||||
|
except ChannelDoesNotExist:
|
||||||
|
logger.info(f"channel does not exist: {name}")
|
||||||
|
raise HTTPException(status_code=404, detail="channel does not exist")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"error creating the channel {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="failed to create the channel")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{name}", status_code=200)
|
||||||
|
def handle_delete_channel(name: str):
|
||||||
|
try:
|
||||||
|
delete_channel(name)
|
||||||
|
TwitchController().remove_client(name)
|
||||||
|
except ChannelDoesNotExist:
|
||||||
|
raise HTTPException(status_code=404, detail="channel was not found to delete")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"failed to delete the channel: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="failed to delete the channel")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", status_code=200)
|
||||||
|
def handle_get_all_channels():
|
||||||
|
try:
|
||||||
|
channels = get_all_channels()
|
||||||
|
return {"channels": channels}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"failed to get all channels {e}")
|
||||||
|
raise HTTPException(status_code=500, detail="failed to get all channels")
|
||||||
0
twitch-vod/src/twitch_vod/services/__init__.py
Normal file
0
twitch-vod/src/twitch_vod/services/__init__.py
Normal file
33
twitch-vod/src/twitch_vod/services/bucket/bucket.py
Normal file
33
twitch-vod/src/twitch_vod/services/bucket/bucket.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
from boto3 import client
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from twitch_vod.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
class S3Bucket:
|
||||||
|
def __init__(self):
|
||||||
|
self._client = None
|
||||||
|
self.config = Config()
|
||||||
|
|
||||||
|
def _get_client(self):
|
||||||
|
if self._client is None:
|
||||||
|
self._client = client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=self.config.S3_ENDPOINT_URL,
|
||||||
|
aws_access_key_id=self.config.S3_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=self.config.S3_SECRET_KEY,
|
||||||
|
region_name=self.config.S3_REGION,
|
||||||
|
)
|
||||||
|
return self._client
|
||||||
|
|
||||||
|
def store_file(self, key: str, file_path: str, bucket_name: str):
|
||||||
|
try:
|
||||||
|
self._get_client().upload_file(file_path, bucket_name, key)
|
||||||
|
except ClientError as e:
|
||||||
|
raise RuntimeError(f"Failed to upload file: {e}")
|
||||||
|
|
||||||
|
def get_file(self, key: str, file_path: str, bucket_name: str):
|
||||||
|
try:
|
||||||
|
self._get_client().download_file(bucket_name, key, file_path)
|
||||||
|
except ClientError as e:
|
||||||
|
raise RuntimeError(f"Failed to download file: {e}")
|
||||||
0
twitch-vod/src/twitch_vod/services/db/__init__.py
Normal file
0
twitch-vod/src/twitch_vod/services/db/__init__.py
Normal file
5
twitch-vod/src/twitch_vod/services/db/models/channel.py
Normal file
5
twitch-vod/src/twitch_vod/services/db/models/channel.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from sqlmodel import Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class Channel(SQLModel, table=True):
|
||||||
|
channel_name: str = Field(primary_key=True)
|
||||||
8
twitch-vod/src/twitch_vod/services/db/models/vod.py
Normal file
8
twitch-vod/src/twitch_vod/services/db/models/vod.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from uuid import uuid4
|
||||||
|
from sqlmodel import UUID, Field, SQLModel
|
||||||
|
|
||||||
|
|
||||||
|
class VOD(SQLModel, table=True):
|
||||||
|
uuid: UUID = Field(default=uuid4(), primary_key=True)
|
||||||
|
vod_bucket_key: str
|
||||||
|
vod_bucket_message_key: str
|
||||||
23
twitch-vod/src/twitch_vod/services/db/postgres.py
Normal file
23
twitch-vod/src/twitch_vod/services/db/postgres.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlmodel import create_engine
|
||||||
|
|
||||||
|
from twitch_vod.config import Config
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
config = Config()
|
||||||
|
|
||||||
|
|
||||||
|
def postgres_engine():
|
||||||
|
try:
|
||||||
|
connection_string = (
|
||||||
|
f"postgresql://{config.POSTGRES_USER}:{config.POSTGRES_PASSWORD}"
|
||||||
|
f"@{config.POSTGRES_HOST}:{config.POSTGRES_PORT}/{config.POSTGRES_DB}"
|
||||||
|
)
|
||||||
|
engine = create_engine(connection_string)
|
||||||
|
logger.info(f"successfully created the postgres engine: {engine}")
|
||||||
|
return engine
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"failed to create the postgres engine: {e}")
|
||||||
|
raise RuntimeError(e)
|
||||||
45
twitch-vod/src/twitch_vod/services/db/repository/channel.py
Normal file
45
twitch-vod/src/twitch_vod/services/db/repository/channel.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from twitch_vod.services.db.models.channel import Channel
|
||||||
|
from twitch_vod.services.db.postgres import postgres_engine
|
||||||
|
from twitch_vod.services.twitch.models.errors import ChannelDoesNotExist
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def create_channel(channel_name):
|
||||||
|
channel = Channel(channel_name=channel_name)
|
||||||
|
|
||||||
|
session = Session(postgres_engine())
|
||||||
|
|
||||||
|
session.add(channel)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(channel)
|
||||||
|
|
||||||
|
return channel
|
||||||
|
|
||||||
|
|
||||||
|
def delete_channel(channel_name: str):
|
||||||
|
session = Session(postgres_engine())
|
||||||
|
|
||||||
|
statement = select(Channel).where(Channel.channel_name == channel_name)
|
||||||
|
result = session.exec(statement)
|
||||||
|
|
||||||
|
try:
|
||||||
|
channel = result.one()
|
||||||
|
session.delete(channel)
|
||||||
|
session.commit()
|
||||||
|
except Exception as e:
|
||||||
|
logger.info("failed to find channel to delete")
|
||||||
|
ChannelDoesNotExist(e)
|
||||||
|
|
||||||
|
|
||||||
|
def get_all_channels():
|
||||||
|
session = Session(postgres_engine())
|
||||||
|
|
||||||
|
statement = select(Channel)
|
||||||
|
result = session.exec(statement)
|
||||||
|
channels = result.all()
|
||||||
|
|
||||||
|
return channels
|
||||||
27
twitch-vod/src/twitch_vod/services/db/repository/vod.py
Normal file
27
twitch-vod/src/twitch_vod/services/db/repository/vod.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
from twitch_vod.services.db.models.vod import VOD
|
||||||
|
from twitch_vod.services.db.postgres import postgres_engine
|
||||||
|
|
||||||
|
|
||||||
|
def create_vod(bucket_video_key: str, bucket_msg_key: str):
|
||||||
|
vod = VOD(vod_bucket_key=bucket_video_key, vod_bucket_message_key=bucket_msg_key)
|
||||||
|
|
||||||
|
session = Session(postgres_engine())
|
||||||
|
|
||||||
|
session.add(vod)
|
||||||
|
session.commit()
|
||||||
|
session.refresh(vod)
|
||||||
|
|
||||||
|
return vod
|
||||||
|
|
||||||
|
|
||||||
|
def delete_vod(uuid: UUID):
|
||||||
|
session = Session(postgres_engine())
|
||||||
|
|
||||||
|
statement = select(VOD).where(VOD.uuid == uuid)
|
||||||
|
result = session.exec(statement)
|
||||||
|
vod = result.one()
|
||||||
|
|
||||||
|
session.delete(vod)
|
||||||
|
session.commit()
|
||||||
78
twitch-vod/src/twitch_vod/services/twitch/controller.py
Normal file
78
twitch-vod/src/twitch_vod/services/twitch/controller.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
from typing import Dict
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
from twitchAPI.twitch import Twitch
|
||||||
|
|
||||||
|
from twitch_vod.config import Config
|
||||||
|
from twitch_vod.services.twitch.models.errors import ChannelDoesNotExist
|
||||||
|
|
||||||
|
from .twitch import _TwitchClient
|
||||||
|
|
||||||
|
|
||||||
|
class TwitchController:
|
||||||
|
_instance = None
|
||||||
|
_lock = __import__("threading").Lock()
|
||||||
|
|
||||||
|
def __new__(cls):
|
||||||
|
if cls._instance is None:
|
||||||
|
with cls._lock:
|
||||||
|
if cls._instance is None:
|
||||||
|
cls._instance = super().__new__(cls)
|
||||||
|
return cls._instance
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
if not hasattr(self, "_initialized"):
|
||||||
|
self._clients: Dict[str, _TwitchClient] = {}
|
||||||
|
self._config = Config()
|
||||||
|
self._initialized = True
|
||||||
|
self._twitch_api_client = None
|
||||||
|
|
||||||
|
async def create(self):
|
||||||
|
self._twitch_api_client = await Twitch(
|
||||||
|
Config().TWITCH_CLIENT_ID, # type: ignore
|
||||||
|
Config().TWITCH_SECRET, # type: ignore
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def add_client(self, channel_name: str):
|
||||||
|
if self._twitch_api_client is None:
|
||||||
|
raise RuntimeError(
|
||||||
|
"need to use the `create` method when creating the TwitchController"
|
||||||
|
)
|
||||||
|
twitch_client = _TwitchClient(channel_name, self._twitch_api_client)
|
||||||
|
|
||||||
|
if not await self.real_channel(channel_name):
|
||||||
|
raise ChannelDoesNotExist("twitch channel not found")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await twitch_client.connect()
|
||||||
|
self._clients[channel_name] = twitch_client
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"failed to add client {twitch_client}")
|
||||||
|
|
||||||
|
async def real_channel(self, channel_name: str) -> bool:
|
||||||
|
try:
|
||||||
|
users = await self._twitch_api_client.get_users(logins=[channel_name]) # type: ignore
|
||||||
|
return len(users) > 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def remove_client(self, channel_name: str):
|
||||||
|
twitch_client = self._clients.get(channel_name)
|
||||||
|
|
||||||
|
if twitch_client is None:
|
||||||
|
logger.warning(
|
||||||
|
f"tried to removed twitch client with channel name {channel_name}, however, it does not exist"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
twitch_client.disconnect()
|
||||||
|
except Exception:
|
||||||
|
logger.error(f"failed to discconect client {twitch_client}")
|
||||||
|
|
||||||
|
del self._clients[channel_name]
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
for _, twitch_client in self._clients.items():
|
||||||
|
twitch_client.disconnect()
|
||||||
16
twitch-vod/src/twitch_vod/services/twitch/models/errors.py
Normal file
16
twitch-vod/src/twitch_vod/services/twitch/models/errors.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
class IRCError(Exception):
|
||||||
|
"""Error when communication with an IRC fails"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UnexpectedError(Exception):
|
||||||
|
"""Catch all errors for errors we did not expect"""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ChannelDoesNotExist(Exception):
|
||||||
|
"""Channel does not exist"""
|
||||||
|
|
||||||
|
pass
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
class Message(BaseModel):
|
||||||
|
username: str
|
||||||
|
message: str
|
||||||
|
timestamp: datetime
|
||||||
223
twitch-vod/src/twitch_vod/services/twitch/twitch.py
Normal file
223
twitch-vod/src/twitch_vod/services/twitch/twitch.py
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from twitchAPI.twitch import Twitch
|
||||||
|
from twitchAPI.oauth import UserAuthenticator
|
||||||
|
from twitchAPI.chat import Chat, ChatEvent, ChatMessage, EventData
|
||||||
|
|
||||||
|
from twitchAPI.type import AuthScope
|
||||||
|
|
||||||
|
from .models.errors import IRCError, UnexpectedError
|
||||||
|
|
||||||
|
|
||||||
|
class _TwitchClient:
|
||||||
|
async def __init__(self, channel: str, twitch_api: Twitch) -> None:
|
||||||
|
self._twitch_api = twitch_api
|
||||||
|
self._chat: Optional[Chat] = None
|
||||||
|
self._socket: Optional[socket.socket] = None
|
||||||
|
self._message_timestamps: deque[float] = deque()
|
||||||
|
self._start_time = datetime.now()
|
||||||
|
self._total_messages = 0
|
||||||
|
self._connected = False
|
||||||
|
self._channel = channel
|
||||||
|
|
||||||
|
self._failed_socket_attempts = 0
|
||||||
|
|
||||||
|
self._recording_thread: Optional[threading.Thread] = None
|
||||||
|
self._is_recording = False
|
||||||
|
self._should_stop_recording = False
|
||||||
|
self._current_vod_path: Optional[str] = None
|
||||||
|
self._recording_process: Optional[subprocess.Popen] = None
|
||||||
|
self._streamlink_process: Optional[subprocess.Popen] = None
|
||||||
|
|
||||||
|
async def _get_user_access_token(self):
|
||||||
|
auth = UserAuthenticator(self._twitch_api, [AuthScope.CHAT_READ])
|
||||||
|
token, refresh_token = await auth.authenticate() # type: ignore
|
||||||
|
await self._twitch_api.set_user_authentication(
|
||||||
|
token, [AuthScope.CHAT_READ], refresh_token
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_connection_status(self):
|
||||||
|
return self._connected
|
||||||
|
|
||||||
|
def get_channel(self):
|
||||||
|
return self._channel
|
||||||
|
|
||||||
|
def get_total_messages(self):
|
||||||
|
return self._total_messages
|
||||||
|
|
||||||
|
def get_start_time(self):
|
||||||
|
return self._start_time
|
||||||
|
|
||||||
|
def start_recording_stream(self):
|
||||||
|
"""Start recording the stream from twitch"""
|
||||||
|
if self._is_recording:
|
||||||
|
raise RuntimeError("Recording is already in progress")
|
||||||
|
|
||||||
|
self._is_recording = True
|
||||||
|
self._should_stop_recording = False
|
||||||
|
|
||||||
|
self._recording_thread = threading.Thread(
|
||||||
|
target=self._record_stream_thread, daemon=True
|
||||||
|
)
|
||||||
|
self._recording_thread.start()
|
||||||
|
|
||||||
|
logger.info(f"Started recording stream for channel {self._channel}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""High level connection function"""
|
||||||
|
logger.info(f"starting connection to Twitch channel {self._channel}")
|
||||||
|
try:
|
||||||
|
await self.irc_connect()
|
||||||
|
self.start_recording_stream()
|
||||||
|
logger.info(
|
||||||
|
f"successfully disconnected from twitch channel {self._channel}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"failed to connect to twitch channel {self._channel}")
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def disconnect(self):
|
||||||
|
"""High level disconnect function"""
|
||||||
|
logger.info(f"starting disconnecting from twitch channel {self._channel}")
|
||||||
|
try:
|
||||||
|
self.irc_disconnect()
|
||||||
|
self.stop_recording_stream()
|
||||||
|
logger.info(
|
||||||
|
f"successfully disconnected from twitch channel {self._channel}"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"failed to disconnect from twitch channel {self._channel}")
|
||||||
|
raise Exception(e)
|
||||||
|
|
||||||
|
def stop_recording_stream(self):
|
||||||
|
"""Stops recording the stream from twitch"""
|
||||||
|
if not self._is_recording:
|
||||||
|
raise RuntimeError("No recording is in progress")
|
||||||
|
|
||||||
|
self._should_stop_recording = True
|
||||||
|
|
||||||
|
if self._streamlink_process:
|
||||||
|
logger.info("stopping stream link")
|
||||||
|
self._streamlink_process.kill()
|
||||||
|
self._streamlink_process = None
|
||||||
|
logger.info("success stopping stream link")
|
||||||
|
|
||||||
|
if self._recording_process:
|
||||||
|
logger.info("stopping ffmpeg")
|
||||||
|
self._recording_process.kill()
|
||||||
|
self._recording_process = None
|
||||||
|
logger.info("success stopping ffmpeg")
|
||||||
|
|
||||||
|
if self._recording_thread:
|
||||||
|
logger.info("waiting for recording thread to join back in")
|
||||||
|
self._recording_thread.join()
|
||||||
|
logger.info("threaded joined")
|
||||||
|
self._recording_thread = None
|
||||||
|
|
||||||
|
self._is_recording = False
|
||||||
|
self._recording_thread = None
|
||||||
|
|
||||||
|
logger.info(f"Stopped recording stream for channel {self._channel}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _path(self):
|
||||||
|
return Path(f"vods/{self._channel}_{self._start_time}/")
|
||||||
|
|
||||||
|
def _vod_path(self):
|
||||||
|
vods_dir = self._path()
|
||||||
|
vods_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
vod_filename = f"{self._channel}_{self._start_time}.mkv"
|
||||||
|
return vods_dir / vod_filename
|
||||||
|
|
||||||
|
def _message_path(self):
|
||||||
|
msg_dir = self._path()
|
||||||
|
msg_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
return msg_dir / "messages.txt"
|
||||||
|
|
||||||
|
def _record_stream_thread(self):
|
||||||
|
twitch_url = f"https://twitch.tv/{self._channel}"
|
||||||
|
self._current_vod_path = str(self._vod_path())
|
||||||
|
|
||||||
|
logger.info(f"Recording to file: {self._current_vod_path}")
|
||||||
|
|
||||||
|
streamlink_cmd = [
|
||||||
|
"streamlink",
|
||||||
|
twitch_url,
|
||||||
|
"best",
|
||||||
|
"--stdout",
|
||||||
|
"--twitch-supported-codecs",
|
||||||
|
"av1,h265",
|
||||||
|
]
|
||||||
|
|
||||||
|
ffmpeg_cmd = [
|
||||||
|
"ffmpeg",
|
||||||
|
"-i",
|
||||||
|
"pipe:0",
|
||||||
|
"-vf",
|
||||||
|
"fps=24",
|
||||||
|
"-c:v",
|
||||||
|
"av1_nvenc",
|
||||||
|
"-preset",
|
||||||
|
"fast",
|
||||||
|
"-crf",
|
||||||
|
"23",
|
||||||
|
"-y",
|
||||||
|
self._current_vod_path,
|
||||||
|
]
|
||||||
|
|
||||||
|
self._streamlink_process = subprocess.Popen(
|
||||||
|
streamlink_cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
self._recording_process = subprocess.Popen(
|
||||||
|
ffmpeg_cmd, stdin=self._streamlink_process.stdout, stderr=subprocess.PIPE
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._streamlink_process.stdout:
|
||||||
|
self._streamlink_process.stdout.close()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if self._should_stop_recording:
|
||||||
|
logger.info("Stop signal received, terminating recording")
|
||||||
|
break
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
async def irc_connect(self):
|
||||||
|
self._chat = await Chat(self._twitch_api)
|
||||||
|
self._chat.register_event(ChatEvent.READY, self._chat_on_ready)
|
||||||
|
self._chat.register_event(ChatEvent.MESSAGE, self._chat_on_message)
|
||||||
|
self._chat.start()
|
||||||
|
|
||||||
|
async def _chat_on_ready(self, ready_event: EventData):
|
||||||
|
await ready_event.chat.join_room(self._channel)
|
||||||
|
|
||||||
|
async def _chat_on_message(self, msg: ChatMessage):
|
||||||
|
self._write_messages(f"{msg.user.name}: {msg.text}: {msg.sent_timestamp}")
|
||||||
|
|
||||||
|
def irc_disconnect(self):
|
||||||
|
if self._chat is None:
|
||||||
|
return
|
||||||
|
self._chat.stop()
|
||||||
|
|
||||||
|
def _write_messages(self, message: str):
|
||||||
|
"""Takes a list of twitch irc messages"""
|
||||||
|
msg_path = self._message_path()
|
||||||
|
msg_path.touch(exist_ok=True)
|
||||||
|
|
||||||
|
with open(msg_path, "a") as f:
|
||||||
|
f.write(f"{message}\n")
|
||||||
109
twitch-vod/src/twitch_vod/tui.py
Normal file
109
twitch-vod/src/twitch_vod/tui.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from config import config_types, Config
|
||||||
|
from textual.app import App, ComposeResult
|
||||||
|
from textual.containers import Horizontal, Vertical
|
||||||
|
from textual.widgets import Input, Log, Button
|
||||||
|
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
# this directly accesses the underlying Twitch client, since this
|
||||||
|
# is only intended to manually test the client
|
||||||
|
from services.twitch.twitch import _TwitchClient as TwitchClient
|
||||||
|
|
||||||
|
|
||||||
|
class TwitchTUI(App):
|
||||||
|
CSS = """
|
||||||
|
.header {
|
||||||
|
height: 3;
|
||||||
|
dock: top;
|
||||||
|
}
|
||||||
|
.main-content {
|
||||||
|
height: 1fr;
|
||||||
|
}
|
||||||
|
#channel-input {
|
||||||
|
width: 1fr;
|
||||||
|
}
|
||||||
|
#connect-btn {
|
||||||
|
margin-left: 1;
|
||||||
|
}
|
||||||
|
#log {
|
||||||
|
height: 1fr;
|
||||||
|
border-top: solid $primary;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.twitch_service = TwitchClient(
|
||||||
|
config.TWITCH_ACCESS_TOKEN, config.TWITCH_USERNAME, ""
|
||||||
|
)
|
||||||
|
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with Vertical(classes="main-content"):
|
||||||
|
with Horizontal(classes="header"):
|
||||||
|
yield Input(placeholder="Enter channel name...", id="channel-input")
|
||||||
|
yield Button("Connect", id="connect-btn")
|
||||||
|
yield Button("Disconnect", id="disconnect-btn")
|
||||||
|
yield Log(id="log")
|
||||||
|
|
||||||
|
async def update(self):
|
||||||
|
while self.twitch_service.get_connection_status():
|
||||||
|
messages = self.twitch_service.read_messages()
|
||||||
|
for m in messages:
|
||||||
|
self.add_log(m)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
self.add_log("worker completed")
|
||||||
|
|
||||||
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
|
if event.button.id == "connect-btn":
|
||||||
|
channel_input = self.query_one("#channel-input", Input)
|
||||||
|
channel = channel_input.value.strip()
|
||||||
|
if channel:
|
||||||
|
self.log(f"Connecting to channel: {channel}")
|
||||||
|
self.start(channel)
|
||||||
|
else:
|
||||||
|
self.log("Please enter a channel name", "warning")
|
||||||
|
elif event.button.id == "disconnect-btn":
|
||||||
|
self.log("Disconnection from channel")
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def on_input_submitted(self, event: Input.Submitted) -> None:
|
||||||
|
if event.input.id == "channel-input":
|
||||||
|
channel = event.input.value.strip()
|
||||||
|
if channel:
|
||||||
|
self.log(f"Connecting to channel: {channel}")
|
||||||
|
event.input.value = ""
|
||||||
|
else:
|
||||||
|
self.log("Please enter a channel name", "warning")
|
||||||
|
|
||||||
|
def start(self, channel: str):
|
||||||
|
self.add_log("Connecting...")
|
||||||
|
try:
|
||||||
|
self.twitch_service._channel = channel
|
||||||
|
self.add_log("starting irc connection")
|
||||||
|
self.twitch_service.irc_connect()
|
||||||
|
self.add_log("successfully connected")
|
||||||
|
|
||||||
|
self.add_log("starting stream recording")
|
||||||
|
self.twitch_service.start_recording_stream()
|
||||||
|
self.add_log("successfully connected")
|
||||||
|
|
||||||
|
self.run_worker(self.update)
|
||||||
|
except Exception as e:
|
||||||
|
self.add_log(f"failed to connect: {e}")
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
try:
|
||||||
|
self.twitch_service.irc_disconnect()
|
||||||
|
self.twitch_service.stop_recording_stream()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def add_log(self, message: str, level: str = "info") -> None:
|
||||||
|
log_widget = self.query_one("#log", Log)
|
||||||
|
log_widget.write_line(f"[{level.upper()}] {message}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
TwitchTUI(config_types["development"]).run()
|
||||||
0
twitch-vod/tests/__init__.py
Normal file
0
twitch-vod/tests/__init__.py
Normal file
Reference in New Issue
Block a user