mirror of
https://github.com/Dasemu/x-space-downloader.git
synced 2026-10-13 13:39:45 +00:00
1018 lines
23 KiB
Markdown
1018 lines
23 KiB
Markdown
# X Space Downloader Implementation Plan
|
|
|
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
|
|
|
**Goal:** Build a Python CLI tool that downloads X (Twitter) Space recordings and outputs MP3 files.
|
|
|
|
**Architecture:** Thin wrapper around yt-dlp with URL validation, error handling, and Click-based CLI. Cookie-based authentication for X access.
|
|
|
|
**Tech Stack:** Python, yt-dlp, Click, pytest
|
|
|
|
---
|
|
|
|
## Task 1: Project Setup and Dependencies
|
|
|
|
**Files:**
|
|
- Create: `requirements.txt`
|
|
- Create: `setup.py`
|
|
- Create: `src/__init__.py`
|
|
- Create: `.gitignore`
|
|
|
|
**Step 1: Create requirements.txt**
|
|
|
|
Create the file with dependencies:
|
|
|
|
```txt
|
|
yt-dlp>=2024.0.0
|
|
click>=8.0.0
|
|
```
|
|
|
|
**Step 2: Create setup.py**
|
|
|
|
Create the file to make the package installable:
|
|
|
|
```python
|
|
from setuptools import setup, find_packages
|
|
|
|
setup(
|
|
name="x-space-downloader",
|
|
version="0.1.0",
|
|
packages=find_packages(),
|
|
install_requires=[
|
|
"yt-dlp>=2024.0.0",
|
|
"click>=8.0.0",
|
|
],
|
|
entry_points={
|
|
"console_scripts": [
|
|
"x-space-dl=src.main:cli",
|
|
],
|
|
},
|
|
python_requires=">=3.8",
|
|
)
|
|
```
|
|
|
|
**Step 3: Create src package init file**
|
|
|
|
```bash
|
|
mkdir -p src
|
|
touch src/__init__.py
|
|
```
|
|
|
|
**Step 4: Create .gitignore**
|
|
|
|
```txt
|
|
# Python
|
|
__pycache__/
|
|
*.py[cod]
|
|
*$py.class
|
|
*.so
|
|
.Python
|
|
env/
|
|
venv/
|
|
ENV/
|
|
*.egg-info/
|
|
dist/
|
|
build/
|
|
|
|
# IDEs
|
|
.vscode/
|
|
.idea/
|
|
*.swp
|
|
*.swo
|
|
|
|
# Testing
|
|
.pytest_cache/
|
|
.coverage
|
|
htmlcov/
|
|
|
|
# Downloads
|
|
*.mp3
|
|
*.m4a
|
|
cookies.txt
|
|
```
|
|
|
|
**Step 5: Install in development mode**
|
|
|
|
Run: `pip install -e .`
|
|
Expected: Package installed successfully
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add requirements.txt setup.py src/__init__.py .gitignore
|
|
git commit -m "feat: add project setup and dependencies"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: URL Validator (TDD)
|
|
|
|
**Files:**
|
|
- Create: `tests/test_validator.py`
|
|
- Create: `src/validator.py`
|
|
|
|
**Step 1: Create test directory**
|
|
|
|
```bash
|
|
mkdir -p tests
|
|
touch tests/__init__.py
|
|
```
|
|
|
|
**Step 2: Write failing test for valid URL**
|
|
|
|
Create `tests/test_validator.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
**Step 3: Run test to verify it fails**
|
|
|
|
Run: `pytest tests/test_validator.py::test_validate_space_url_valid -v`
|
|
Expected: FAIL with "cannot import name 'validate_space_url'"
|
|
|
|
**Step 4: Write minimal validator implementation**
|
|
|
|
Create `src/validator.py`:
|
|
|
|
```python
|
|
import re
|
|
from typing import Tuple, Optional
|
|
|
|
|
|
def validate_space_url(url: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
|
"""
|
|
Validates X Space URL format.
|
|
|
|
Args:
|
|
url: The URL to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, space_id, error_message)
|
|
"""
|
|
# Pattern for X Space URLs
|
|
pattern = r"https://x\.com/i/spaces/([a-zA-Z0-9]+)"
|
|
match = re.match(pattern, url)
|
|
|
|
if not match:
|
|
return False, None, "Invalid URL format. Expected: https://x.com/i/spaces/{SPACE_ID}"
|
|
|
|
space_id = match.group(1)
|
|
return True, space_id, None
|
|
```
|
|
|
|
**Step 5: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_validator.py::test_validate_space_url_valid -v`
|
|
Expected: PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add tests/__init__.py tests/test_validator.py src/validator.py
|
|
git commit -m "feat: add URL validator for X Space URLs"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Validator Error Cases (TDD)
|
|
|
|
**Files:**
|
|
- Modify: `tests/test_validator.py`
|
|
|
|
**Step 1: Write test for invalid URL formats**
|
|
|
|
Add to `tests/test_validator.py`:
|
|
|
|
```python
|
|
def test_validate_space_url_invalid_format():
|
|
"""Test that invalid URLs are rejected."""
|
|
invalid_urls = [
|
|
"https://twitter.com/i/spaces/1ZkKzZWnVRRKv", # Old twitter.com
|
|
"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
|
|
```
|
|
|
|
**Step 2: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_validator.py::test_validate_space_url_invalid_format -v`
|
|
Expected: PASS (implementation already handles these)
|
|
|
|
**Step 3: Write test for twitter.com URL support**
|
|
|
|
Add to `tests/test_validator.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
**Step 4: Run test to verify it fails**
|
|
|
|
Run: `pytest tests/test_validator.py::test_validate_space_url_twitter_domain -v`
|
|
Expected: FAIL
|
|
|
|
**Step 5: Update validator to support twitter.com**
|
|
|
|
Modify `src/validator.py`:
|
|
|
|
```python
|
|
def validate_space_url(url: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
|
"""
|
|
Validates X Space URL format.
|
|
|
|
Args:
|
|
url: The URL to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, space_id, error_message)
|
|
"""
|
|
# Pattern for X Space URLs (supports both x.com and twitter.com)
|
|
pattern = r"https://(x|twitter)\.com/i/spaces/([a-zA-Z0-9]+)"
|
|
match = re.match(pattern, url)
|
|
|
|
if not match:
|
|
return False, None, "Invalid URL format. Expected: https://x.com/i/spaces/{SPACE_ID}"
|
|
|
|
space_id = match.group(2) # Changed from group(1) to group(2)
|
|
return True, space_id, None
|
|
```
|
|
|
|
**Step 6: Run all validator tests**
|
|
|
|
Run: `pytest tests/test_validator.py -v`
|
|
Expected: All tests PASS
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add tests/test_validator.py src/validator.py
|
|
git commit -m "feat: add validator error cases and twitter.com support"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Downloader Module (TDD)
|
|
|
|
**Files:**
|
|
- Create: `tests/test_downloader.py`
|
|
- Create: `src/downloader.py`
|
|
|
|
**Step 1: Write failing test for downloader**
|
|
|
|
Create `tests/test_downloader.py`:
|
|
|
|
```python
|
|
import pytest
|
|
from unittest.mock import Mock, patch
|
|
from src.downloader import download_space
|
|
|
|
|
|
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
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pytest tests/test_downloader.py::test_download_space_basic -v`
|
|
Expected: FAIL with "cannot import name 'download_space'"
|
|
|
|
**Step 3: Write minimal downloader implementation**
|
|
|
|
Create `src/downloader.py`:
|
|
|
|
```python
|
|
import os
|
|
import yt_dlp
|
|
from typing import Optional
|
|
|
|
|
|
def download_space(
|
|
space_url: str,
|
|
output_dir: str,
|
|
cookies_file: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
Downloads X Space recording using yt-dlp.
|
|
|
|
Args:
|
|
space_url: URL of the X Space recording
|
|
output_dir: Directory to save the MP3 file
|
|
cookies_file: Optional path to cookies file for authentication
|
|
|
|
Returns:
|
|
Path to the downloaded MP3 file
|
|
"""
|
|
# Extract space ID from URL for filename
|
|
space_id = space_url.split('/')[-1]
|
|
|
|
# Configure yt-dlp options
|
|
ydl_opts = {
|
|
'format': 'bestaudio/best',
|
|
'outtmpl': os.path.join(output_dir, f'space_{space_id}.%(ext)s'),
|
|
'postprocessors': [{
|
|
'key': 'FFmpegExtractAudio',
|
|
'preferredcodec': 'mp3',
|
|
}],
|
|
'quiet': False,
|
|
'no_warnings': False,
|
|
}
|
|
|
|
# Add cookies if provided
|
|
if cookies_file:
|
|
ydl_opts['cookiefile'] = cookies_file
|
|
|
|
# Download with yt-dlp
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
ydl.extract_info(space_url, download=True)
|
|
|
|
# Return the expected output path
|
|
return os.path.join(output_dir, f'space_{space_id}.mp3')
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_downloader.py::test_download_space_basic -v`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/test_downloader.py src/downloader.py
|
|
git commit -m "feat: add downloader module with yt-dlp integration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Downloader Error Handling (TDD)
|
|
|
|
**Files:**
|
|
- Modify: `tests/test_downloader.py`
|
|
- Modify: `src/downloader.py`
|
|
|
|
**Step 1: Write test for download errors**
|
|
|
|
Add to `tests/test_downloader.py`:
|
|
|
|
```python
|
|
from src.downloader import DownloadError
|
|
|
|
|
|
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)
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pytest tests/test_downloader.py::test_download_space_error_handling -v`
|
|
Expected: FAIL with "cannot import name 'DownloadError'"
|
|
|
|
**Step 3: Add error handling to downloader**
|
|
|
|
Modify `src/downloader.py`:
|
|
|
|
```python
|
|
import os
|
|
import yt_dlp
|
|
from typing import Optional
|
|
|
|
|
|
class DownloadError(Exception):
|
|
"""Raised when download fails."""
|
|
pass
|
|
|
|
|
|
def download_space(
|
|
space_url: str,
|
|
output_dir: str,
|
|
cookies_file: Optional[str] = None
|
|
) -> str:
|
|
"""
|
|
Downloads X Space recording using yt-dlp.
|
|
|
|
Args:
|
|
space_url: URL of the X Space recording
|
|
output_dir: Directory to save the MP3 file
|
|
cookies_file: Optional path to cookies file for authentication
|
|
|
|
Returns:
|
|
Path to the downloaded MP3 file
|
|
|
|
Raises:
|
|
DownloadError: If download fails
|
|
"""
|
|
# Extract space ID from URL for filename
|
|
space_id = space_url.split('/')[-1]
|
|
|
|
# Configure yt-dlp options
|
|
ydl_opts = {
|
|
'format': 'bestaudio/best',
|
|
'outtmpl': os.path.join(output_dir, f'space_{space_id}.%(ext)s'),
|
|
'postprocessors': [{
|
|
'key': 'FFmpegExtractAudio',
|
|
'preferredcodec': 'mp3',
|
|
}],
|
|
'quiet': False,
|
|
'no_warnings': False,
|
|
}
|
|
|
|
# Add cookies if provided
|
|
if cookies_file:
|
|
ydl_opts['cookiefile'] = cookies_file
|
|
|
|
# Download with yt-dlp
|
|
try:
|
|
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
ydl.extract_info(space_url, download=True)
|
|
except Exception as e:
|
|
raise DownloadError(f"Failed to download Space: {str(e)}")
|
|
|
|
# Return the expected output path
|
|
return os.path.join(output_dir, f'space_{space_id}.mp3')
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_downloader.py::test_download_space_error_handling -v`
|
|
Expected: PASS
|
|
|
|
**Step 5: Run all downloader tests**
|
|
|
|
Run: `pytest tests/test_downloader.py -v`
|
|
Expected: All tests PASS
|
|
|
|
**Step 6: Commit**
|
|
|
|
```bash
|
|
git add tests/test_downloader.py src/downloader.py
|
|
git commit -m "feat: add error handling to downloader"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: CLI Main Module (TDD)
|
|
|
|
**Files:**
|
|
- Create: `tests/test_main.py`
|
|
- Create: `src/main.py`
|
|
|
|
**Step 1: Write test for CLI entry point**
|
|
|
|
Create `tests/test_main.py`:
|
|
|
|
```python
|
|
import pytest
|
|
from click.testing import CliRunner
|
|
from unittest.mock import patch, Mock
|
|
from src.main import cli
|
|
|
|
|
|
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'])
|
|
|
|
assert result.exit_code == 0
|
|
assert "Successfully downloaded" in result.output
|
|
assert "space_1ZkKzZWnVRRKv.mp3" in result.output
|
|
```
|
|
|
|
**Step 2: Run test to verify it fails**
|
|
|
|
Run: `pytest tests/test_main.py::test_cli_valid_url -v`
|
|
Expected: FAIL with "cannot import name 'cli'"
|
|
|
|
**Step 3: Write minimal CLI implementation**
|
|
|
|
Create `src/main.py`:
|
|
|
|
```python
|
|
import click
|
|
import os
|
|
from src.validator import validate_space_url
|
|
from src.downloader import download_space, DownloadError
|
|
|
|
|
|
@click.command()
|
|
@click.argument('url')
|
|
@click.option('--cookies', '-c', type=click.Path(exists=True), help='Path to cookies file')
|
|
@click.option('--output', '-o', type=click.Path(), default='.', help='Output directory')
|
|
def cli(url, cookies, output):
|
|
"""
|
|
Download X (Twitter) Space recordings as MP3 files.
|
|
|
|
Example:
|
|
x-space-dl https://x.com/i/spaces/1ZkKzZWnVRRKv
|
|
"""
|
|
# Validate URL
|
|
is_valid, space_id, error = validate_space_url(url)
|
|
if not is_valid:
|
|
click.echo(f"Error: {error}", err=True)
|
|
raise click.Exit(1)
|
|
|
|
# Download the Space
|
|
try:
|
|
click.echo(f"Downloading Space: {space_id}")
|
|
output_path = download_space(url, output, cookies)
|
|
click.echo(f"Successfully downloaded to: {output_path}")
|
|
except DownloadError as e:
|
|
click.echo(f"Error: {str(e)}", err=True)
|
|
raise click.Exit(2)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
cli()
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_main.py::test_cli_valid_url -v`
|
|
Expected: PASS
|
|
|
|
**Step 5: Commit**
|
|
|
|
```bash
|
|
git add tests/test_main.py src/main.py
|
|
git commit -m "feat: add CLI main module with Click integration"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: CLI Error Cases (TDD)
|
|
|
|
**Files:**
|
|
- Modify: `tests/test_main.py`
|
|
|
|
**Step 1: Write test for invalid URL**
|
|
|
|
Add to `tests/test_main.py`:
|
|
|
|
```python
|
|
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
|
|
```
|
|
|
|
**Step 2: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_main.py::test_cli_invalid_url -v`
|
|
Expected: PASS (implementation already handles this)
|
|
|
|
**Step 3: Write test for download failure**
|
|
|
|
Add to `tests/test_main.py`:
|
|
|
|
```python
|
|
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'])
|
|
|
|
assert result.exit_code == 2
|
|
assert "Video unavailable" in result.output
|
|
```
|
|
|
|
**Step 4: Run test to verify it passes**
|
|
|
|
Run: `pytest tests/test_main.py::test_cli_download_failure -v`
|
|
Expected: PASS (implementation already handles this)
|
|
|
|
**Step 5: Write test for cookies option**
|
|
|
|
Add to `tests/test_main.py`:
|
|
|
|
```python
|
|
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)
|
|
])
|
|
|
|
assert result.exit_code == 0
|
|
mock_download.assert_called_once()
|
|
assert mock_download.call_args[1]['cookies_file'] == str(cookies_file)
|
|
```
|
|
|
|
**Step 6: Run all CLI tests**
|
|
|
|
Run: `pytest tests/test_main.py -v`
|
|
Expected: All tests PASS
|
|
|
|
**Step 7: Commit**
|
|
|
|
```bash
|
|
git add tests/test_main.py
|
|
git commit -m "test: add CLI error cases and cookies option tests"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: README Documentation
|
|
|
|
**Files:**
|
|
- Create: `README.md`
|
|
|
|
**Step 1: Create comprehensive README**
|
|
|
|
```markdown
|
|
# X Space Downloader
|
|
|
|
A Python CLI tool that downloads X (Twitter) Space recordings and outputs them as MP3 files.
|
|
|
|
## Features
|
|
|
|
- Download X Space recordings as MP3 files
|
|
- Support for authenticated downloads using cookies
|
|
- Simple command-line interface
|
|
- Clear error messages
|
|
|
|
## Installation
|
|
|
|
### Prerequisites
|
|
|
|
- Python 3.8 or higher
|
|
- ffmpeg (required by yt-dlp for audio conversion)
|
|
|
|
Install ffmpeg:
|
|
```bash
|
|
# macOS
|
|
brew install ffmpeg
|
|
|
|
# Ubuntu/Debian
|
|
sudo apt install ffmpeg
|
|
|
|
# Windows
|
|
# Download from https://ffmpeg.org/download.html
|
|
```
|
|
|
|
### Install the tool
|
|
|
|
```bash
|
|
git clone https://github.com/synapz-org/x-space-downloader.git
|
|
cd x-space-downloader
|
|
pip install -e .
|
|
```
|
|
|
|
## Usage
|
|
|
|
### Basic usage
|
|
|
|
```bash
|
|
x-space-dl https://x.com/i/spaces/1ZkKzZWnVRRKv
|
|
```
|
|
|
|
This will download the Space recording and save it as `space_1ZkKzZWnVRRKv.mp3` in the current directory.
|
|
|
|
### With authentication (recommended)
|
|
|
|
For better reliability and access to private Spaces, provide cookies from your logged-in X session:
|
|
|
|
```bash
|
|
x-space-dl --cookies cookies.txt https://x.com/i/spaces/1ZkKzZWnVRRKv
|
|
```
|
|
|
|
### Specify output directory
|
|
|
|
```bash
|
|
x-space-dl --output ~/Downloads https://x.com/i/spaces/1ZkKzZWnVRRKv
|
|
```
|
|
|
|
### Full options
|
|
|
|
```bash
|
|
x-space-dl --help
|
|
```
|
|
|
|
## Exporting Cookies
|
|
|
|
To download Spaces that require authentication, you need to export cookies from your logged-in X session.
|
|
|
|
### Method 1: Browser Extension (Easiest)
|
|
|
|
1. Install a cookie export extension:
|
|
- Chrome/Edge: [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc)
|
|
- Firefox: [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/)
|
|
|
|
2. Log in to X (twitter.com or x.com)
|
|
3. Click the extension icon and export cookies
|
|
4. Save as `cookies.txt`
|
|
|
|
### Method 2: Manual Export (Advanced)
|
|
|
|
1. Log in to X
|
|
2. Open Developer Tools (F12)
|
|
3. Go to Application/Storage → Cookies
|
|
4. Copy cookies in Netscape format to a file
|
|
|
|
## Requirements
|
|
|
|
- Python 3.8+
|
|
- yt-dlp
|
|
- click
|
|
- ffmpeg (system dependency)
|
|
|
|
## Supported URLs
|
|
|
|
The tool supports X Space URLs in these formats:
|
|
|
|
- `https://x.com/i/spaces/{SPACE_ID}`
|
|
- `https://twitter.com/i/spaces/{SPACE_ID}`
|
|
|
|
**Note:** Only recordings are supported. Live Spaces must be recorded before downloading.
|
|
|
|
## Error Messages
|
|
|
|
| Error | Meaning |
|
|
|-------|---------|
|
|
| "Invalid URL format" | URL doesn't match X Space format |
|
|
| "Failed to download Space" | Recording not found or network error |
|
|
| "Authentication required" | Space requires login (use --cookies) |
|
|
|
|
## Development
|
|
|
|
### Running tests
|
|
|
|
```bash
|
|
pip install pytest pytest-mock
|
|
pytest
|
|
```
|
|
|
|
### Project structure
|
|
|
|
```
|
|
x-space-downloader/
|
|
├── src/
|
|
│ ├── main.py # CLI entry point
|
|
│ ├── downloader.py # yt-dlp wrapper
|
|
│ └── validator.py # URL validation
|
|
├── tests/
|
|
│ ├── test_main.py
|
|
│ ├── test_downloader.py
|
|
│ └── test_validator.py
|
|
└── docs/
|
|
└── plans/ # Design documents
|
|
```
|
|
|
|
## License
|
|
|
|
MIT License - See LICENSE file for details
|
|
|
|
## Contributing
|
|
|
|
Contributions are welcome! Please open an issue or submit a pull request.
|
|
|
|
## Troubleshooting
|
|
|
|
### "ffmpeg not found"
|
|
Install ffmpeg using your system's package manager (see Installation section).
|
|
|
|
### "Video unavailable"
|
|
The Space recording may have been deleted or is private. Try using the --cookies option.
|
|
|
|
### "Authentication required"
|
|
Export cookies from your logged-in X session and use the --cookies option.
|
|
|
|
## Acknowledgments
|
|
|
|
Built with:
|
|
- [yt-dlp](https://github.com/yt-dlp/yt-dlp) - Media downloader
|
|
- [Click](https://click.palletsprojects.com/) - CLI framework
|
|
```
|
|
|
|
**Step 2: Commit README**
|
|
|
|
```bash
|
|
git add README.md
|
|
git commit -m "docs: add comprehensive README with usage and installation"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 9: Development Dependencies
|
|
|
|
**Files:**
|
|
- Create: `requirements-dev.txt`
|
|
|
|
**Step 1: Create development requirements**
|
|
|
|
```txt
|
|
pytest>=7.0.0
|
|
pytest-mock>=3.10.0
|
|
```
|
|
|
|
**Step 2: Commit**
|
|
|
|
```bash
|
|
git add requirements-dev.txt
|
|
git commit -m "chore: add development dependencies"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 10: Integration Test
|
|
|
|
**Files:**
|
|
- Create: `tests/test_integration.py`
|
|
|
|
**Step 1: Write integration test**
|
|
|
|
Create `tests/test_integration.py`:
|
|
|
|
```python
|
|
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)
|
|
])
|
|
|
|
# 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']
|
|
```
|
|
|
|
**Step 2: Run integration test**
|
|
|
|
Run: `pytest tests/test_integration.py -v`
|
|
Expected: PASS
|
|
|
|
**Step 3: Run all tests**
|
|
|
|
Run: `pytest -v`
|
|
Expected: All tests PASS
|
|
|
|
**Step 4: Commit**
|
|
|
|
```bash
|
|
git add tests/test_integration.py
|
|
git commit -m "test: add integration test for complete workflow"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 11: Push to GitHub
|
|
|
|
**Files:**
|
|
- N/A (git operations)
|
|
|
|
**Step 1: Push all commits to GitHub**
|
|
|
|
```bash
|
|
git push -u origin master
|
|
```
|
|
|
|
Expected: All commits pushed successfully
|
|
|
|
---
|
|
|
|
## Verification Steps
|
|
|
|
After completing all tasks:
|
|
|
|
1. **Install the package:**
|
|
```bash
|
|
pip install -e .
|
|
```
|
|
|
|
2. **Run all tests:**
|
|
```bash
|
|
pytest -v
|
|
```
|
|
Expected: All tests pass
|
|
|
|
3. **Test CLI is installed:**
|
|
```bash
|
|
x-space-dl --help
|
|
```
|
|
Expected: Help text displays
|
|
|
|
4. **Test with invalid URL:**
|
|
```bash
|
|
x-space-dl https://invalid.com/test
|
|
```
|
|
Expected: Error message about invalid URL format
|
|
|
|
## Notes for Implementation
|
|
|
|
- **TDD strictly:** Write test first, watch it fail, implement, watch it pass
|
|
- **Commit frequently:** After each passing test
|
|
- **YAGNI:** Don't add features not in the design (no GUI, no batch downloads, etc.)
|
|
- **DRY:** Reuse validator and downloader functions, don't duplicate logic
|
|
- **Error messages:** Keep them user-friendly and actionable
|
|
- **Testing:** Mock yt-dlp in tests to avoid actual network calls
|