mirror of
https://github.com/Dasemu/x-space-downloader.git
synced 2026-10-13 13:39:45 +00:00
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:
25
.github/workflows/tests.yml
vendored
Normal file
25
.github/workflows/tests.yml
vendored
Normal 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
21
LICENSE
Normal 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.
|
||||||
17
README.md
17
README.md
@@ -1,5 +1,8 @@
|
|||||||
# X Space Downloader
|
# X Space Downloader
|
||||||
|
|
||||||
|
[](https://github.com/Dasemu/x-space-downloader/actions/workflows/tests.yml)
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
A Python CLI tool that downloads X (Twitter) Space recordings and renders an MP4 replay with audio and a compact participant view.
|
A Python CLI tool that downloads X (Twitter) Space recordings and renders an MP4 replay with audio and a compact participant view.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -35,7 +38,7 @@ sudo apt install ffmpeg
|
|||||||
### Install the tool
|
### Install the tool
|
||||||
|
|
||||||
```bash
|
```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
|
cd x-space-downloader
|
||||||
pip install -e .
|
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.
|
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
|
### 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:
|
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:
|
||||||
|
|||||||
6
setup.py
6
setup.py
@@ -3,6 +3,12 @@ from setuptools import setup, find_packages
|
|||||||
setup(
|
setup(
|
||||||
name="x-space-downloader",
|
name="x-space-downloader",
|
||||||
version="0.1.0",
|
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(),
|
packages=find_packages(),
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"yt-dlp>=2024.0.0",
|
"yt-dlp>=2024.0.0",
|
||||||
|
|||||||
@@ -116,13 +116,6 @@ def download_space_video(
|
|||||||
raise DownloadError(f"Failed to render Space video: {exc}") from exc
|
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:
|
def fetch_required_metadata(space_id: str, cookies_file: Optional[str]) -> SpaceMetadata:
|
||||||
try:
|
try:
|
||||||
return XApiClient(cookies_file=cookies_file).fetch_space_metadata(space_id)
|
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)
|
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:
|
def merge_history_participants(metadata: SpaceMetadata, history_participants) -> None:
|
||||||
existing = {participant.username.lower() for participant in metadata.participants}
|
existing = {participant.username.lower() for participant in metadata.participants}
|
||||||
for participant in history_participants:
|
for participant in history_participants:
|
||||||
|
|||||||
@@ -19,5 +19,5 @@ def validate_space_url(url: str) -> Tuple[bool, Optional[str], Optional[str]]:
|
|||||||
if not match:
|
if not match:
|
||||||
return False, None, "Invalid URL format. Expected: https://x.com/i/spaces/{SPACE_ID}"
|
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
|
return True, space_id, None
|
||||||
|
|||||||
@@ -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:
|
while trimmed and draw.textlength(trimmed + suffix, font=font) > max_width:
|
||||||
trimmed = trimmed[:-1]
|
trimmed = trimmed[:-1]
|
||||||
return (trimmed or value[:1]) + suffix
|
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}"
|
|
||||||
|
|||||||
Reference in New Issue
Block a user