headless-music 1.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,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 imnotgoingtohindiclass
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,114 @@
1
+ Metadata-Version: 2.4
2
+ Name: headless-music
3
+ Version: 1.1.0
4
+ Summary: headless music: for minimal cpu/gpu usage while listening to music
5
+ Author: Rishav Ganguly
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/imnotgoingtohindiclass/headless_music
8
+ Requires-Python: >=3.8
9
+ Description-Content-Type: text/markdown
10
+ License-File: LICENSE
11
+ Requires-Dist: yt-dlp
12
+ Requires-Dist: spotipy
13
+ Requires-Dist: python-mpv
14
+ Requires-Dist: rich
15
+ Dynamic: license-file
16
+
17
+ # headless_music
18
+
19
+ A Spotify-inspired, terminal-based music player. `headless_music` brings music to your terminal with full-color ASCII album art and an endless recommendation queue.
20
+
21
+ ## Features
22
+
23
+ - Spotify-inspired terminal UI with a modern 3-panel layout (built with `rich`).
24
+ - Color ASCII album art (when available from Spotify).
25
+ - Playlist-first playback for Spotify or YouTube playlists.
26
+ - Endless radio mode that queues recommendations after the playlist ends.
27
+ - Smooth, pulsing progress bar showing playback and loading status.
28
+ - First-run configuration wizard for easy setup.
29
+ - Cross-platform: runs on systems with Python and `mpv`.
30
+
31
+ ## Prerequisites
32
+
33
+ `mpv` is required as the audio backend.
34
+
35
+ ### macOS (Homebrew)
36
+ ```bash
37
+ brew install mpv
38
+ ```
39
+
40
+ ### Ubuntu/Debian
41
+ ```bash
42
+ sudo apt update && sudo apt install mpv
43
+ ```
44
+
45
+ ### Windows (Chocolatey)
46
+ ```bash
47
+ choco install mpv
48
+ ```
49
+
50
+ ## Installation
51
+
52
+ To install, simply use the Python package manager pip:
53
+ ```bash
54
+ pip install headless_music
55
+ ```
56
+
57
+ ## Usage
58
+
59
+ To start the player, type into your terminal
60
+ ```bash
61
+ headless_music
62
+ ```
63
+
64
+ On first launch a setup wizard will walk you through adding your Spotify API credentials. Obtain these from the Spotify Developer Dashboard: [https://developer.spotify.com/dashboard/](https://developer.spotify.com/dashboard/)
65
+
66
+ ## Controls
67
+
68
+ | Key | Action |
69
+ | ----- | ------------------------------------------------ |
70
+ | Space | Play / Pause |
71
+ | n | Next track |
72
+ | p | Previous track |
73
+ | c | Re-run the configuration wizard (stops playback) |
74
+ | q | Quit |
75
+
76
+ ## Configuration
77
+
78
+ * Spotify API credentials (Client ID, Client Secret) are required for full functionality.
79
+ * The configuration wizard writes credentials to a user config file (see `config` in repo for format).
80
+ * `mpv` path and additional mpv options can be set in the config.
81
+
82
+
83
+ ## Contribution
84
+
85
+ Contributions are welcome. Please open issues for bugs or feature requests and submit pull requests against `main`. Follow the existing code style and include tests for new functionality.
86
+
87
+ ## License
88
+
89
+ This project is licensed under the MIT License. See `LICENSE` for details.
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
99
+
100
+
101
+
102
+
103
+
104
+
105
+
106
+
107
+
108
+
109
+
110
+
111
+
112
+
113
+
114
+
@@ -0,0 +1,98 @@
1
+ # headless_music
2
+
3
+ A Spotify-inspired, terminal-based music player. `headless_music` brings music to your terminal with full-color ASCII album art and an endless recommendation queue.
4
+
5
+ ## Features
6
+
7
+ - Spotify-inspired terminal UI with a modern 3-panel layout (built with `rich`).
8
+ - Color ASCII album art (when available from Spotify).
9
+ - Playlist-first playback for Spotify or YouTube playlists.
10
+ - Endless radio mode that queues recommendations after the playlist ends.
11
+ - Smooth, pulsing progress bar showing playback and loading status.
12
+ - First-run configuration wizard for easy setup.
13
+ - Cross-platform: runs on systems with Python and `mpv`.
14
+
15
+ ## Prerequisites
16
+
17
+ `mpv` is required as the audio backend.
18
+
19
+ ### macOS (Homebrew)
20
+ ```bash
21
+ brew install mpv
22
+ ```
23
+
24
+ ### Ubuntu/Debian
25
+ ```bash
26
+ sudo apt update && sudo apt install mpv
27
+ ```
28
+
29
+ ### Windows (Chocolatey)
30
+ ```bash
31
+ choco install mpv
32
+ ```
33
+
34
+ ## Installation
35
+
36
+ To install, simply use the Python package manager pip:
37
+ ```bash
38
+ pip install headless_music
39
+ ```
40
+
41
+ ## Usage
42
+
43
+ To start the player, type into your terminal
44
+ ```bash
45
+ headless_music
46
+ ```
47
+
48
+ On first launch a setup wizard will walk you through adding your Spotify API credentials. Obtain these from the Spotify Developer Dashboard: [https://developer.spotify.com/dashboard/](https://developer.spotify.com/dashboard/)
49
+
50
+ ## Controls
51
+
52
+ | Key | Action |
53
+ | ----- | ------------------------------------------------ |
54
+ | Space | Play / Pause |
55
+ | n | Next track |
56
+ | p | Previous track |
57
+ | c | Re-run the configuration wizard (stops playback) |
58
+ | q | Quit |
59
+
60
+ ## Configuration
61
+
62
+ * Spotify API credentials (Client ID, Client Secret) are required for full functionality.
63
+ * The configuration wizard writes credentials to a user config file (see `config` in repo for format).
64
+ * `mpv` path and additional mpv options can be set in the config.
65
+
66
+
67
+ ## Contribution
68
+
69
+ Contributions are welcome. Please open issues for bugs or feature requests and submit pull requests against `main`. Follow the existing code style and include tests for new functionality.
70
+
71
+ ## License
72
+
73
+ This project is licensed under the MIT License. See `LICENSE` for details.
74
+
75
+
76
+
77
+
78
+
79
+
80
+
81
+
82
+
83
+
84
+
85
+
86
+
87
+
88
+
89
+
90
+
91
+
92
+
93
+
94
+
95
+
96
+
97
+
98
+
@@ -0,0 +1,2 @@
1
+ __version__ = "1.1.0"
2
+ __author__ = "Rishav Ganguly"
@@ -0,0 +1,123 @@
1
+ import json
2
+ import logging
3
+ from pathlib import Path
4
+ from rich.console import Console
5
+ from rich.prompt import Prompt, Confirm
6
+
7
+ console = Console()
8
+
9
+ CONFIG_FILE = Path.home() / ".headless_music_config.json"
10
+ LOG_FILE = Path.home() / ".headless_music.log"
11
+
12
+
13
+ def setup_logging():
14
+ logging.basicConfig(
15
+ filename=LOG_FILE,
16
+ level=logging.WARNING,
17
+ format='%(asctime)s - %(levelname)s - %(message)s'
18
+ )
19
+
20
+
21
+ def load_config():
22
+ if CONFIG_FILE.exists():
23
+ try:
24
+ with open(CONFIG_FILE, 'r') as f:
25
+ return json.load(f)
26
+ except Exception as e:
27
+ logging.warning(f"Could not load config: {e}")
28
+ console.print(f"[yellow]Warning: Could not load config: {e}[/yellow]")
29
+ return {}
30
+
31
+
32
+ def save_config(config):
33
+ try:
34
+ with open(CONFIG_FILE, 'w') as f:
35
+ json.dump(config, f, indent=2)
36
+ return True
37
+ except Exception as e:
38
+ logging.error(f"Error saving config: {e}")
39
+ console.print(f"[red]Error saving config: {e}[/red]")
40
+ return False
41
+
42
+
43
+ def validate_config(config):
44
+ required = ['SPOTIFY_CLIENT_ID', 'SPOTIFY_CLIENT_SECRET',
45
+ 'PLAYLIST_SOURCE', 'PLAYLIST_URL']
46
+ missing = [key for key in required if not config.get(key)]
47
+
48
+ if missing:
49
+ console.print(f"[red]Missing configuration: {', '.join(missing)}[/red]")
50
+ return False
51
+ return True
52
+
53
+
54
+ def setup_wizard():
55
+ console.clear()
56
+ console.print("=" * 60, style="cyan")
57
+ console.print("🎵 Welcome to headless_music Setup!",
58
+ style="bold cyan", justify="center")
59
+ console.print("=" * 60, style="cyan")
60
+ console.print()
61
+
62
+ config = load_config()
63
+
64
+ console.print("📱 [bold]Spotify API Credentials[/bold]")
65
+ console.print(" Get these from: https://developer.spotify.com/dashboard",
66
+ style="dim")
67
+ console.print()
68
+
69
+ spotify_id = Prompt.ask(
70
+ " Spotify Client ID",
71
+ default=config.get('SPOTIFY_CLIENT_ID', '')
72
+ )
73
+ spotify_secret = Prompt.ask(
74
+ " Spotify Client Secret",
75
+ default=config.get('SPOTIFY_CLIENT_SECRET', ''),
76
+ password=True
77
+ )
78
+
79
+ console.print()
80
+ console.print("🎧 [bold]Playlist Source[/bold]")
81
+ console.print()
82
+
83
+ source_choice = Prompt.ask(
84
+ " Choose your playlist source",
85
+ choices=["spotify", "youtube"],
86
+ default=config.get('PLAYLIST_SOURCE', 'spotify')
87
+ )
88
+
89
+ console.print()
90
+
91
+ if source_choice == "spotify":
92
+ console.print(" Enter your Spotify playlist URL or URI", style="dim")
93
+ console.print(" Example: https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M",
94
+ style="dim")
95
+ playlist_url = Prompt.ask(" Spotify Playlist URL/URI")
96
+ else:
97
+ console.print(" Enter your YouTube playlist URL", style="dim")
98
+ console.print(" Example: https://www.youtube.com/playlist?list=...",
99
+ style="dim")
100
+ playlist_url = Prompt.ask(" YouTube Playlist URL")
101
+
102
+ console.print()
103
+
104
+ new_config = {
105
+ 'SPOTIFY_CLIENT_ID': spotify_id,
106
+ 'SPOTIFY_CLIENT_SECRET': spotify_secret,
107
+ 'PLAYLIST_SOURCE': source_choice,
108
+ 'PLAYLIST_URL': playlist_url
109
+ }
110
+
111
+ if save_config(new_config):
112
+ console.print("✓ Configuration saved!", style="bold green")
113
+ console.print(f" Config location: {CONFIG_FILE}", style="dim")
114
+ console.print(f" Log location: {LOG_FILE}", style="dim")
115
+ else:
116
+ console.print("⚠️ Could not save configuration.", style="yellow")
117
+
118
+ console.print()
119
+ if Confirm.ask("Start headless_music now?", default=True):
120
+ return new_config
121
+ else:
122
+ console.print("👋 Run this script again when you're ready!", style="cyan")
123
+ return None
@@ -0,0 +1,4 @@
1
+ from .spotify import SpotifyFetcher
2
+ from .youtube import YouTubeFetcher
3
+
4
+ __all__ = ['SpotifyFetcher', 'YouTubeFetcher']
@@ -0,0 +1,166 @@
1
+ import os
2
+ import logging
3
+ import random
4
+ import spotipy
5
+ from spotipy.oauth2 import SpotifyClientCredentials
6
+ from spotipy.cache_handler import CacheFileHandler
7
+
8
+
9
+ cache_path = os.path.join(os.path.expanduser("~"), ".cache_headless_music")
10
+ cache_handler = CacheFileHandler(cache_path=cache_path)
11
+
12
+
13
+ class SpotifyFetcher:
14
+ """Handle all Spotify API interactions."""
15
+
16
+ def __init__(self, client_id, client_secret):
17
+ self.client = None
18
+ self.client_id = client_id
19
+ self.client_secret = client_secret
20
+ self._initialize_client()
21
+
22
+ def _initialize_client(self):
23
+ """Initialize Spotify client with credentials."""
24
+ try:
25
+ self.client = spotipy.Spotify(
26
+ auth_manager=SpotifyClientCredentials(
27
+ client_id=self.client_id,
28
+ client_secret=self.client_secret,
29
+ cache_handler=cache_handler
30
+ ),
31
+ requests_timeout=5
32
+ )
33
+ logging.info("Spotify client initialized")
34
+ except Exception as e:
35
+ logging.error(f"Failed to initialize Spotify: {e}")
36
+ self.client = None
37
+
38
+ def get_playlist_tracks(self, playlist_url):
39
+ """Fetch all tracks from a Spotify playlist."""
40
+ if not self.client:
41
+ return []
42
+
43
+ try:
44
+ playlist_id = self._extract_playlist_id(playlist_url)
45
+ results = []
46
+ offset = 0
47
+
48
+ while True:
49
+ response = self.client.playlist_tracks(playlist_id, offset=offset, limit=100)
50
+ for item in response['items']:
51
+ if item['track']:
52
+ track = item['track']
53
+ image_url = (track['album']['images'][-1]['url']
54
+ if track['album']['images'] else None)
55
+ results.append((
56
+ track['name'],
57
+ track['artists'][0]['name'],
58
+ "Spotify",
59
+ image_url
60
+ ))
61
+
62
+ if not response['next']:
63
+ break
64
+ offset += 100
65
+
66
+ logging.info(f"Fetched {len(results)} tracks from Spotify")
67
+ return results
68
+ except Exception as e:
69
+ logging.error(f"Error fetching Spotify playlist: {e}")
70
+ return []
71
+
72
+ def get_recommendations(self, seed_tracks, limit=20):
73
+ """Get Spotify recommendations based on seed tracks."""
74
+ if not self.client:
75
+ return []
76
+
77
+ results = []
78
+ seen_ids = set()
79
+
80
+ sample_size = min(len(seed_tracks), 5)
81
+ sampled = random.sample(seed_tracks, sample_size)
82
+
83
+ for title, artist, _, _ in sampled:
84
+ if len(results) >= limit:
85
+ break
86
+
87
+ try:
88
+ search_results = self.client.search(
89
+ q=f"{title} {artist}", type="track", limit=1
90
+ )
91
+ if search_results['tracks']['items']:
92
+ seed_id = search_results['tracks']['items'][0]['id']
93
+ try:
94
+ recs = self.client.recommendations(seed_tracks=[seed_id], limit=5)
95
+ for track in recs['tracks']:
96
+ track_id = track['id']
97
+ if track_id not in seen_ids:
98
+ image_url = (track['album']['images'][-1]['url']
99
+ if track['album']['images'] else None)
100
+ results.append((
101
+ track['name'],
102
+ track['artists'][0]['name'],
103
+ "Spotify",
104
+ image_url
105
+ ))
106
+ seen_ids.add(track_id)
107
+ except Exception:
108
+ pass
109
+ except Exception:
110
+ continue
111
+
112
+ # Fallback: related artists
113
+ if len(results) < limit:
114
+ results.extend(self._get_related_artist_tracks(
115
+ sampled, limit - len(results), seen_ids
116
+ ))
117
+
118
+ logging.info(f"Generated {len(results)} recommendations")
119
+ return results
120
+
121
+ def _get_related_artist_tracks(self, seed_tracks, limit, seen_ids):
122
+ """Get tracks from related artists."""
123
+ results = []
124
+
125
+ for title, artist, _, _ in seed_tracks:
126
+ if len(results) >= limit:
127
+ break
128
+
129
+ try:
130
+ artist_results = self.client.search(
131
+ q=f"artist:{artist}", type="artist", limit=1
132
+ )
133
+ if artist_results['artists']['items']:
134
+ artist_id = artist_results['artists']['items'][0]['id']
135
+ related = self.client.artist_related_artists(artist_id)
136
+
137
+ for rel_artist in related['artists'][:3]:
138
+ top_tracks = self.client.artist_top_tracks(rel_artist['id'])
139
+ for track in top_tracks['tracks'][:2]:
140
+ track_id = track['id']
141
+ if track_id not in seen_ids:
142
+ image_url = (track['album']['images'][-1]['url']
143
+ if track['album']['images'] else None)
144
+ results.append((
145
+ track['name'],
146
+ track['artists'][0]['name'],
147
+ "Spotify",
148
+ image_url
149
+ ))
150
+ seen_ids.add(track_id)
151
+ if len(results) >= limit:
152
+ return results
153
+ except Exception:
154
+ continue
155
+
156
+ return results
157
+
158
+ @staticmethod
159
+ def _extract_playlist_id(playlist_url):
160
+ """Extract playlist ID from various URL formats."""
161
+ if 'spotify.com' in playlist_url:
162
+ return playlist_url.split('playlist/')[-1].split('?')[0]
163
+ elif 'spotify:playlist:' in playlist_url:
164
+ return playlist_url.split('spotify:playlist:')[-1]
165
+ return playlist_url
166
+
@@ -0,0 +1,59 @@
1
+ import logging
2
+ import random
3
+ import yt_dlp
4
+
5
+
6
+ class YouTubeFetcher:
7
+ def __init__(self):
8
+ self.ydl_opts_flat = {
9
+ 'quiet': True,
10
+ 'extract_flat': True,
11
+ 'no_warnings': True,
12
+ 'ignoreerrors': True,
13
+ 'no_color': True
14
+ }
15
+ self.ydl_opts_full = {
16
+ 'quiet': True,
17
+ 'extract_flat': False,
18
+ 'no_warnings': True,
19
+ 'ignoreerrors': True,
20
+ 'no_color': True
21
+ }
22
+
23
+ def get_playlist_titles(self, url):
24
+ try:
25
+ with yt_dlp.YoutubeDL(self.ydl_opts_flat) as ydl:
26
+ info = ydl.extract_info(url, download=False)
27
+ return [(e['title'], e.get('uploader', 'Unknown'), "YouTube", None)
28
+ for e in info['entries'] if e]
29
+ except Exception as e:
30
+ logging.error(f"Error fetching YouTube playlist: {e}")
31
+ return []
32
+
33
+ def search_similar_tracks(self, seed_tracks, limit=10):
34
+ results = []
35
+ sample = random.sample(seed_tracks, min(3, len(seed_tracks)))
36
+
37
+ for title, artist, _, _ in sample:
38
+ try:
39
+ search_query = f"{artist} {title} audio"
40
+ with yt_dlp.YoutubeDL(self.ydl_opts_full) as ydl:
41
+ search_results = ydl.extract_info(
42
+ f"ytsearch5:{search_query}", download=False
43
+ )
44
+ if search_results and 'entries' in search_results:
45
+ for entry in search_results['entries']:
46
+ if entry:
47
+ image_url = entry.get('thumbnail')
48
+ results.append((
49
+ entry.get('title', 'Unknown'),
50
+ entry.get('uploader', 'Unknown'),
51
+ "YouTube",
52
+ image_url
53
+ ))
54
+ if len(results) >= limit:
55
+ return results
56
+ except Exception:
57
+ continue
58
+
59
+ return results