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.
- get_spotify-0.1.0/.gitignore +21 -0
- get_spotify-0.1.0/AGENTS.md +66 -0
- get_spotify-0.1.0/PKG-INFO +116 -0
- get_spotify-0.1.0/README.md +96 -0
- get_spotify-0.1.0/pyproject.toml +53 -0
- get_spotify-0.1.0/src/get_spotify/__init__.py +3 -0
- get_spotify-0.1.0/src/get_spotify/__main__.py +5 -0
- get_spotify-0.1.0/src/get_spotify/cli.py +222 -0
- get_spotify-0.1.0/src/get_spotify/download.py +125 -0
- get_spotify-0.1.0/src/get_spotify/embed.py +332 -0
- get_spotify-0.1.0/src/get_spotify/urls.py +95 -0
- get_spotify-0.1.0/tests/fixtures/episode_drm.html +13 -0
- get_spotify-0.1.0/tests/fixtures/episode_passthrough.html +34 -0
- get_spotify-0.1.0/tests/fixtures/show_with_episodes.html +11 -0
- get_spotify-0.1.0/tests/test_cli.py +54 -0
- get_spotify-0.1.0/tests/test_download.py +37 -0
- get_spotify-0.1.0/tests/test_embed.py +75 -0
- get_spotify-0.1.0/tests/test_urls.py +54 -0
|
@@ -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,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())
|