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.
@@ -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()