get-spotify 0.1.0__py3-none-any.whl

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,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())
get_spotify/cli.py ADDED
@@ -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())
@@ -0,0 +1,125 @@
1
+ """Stream-download media files with safe filenames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import sys
7
+ from pathlib import Path
8
+ from urllib.error import HTTPError, URLError
9
+ from urllib.request import Request, urlopen
10
+
11
+ from get_spotify.embed import USER_AGENT, MediaCandidate
12
+
13
+ _WINDOWS_BAD = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
14
+ _MULTI_SPACE = re.compile(r"\s+")
15
+
16
+
17
+ class DownloadError(RuntimeError):
18
+ """Raised when a media file cannot be downloaded."""
19
+
20
+
21
+ def sanitize_filename(name: str, *, max_len: int = 120) -> str:
22
+ """Make a string safe for Windows and POSIX filenames."""
23
+ cleaned = _WINDOWS_BAD.sub("", name)
24
+ cleaned = cleaned.replace("\n", " ").replace("\r", " ")
25
+ cleaned = _MULTI_SPACE.sub(" ", cleaned).strip(" .")
26
+ if not cleaned:
27
+ cleaned = "spotify-media"
28
+ if len(cleaned) > max_len:
29
+ cleaned = cleaned[:max_len].rstrip(" .")
30
+ return cleaned
31
+
32
+
33
+ def build_filename(
34
+ candidate: MediaCandidate,
35
+ *,
36
+ title: str | None,
37
+ resource_id: str,
38
+ index: int,
39
+ total: int,
40
+ ) -> str:
41
+ base = sanitize_filename(title) if title else sanitize_filename(resource_id)
42
+ if total > 1:
43
+ base = f"{base}-{index}"
44
+ if candidate.kind == "preview" and "preview" not in base.lower():
45
+ base = f"{base}-preview"
46
+ return f"{base}.{candidate.ext}"
47
+
48
+
49
+ def _content_ok(content_type: str | None, ext: str) -> bool:
50
+ if not content_type:
51
+ return True
52
+ ctype = content_type.split(";", 1)[0].strip().lower()
53
+ if ctype in {"application/octet-stream", "binary/octet-stream"}:
54
+ return True
55
+ if ext == "mp3":
56
+ return ctype in {"audio/mpeg", "audio/mp3", "audio/mpeg3", "audio/x-mpeg"}
57
+ if ext == "mp4":
58
+ return ctype in {
59
+ "video/mp4",
60
+ "audio/mp4",
61
+ "application/mp4",
62
+ "video/mpeg",
63
+ }
64
+ return False
65
+
66
+
67
+ def download_file(
68
+ candidate: MediaCandidate,
69
+ dest: Path,
70
+ *,
71
+ timeout: float = 60.0,
72
+ progress: bool = True,
73
+ ) -> Path:
74
+ """Download one media URL to dest. Returns the final path."""
75
+ dest.parent.mkdir(parents=True, exist_ok=True)
76
+ request = Request(
77
+ candidate.url,
78
+ headers={
79
+ "User-Agent": USER_AGENT,
80
+ "Accept": "*/*",
81
+ "Referer": "https://open.spotify.com/",
82
+ },
83
+ )
84
+ try:
85
+ with urlopen(request, timeout=timeout) as response:
86
+ content_type = response.headers.get("Content-Type")
87
+ if not _content_ok(content_type, candidate.ext):
88
+ raise DownloadError(
89
+ f"Skipping {candidate.url}: unexpected Content-Type "
90
+ f"{content_type!r}"
91
+ )
92
+ total = response.headers.get("Content-Length")
93
+ total_bytes = int(total) if total and total.isdigit() else None
94
+ tmp = dest.with_suffix(dest.suffix + ".part")
95
+ downloaded = 0
96
+ with tmp.open("wb") as handle:
97
+ while True:
98
+ chunk = response.read(64 * 1024)
99
+ if not chunk:
100
+ break
101
+ handle.write(chunk)
102
+ downloaded += len(chunk)
103
+ if progress:
104
+ _print_progress(dest.name, downloaded, total_bytes)
105
+ tmp.replace(dest)
106
+ if progress:
107
+ sys.stderr.write("\n")
108
+ sys.stderr.flush()
109
+ return dest
110
+ except HTTPError as exc:
111
+ raise DownloadError(f"HTTP {exc.code} downloading {candidate.url}") from exc
112
+ except URLError as exc:
113
+ raise DownloadError(
114
+ f"Network error downloading {candidate.url}: {exc.reason}"
115
+ ) from exc
116
+
117
+
118
+ def _print_progress(name: str, downloaded: int, total: int | None) -> None:
119
+ if total:
120
+ pct = min(100.0, downloaded * 100.0 / total)
121
+ msg = f"\rDownloading {name}: {pct:5.1f}% ({downloaded}/{total} bytes)"
122
+ else:
123
+ msg = f"\rDownloading {name}: {downloaded} bytes"
124
+ sys.stderr.write(msg)
125
+ sys.stderr.flush()
get_spotify/embed.py ADDED
@@ -0,0 +1,332 @@
1
+ """Fetch Spotify embed HTML and extract mp3/mp4 media URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from dataclasses import dataclass
8
+ from html import unescape
9
+ from typing import Iterable
10
+ from urllib.error import HTTPError, URLError
11
+ from urllib.request import Request, urlopen
12
+
13
+ USER_AGENT = (
14
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
15
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
16
+ "Chrome/122.0.0.0 Safari/537.36"
17
+ )
18
+
19
+ PREVIEW_HINTS = (
20
+ "p.scdn.co/mp3-preview/",
21
+ "smart-preview-clips.spotifycdn.com",
22
+ "/previews/",
23
+ )
24
+ IMAGE_EXT = (".png", ".jpg", ".jpeg", ".gif", ".svg", ".ico", ".webp")
25
+ _NAME_RE = re.compile(r'"name"\s*:\s*"((?:\\.|[^"\\])*)"')
26
+
27
+ _TITLE_RE = re.compile(r"<title[^>]*>([^<]*)</title>", re.IGNORECASE | re.DOTALL)
28
+ _OG_VIDEO_RE = re.compile(
29
+ r'<meta[^>]+property=["\']og:video(?::url)?["\'][^>]+content=["\']([^"\']+)["\']',
30
+ re.IGNORECASE,
31
+ )
32
+ _OG_VIDEO_RE_ALT = re.compile(
33
+ r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:video(?::url)?["\']',
34
+ re.IGNORECASE,
35
+ )
36
+ _OG_AUDIO_RE = re.compile(
37
+ r'<meta[^>]+property=["\']og:audio(?::url)?["\'][^>]+content=["\']([^"\']+)["\']',
38
+ re.IGNORECASE,
39
+ )
40
+ _OG_AUDIO_RE_ALT = re.compile(
41
+ r'<meta[^>]+content=["\']([^"\']+)["\'][^>]+property=["\']og:audio(?::url)?["\']',
42
+ re.IGNORECASE,
43
+ )
44
+ _PASSTHROUGH_RE = re.compile(
45
+ r'"passthroughUrl"\s*:\s*"(https?://[^"\\]+)"',
46
+ re.IGNORECASE,
47
+ )
48
+ _MEDIA_URL_RE = re.compile(
49
+ r'https?://[^\s"\'<>\\]+?\.(?:mp3|mp4)(?:\?[^\s"\'<>\\]*)?',
50
+ re.IGNORECASE,
51
+ )
52
+ _EPISODE_LINK_RE = re.compile(
53
+ r"https?://open\.spotify\.com/(?:embed/)?episode/([A-Za-z0-9]+)",
54
+ re.IGNORECASE,
55
+ )
56
+ _ESCAPED_MEDIA_RE = re.compile(
57
+ r'https?:\\+/\\+/[^\s"\'<>]+?\.(?:mp3|mp4)(?:\\?[^"\'\\]*)?',
58
+ re.IGNORECASE,
59
+ )
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class MediaCandidate:
64
+ """A downloadable media URL discovered in an embed page."""
65
+
66
+ url: str
67
+ ext: str
68
+ kind: str # passthrough | video | preview | unknown
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class EmbedResult:
73
+ """Parsed result from one embed page fetch."""
74
+
75
+ page_url: str
76
+ title: str | None
77
+ media: list[MediaCandidate]
78
+ episode_ids: list[str]
79
+
80
+
81
+ class EmbedError(RuntimeError):
82
+ """Raised when an embed page cannot be fetched."""
83
+
84
+
85
+ def fetch_html(url: str, *, timeout: float = 30.0) -> str:
86
+ """GET a URL and return decoded HTML text."""
87
+ request = Request(
88
+ url,
89
+ headers={
90
+ "User-Agent": USER_AGENT,
91
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
92
+ "Accept-Language": "en-US,en;q=0.9",
93
+ },
94
+ )
95
+ try:
96
+ with urlopen(request, timeout=timeout) as response:
97
+ charset = response.headers.get_content_charset() or "utf-8"
98
+ return response.read().decode(charset, errors="replace")
99
+ except HTTPError as exc:
100
+ raise EmbedError(f"HTTP {exc.code} fetching {url}") from exc
101
+ except URLError as exc:
102
+ raise EmbedError(f"Network error fetching {url}: {exc.reason}") from exc
103
+
104
+
105
+ def extract_title(html: str) -> str | None:
106
+ match = _TITLE_RE.search(html)
107
+ if match:
108
+ title = unescape(match.group(1)).strip()
109
+ title = re.sub(r"\s*\|\s*Spotify\s*$", "", title, flags=re.IGNORECASE)
110
+ if title:
111
+ return title
112
+
113
+ # Embed pages often ship an empty <title>; fall back to JSON "name".
114
+ for match in _NAME_RE.finditer(html):
115
+ raw = match.group(1)
116
+ try:
117
+ name = json.loads(f'"{raw}"')
118
+ except json.JSONDecodeError:
119
+ name = unescape(raw)
120
+ name = str(name).strip()
121
+ if name and name.lower() != "spotify":
122
+ return name
123
+ return None
124
+
125
+
126
+ def _ext_from_url(url: str) -> str:
127
+ path = url.split("?", 1)[0].lower()
128
+ if path.endswith(".mp4"):
129
+ return "mp4"
130
+ if path.endswith(".mp3"):
131
+ return "mp3"
132
+ # Preview hosts sometimes omit a real extension in path segments.
133
+ if "mp3-preview" in path:
134
+ return "mp3"
135
+ return "mp3"
136
+
137
+
138
+ def _is_preview_url(url: str) -> bool:
139
+ lower = url.lower()
140
+ return any(hint in lower for hint in PREVIEW_HINTS)
141
+
142
+
143
+ def _kind_for(url: str, *, forced: str | None = None) -> str:
144
+ if forced == "passthrough":
145
+ return "passthrough"
146
+ if forced == "preview" or _is_preview_url(url):
147
+ return "preview"
148
+ if forced:
149
+ return forced
150
+ if url.lower().split("?", 1)[0].endswith(".mp4"):
151
+ return "video"
152
+ return "unknown"
153
+
154
+
155
+ def _is_image_url(url: str) -> bool:
156
+ path = url.split("?", 1)[0].lower()
157
+ return any(path.endswith(ext) for ext in IMAGE_EXT)
158
+
159
+
160
+ def _normalize_url(url: str) -> str:
161
+ url = unescape(url.strip())
162
+ url = url.replace("\\/", "/")
163
+ url = url.replace("\\u0026", "&")
164
+ return url
165
+
166
+
167
+ def _add_candidate(
168
+ bucket: dict[str, MediaCandidate],
169
+ url: str,
170
+ *,
171
+ kind: str | None = None,
172
+ ) -> None:
173
+ url = _normalize_url(url)
174
+ if not url.startswith("http"):
175
+ return
176
+ if _is_image_url(url):
177
+ return
178
+ ext = _ext_from_url(url)
179
+ if ext not in {"mp3", "mp4"}:
180
+ return
181
+ candidate = MediaCandidate(
182
+ url=url,
183
+ ext=ext,
184
+ kind=_kind_for(url, forced=kind),
185
+ )
186
+ existing = bucket.get(url)
187
+ if existing is None:
188
+ bucket[url] = candidate
189
+ return
190
+ # Prefer more specific kinds over unknown/preview.
191
+ rank = {
192
+ "passthrough": 3,
193
+ "video": 2,
194
+ "unknown": 1,
195
+ "preview": 0,
196
+ }
197
+ if rank.get(candidate.kind, 0) > rank.get(existing.kind, 0):
198
+ bucket[url] = candidate
199
+
200
+
201
+ def _walk_json(node: object) -> Iterable[tuple[str, str | None]]:
202
+ """Yield (url, kind_hint) pairs from nested JSON-like structures."""
203
+ if isinstance(node, dict):
204
+ passthrough = node.get("passthroughUrl")
205
+ if isinstance(passthrough, str) and passthrough:
206
+ yield passthrough, "passthrough"
207
+
208
+ audio_preview = node.get("audioPreview")
209
+ if isinstance(audio_preview, dict):
210
+ preview_url = audio_preview.get("url")
211
+ if isinstance(preview_url, str) and preview_url:
212
+ yield preview_url, "preview"
213
+
214
+ audio = node.get("audio")
215
+ if isinstance(audio, dict):
216
+ items = audio.get("items") or []
217
+ if isinstance(items, list):
218
+ for item in items:
219
+ if not isinstance(item, dict):
220
+ continue
221
+ item_url = item.get("url")
222
+ if not isinstance(item_url, str) or not item_url:
223
+ continue
224
+ if item.get("externallyHosted"):
225
+ yield item_url, "passthrough"
226
+ elif ".mp3" in item_url.lower() or ".mp4" in item_url.lower():
227
+ yield item_url, None
228
+
229
+ for value in node.values():
230
+ yield from _walk_json(value)
231
+ elif isinstance(node, list):
232
+ for item in node:
233
+ yield from _walk_json(item)
234
+
235
+
236
+ def _extract_json_blobs(html: str) -> list[object]:
237
+ blobs: list[object] = []
238
+ # Common embed payload markers.
239
+ patterns = [
240
+ r'<script[^>]+id="__NEXT_DATA__"[^>]*>(\{.*?\})</script>',
241
+ r"Spotify\.Entity\s*=\s*(\{.*?\});",
242
+ r'"props"\s*:\s*(\{.*?\})\s*,\s*"page"',
243
+ ]
244
+ for pattern in patterns:
245
+ for match in re.finditer(pattern, html, re.DOTALL):
246
+ raw = match.group(1)
247
+ try:
248
+ blobs.append(json.loads(raw))
249
+ except json.JSONDecodeError:
250
+ continue
251
+ return blobs
252
+
253
+
254
+ def extract_media(html: str) -> list[MediaCandidate]:
255
+ """Extract mp3/mp4 candidates from embed HTML."""
256
+ bucket: dict[str, MediaCandidate] = {}
257
+
258
+ for match in _PASSTHROUGH_RE.finditer(html):
259
+ _add_candidate(bucket, match.group(1), kind="passthrough")
260
+
261
+ for blob in _extract_json_blobs(html):
262
+ for url, kind in _walk_json(blob):
263
+ _add_candidate(bucket, url, kind=kind)
264
+
265
+ # Also try to parse large JSON-looking substrings that include passthrough.
266
+ for match in re.finditer(r"\{[^{}]*passthroughUrl[^{}]*\}", html):
267
+ try:
268
+ obj = json.loads(match.group(0))
269
+ except json.JSONDecodeError:
270
+ continue
271
+ for url, kind in _walk_json(obj):
272
+ _add_candidate(bucket, url, kind=kind)
273
+
274
+ for regex in (_OG_VIDEO_RE, _OG_VIDEO_RE_ALT):
275
+ for match in regex.finditer(html):
276
+ _add_candidate(bucket, match.group(1), kind="video")
277
+
278
+ for regex in (_OG_AUDIO_RE, _OG_AUDIO_RE_ALT):
279
+ for match in regex.finditer(html):
280
+ _add_candidate(bucket, match.group(1), kind=None)
281
+
282
+ for match in _MEDIA_URL_RE.finditer(html):
283
+ _add_candidate(bucket, match.group(0), kind=None)
284
+
285
+ for match in _ESCAPED_MEDIA_RE.finditer(html):
286
+ _add_candidate(bucket, match.group(0), kind=None)
287
+
288
+ return _rank_media(list(bucket.values()))
289
+
290
+
291
+ def _rank_media(media: list[MediaCandidate]) -> list[MediaCandidate]:
292
+ """Prefer passthrough/full media over short previews when both exist."""
293
+ rank = {
294
+ "passthrough": 0,
295
+ "video": 1,
296
+ "unknown": 2,
297
+ "preview": 3,
298
+ }
299
+ sorted_media = sorted(
300
+ media,
301
+ key=lambda m: (rank.get(m.kind, 9), m.ext != "mp3", m.url),
302
+ )
303
+ has_full = any(m.kind in {"passthrough", "video", "unknown"} for m in sorted_media)
304
+ if has_full:
305
+ return [m for m in sorted_media if m.kind != "preview"]
306
+ return sorted_media
307
+
308
+
309
+ def extract_episode_ids(html: str) -> list[str]:
310
+ """Collect episode ids linked from a show embed page."""
311
+ seen: set[str] = set()
312
+ ordered: list[str] = []
313
+ for match in _EPISODE_LINK_RE.finditer(html):
314
+ episode_id = match.group(1)
315
+ if episode_id not in seen:
316
+ seen.add(episode_id)
317
+ ordered.append(episode_id)
318
+ return ordered
319
+
320
+
321
+ def parse_embed(page_url: str, html: str) -> EmbedResult:
322
+ return EmbedResult(
323
+ page_url=page_url,
324
+ title=extract_title(html),
325
+ media=extract_media(html),
326
+ episode_ids=extract_episode_ids(html),
327
+ )
328
+
329
+
330
+ def fetch_embed(page_url: str, *, timeout: float = 30.0) -> EmbedResult:
331
+ html = fetch_html(page_url, timeout=timeout)
332
+ return parse_embed(page_url, html)
get_spotify/urls.py ADDED
@@ -0,0 +1,95 @@
1
+ """Parse Spotify URLs and normalize them to embed player URLs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from urllib.parse import urlparse
8
+
9
+ SUPPORTED_TYPES = frozenset({"episode", "track", "show"})
10
+
11
+ # open.spotify.com/{embed/}?{type}/{id}[/video]
12
+ _OPEN_PATH = re.compile(
13
+ r"^/(?:embed/)?(?P<type>episode|track|show)/(?P<id>[A-Za-z0-9]+)"
14
+ r"(?P<video>/video)?/?$",
15
+ re.IGNORECASE,
16
+ )
17
+
18
+ # spotify:episode:ID / spotify:track:ID / spotify:show:ID
19
+ _URI = re.compile(
20
+ r"^spotify:(?P<type>episode|track|show):(?P<id>[A-Za-z0-9]+)$",
21
+ re.IGNORECASE,
22
+ )
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class SpotifyRef:
27
+ """A normalized Spotify resource reference."""
28
+
29
+ type: str
30
+ id: str
31
+ video: bool = False
32
+
33
+ def embed_url(self, *, video: bool | None = None) -> str:
34
+ use_video = self.video if video is None else video
35
+ base = f"https://open.spotify.com/embed/{self.type}/{self.id}"
36
+ if use_video and self.type == "episode":
37
+ return f"{base}/video"
38
+ return base
39
+
40
+ def embed_candidates(self) -> list[str]:
41
+ """Ordered embed URLs to try (prefer the form the user opened)."""
42
+ if self.type != "episode":
43
+ return [self.embed_url(video=False)]
44
+
45
+ primary = self.embed_url(video=self.video)
46
+ secondary = self.embed_url(video=not self.video)
47
+ urls = [primary]
48
+ if secondary not in urls:
49
+ urls.append(secondary)
50
+ return urls
51
+
52
+
53
+ class UrlError(ValueError):
54
+ """Raised when the input is not a supported Spotify URL."""
55
+
56
+
57
+ def parse_spotify_url(raw: str) -> SpotifyRef:
58
+ """Parse an open.spotify.com URL or spotify: URI into a SpotifyRef."""
59
+ text = (raw or "").strip()
60
+ if not text:
61
+ raise UrlError("URL is empty")
62
+
63
+ uri_match = _URI.match(text)
64
+ if uri_match:
65
+ return SpotifyRef(
66
+ type=uri_match.group("type").lower(),
67
+ id=uri_match.group("id"),
68
+ )
69
+
70
+ if not re.match(r"^https?://", text, re.IGNORECASE):
71
+ text = "https://" + text
72
+
73
+ parsed = urlparse(text)
74
+ host = (parsed.hostname or "").lower()
75
+ if host not in {"open.spotify.com", "www.open.spotify.com"}:
76
+ raise UrlError(
77
+ f"Unsupported host {host!r}; expected open.spotify.com"
78
+ )
79
+
80
+ path_match = _OPEN_PATH.match(parsed.path or "")
81
+ if not path_match:
82
+ raise UrlError(
83
+ "Unsupported Spotify path; expected "
84
+ "/episode|/track|/show/{id} (or /embed/...)"
85
+ )
86
+
87
+ resource_type = path_match.group("type").lower()
88
+ if resource_type not in SUPPORTED_TYPES:
89
+ raise UrlError(f"Unsupported resource type {resource_type!r}")
90
+
91
+ return SpotifyRef(
92
+ type=resource_type,
93
+ id=path_match.group("id"),
94
+ video=bool(path_match.group("video")),
95
+ )
@@ -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,10 @@
1
+ get_spotify/__init__.py,sha256=Lb9ln7ut_CsepsdP_EuRw0OSfa1Ta2nlU1BpfLRY-64,87
2
+ get_spotify/__main__.py,sha256=pecbDlHSzDaxUm4ZEgMt2T6lz4zOxGDreg1L-Axr7k0,104
3
+ get_spotify/cli.py,sha256=5Mt6V0ymDo6U8mTBQv1KEdtdoD5Mqc3TO9tVJyysBTQ,6710
4
+ get_spotify/download.py,sha256=0zAlztvxuOnQJ3Rk_SAqq7S6PBn3Dyr0GzRrYg7xqLg,4167
5
+ get_spotify/embed.py,sha256=2nUQAryVWXd1o3_4PxfHFnZ24ntVIFHle2YFPC1_X5s,10566
6
+ get_spotify/urls.py,sha256=fXTpxpCtaSrcTsG4TliBxpy-SxN_6Is2BpUOYaK84i0,2908
7
+ get_spotify-0.1.0.dist-info/METADATA,sha256=60eOAqQgCiZSuG_d1YJ9cP1iDxMCWuuWaDOcwfhL6R0,3395
8
+ get_spotify-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
9
+ get_spotify-0.1.0.dist-info/entry_points.txt,sha256=awzfubxfUuu6dvkdmtr4kCJxiw_fVB8NhQiSoRx4jcg,53
10
+ get_spotify-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ get-spotify = get_spotify.cli:main