diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..2e4f8de --- /dev/null +++ b/.github/workflows/tests.yml @@ -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" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3b7f27f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md index 0e10b21..10fbf67 100644 --- a/README.md +++ b/README.md @@ -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="" +``` + +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: diff --git a/setup.py b/setup.py index 153e5ea..6d79a43 100644 --- a/setup.py +++ b/setup.py @@ -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", diff --git a/src/downloader.py b/src/downloader.py index 9f715fd..2f18970 100644 --- a/src/downloader.py +++ b/src/downloader.py @@ -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: diff --git a/src/validator.py b/src/validator.py index c55c655..d51fd3d 100644 --- a/src/validator.py +++ b/src/validator.py @@ -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 diff --git a/src/video.py b/src/video.py index 0aa9040..dcddb04 100644 --- a/src/video.py +++ b/src/video.py @@ -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}"