cassettify 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.
- cassettify-0.1.0/Formula/cassettify.rb +19 -0
- cassettify-0.1.0/PKG-INFO +52 -0
- cassettify-0.1.0/README.md +35 -0
- cassettify-0.1.0/cassettify/__init__.py +0 -0
- cassettify-0.1.0/cassettify/auth.py +29 -0
- cassettify-0.1.0/cassettify/cache.py +25 -0
- cassettify-0.1.0/cassettify/cli.py +66 -0
- cassettify-0.1.0/cassettify/config.py +30 -0
- cassettify-0.1.0/cassettify/downloader.py +42 -0
- cassettify-0.1.0/cassettify/spotify.py +72 -0
- cassettify-0.1.0/cassettify/ui/__init__.py +0 -0
- cassettify-0.1.0/cassettify/ui/app.py +57 -0
- cassettify-0.1.0/cassettify/ui/picker.py +78 -0
- cassettify-0.1.0/cassettify/ui/progress.py +115 -0
- cassettify-0.1.0/cassettify/ui/wizard.py +120 -0
- cassettify-0.1.0/docs/superpowers/plans/2026-05-30-cassettify.md +1374 -0
- cassettify-0.1.0/docs/superpowers/specs/2026-05-30-cassettify-design.md +178 -0
- cassettify-0.1.0/pyproject.toml +34 -0
- cassettify-0.1.0/tests/__init__.py +0 -0
- cassettify-0.1.0/tests/test_cache.py +44 -0
- cassettify-0.1.0/tests/test_config.py +47 -0
- cassettify-0.1.0/tests/test_spotify.py +111 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
class Cassettify < Formula
|
|
2
|
+
include Language::Python::Virtualenv
|
|
3
|
+
|
|
4
|
+
desc "Download your Spotify playlists for your iPod Classic"
|
|
5
|
+
homepage "https://github.com/bsolidgold/cassettify"
|
|
6
|
+
url "https://files.pythonhosted.org/packages/source/c/cassettify/cassettify-0.1.0.tar.gz"
|
|
7
|
+
sha256 "REPLACE_WITH_SHA256_AFTER_PYPI_RELEASE"
|
|
8
|
+
license "MIT"
|
|
9
|
+
|
|
10
|
+
depends_on "python@3.11"
|
|
11
|
+
|
|
12
|
+
def install
|
|
13
|
+
virtualenv_install_with_resources
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
test do
|
|
17
|
+
system bin/"cassettify", "--help"
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: cassettify
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Download your Spotify playlists for your iPod Classic
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Requires-Dist: requests>=2.31.0
|
|
8
|
+
Requires-Dist: rich>=13.7.0
|
|
9
|
+
Requires-Dist: spotdl>=4.2.0
|
|
10
|
+
Requires-Dist: spotipy>=2.23.0
|
|
11
|
+
Requires-Dist: textual>=0.47.0
|
|
12
|
+
Requires-Dist: typer>=0.9.0
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
|
|
15
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# cassettify
|
|
19
|
+
|
|
20
|
+
Download your Spotify playlists for your iPod Classic.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
# via pip
|
|
26
|
+
pip install cassettify
|
|
27
|
+
|
|
28
|
+
# via Homebrew
|
|
29
|
+
brew tap bsolidgold/cassettify
|
|
30
|
+
brew install cassettify
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Usage
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
cassettify # interactive playlist picker
|
|
37
|
+
cassettify "Dark Side of the Moon" # download a specific playlist
|
|
38
|
+
cassettify --all # download everything
|
|
39
|
+
cassettify --output ~/Music/iPod # set output directory
|
|
40
|
+
cassettify --setup # re-run setup wizard
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Songs are saved as: `<output>/<Artist>/<Album>/<track-number> - <title>.mp3`
|
|
44
|
+
|
|
45
|
+
## First run
|
|
46
|
+
|
|
47
|
+
Cassettify will walk you through connecting your Spotify account. You'll need
|
|
48
|
+
to create a free app at developer.spotify.com — the wizard explains exactly how.
|
|
49
|
+
|
|
50
|
+
## Notes
|
|
51
|
+
|
|
52
|
+
Downloads via YouTube Music. Intended for personal archival use only.
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# cassettify
|
|
2
|
+
|
|
3
|
+
Download your Spotify playlists for your iPod Classic.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# via pip
|
|
9
|
+
pip install cassettify
|
|
10
|
+
|
|
11
|
+
# via Homebrew
|
|
12
|
+
brew tap bsolidgold/cassettify
|
|
13
|
+
brew install cassettify
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Usage
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
cassettify # interactive playlist picker
|
|
20
|
+
cassettify "Dark Side of the Moon" # download a specific playlist
|
|
21
|
+
cassettify --all # download everything
|
|
22
|
+
cassettify --output ~/Music/iPod # set output directory
|
|
23
|
+
cassettify --setup # re-run setup wizard
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Songs are saved as: `<output>/<Artist>/<Album>/<track-number> - <title>.mp3`
|
|
27
|
+
|
|
28
|
+
## First run
|
|
29
|
+
|
|
30
|
+
Cassettify will walk you through connecting your Spotify account. You'll need
|
|
31
|
+
to create a free app at developer.spotify.com — the wizard explains exactly how.
|
|
32
|
+
|
|
33
|
+
## Notes
|
|
34
|
+
|
|
35
|
+
Downloads via YouTube Music. Intended for personal archival use only.
|
|
File without changes
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import stat
|
|
3
|
+
import spotipy
|
|
4
|
+
from spotipy.oauth2 import SpotifyOAuth
|
|
5
|
+
from cassettify.config import Config, CONFIG_DIR
|
|
6
|
+
|
|
7
|
+
SCOPE = "playlist-read-private playlist-read-collaborative"
|
|
8
|
+
_CACHE_PATH = str(CONFIG_DIR / ".spotify_cache")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_client(config: Config) -> spotipy.Spotify:
|
|
12
|
+
auth_manager = SpotifyOAuth(
|
|
13
|
+
client_id=config.client_id,
|
|
14
|
+
client_secret=config.client_secret,
|
|
15
|
+
redirect_uri="http://localhost:8888/callback",
|
|
16
|
+
scope=SCOPE,
|
|
17
|
+
cache_path=_CACHE_PATH,
|
|
18
|
+
open_browser=True,
|
|
19
|
+
)
|
|
20
|
+
client = spotipy.Spotify(auth_manager=auth_manager)
|
|
21
|
+
_secure_cache_file()
|
|
22
|
+
return client
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _secure_cache_file() -> None:
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
cache = Path(_CACHE_PATH)
|
|
28
|
+
if cache.exists():
|
|
29
|
+
cache.chmod(stat.S_IRUSR | stat.S_IWUSR) # 0o600
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
CACHE_FILE = Path.home() / ".cassettify" / "cache.json"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load() -> set[str]:
|
|
9
|
+
if not CACHE_FILE.exists():
|
|
10
|
+
return set()
|
|
11
|
+
return set(json.loads(CACHE_FILE.read_text()))
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def add(track_id: str) -> None:
|
|
15
|
+
import os
|
|
16
|
+
ids = load()
|
|
17
|
+
ids.add(track_id)
|
|
18
|
+
CACHE_FILE.parent.mkdir(exist_ok=True)
|
|
19
|
+
tmp = CACHE_FILE.with_suffix(".tmp")
|
|
20
|
+
tmp.write_text(json.dumps(sorted(ids), indent=2))
|
|
21
|
+
os.replace(tmp, CACHE_FILE)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def contains(track_id: str) -> bool:
|
|
25
|
+
return track_id in load()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import typer
|
|
5
|
+
from cassettify.config import Config
|
|
6
|
+
from cassettify.auth import get_client
|
|
7
|
+
from cassettify.spotify import get_playlists, find_playlist_by_name
|
|
8
|
+
from cassettify.ui.app import run_wizard, run_picker, run_downloads
|
|
9
|
+
|
|
10
|
+
app = typer.Typer(
|
|
11
|
+
name="cassettify",
|
|
12
|
+
help="Download your Spotify playlists for your iPod Classic.",
|
|
13
|
+
add_completion=False,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _ensure_config() -> Config:
|
|
18
|
+
config = Config.load()
|
|
19
|
+
if config is None:
|
|
20
|
+
typer.echo("First time setup — let's connect Spotify.")
|
|
21
|
+
config = run_wizard()
|
|
22
|
+
return config
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback(invoke_without_command=True)
|
|
26
|
+
def main(
|
|
27
|
+
ctx: typer.Context,
|
|
28
|
+
playlist: Optional[str] = typer.Argument(
|
|
29
|
+
None, help="Name of a playlist to download (skips the picker)"
|
|
30
|
+
),
|
|
31
|
+
all_playlists: bool = typer.Option(
|
|
32
|
+
False, "--all", "-a", help="Download every playlist"
|
|
33
|
+
),
|
|
34
|
+
output: Optional[Path] = typer.Option(
|
|
35
|
+
None, "--output", "-o", help="Output directory (overrides saved default)"
|
|
36
|
+
),
|
|
37
|
+
setup: bool = typer.Option(
|
|
38
|
+
False, "--setup", help="Re-run the first-time setup wizard"
|
|
39
|
+
),
|
|
40
|
+
) -> None:
|
|
41
|
+
if ctx.invoked_subcommand is not None:
|
|
42
|
+
return
|
|
43
|
+
|
|
44
|
+
if setup:
|
|
45
|
+
run_wizard()
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
config = _ensure_config()
|
|
49
|
+
output_dir = str(output) if output else config.output_dir
|
|
50
|
+
sp = get_client(config)
|
|
51
|
+
|
|
52
|
+
if all_playlists:
|
|
53
|
+
playlists = get_playlists(sp)
|
|
54
|
+
elif playlist:
|
|
55
|
+
all_pls = get_playlists(sp)
|
|
56
|
+
match = find_playlist_by_name(all_pls, playlist)
|
|
57
|
+
if not match:
|
|
58
|
+
typer.echo(f"Playlist '{playlist}' not found.", err=True)
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
playlists = [match]
|
|
61
|
+
else:
|
|
62
|
+
playlists = run_picker(sp)
|
|
63
|
+
if not playlists:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
run_downloads(sp, playlists, output_dir)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass, asdict
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
CONFIG_DIR = Path.home() / ".cassettify"
|
|
9
|
+
CONFIG_FILE = CONFIG_DIR / "config.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class Config:
|
|
14
|
+
client_id: str
|
|
15
|
+
client_secret: str
|
|
16
|
+
output_dir: str
|
|
17
|
+
|
|
18
|
+
@classmethod
|
|
19
|
+
def load(cls) -> Optional["Config"]:
|
|
20
|
+
if not CONFIG_FILE.exists():
|
|
21
|
+
return None
|
|
22
|
+
data = json.loads(CONFIG_FILE.read_text())
|
|
23
|
+
return cls(**data)
|
|
24
|
+
|
|
25
|
+
def save(self) -> None:
|
|
26
|
+
CONFIG_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
27
|
+
CONFIG_DIR.chmod(0o700) # re-apply in case dir already existed
|
|
28
|
+
fd = os.open(CONFIG_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
29
|
+
with os.fdopen(fd, "w") as f:
|
|
30
|
+
json.dump(asdict(self), f, indent=2)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import subprocess
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from cassettify.spotify import Track
|
|
6
|
+
|
|
7
|
+
FAILED_LOG = Path.home() / ".cassettify" / "failed.log"
|
|
8
|
+
|
|
9
|
+
_OUTPUT_TEMPLATE = "{artists}/{album}/{track-number} - {title}.{output-ext}"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def download_track(track: Track, output_dir: str) -> bool:
|
|
13
|
+
"""Download a single track via spotdl. Returns True on success."""
|
|
14
|
+
full_template = str(Path(output_dir) / _OUTPUT_TEMPLATE)
|
|
15
|
+
try:
|
|
16
|
+
result = subprocess.run(
|
|
17
|
+
[
|
|
18
|
+
sys.executable, "-m", "spotdl",
|
|
19
|
+
track.spotify_url,
|
|
20
|
+
"--output", full_template,
|
|
21
|
+
"--format", "mp3",
|
|
22
|
+
],
|
|
23
|
+
capture_output=True,
|
|
24
|
+
text=True,
|
|
25
|
+
timeout=120,
|
|
26
|
+
)
|
|
27
|
+
if result.returncode != 0:
|
|
28
|
+
_log_failure(track, result.stderr.strip())
|
|
29
|
+
return False
|
|
30
|
+
return True
|
|
31
|
+
except subprocess.TimeoutExpired:
|
|
32
|
+
_log_failure(track, "timeout after 120s")
|
|
33
|
+
return False
|
|
34
|
+
except Exception as e:
|
|
35
|
+
_log_failure(track, str(e))
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _log_failure(track: Track, reason: str) -> None:
|
|
40
|
+
FAILED_LOG.parent.mkdir(exist_ok=True)
|
|
41
|
+
with FAILED_LOG.open("a") as f:
|
|
42
|
+
f.write(f"{track.artist} - {track.name} ({track.id}): {reason}\n")
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from dataclasses import dataclass
|
|
3
|
+
from typing import Optional
|
|
4
|
+
import spotipy
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass
|
|
8
|
+
class Track:
|
|
9
|
+
id: str
|
|
10
|
+
name: str
|
|
11
|
+
artist: str
|
|
12
|
+
album: str
|
|
13
|
+
album_art_url: Optional[str]
|
|
14
|
+
spotify_url: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Playlist:
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
track_count: int
|
|
22
|
+
cover_url: Optional[str]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_playlists(sp: spotipy.Spotify) -> list[Playlist]:
|
|
26
|
+
playlists = []
|
|
27
|
+
results = sp.current_user_playlists(limit=50)
|
|
28
|
+
while results:
|
|
29
|
+
for item in results["items"]:
|
|
30
|
+
cover = item["images"][0]["url"] if item["images"] else None
|
|
31
|
+
playlists.append(Playlist(
|
|
32
|
+
id=item["id"],
|
|
33
|
+
name=item["name"],
|
|
34
|
+
track_count=item["tracks"]["total"],
|
|
35
|
+
cover_url=cover,
|
|
36
|
+
))
|
|
37
|
+
results = sp.next(results) if results["next"] else None
|
|
38
|
+
return playlists
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_tracks(sp: spotipy.Spotify, playlist_id: str) -> list[Track]:
|
|
42
|
+
tracks = []
|
|
43
|
+
results = sp.playlist_tracks(playlist_id)
|
|
44
|
+
while results:
|
|
45
|
+
for item in results["items"]:
|
|
46
|
+
t = item.get("track")
|
|
47
|
+
if not t or t.get("is_local") or not t.get("id"):
|
|
48
|
+
continue
|
|
49
|
+
spotify_url = t.get("external_urls", {}).get("spotify", "")
|
|
50
|
+
if not spotify_url:
|
|
51
|
+
continue
|
|
52
|
+
art = t["album"]["images"][0]["url"] if t["album"]["images"] else None
|
|
53
|
+
tracks.append(Track(
|
|
54
|
+
id=t["id"],
|
|
55
|
+
name=t["name"],
|
|
56
|
+
artist=t["artists"][0]["name"] if t.get("artists") else "Unknown Artist",
|
|
57
|
+
album=t["album"]["name"],
|
|
58
|
+
album_art_url=art,
|
|
59
|
+
spotify_url=spotify_url,
|
|
60
|
+
))
|
|
61
|
+
results = sp.next(results) if results["next"] else None
|
|
62
|
+
return tracks
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def find_playlist_by_name(
|
|
66
|
+
playlists: list[Playlist], name: str
|
|
67
|
+
) -> Optional[Playlist]:
|
|
68
|
+
name_lower = name.lower()
|
|
69
|
+
for p in playlists:
|
|
70
|
+
if p.name.lower() == name_lower:
|
|
71
|
+
return p
|
|
72
|
+
return None
|
|
File without changes
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import spotipy
|
|
3
|
+
from cassettify.config import Config
|
|
4
|
+
from cassettify.auth import get_client
|
|
5
|
+
from cassettify.spotify import get_playlists, get_tracks, Playlist
|
|
6
|
+
from cassettify import cache
|
|
7
|
+
from cassettify.downloader import download_track
|
|
8
|
+
from cassettify.ui.wizard import WizardApp
|
|
9
|
+
from cassettify.ui.picker import PickerApp
|
|
10
|
+
from cassettify.ui.progress import ProgressApp
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_wizard() -> Config:
|
|
14
|
+
"""Run the first-run setup wizard. Saves and returns Config."""
|
|
15
|
+
import typer
|
|
16
|
+
result = WizardApp().run()
|
|
17
|
+
if result is None:
|
|
18
|
+
typer.echo("Setup cancelled.")
|
|
19
|
+
raise typer.Exit(code=0)
|
|
20
|
+
config = Config(
|
|
21
|
+
client_id=result.client_id,
|
|
22
|
+
client_secret=result.client_secret,
|
|
23
|
+
output_dir=result.output_dir,
|
|
24
|
+
)
|
|
25
|
+
config.save()
|
|
26
|
+
return config
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def run_picker(sp: spotipy.Spotify) -> list[Playlist]:
|
|
30
|
+
"""Run the interactive playlist picker. Returns selected playlists."""
|
|
31
|
+
playlists = get_playlists(sp)
|
|
32
|
+
if not playlists:
|
|
33
|
+
return []
|
|
34
|
+
return PickerApp(playlists).run() or []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run_downloads(
|
|
38
|
+
sp: spotipy.Spotify, playlists: list[Playlist], output_dir: str
|
|
39
|
+
) -> None:
|
|
40
|
+
"""Collect tracks from playlists, skip cached, run the progress UI."""
|
|
41
|
+
all_tracks = []
|
|
42
|
+
for playlist in playlists:
|
|
43
|
+
tracks = get_tracks(sp, playlist.id)
|
|
44
|
+
new = [t for t in tracks if not cache.contains(t.id)]
|
|
45
|
+
all_tracks.extend(new)
|
|
46
|
+
|
|
47
|
+
if not all_tracks:
|
|
48
|
+
print("Nothing new to download — all tracks already in cache.")
|
|
49
|
+
return
|
|
50
|
+
|
|
51
|
+
def download_and_cache(track):
|
|
52
|
+
success = download_track(track, output_dir)
|
|
53
|
+
if success:
|
|
54
|
+
cache.add(track.id)
|
|
55
|
+
return success
|
|
56
|
+
|
|
57
|
+
ProgressApp(all_tracks, download_and_cache).run()
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from textual.app import App, ComposeResult
|
|
3
|
+
from textual.widgets import DataTable, Input, Static, Footer, Header
|
|
4
|
+
from textual.containers import Vertical
|
|
5
|
+
from textual import on
|
|
6
|
+
from cassettify.spotify import Playlist
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class PickerApp(App):
|
|
10
|
+
"""Interactive playlist picker. Returns list of selected Playlist objects."""
|
|
11
|
+
|
|
12
|
+
BINDINGS = [
|
|
13
|
+
("space", "toggle_selection", "Select"),
|
|
14
|
+
("enter", "confirm", "Download"),
|
|
15
|
+
("escape", "quit_app", "Quit"),
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
CSS = """
|
|
19
|
+
#search { dock: top; margin: 1 2; }
|
|
20
|
+
#status { dock: bottom; height: 1; margin: 0 2; color: $text-muted; }
|
|
21
|
+
DataTable { margin: 0 2; }
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, playlists: list[Playlist]) -> None:
|
|
25
|
+
super().__init__()
|
|
26
|
+
self._all = playlists
|
|
27
|
+
self._filtered = list(playlists)
|
|
28
|
+
self._selected: set[str] = set()
|
|
29
|
+
|
|
30
|
+
def compose(self) -> ComposeResult:
|
|
31
|
+
yield Header(show_clock=False)
|
|
32
|
+
yield Input(placeholder="Search playlists...", id="search")
|
|
33
|
+
yield DataTable(id="table", cursor_type="row")
|
|
34
|
+
yield Static("", id="status")
|
|
35
|
+
yield Footer()
|
|
36
|
+
|
|
37
|
+
def on_mount(self) -> None:
|
|
38
|
+
table = self.query_one(DataTable)
|
|
39
|
+
table.add_columns("", "Playlist", "Tracks")
|
|
40
|
+
self._refresh_table()
|
|
41
|
+
|
|
42
|
+
def _refresh_table(self) -> None:
|
|
43
|
+
table = self.query_one(DataTable)
|
|
44
|
+
table.clear()
|
|
45
|
+
for p in self._filtered:
|
|
46
|
+
check = "✓" if p.id in self._selected else " "
|
|
47
|
+
table.add_row(check, p.name, str(p.track_count), key=p.id)
|
|
48
|
+
n = len(self._selected)
|
|
49
|
+
self.query_one("#status", Static).update(
|
|
50
|
+
f"{n} playlist{'s' if n != 1 else ''} selected · "
|
|
51
|
+
"Space to toggle · Enter to download · Esc to quit"
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
@on(Input.Changed, "#search")
|
|
55
|
+
def filter_playlists(self, event: Input.Changed) -> None:
|
|
56
|
+
q = event.value.lower()
|
|
57
|
+
self._filtered = [p for p in self._all if q in p.name.lower()]
|
|
58
|
+
self._refresh_table()
|
|
59
|
+
|
|
60
|
+
def action_toggle_selection(self) -> None:
|
|
61
|
+
table = self.query_one(DataTable)
|
|
62
|
+
row_index = table.cursor_row
|
|
63
|
+
if row_index is None or row_index >= len(self._filtered):
|
|
64
|
+
return
|
|
65
|
+
playlist_id = self._filtered[row_index].id
|
|
66
|
+
if playlist_id in self._selected:
|
|
67
|
+
self._selected.discard(playlist_id)
|
|
68
|
+
else:
|
|
69
|
+
self._selected.add(playlist_id)
|
|
70
|
+
self._refresh_table()
|
|
71
|
+
table.move_cursor(row=row_index)
|
|
72
|
+
|
|
73
|
+
def action_confirm(self) -> None:
|
|
74
|
+
selected = [p for p in self._all if p.id in self._selected]
|
|
75
|
+
self.exit(selected if selected else None)
|
|
76
|
+
|
|
77
|
+
def action_quit_app(self) -> None:
|
|
78
|
+
self.exit(None)
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
import asyncio
|
|
3
|
+
from typing import Callable
|
|
4
|
+
from textual.app import App, ComposeResult
|
|
5
|
+
from textual.widgets import (
|
|
6
|
+
Static, ProgressBar, ListView, ListItem, Label, Header, Footer
|
|
7
|
+
)
|
|
8
|
+
from textual.containers import Horizontal, Vertical, Container
|
|
9
|
+
from textual.reactive import reactive
|
|
10
|
+
from cassettify.spotify import Track
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class ProgressApp(App):
|
|
14
|
+
"""Download progress display. Calls download_fn(track) -> bool per track."""
|
|
15
|
+
|
|
16
|
+
BINDINGS = [("q", "quit_app", "Quit")]
|
|
17
|
+
|
|
18
|
+
CSS = """
|
|
19
|
+
#now-playing {
|
|
20
|
+
height: 9;
|
|
21
|
+
border: solid $primary;
|
|
22
|
+
margin: 1 2;
|
|
23
|
+
padding: 1 2;
|
|
24
|
+
}
|
|
25
|
+
#art {
|
|
26
|
+
width: 10;
|
|
27
|
+
height: 5;
|
|
28
|
+
border: solid $primary;
|
|
29
|
+
margin-right: 2;
|
|
30
|
+
content-align: center middle;
|
|
31
|
+
color: $text-muted;
|
|
32
|
+
}
|
|
33
|
+
#track-info { height: 5; width: 1fr; }
|
|
34
|
+
#track-name { text-style: bold; }
|
|
35
|
+
#artist-album { color: $text-muted; margin-bottom: 1; }
|
|
36
|
+
#panels { height: 1fr; margin: 0 2; }
|
|
37
|
+
#queue-panel, #done-panel { height: 1fr; width: 1fr; border: solid $surface; padding: 1; }
|
|
38
|
+
.panel-title { color: $text-muted; margin-bottom: 1; }
|
|
39
|
+
.done-label { color: $success; }
|
|
40
|
+
.fail-label { color: $error; }
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(self, tracks: list[Track], download_fn: Callable[[Track], bool]) -> None:
|
|
44
|
+
super().__init__()
|
|
45
|
+
self._tracks = tracks
|
|
46
|
+
self._download_fn = download_fn
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
yield Header(show_clock=False)
|
|
50
|
+
yield Container(
|
|
51
|
+
Horizontal(
|
|
52
|
+
Static("♫", id="art"),
|
|
53
|
+
Vertical(
|
|
54
|
+
Static("Preparing...", id="track-name"),
|
|
55
|
+
Static("", id="artist-album"),
|
|
56
|
+
ProgressBar(total=len(self._tracks), show_eta=False, id="progress"),
|
|
57
|
+
id="track-info",
|
|
58
|
+
),
|
|
59
|
+
id="now-playing",
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
yield Horizontal(
|
|
63
|
+
Vertical(
|
|
64
|
+
Static(f"Queue ({len(self._tracks)} tracks)", classes="panel-title", id="queue-title"),
|
|
65
|
+
ListView(id="queue"),
|
|
66
|
+
id="queue-panel",
|
|
67
|
+
),
|
|
68
|
+
Vertical(
|
|
69
|
+
Static("✓ Done", classes="panel-title"),
|
|
70
|
+
ListView(id="done"),
|
|
71
|
+
id="done-panel",
|
|
72
|
+
),
|
|
73
|
+
id="panels",
|
|
74
|
+
)
|
|
75
|
+
yield Footer()
|
|
76
|
+
|
|
77
|
+
def on_mount(self) -> None:
|
|
78
|
+
queue = self.query_one("#queue", ListView)
|
|
79
|
+
for t in self._tracks:
|
|
80
|
+
queue.append(ListItem(Label(f" {t.artist} — {t.name}"), id=f"q-{t.id}"))
|
|
81
|
+
self.run_worker(self._run_downloads())
|
|
82
|
+
|
|
83
|
+
async def _run_downloads(self) -> None:
|
|
84
|
+
done_count = 0
|
|
85
|
+
for track in self._tracks:
|
|
86
|
+
self.query_one("#track-name", Static).update(f"[bold]{track.name}[/bold]")
|
|
87
|
+
self.query_one("#artist-album", Static).update(
|
|
88
|
+
f"{track.artist} · {track.album}"
|
|
89
|
+
)
|
|
90
|
+
try:
|
|
91
|
+
self.query_one(f"#q-{track.id}").remove()
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
remaining = len(self._tracks) - done_count - 1
|
|
95
|
+
self.query_one("#queue-title", Static).update(
|
|
96
|
+
f"Queue ({remaining} remaining)"
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
success = await asyncio.to_thread(self._download_fn, track)
|
|
100
|
+
done_count += 1
|
|
101
|
+
self.query_one("#progress", ProgressBar).advance(1)
|
|
102
|
+
|
|
103
|
+
done_list = self.query_one("#done", ListView)
|
|
104
|
+
if success:
|
|
105
|
+
await done_list.append(ListItem(Label(f"✓ {track.name}", classes="done-label")))
|
|
106
|
+
else:
|
|
107
|
+
await done_list.append(ListItem(Label(f"✗ {track.name}", classes="fail-label")))
|
|
108
|
+
|
|
109
|
+
self.query_one("#track-name", Static).update(
|
|
110
|
+
f"[bold green]All done![/bold green] {done_count}/{len(self._tracks)} tracks downloaded"
|
|
111
|
+
)
|
|
112
|
+
self.query_one("#artist-album", Static).update("Press Q to quit")
|
|
113
|
+
|
|
114
|
+
def action_quit_app(self) -> None:
|
|
115
|
+
self.exit()
|