chore: polish repo for public release

Add MIT LICENSE, fix README clone URL and add CI/license badges,
flesh out setup.py metadata, add a GitHub Actions test workflow,
and remove dead code left over from earlier iterations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-24 03:37:26 +02:00
parent b66b22923d
commit 0ca953cebc
7 changed files with 71 additions and 30 deletions

25
.github/workflows/tests.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Tests
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ["3.9", "3.10", "3.11", "3.12"]
steps:
- uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt -r requirements-dev.txt
- name: Run tests
run: pytest -m "not integration"

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Dario Sevilla
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -1,5 +1,8 @@
# X Space Downloader
[![Tests](https://github.com/Dasemu/x-space-downloader/actions/workflows/tests.yml/badge.svg)](https://github.com/Dasemu/x-space-downloader/actions/workflows/tests.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
A Python CLI tool that downloads X (Twitter) Space recordings and renders an MP4 replay with audio and a compact participant view.
## Features
@@ -35,7 +38,7 @@ sudo apt install ffmpeg
### Install the tool
```bash
git clone https://github.com/synapz-org/x-space-downloader.git
git clone https://github.com/Dasemu/x-space-downloader.git
cd x-space-downloader
pip install -e .
```
@@ -68,6 +71,18 @@ x-space-dl --cookies cookies.txt https://x.com/i/spaces/1ZkKzZWnVRRKv
Cookies are also used to fetch the Space title, host, speakers, avatars, and replay chat timeline from X/Periscope metadata.
### Environment variables
Fetching metadata (required for the default video mode) needs a bearer token for X's web GraphQL API:
```bash
export X_BEARER_TOKEN="<token>"
```
Get this value from your own browser: open x.com while logged in, open DevTools → Network, and copy the `authorization: Bearer ...` header from any request to `x.com/i/api/graphql`. This is the public token issued to X's web client, not a personal secret, but it's not bundled with this tool — you provide your own.
`X_AUDIO_SPACE_QUERY_ID` can optionally override the GraphQL query ID used for `AudioSpaceById` if X changes it.
### Speaker timeline
With cookies, the tool automatically calls the replay `history` endpoint and builds participant/speaker timing from Space microphone events. If you already have speaker intervals from another extractor, you can override that with JSON:

View File

@@ -3,6 +3,12 @@ from setuptools import setup, find_packages
setup(
name="x-space-downloader",
version="0.1.0",
description="Download X (Twitter) Space recordings as MP4 replays or MP3 audio",
long_description=open("README.md", encoding="utf-8").read(),
long_description_content_type="text/markdown",
url="https://github.com/Dasemu/x-space-downloader",
author="Dario Sevilla",
license="MIT",
packages=find_packages(),
install_requires=[
"yt-dlp>=2024.0.0",

View File

@@ -116,13 +116,6 @@ def download_space_video(
raise DownloadError(f"Failed to render Space video: {exc}") from exc
def fetch_metadata_or_fallback(space_id: str, cookies_file: Optional[str]) -> SpaceMetadata:
try:
return XApiClient(cookies_file=cookies_file).fetch_space_metadata(space_id)
except XApiError:
return SpaceMetadata(space_id=space_id, title=f"X Space {space_id}", participants=[])
def fetch_required_metadata(space_id: str, cookies_file: Optional[str]) -> SpaceMetadata:
try:
return XApiClient(cookies_file=cookies_file).fetch_space_metadata(space_id)
@@ -144,17 +137,6 @@ def fetch_required_timeline(metadata: SpaceMetadata, cookies_file: Optional[str]
return SpeakerTimeline(intervals, first_seen)
def fetch_timeline_or_none(metadata: SpaceMetadata, cookies_file: str) -> Optional[SpeakerTimeline]:
try:
intervals, first_seen, history_participants = XApiClient(cookies_file=cookies_file).fetch_space_timeline(metadata)
except XApiError:
return None
merge_history_participants(metadata, history_participants)
if not intervals and not first_seen:
return None
return SpeakerTimeline(intervals, first_seen)
def merge_history_participants(metadata: SpaceMetadata, history_participants) -> None:
existing = {participant.username.lower() for participant in metadata.participants}
for participant in history_participants:

View File

@@ -19,5 +19,5 @@ def validate_space_url(url: str) -> Tuple[bool, Optional[str], Optional[str]]:
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)
space_id = match.group(2)
return True, space_id, None

View File

@@ -203,8 +203,8 @@ def build_ffmpeg_command(audio_path: str, output_path: str, width: int, height:
"veryfast",
"-pix_fmt",
"yuv420p",
"-c:a",
"copy",
"-c:a",
"copy",
"-shortest",
output_path,
]
@@ -454,11 +454,3 @@ def ellipsize(draw: ImageDraw.ImageDraw, value: str, font, max_width: int) -> st
while trimmed and draw.textlength(trimmed + suffix, font=font) > max_width:
trimmed = trimmed[:-1]
return (trimmed or value[:1]) + suffix
def format_time(seconds: float) -> str:
minutes, secs = divmod(int(seconds), 60)
hours, minutes = divmod(minutes, 60)
if hours:
return f"{hours:d}:{minutes:02d}:{secs:02d}"
return f"{minutes:d}:{secs:02d}"