mirror of
https://github.com/Dasemu/x-space-downloader.git
synced 2026-10-13 13:39:45 +00:00
chore: initial commit
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
104
tests/test_downloader.py
Normal file
104
tests/test_downloader.py
Normal 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
33
tests/test_integration.py
Normal 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
100
tests/test_main.py
Normal 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
38
tests/test_validator.py
Normal 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
47
tests/test_video.py
Normal 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
89
tests/test_x_api.py
Normal 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)}
|
||||
Reference in New Issue
Block a user