get-spotify 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ dist/
6
+ build/
7
+ .pytest_cache/
8
+ .venv/
9
+ venv/
10
+ uv.lock.bak
11
+ .env
12
+ *.part
13
+ *.mp3
14
+ *.mp4
15
+ out/
16
+ _tmp.html
17
+ tmp_embed.html
18
+ tmp_dl/
19
+ .python-version
20
+
21
+ out/
@@ -0,0 +1,66 @@
1
+ # AGENTS.md — get-spotify
2
+
3
+ Guidance for coding agents working in this repository.
4
+
5
+ ## What this project is
6
+
7
+ A small **stdlib-first Python CLI** that:
8
+
9
+ 1. Accepts a Spotify episode / track / show URL (or `spotify:` URI)
10
+ 2. Rewrites it to the public **embed** page
11
+ 3. Scrapes **only** `.mp3` / `.mp4` (and `passthroughUrl`) from that HTML
12
+ 4. Downloads those files
13
+
14
+ It does **not** wrap `you-get`, use the Spotify Web API, or bypass DRM/Widevine.
15
+
16
+ ## Layout
17
+
18
+ | Path | Role |
19
+ | --- | --- |
20
+ | `src/get_spotify/cli.py` | argparse entry, exit codes |
21
+ | `src/get_spotify/urls.py` | URL / URI → embed candidates |
22
+ | `src/get_spotify/embed.py` | fetch HTML + media extraction |
23
+ | `src/get_spotify/download.py` | stream download + safe filenames |
24
+ | `tests/` | fixtures + unit tests (no live Spotify required in CI) |
25
+ | `pyproject.toml` | hatchling package; console script `get-spotify` |
26
+
27
+ ## Commands
28
+
29
+ ```bash
30
+ # Local editable (uv)
31
+ uv sync
32
+ uv run get-spotify "https://open.spotify.com/episode/..." --dry-run -v
33
+ uv run pytest
34
+
35
+ # Or pip
36
+ python -m pip install -e ".[dev]"
37
+ pytest
38
+ python -m get_spotify "https://open.spotify.com/episode/..." --dry-run
39
+ ```
40
+
41
+ Install from GitHub (after the repo is public):
42
+
43
+ ```bash
44
+ pip install git+https://github.com/involvex/get-spotify.git
45
+ uv tool install git+https://github.com/involvex/get-spotify.git
46
+ ```
47
+
48
+ ## Invariants
49
+
50
+ - **Stdlib networking** (`urllib`) unless the user asks for a HTTP library.
51
+ - **Media filter only**: never download favicons / png / jpeg / ico as the main result.
52
+ - **No DRM circumvention.** If embed HTML has `passthrough: NONE`, preview clips or a clear failure are correct.
53
+ - **Exit codes:** `0` success / dry-run list, `1` no media, `2` bad URL or network, `130` interrupt.
54
+ - Prefer small diffs. Do not add OAuth, playlist batching, or `you-get` unless requested.
55
+
56
+ ## Good change targets
57
+
58
+ - Better title / filename extraction from embed JSON
59
+ - Clearer messaging when only previews are found
60
+ - Extra fixtures for real embed HTML shapes (sanitized, committed under `tests/fixtures/`)
61
+
62
+ ## Avoid
63
+
64
+ - Committing downloaded `out/`, `*.mp3`, `*.mp4`, or `_tmp.html`
65
+ - Printing secrets or Spotify auth tokens (this tool should not need them)
66
+ - Claiming full track downloads work for DRM music
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.5
2
+ Name: get-spotify
3
+ Version: 0.1.0
4
+ Summary: Download mp3/mp4 media exposed by Spotify embed pages
5
+ Project-URL: Homepage, https://github.com/involvex/get-spotify
6
+ Project-URL: Repository, https://github.com/involvex/get-spotify
7
+ Project-URL: Issues, https://github.com/involvex/get-spotify/issues
8
+ Author: involvex
9
+ License-Expression: MIT
10
+ Keywords: cli,download,podcast,spotify
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Multimedia :: Sound/Audio
16
+ Requires-Python: >=3.10
17
+ Provides-Extra: dev
18
+ Requires-Dist: pytest>=8.0; extra == 'dev'
19
+ Description-Content-Type: text/markdown
20
+
21
+ # get-spotify
22
+
23
+ CLI that downloads **mp3/mp4** media URLs exposed by Spotify **embed** pages.
24
+
25
+ It does **not** bypass DRM. It only downloads direct media links already present
26
+ in the public embed HTML (the same approach as feeding an embed URL to
27
+ `you-get`'s universal scraper, but with a strict media-only filter so favicons
28
+ and images are skipped).
29
+
30
+ ## Install
31
+
32
+ ### From GitHub (pip)
33
+
34
+ ```bash
35
+ pip install git+https://github.com/involvex/get-spotify.git
36
+ ```
37
+
38
+ ### From GitHub (uv)
39
+
40
+ ```bash
41
+ # Install as a user tool (gets the `get-spotify` command)
42
+ uv tool install git+https://github.com/involvex/get-spotify.git
43
+
44
+ # Or one-off without installing permanently
45
+ uvx --from git+https://github.com/involvex/get-spotify.git get-spotify --help
46
+ ```
47
+
48
+ ### Local development
49
+
50
+ With **uv** (recommended):
51
+
52
+ ```bash
53
+ uv sync
54
+ uv run get-spotify --help
55
+ uv run pytest
56
+ ```
57
+
58
+ With **pip**:
59
+
60
+ ```bash
61
+ python -m pip install -e ".[dev]"
62
+ python -m get_spotify --help
63
+ pytest
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ ```bash
69
+ get-spotify <spotify-url> [-o DIR] [--dry-run] [-v]
70
+ ```
71
+
72
+ Examples:
73
+
74
+ ```bash
75
+ # Podcast episode
76
+ get-spotify "https://open.spotify.com/episode/1TzHqdEz21lHPS9Akc9vRl"
77
+
78
+ # Already an embed / video embed URL
79
+ get-spotify "https://open.spotify.com/embed/episode/1TzHqdEz21lHPS9Akc9vRl/video"
80
+
81
+ # List media URLs without downloading
82
+ get-spotify "https://open.spotify.com/episode/..." --dry-run
83
+
84
+ # Track (usually fails or only finds a short preview)
85
+ get-spotify "https://open.spotify.com/track/..." -v
86
+ ```
87
+
88
+ ### Exit codes
89
+
90
+ | Code | Meaning |
91
+ | ---: | --- |
92
+ | 0 | At least one file saved (or listed with `--dry-run`) |
93
+ | 1 | Valid Spotify URL, but no downloadable mp3/mp4 |
94
+ | 2 | Bad URL or network/HTTP failure |
95
+
96
+ ## What works / what does not
97
+
98
+ | Input | Typical result |
99
+ | --- | --- |
100
+ | Podcast/episode with publisher passthrough (`passthroughUrl`) | Downloads full mp3 (and mp4 when the video embed exposes it) |
101
+ | Episode with DRM / `passthrough: NONE` | Downloads only short preview clips if the embed exposes them; prints a note |
102
+ | Music track | Usually preview-only or empty; full songs are DRM-protected |
103
+ | Show URL | Tries the show embed, then the first linked episode |
104
+
105
+ ## How it works
106
+
107
+ 1. Normalize the input to `https://open.spotify.com/embed/{type}/{id}`
108
+ 2. For episodes, also try `.../video` if the audio embed has no media
109
+ 3. Parse embed HTML for `passthroughUrl`, Open Graph media, and `.mp3`/`.mp4` links
110
+ 4. Download only those files
111
+
112
+ ## Disclaimer
113
+
114
+ Use this only for content you have the right to download. Spotify's terms
115
+ restrict downloading Spotify-hosted/DRM content. Publisher-hosted podcast files
116
+ exposed via passthrough are still subject to the publisher's rights.
@@ -0,0 +1,96 @@
1
+ # get-spotify
2
+
3
+ CLI that downloads **mp3/mp4** media URLs exposed by Spotify **embed** pages.
4
+
5
+ It does **not** bypass DRM. It only downloads direct media links already present
6
+ in the public embed HTML (the same approach as feeding an embed URL to
7
+ `you-get`'s universal scraper, but with a strict media-only filter so favicons
8
+ and images are skipped).
9
+
10
+ ## Install
11
+
12
+ ### From GitHub (pip)
13
+
14
+ ```bash
15
+ pip install git+https://github.com/involvex/get-spotify.git
16
+ ```
17
+
18
+ ### From GitHub (uv)
19
+
20
+ ```bash
21
+ # Install as a user tool (gets the `get-spotify` command)
22
+ uv tool install git+https://github.com/involvex/get-spotify.git
23
+
24
+ # Or one-off without installing permanently
25
+ uvx --from git+https://github.com/involvex/get-spotify.git get-spotify --help
26
+ ```
27
+
28
+ ### Local development
29
+
30
+ With **uv** (recommended):
31
+
32
+ ```bash
33
+ uv sync
34
+ uv run get-spotify --help
35
+ uv run pytest
36
+ ```
37
+
38
+ With **pip**:
39
+
40
+ ```bash
41
+ python -m pip install -e ".[dev]"
42
+ python -m get_spotify --help
43
+ pytest
44
+ ```
45
+
46
+ ## Usage
47
+
48
+ ```bash
49
+ get-spotify <spotify-url> [-o DIR] [--dry-run] [-v]
50
+ ```
51
+
52
+ Examples:
53
+
54
+ ```bash
55
+ # Podcast episode
56
+ get-spotify "https://open.spotify.com/episode/1TzHqdEz21lHPS9Akc9vRl"
57
+
58
+ # Already an embed / video embed URL
59
+ get-spotify "https://open.spotify.com/embed/episode/1TzHqdEz21lHPS9Akc9vRl/video"
60
+
61
+ # List media URLs without downloading
62
+ get-spotify "https://open.spotify.com/episode/..." --dry-run
63
+
64
+ # Track (usually fails or only finds a short preview)
65
+ get-spotify "https://open.spotify.com/track/..." -v
66
+ ```
67
+
68
+ ### Exit codes
69
+
70
+ | Code | Meaning |
71
+ | ---: | --- |
72
+ | 0 | At least one file saved (or listed with `--dry-run`) |
73
+ | 1 | Valid Spotify URL, but no downloadable mp3/mp4 |
74
+ | 2 | Bad URL or network/HTTP failure |
75
+
76
+ ## What works / what does not
77
+
78
+ | Input | Typical result |
79
+ | --- | --- |
80
+ | Podcast/episode with publisher passthrough (`passthroughUrl`) | Downloads full mp3 (and mp4 when the video embed exposes it) |
81
+ | Episode with DRM / `passthrough: NONE` | Downloads only short preview clips if the embed exposes them; prints a note |
82
+ | Music track | Usually preview-only or empty; full songs are DRM-protected |
83
+ | Show URL | Tries the show embed, then the first linked episode |
84
+
85
+ ## How it works
86
+
87
+ 1. Normalize the input to `https://open.spotify.com/embed/{type}/{id}`
88
+ 2. For episodes, also try `.../video` if the audio embed has no media
89
+ 3. Parse embed HTML for `passthroughUrl`, Open Graph media, and `.mp3`/`.mp4` links
90
+ 4. Download only those files
91
+
92
+ ## Disclaimer
93
+
94
+ Use this only for content you have the right to download. Spotify's terms
95
+ restrict downloading Spotify-hosted/DRM content. Publisher-hosted podcast files
96
+ exposed via passthrough are still subject to the publisher's rights.
@@ -0,0 +1,53 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "get-spotify"
7
+ version = "0.1.0"
8
+ description = "Download mp3/mp4 media exposed by Spotify embed pages"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "involvex" }]
13
+ keywords = ["spotify", "podcast", "cli", "download"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Environment :: Console",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Multimedia :: Sound/Audio",
20
+ ]
21
+ dependencies = []
22
+
23
+ [project.optional-dependencies]
24
+ dev = ["pytest>=8.0"]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/involvex/get-spotify"
28
+ Repository = "https://github.com/involvex/get-spotify"
29
+ Issues = "https://github.com/involvex/get-spotify/issues"
30
+
31
+ [project.scripts]
32
+ get-spotify = "get_spotify.cli:main"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/get_spotify"]
36
+
37
+ [tool.hatch.build.targets.sdist]
38
+ include = [
39
+ "/src",
40
+ "/tests",
41
+ "/README.md",
42
+ "/AGENTS.md",
43
+ ]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
47
+ pythonpath = ["src"]
48
+
49
+ [tool.uv]
50
+ default-groups = ["dev"]
51
+
52
+ [dependency-groups]
53
+ dev = ["pytest>=8.0"]
@@ -0,0 +1,3 @@
1
+ """Download mp3/mp4 media exposed by Spotify embed pages."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ """Allow ``python -m get_spotify``."""
2
+
3
+ from get_spotify.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,222 @@
1
+ """Command-line entry point for get-spotify."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ from get_spotify import __version__
10
+ from get_spotify.download import DownloadError, build_filename, download_file
11
+ from get_spotify.embed import (
12
+ EmbedError,
13
+ EmbedResult,
14
+ MediaCandidate,
15
+ fetch_embed,
16
+ )
17
+ from get_spotify.urls import SpotifyRef, UrlError, parse_spotify_url
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="get-spotify",
23
+ description=(
24
+ "Download mp3/mp4 media URLs exposed by Spotify embed pages. "
25
+ "Works best for podcasts/episodes with passthrough media; "
26
+ "full music tracks are usually DRM-protected and will fail."
27
+ ),
28
+ )
29
+ parser.add_argument(
30
+ "url",
31
+ help="Spotify episode, track, show, or embed URL (or spotify: URI)",
32
+ )
33
+ parser.add_argument(
34
+ "-o",
35
+ "--output",
36
+ default=".",
37
+ help="Output directory (default: current directory)",
38
+ )
39
+ parser.add_argument(
40
+ "--dry-run",
41
+ action="store_true",
42
+ help="List discovered media URLs without downloading",
43
+ )
44
+ parser.add_argument(
45
+ "-v",
46
+ "--verbose",
47
+ action="store_true",
48
+ help="Print extra diagnostics",
49
+ )
50
+ parser.add_argument(
51
+ "--version",
52
+ action="version",
53
+ version=f"%(prog)s {__version__}",
54
+ )
55
+ return parser
56
+
57
+
58
+ def _log(verbose: bool, message: str) -> None:
59
+ if verbose:
60
+ print(message, file=sys.stderr)
61
+
62
+
63
+ def _is_full_media(media: list[MediaCandidate]) -> bool:
64
+ return any(item.kind != "preview" for item in media)
65
+
66
+
67
+ def _merge_media(
68
+ existing: list[MediaCandidate],
69
+ incoming: list[MediaCandidate],
70
+ ) -> list[MediaCandidate]:
71
+ bucket = {item.url: item for item in existing}
72
+ for item in incoming:
73
+ prev = bucket.get(item.url)
74
+ if prev is None or (
75
+ item.kind != "preview" and prev.kind == "preview"
76
+ ):
77
+ bucket[item.url] = item
78
+ merged = list(bucket.values())
79
+ if _is_full_media(merged):
80
+ return [item for item in merged if item.kind != "preview"]
81
+ return merged
82
+
83
+
84
+ def _discover_for_ref(
85
+ ref: SpotifyRef,
86
+ *,
87
+ verbose: bool,
88
+ ) -> tuple[EmbedResult | None, list[MediaCandidate]]:
89
+ last_result: EmbedResult | None = None
90
+ media: list[MediaCandidate] = []
91
+ for page_url in ref.embed_candidates():
92
+ _log(verbose, f"Fetching embed: {page_url}")
93
+ result = fetch_embed(page_url)
94
+ last_result = result
95
+ if result.media:
96
+ media = _merge_media(media, result.media)
97
+ if _is_full_media(media):
98
+ return result, media
99
+ _log(verbose, f"Only preview media in {page_url}; trying next")
100
+ else:
101
+ _log(verbose, f"No media in {page_url}")
102
+ return last_result, media
103
+
104
+
105
+ def _resolve_media(
106
+ ref: SpotifyRef,
107
+ *,
108
+ verbose: bool,
109
+ ) -> tuple[SpotifyRef, EmbedResult | None, list[MediaCandidate]]:
110
+ result, media = _discover_for_ref(ref, verbose=verbose)
111
+ if media:
112
+ return ref, result, media
113
+
114
+ # Show pages: try the first linked episode.
115
+ if ref.type == "show" and result and result.episode_ids:
116
+ episode_id = result.episode_ids[0]
117
+ _log(verbose, f"Show has no direct media; trying episode {episode_id}")
118
+ episode_ref = SpotifyRef(type="episode", id=episode_id)
119
+ episode_result, episode_media = _discover_for_ref(
120
+ episode_ref,
121
+ verbose=verbose,
122
+ )
123
+ return episode_ref, episode_result, episode_media
124
+
125
+ return ref, result, []
126
+
127
+
128
+ def _preview_only(media: list[MediaCandidate]) -> bool:
129
+ return bool(media) and all(item.kind == "preview" for item in media)
130
+
131
+
132
+ def run(argv: list[str] | None = None) -> int:
133
+ parser = build_parser()
134
+ args = parser.parse_args(argv)
135
+
136
+ try:
137
+ ref = parse_spotify_url(args.url)
138
+ except UrlError as exc:
139
+ print(f"error: {exc}", file=sys.stderr)
140
+ return 2
141
+
142
+ try:
143
+ active_ref, result, media = _resolve_media(ref, verbose=args.verbose)
144
+ except EmbedError as exc:
145
+ print(f"error: {exc}", file=sys.stderr)
146
+ return 2
147
+
148
+ title = result.title if result else None
149
+ if args.verbose and title:
150
+ _log(True, f"Title: {title}")
151
+
152
+ if not media:
153
+ if ref.type == "track":
154
+ print(
155
+ "error: no downloadable mp3/mp4 found for this track. "
156
+ "Spotify music is usually DRM-protected; only short "
157
+ "previews (or nothing) appear in the public embed.",
158
+ file=sys.stderr,
159
+ )
160
+ else:
161
+ print(
162
+ "error: no downloadable mp3/mp4 found in the Spotify embed. "
163
+ "This episode may be DRM-protected or lack passthrough media "
164
+ '(passthrough: "NONE").',
165
+ file=sys.stderr,
166
+ )
167
+ return 1
168
+
169
+ if _preview_only(media):
170
+ if active_ref.type == "track":
171
+ print(
172
+ "note: only a short Spotify preview was found for this track "
173
+ "(full songs are not available via embed).",
174
+ file=sys.stderr,
175
+ )
176
+ else:
177
+ print(
178
+ "note: only preview media was found "
179
+ "(no passthrough/full mp3/mp4 in the embed HTML).",
180
+ file=sys.stderr,
181
+ )
182
+
183
+ if args.dry_run:
184
+ for item in media:
185
+ print(f"{item.kind}\t{item.ext}\t{item.url}")
186
+ return 0
187
+
188
+ output_dir = Path(args.output)
189
+ saved = 0
190
+ for index, item in enumerate(media, start=1):
191
+ filename = build_filename(
192
+ item,
193
+ title=title,
194
+ resource_id=active_ref.id,
195
+ index=index,
196
+ total=len(media),
197
+ )
198
+ dest = output_dir / filename
199
+ _log(args.verbose, f"Saving {item.kind} -> {dest}")
200
+ try:
201
+ path = download_file(item, dest, progress=True)
202
+ except DownloadError as exc:
203
+ print(f"error: {exc}", file=sys.stderr)
204
+ continue
205
+ print(f"saved: {path}")
206
+ saved += 1
207
+
208
+ if saved == 0:
209
+ return 1
210
+ return 0
211
+
212
+
213
+ def main(argv: list[str] | None = None) -> int:
214
+ try:
215
+ return run(argv)
216
+ except KeyboardInterrupt:
217
+ print("interrupted", file=sys.stderr)
218
+ return 130
219
+
220
+
221
+ if __name__ == "__main__":
222
+ raise SystemExit(main())