chore: initial commit

This commit is contained in:
2026-07-17 16:57:55 +02:00
commit b66b22923d
21 changed files with 2077 additions and 0 deletions

0
tests/__init__.py Normal file
View File

104
tests/test_downloader.py Normal file
View File

@@ -0,0 +1,104 @@
import pytest
from unittest.mock import Mock, patch
from src.downloader import download_space, DownloadError
def test_download_space_basic(tmp_path):
"""Test basic download functionality."""
space_url = "https://x.com/i/spaces/1ZkKzZWnVRRKv"
with patch('src.downloader.yt_dlp.YoutubeDL') as mock_ytdl_class:
# Mock the YoutubeDL instance
mock_ytdl = Mock()
mock_ytdl_class.return_value.__enter__.return_value = mock_ytdl
mock_ytdl.extract_info.return_value = {'id': '1ZkKzZWnVRRKv'}
# Call download_space
result = download_space(
space_url=space_url,
output_dir=str(tmp_path),
cookies_file=None
)
# Verify YoutubeDL was called
mock_ytdl.extract_info.assert_called_once_with(space_url, download=True)
# Verify result contains expected filename
assert "space_1ZkKzZWnVRRKv" in result
def test_download_space_with_cookies(tmp_path):
"""Test that cookies file is passed to yt-dlp when provided."""
space_url = "https://x.com/i/spaces/1ZkKzZWnVRRKv"
cookies_path = "/path/to/cookies.txt"
with patch('src.downloader.yt_dlp.YoutubeDL') as mock_ytdl_class:
# Mock the YoutubeDL instance
mock_ytdl = Mock()
mock_ytdl_class.return_value.__enter__.return_value = mock_ytdl
mock_ytdl.extract_info.return_value = {'id': '1ZkKzZWnVRRKv'}
# Call download_space with cookies
result = download_space(
space_url=space_url,
output_dir=str(tmp_path),
cookies_file=cookies_path
)
# Verify YoutubeDL was initialized with correct options
call_args = mock_ytdl_class.call_args[0][0]
assert 'cookiefile' in call_args
assert call_args['cookiefile'] == cookies_path
def test_download_space_ytdlp_configuration(tmp_path):
"""Test that yt-dlp is configured correctly with format, outtmpl, and postprocessors."""
space_url = "https://x.com/i/spaces/1ZkKzZWnVRRKv"
with patch('src.downloader.yt_dlp.YoutubeDL') as mock_ytdl_class:
# Mock the YoutubeDL instance
mock_ytdl = Mock()
mock_ytdl_class.return_value.__enter__.return_value = mock_ytdl
mock_ytdl.extract_info.return_value = {'id': '1ZkKzZWnVRRKv'}
# Call download_space
result = download_space(
space_url=space_url,
output_dir=str(tmp_path),
cookies_file=None
)
# Verify YoutubeDL was initialized with correct options
call_args = mock_ytdl_class.call_args[0][0]
# Check format
assert call_args['format'] == 'bestaudio/best'
# Check outtmpl contains the space_id
assert 'outtmpl' in call_args
assert 'space_1ZkKzZWnVRRKv' in call_args['outtmpl']
# Check postprocessors for MP3 conversion
assert 'postprocessors' in call_args
assert len(call_args['postprocessors']) > 0
assert call_args['postprocessors'][0]['key'] == 'FFmpegExtractAudio'
assert call_args['postprocessors'][0]['preferredcodec'] == 'mp3'
def test_download_space_error_handling(tmp_path):
"""Test that download errors are properly caught and wrapped."""
space_url = "https://x.com/i/spaces/invalid"
with patch('src.downloader.yt_dlp.YoutubeDL') as mock_ytdl_class:
mock_ytdl = Mock()
mock_ytdl_class.return_value.__enter__.return_value = mock_ytdl
mock_ytdl.extract_info.side_effect = Exception("Video unavailable")
with pytest.raises(DownloadError) as exc_info:
download_space(
space_url=space_url,
output_dir=str(tmp_path),
cookies_file=None
)
assert "Failed to download" in str(exc_info.value)

33
tests/test_integration.py Normal file
View File

