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.
- get_spotify/__init__.py +3 -0
- get_spotify/__main__.py +5 -0
- get_spotify/cli.py +222 -0
- get_spotify/download.py +125 -0
- get_spotify/embed.py +332 -0
- get_spotify/urls.py +95 -0
- get_spotify-0.1.0.dist-info/METADATA +116 -0
- get_spotify-0.1.0.dist-info/RECORD +10 -0
- get_spotify-0.1.0.dist-info/WHEEL +4 -0
- get_spotify-0.1.0.dist-info/entry_points.txt +2 -0
get_spotify/__init__.py
ADDED
get_spotify/__main__.py
ADDED
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())
|
get_spotify/download.py
ADDED
|
@@ -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,,
|