@@ -0,0 +1,33 @@
import pytest
from click.testing import CliRunner
from src.main import cli
@pytest.mark.integration
def test_full_workflow_with_mocks(tmp_path):
"""Integration test for complete workflow."""
runner = CliRunner()
from unittest.mock import patch
with patch('src.downloader.yt_dlp.YoutubeDL') as mock_ytdl_class:
# Mock successful download
mock_ytdl = mock_ytdl_class.return_value.__enter__.return_value
mock_ytdl.extract_info.return_value = {'id': '1ZkKzZWnVRRKv'}
# Run CLI
result = runner.invoke(cli, [
'https://x.com/i/spaces/1ZkKzZWnVRRKv',
'--output', str(tmp_path),
'--mode', 'audio',
])
# Verify success
assert result.exit_code == 0
assert "Successfully downloaded" in result.output
# Verify yt-dlp was called correctly
mock_ytdl.extract_info.assert_called_once()
call_args = mock_ytdl_class.call_args
assert 'bestaudio' in call_args[0][0]['format']
assert 'mp3' in call_args[0][0]['postprocessors'][0]['preferredcodec']

100
tests/test_main.py Normal file
View File

@@ -0,0 +1,100 @@
import pytest
from click.testing import CliRunner
from unittest.mock import patch, Mock
from src.main import cli
from src.downloader import DownloadError
def test_cli_valid_url(tmp_path):
"""Test CLI with valid URL."""
runner = CliRunner()
with patch('src.main.validate_space_url') as mock_validate, \
patch('src.main.download_space') as mock_download:
mock_validate.return_value = (True, "1ZkKzZWnVRRKv", None)
mock_download.return_value = str(tmp_path / "space_1ZkKzZWnVRRKv.mp3")
result = runner.invoke(cli, ['https://x.com/i/spaces/1ZkKzZWnVRRKv', '--mode', 'audio'])
assert result.exit_code == 0
assert "Successfully downloaded" in result.output
assert "space_1ZkKzZWnVRRKv.mp3" in result.output
def test_cli_invalid_url():
"""Test CLI with invalid URL."""
runner = CliRunner()
result = runner.invoke(cli, ['https://invalid.com/spaces/123'])
assert result.exit_code == 1
assert "Invalid URL format" in result.output
def test_cli_download_failure():
"""Test CLI when download fails."""
runner = CliRunner()
with patch('src.main.validate_space_url') as mock_validate, \
patch('src.main.download_space') as mock_download:
mock_validate.return_value = (True, "1ZkKzZWnVRRKv", None)
mock_download.side_effect = DownloadError("Video unavailable")
result = runner.invoke(cli, ['https://x.com/i/spaces/1ZkKzZWnVRRKv', '--mode', 'audio'])
assert result.exit_code == 2
assert "Video unavailable" in result.output
def test_cli_with_cookies(tmp_path):
"""Test CLI with cookies file."""
runner = CliRunner()
# Create a fake cookies file
cookies_file = tmp_path / "cookies.txt"
cookies_file.write_text("# Netscape HTTP Cookie File\n")
with patch('src.main.validate_space_url') as mock_validate, \
patch('src.main.download_space') as mock_download:
mock_validate.return_value = (True, "1ZkKzZWnVRRKv", None)
mock_download.return_value = str(tmp_path / "space_1ZkKzZWnVRRKv.mp3")
result = runner.invoke(cli, [
'https://x.com/i/spaces/1ZkKzZWnVRRKv',
'--cookies', str(cookies_file),
'--mode', 'audio',
])
assert result.exit_code == 0
# Verify the call was made with correct arguments including cookies_file
mock_download.assert_called_once_with(
'https://x.com/i/spaces/1ZkKzZWnVRRKv',
'.',
str(cookies_file)
)
def test_cli_video_mode(tmp_path):
"""Test CLI default video mode."""
runner = CliRunner()
with patch('src.main.validate_space_url') as mock_validate, \
patch('src.main.download_space_video') as mock_download:
mock_validate.return_value = (True, "1ZkKzZWnVRRKv", None)
mock_download.return_value = str(tmp_path / "space_1ZkKzZWnVRRKv.mp4")
result = runner.invoke(cli, ['https://x.com/i/spaces/1ZkKzZWnVRRKv'])
assert result.exit_code == 0
assert "space_1ZkKzZWnVRRKv.mp4" in result.output
mock_download.assert_called_once_with(
'https://x.com/i/spaces/1ZkKzZWnVRRKv',
'.',
None,
None,
None,
)

38
tests/test_validator.py Normal file
View File

@@ -0,0 +1,38 @@
import pytest
from src.validator import validate_space_url
def test_validate_space_url_valid():
"""Test that valid X Space URL is accepted."""
url = "https://x.com/i/spaces/1ZkKzZWnVRRKv"
is_valid, space_id, error = validate_space_url(url)
assert is_valid is True
assert space_id == "1ZkKzZWnVRRKv"
assert error is None
def test_validate_space_url_invalid_format():
"""Test that invalid URLs are rejected."""
invalid_urls = [
"https://x.com/spaces/1ZkKzZWnVRRKv", # Missing /i/
"https://x.com/i/spaces/", # Missing space ID
"not a url", # Completely invalid
"", # Empty string
]
for url in invalid_urls:
is_valid, space_id, error = validate_space_url(url)
assert is_valid is False
assert space_id is None
assert "Invalid URL format" in error
def test_validate_space_url_twitter_domain():
"""Test that twitter.com URLs are also accepted."""
url = "https://twitter.com/i/spaces/1ZkKzZWnVRRKv"
is_valid, space_id, error = validate_space_url(url)
assert is_valid is True
assert space_id == "1ZkKzZWnVRRKv"
assert error is None

47
tests/test_video.py Normal file
View File

@@ -0,0 +1,47 @@
import json
from src.models import Participant, SpaceMetadata
from src.video import SpeakerTimeline, build_ffmpeg_command, render_frame
def test_speaker_timeline_reads_active_speaker(tmp_path):
timeline_file = tmp_path / "timeline.json"
timeline_file.write_text(
json.dumps([
{"start": 0, "end": 2.5, "speaker": "alice"},
{"start": 2.5, "end": 4, "speaker": "@bob"},
]),
encoding="utf-8",
)
timeline = SpeakerTimeline.from_file(str(timeline_file))
assert timeline.active_at(1) == "alice"
assert timeline.active_at(3) == "bob"
assert timeline.active_at(5) is None
def test_build_ffmpeg_command_uses_audio_and_raw_video():
command = build_ffmpeg_command("audio.mp3", "out.mp4", 720, 720, 24)
assert command[:2] == ["ffmpeg", "-y"]
assert "audio.mp3" in command
assert "out.mp4" in command
assert "720x720" in command
def test_render_frame_returns_rgb_image():
metadata = SpaceMetadata(
space_id="1ZkKzZWnVRRKv",
title="Example Space",
participants=[
Participant("1", "Alice", "alice", role="host"),
Participant("2", "Bob", "bob", role="speaker"),
],
)
timeline = SpeakerTimeline.from_file_data([{"start": 0, "end": 10, "speaker": "bob"}])
frame = render_frame(metadata, {}, timeline, 1.0, 720, 720)
assert frame.mode == "RGB"
assert frame.size == (720, 720)

89
tests/test_x_api.py Normal file
View File

@@ -0,0 +1,89 @@
import json
from src.x_api import build_timeline_from_history, parse_audio_space_metadata
def test_parse_audio_space_metadata_extracts_title_creator_and_speakers():
payload = {
"data": {
"audioSpace": {
"metadata": {
"rest_id": "1ZkKzZWnVRRKv",
"title": "Example Space",
"media_key": "28_123",
"creator_results": {
"result": {
"rest_id": "10",
"legacy": {
"name": "Host User",
"screen_name": "host",
"profile_image_url_https": "https://example.com/host.jpg",
},
}
},
},
"participants": {
"speakers": [
{
"display_name": "Speaker User",
"twitter_screen_name": "speaker",
"avatar_url": "https://example.com/speaker.jpg",
"periscope_user_id": "20",
}
]
},
}
}
}
metadata = parse_audio_space_metadata(payload, "1ZkKzZWnVRRKv")
assert metadata.title == "Example Space"
assert metadata.media_key == "28_123"
assert [participant.username for participant in metadata.participants] == ["host", "speaker"]
assert metadata.participants[0].role == "host"
assert metadata.participants[1].role == "speaker"
def test_build_timeline_uses_unmute_mute_events_not_captions():
start_ms = 1_000_000
messages = [
_history_message("alice", 17, start_ms + 5_000),
_caption_message("bob", start_ms + 6_000),
_history_message("alice", 16, start_ms + 12_000),
]
intervals, first_seen, participants = build_timeline_from_history(messages, start_ms)
assert len(intervals) == 1
assert intervals[0].speaker == "alice"
assert intervals[0].start == 5
assert intervals[0].end == 12
assert first_seen == {"alice": 5}
assert {participant.username for participant in participants} == {"alice", "bob"}
def _history_message(username, event, timestamp_ms):
payload = {
"body": json.dumps({
"guestBroadcastingEvent": event,
"guestRemoteID": username,
"guestUsername": username,
"type": 40,
}),
"timestamp": timestamp_ms * 1_000_000,
}
return {"payload": json.dumps(payload)}
def _caption_message(username, timestamp_ms):
payload = {
"body": json.dumps({
"type": 45,
"username": username,
"displayName": username.title(),
"timestamp": timestamp_ms,
}),
"timestamp": timestamp_ms * 1_000_000,
}
return {"payload": json.dumps(payload)}