headless-music 1.1.1__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.
headless_music/main.py ADDED
@@ -0,0 +1,279 @@
1
+ import sys
2
+ import time
3
+ import select
4
+ import threading
5
+ from rich.console import Console
6
+ from rich.live import Live
7
+ import logging
8
+
9
+ # Import our modules
10
+ from config import (
11
+ setup_logging, load_config, validate_config,
12
+ setup_wizard, CONFIG_FILE
13
+ )
14
+ from player import MusicPlayer
15
+ from fetchers.spotify import SpotifyFetcher
16
+ from fetchers.youtube import YouTubeFetcher
17
+ from ui.layout import create_layout
18
+ from ui.panels import (
19
+ create_controls_panel, create_now_playing_panel,
20
+ create_queue_panel, create_progress_panel
21
+ )
22
+ from ui.art import create_ascii_art_panel
23
+ from utils.cache import panel_cache
24
+
25
+ console = Console()
26
+
27
+
28
+ def input_thread(player):
29
+ """Handle keyboard input with adaptive polling."""
30
+ poll_interval = 0.5
31
+
32
+ while player.is_running:
33
+ try:
34
+ readable, _, _ = select.select([sys.stdin], [], [], poll_interval)
35
+ if readable:
36
+ char = sys.stdin.read(1)
37
+ poll_interval = 0.1 # Fast response after input
38
+
39
+ if char == 'n':
40
+ player.command_queue.put("next")
41
+ elif char == 'p':
42
+ player.command_queue.put("prev")
43
+ elif char == ' ':
44
+ player.command_queue.put("pause")
45
+ elif char == 'q':
46
+ player.command_queue.put("quit")
47
+ elif char == 'c':
48
+ player.command_queue.put("config")
49
+ else:
50
+ poll_interval = min(poll_interval * 1.5, 0.5)
51
+ except Exception as e:
52
+ logging.error(f"Input thread error: {e}")
53
+ player.is_running = False
54
+
55
+
56
+ def refresh_queue(player, spotify_fetcher, youtube_fetcher):
57
+ """Refresh queue with new recommendations."""
58
+ if not player.needs_queue_refresh():
59
+ return
60
+
61
+ seed_tracks = player.playlist[
62
+ max(0, player.current_index - 10):player.current_index + 1
63
+ ]
64
+
65
+ new_tracks = []
66
+
67
+ if spotify_fetcher:
68
+ new_tracks = spotify_fetcher.get_recommendations(seed_tracks, limit=15)
69
+
70
+ if len(new_tracks) < 5 and youtube_fetcher:
71
+ yt_tracks = youtube_fetcher.search_similar_tracks(seed_tracks, limit=10)
72
+ new_tracks.extend(yt_tracks)
73
+
74
+ if new_tracks:
75
+ player.extend_playlist(new_tracks)
76
+ else:
77
+ # Loop playlist if no new tracks found
78
+ player.extend_playlist(player.playlist[:20])
79
+
80
+
81
+ def main():
82
+ """Main application loop."""
83
+ setup_logging()
84
+
85
+ config = load_config()
86
+ if not validate_config(config):
87
+ config = setup_wizard()
88
+ if not config:
89
+ return
90
+
91
+ console.print("🎵 Initialising headless_music...", style="bold cyan")
92
+ console.print(f"📡 Fetching {config['PLAYLIST_SOURCE'].title()} playlist...",
93
+ style="bold green")
94
+
95
+ # Initialize fetchers
96
+ spotify_fetcher = SpotifyFetcher(
97
+ config['SPOTIFY_CLIENT_ID'],
98
+ config['SPOTIFY_CLIENT_SECRET']
99
+ )
100
+ youtube_fetcher = YouTubeFetcher()
101
+
102
+ # Fetch initial playlist
103
+ if config['PLAYLIST_SOURCE'] == 'spotify':
104
+ if not spotify_fetcher.client:
105
+ console.print("[red]Failed to initialize Spotify. Exiting.[/red]")
106
+ return
107
+ initial_tracks = spotify_fetcher.get_playlist_tracks(config['PLAYLIST_URL'])
108
+ else:
109
+ initial_tracks = youtube_fetcher.get_playlist_titles(config['PLAYLIST_URL'])
110
+
111
+ if not initial_tracks:
112
+ console.print("[red]Failed to fetch playlist. Exiting.[/red]")
113
+ return
114
+
115
+ console.print(f"✓ Found {len(initial_tracks)} tracks", style="green")
116
+ console.print("🎧 Fetching additional tracks...", style="bold green")
117
+
118
+ # Fetch additional recommendations
119
+ additional_tracks = []
120
+ if spotify_fetcher.client:
121
+ additional_tracks = spotify_fetcher.get_recommendations(initial_tracks, limit=30)
122
+ if additional_tracks:
123
+ console.print(f"✓ Added {len(additional_tracks)} tracks", style="green")
124
+
125
+ # Initialize player
126
+ player = MusicPlayer()
127
+ player.playlist = initial_tracks + additional_tracks
128
+
129
+ if not player.playlist:
130
+ console.print("[red]No tracks available. Exiting.[/red]")
131
+ return
132
+
133
+ # Setup terminal
134
+ try:
135
+ import tty
136
+ import termios
137
+ old_settings = termios.tcgetattr(sys.stdin)
138
+ tty.setcbreak(sys.stdin.fileno())
139
+ except Exception:
140
+ old_settings = None
141
+
142
+ # Start input thread
143
+ input_handler = threading.Thread(
144
+ target=input_thread,
145
+ args=(player,),
146
+ daemon=True
147
+ )
148
+ input_handler.start()
149
+
150
+ console.print("\n✨ Starting playback...\n", style="bold magenta")
151
+ time.sleep(0.5)
152
+
153
+ # Create UI layout
154
+ layout = create_layout()
155
+ last_progress_update = time.time()
156
+ force_refresh_counter = 0
157
+
158
+ try:
159
+ with Live(layout, console=console, screen=True, refresh_per_second=0.5) as live:
160
+ # Initialize UI
161
+ player.play_track(0)
162
+ layout["footer"].update(create_controls_panel())
163
+
164
+ # Initial UI update
165
+ track = player.get_current_track()
166
+ if track:
167
+ title, artist, source, image_url = track
168
+ available_height = max(1, console.height - 11)
169
+
170
+ layout["art"].update(
171
+ create_ascii_art_panel(image_url, available_height, panel_cache)
172
+ )
173
+ layout["now_playing"].update(
174
+ create_now_playing_panel(title, artist, source)
175
+ )
176
+ layout["sidebar"].update(
177
+ create_queue_panel(player.playlist, player.current_index)
178
+ )
179
+
180
+ # Main loop
181
+ while player.is_running:
182
+ try:
183
+ cmd = player.command_queue.get(timeout=2.0)
184
+ except:
185
+ cmd = None
186
+
187
+ if cmd == "next":
188
+ refresh_queue(player, spotify_fetcher, youtube_fetcher)
189
+ if player.next_track():
190
+ track = player.get_current_track()
191
+ if track:
192
+ title, artist, source, image_url = track
193
+ available_height = max(1, console.height - 11)
194
+
195
+ layout["art"].update(
196
+ create_ascii_art_panel(image_url, available_height, panel_cache)
197
+ )
198
+ layout["now_playing"].update(
199
+ create_now_playing_panel(title, artist, source)
200
+ )
201
+ layout["sidebar"].update(
202
+ create_queue_panel(player.playlist, player.current_index)
203
+ )
204
+ force_refresh_counter = 0
205
+
206
+ elif cmd == "prev":
207
+ if player.prev_track():
208
+ track = player.get_current_track()
209
+ if track:
210
+ title, artist, source, image_url = track
211
+ available_height = max(1, console.height - 11)
212
+
213
+ layout["art"].update(
214
+ create_ascii_art_panel(image_url, available_height, panel_cache)
215
+ )
216
+ layout["now_playing"].update(
217
+ create_now_playing_panel(title, artist, source)
218
+ )
219
+ layout["sidebar"].update(
220
+ create_queue_panel(player.playlist, player.current_index)
221
+ )
222
+ force_refresh_counter = 0
223
+
224
+ elif cmd == "pause":
225
+ player.toggle_pause()
226
+ force_refresh_counter = 0
227
+
228
+ elif cmd == "config":
229
+ player.is_running = False
230
+ player.mpv.pause = True
231
+ live.stop()
232
+ console.clear()
233
+ new_config = setup_wizard()
234
+ if new_config:
235
+ console.print("\n[yellow]Please restart headless_music to apply new settings.[/yellow]")
236
+ break
237
+
238
+ elif cmd == "quit":
239
+ break
240
+
241
+ # Update progress every 2 seconds
242
+ current_time = time.time()
243
+ if current_time - last_progress_update >= 2.0:
244
+ try:
245
+ layout["progress"].update(create_progress_panel(player.mpv))
246
+ last_progress_update = current_time
247
+ except Exception:
248
+ pass
249
+
250
+ # Periodic forced refresh
251
+ force_refresh_counter += 1
252
+ if force_refresh_counter >= 10:
253
+ live.refresh()
254
+ force_refresh_counter = 0
255
+
256
+ # Sleep when idle
257
+ if cmd is None:
258
+ time.sleep(0.2)
259
+
260
+ except KeyboardInterrupt:
261
+ player.is_running = False
262
+
263
+ finally:
264
+ player.quit()
265
+
266
+ # Restore terminal
267
+ if old_settings:
268
+ try:
269
+ import termios
270
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
271
+ except Exception:
272
+ pass
273
+
274
+ console.clear()
275
+ console.print("\nheadless_music stopped. goodbye! 👋 \n", style="bold yellow")
276
+
277
+
278
+ if __name__ == "__main__":
279
+ main()
@@ -0,0 +1,75 @@
1
+ import logging
2
+ import queue
3
+ import threading
4
+ from mpv import MPV
5
+
6
+
7
+ class MusicPlayer:
8
+ def __init__(self):
9
+ self.mpv = MPV(
10
+ ytdl=True,
11
+ video=False,
12
+ keep_open=False,
13
+ keep_open_pause=False,
14
+ cache=True,
15
+ demuxer_max_bytes='50M',
16
+ cache_secs=10
17
+ )
18
+ self.current_index = 0
19
+ self.playlist = []
20
+ self.command_queue = queue.Queue()
21
+ self.is_running = True
22
+ self._setup_observers()
23
+
24
+ def _setup_observers(self):
25
+ @self.mpv.property_observer('idle-active')
26
+ def handle_song_end(_name, value):
27
+ if value and self.is_running:
28
+ self.command_queue.put("next")
29
+
30
+ def play_track(self, index):
31
+ if index < 0 or index >= len(self.playlist):
32
+ if index >= len(self.playlist) and len(self.playlist) > 0:
33
+ index = 0
34
+ else:
35
+ return False
36
+
37
+ self.current_index = index
38
+ title, artist, _, _ = self.playlist[self.current_index]
39
+
40
+ try:
41
+ query = f"{title} {artist} audio"
42
+ self.mpv.play(f"ytdl://ytsearch1:{query}")
43
+ self.mpv.pause = False
44
+ return True
45
+ except Exception as e:
46
+ logging.error(f"Error playing track: {e}")
47
+ self.command_queue.put("next")
48
+ return False
49
+
50
+ def next_track(self):
51
+ return self.play_track(self.current_index + 1)
52
+
53
+ def prev_track(self):
54
+ return self.play_track(max(0, self.current_index - 1))
55
+
56
+ def toggle_pause(self):
57
+ self.mpv.pause = not self.mpv.pause
58
+
59
+ def get_current_track(self):
60
+ if 0 <= self.current_index < len(self.playlist):
61
+ return self.playlist[self.current_index]
62
+ return None
63
+
64
+ def needs_queue_refresh(self, threshold=5):
65
+ return len(self.playlist) - self.current_index < threshold
66
+
67
+ def extend_playlist(self, tracks):
68
+ self.playlist.extend(tracks)
69
+
70
+ def quit(self):
71
+ self.is_running = False
72
+ try:
73
+ self.mpv.quit()
74
+ except Exception:
75
+ pass
@@ -0,0 +1,17 @@
1
+ from .layout import create_layout
2
+ from .panels import (
3
+ create_controls_panel,
4
+ create_now_playing_panel,
5
+ create_queue_panel,
6
+ create_progress_panel
7
+ )
8
+ from .art import create_ascii_art_panel
9
+
10
+ __all__ = [
11
+ 'create_layout',
12
+ 'create_controls_panel',
13
+ 'create_now_playing_panel',
14
+ 'create_queue_panel',
15
+ 'create_progress_panel',
16
+ 'create_ascii_art_panel'
17
+ ]
@@ -0,0 +1,95 @@
1
+ import io
2
+ import logging
3
+ import requests
4
+ from functools import lru_cache
5
+ from PIL import Image
6
+ from rich.text import Text
7
+ from rich.panel import Panel
8
+
9
+
10
+ @lru_cache(maxsize=32)
11
+ def generate_ascii_art(image_url, height):
12
+ try:
13
+ height = max(1, min(height, 30))
14
+ width = height * 2
15
+
16
+ response = requests.get(image_url, timeout=3)
17
+ response.raise_for_status()
18
+
19
+ with Image.open(io.BytesIO(response.content)) as img:
20
+ img = img.resize((width, height), Image.Resampling.LANCZOS)
21
+ img = img.convert("RGB")
22
+
23
+ ascii_lines = [None] * height
24
+
25
+ for y in range(height):
26
+ line_parts = []
27
+ for x in range(width):
28
+ r, g, b = img.getpixel((x, y))
29
+ line_parts.append(f"[rgb({r},{g},{b}) on rgb({r},{g},{b})]▀[/]")
30
+ ascii_lines[y] = ''.join(line_parts)
31
+
32
+ return Text.from_markup("\n".join(ascii_lines), justify="center")
33
+
34
+ except Exception as e:
35
+ logging.warning(f"Failed to generate ASCII art: {e}")
36
+ return None
37
+
38
+ _PLACEHOLDER_CACHE = {}
39
+
40
+
41
+ def get_placeholder_art(height=8):
42
+ if height in _PLACEHOLDER_CACHE:
43
+ return _PLACEHOLDER_CACHE[height]
44
+
45
+ base_art = [
46
+ "╔══════════════╗",
47
+ "║ ║",
48
+ "║ [bold]🎵[/bold] ║",
49
+ "║ ║",
50
+ "║ [dim]headless[/dim] ║",
51
+ "║ [dim]_music[/dim] ║",
52
+ "║ ║",
53
+ "╚══════════════╝",
54
+ ]
55
+ base_height = len(base_art)
56
+ scaled_art_lines = []
57
+
58
+ if height <= 0:
59
+ result = Text("")
60
+ elif height < base_height:
61
+ start_index = (base_height - height) // 2
62
+ for i in range(height):
63
+ scaled_art_lines.append(f" [green]{base_art[start_index + i]}[/green]")
64
+ result = Text.from_markup("\n".join(scaled_art_lines), justify="center")
65
+ else:
66
+ top_padding = (height - base_height) // 2
67
+ bottom_padding = height - base_height - top_padding
68
+ scaled_art_lines.extend([""] * top_padding)
69
+ for line in base_art:
70
+ scaled_art_lines.append(f" [green]{line}[/green]")
71
+ scaled_art_lines.extend([""] * bottom_padding)
72
+ result = Text.from_markup("\n".join(scaled_art_lines), justify="center")
73
+
74
+ _PLACEHOLDER_CACHE[height] = result
75
+ return result
76
+
77
+
78
+ def create_ascii_art_panel(image_url, height, cache):
79
+ cache_key = f"{image_url}_{height}"
80
+ cached = cache.get(cache_key)
81
+ if cached:
82
+ return cached
83
+
84
+ if image_url:
85
+ try:
86
+ art = generate_ascii_art(image_url, height)
87
+ if art:
88
+ panel = Panel(art, border_style="dim", padding=(0, 0))
89
+ cache.set(cache_key, panel)
90
+ return panel
91
+ except Exception:
92
+ pass
93
+
94
+ panel = Panel(get_placeholder_art(height), border_style="dim")
95
+ return panel
@@ -0,0 +1,19 @@
1
+ from rich.layout import Layout
2
+
3
+
4
+ def create_layout():
5
+ layout = Layout()
6
+
7
+ layout.split_row(
8
+ Layout(name="sidebar", size=40),
9
+ Layout(name="main", ratio=1)
10
+ )
11
+
12
+ layout["main"].split_column(
13
+ Layout(name="art", ratio=1),
14
+ Layout(name="now_playing", size=5),
15
+ Layout(name="progress", size=1),
16
+ Layout(name="footer", size=3)
17
+ )
18
+
19
+ return layout
@@ -0,0 +1,103 @@
1
+ from rich.panel import Panel
2
+ from rich.text import Text
3
+ from rich.progress_bar import ProgressBar
4
+ from rich.layout import Layout
5
+ from utils.helpers import format_time, truncate_string
6
+
7
+ _CONTROLS_PANEL = None
8
+
9
+
10
+ def create_controls_panel():
11
+ global _CONTROLS_PANEL
12
+ if _CONTROLS_PANEL is None:
13
+ _CONTROLS_PANEL = Panel(
14
+ Text.from_markup(
15
+ "[bold cyan]n[/bold cyan] next • [bold cyan]p[/bold cyan] prev • "
16
+ "[bold cyan]space[/bold cyan] pause/play • [bold cyan]q[/bold cyan] quit • "
17
+ "[bold cyan]c[/bold cyan] config",
18
+ justify="center"
19
+ ),
20
+ border_style="dim"
21
+ )
22
+ return _CONTROLS_PANEL
23
+
24
+
25
+ def create_now_playing_panel(title, artist, source):
26
+ source_color = "red" if source == "YouTube" else "green"
27
+
28
+ title = truncate_string(title, 60)
29
+ artist = truncate_string(artist, 40)
30
+
31
+ display_text = Text.from_markup(
32
+ f"\n[bold]{title}[/bold]\n[dim]{artist}[/dim]\n"
33
+ f"Source: [{source_color}]{source}[/{source_color}]\n"
34
+ )
35
+ return Panel(display_text, title="Now Playing",
36
+ padding=(0, 2, 0, 2), border_style="dim")
37
+
38
+
39
+ def create_queue_panel(master_playlist, current_index):
40
+ lines = []
41
+
42
+ if 0 <= current_index < len(master_playlist):
43
+ title, artist, _, _ = master_playlist[current_index]
44
+ title = truncate_string(title, 30)
45
+ artist = truncate_string(artist, 30)
46
+
47
+ lines.append(Text.from_markup(f"[bold green] > {title}[/bold green]"))
48
+ lines.append(Text.from_markup(f" [dim]{artist}[/dim]"))
49
+ lines.append(Text.from_markup("---"))
50
+
51
+ end_idx = min(current_index + 11, len(master_playlist))
52
+
53
+ if end_idx == current_index + 1 and len(master_playlist) > 0:
54
+ lines.append(Text.from_markup(" [dim]Fetching more tracks...[/dim]"))
55
+ else:
56
+ for i in range(current_index + 1, end_idx):
57
+ title, artist, _, _ = master_playlist[i]
58
+ title = truncate_string(title, 30)
59
+ artist = truncate_string(artist, 30)
60
+
61
+ lines.append(Text.from_markup(f" {i - current_index}. {title}"))
62
+ lines.append(Text.from_markup(f" [dim]{artist}[/dim]"))
63
+
64
+ if not lines:
65
+ return Panel(Text(" [dim]Loading queue...[/dim]"),
66
+ title="Queue", border_style="dim", padding=(1,1))
67
+
68
+ return Panel(Text("\n").join(lines), title="Queue",
69
+ border_style="dim", padding=(1,1))
70
+
71
+ _progress_layout = None
72
+
73
+
74
+ def create_progress_panel(player):
75
+ global _progress_layout
76
+
77
+ try:
78
+ time_pos = player.time_pos or 0
79
+ duration = player.duration or 0
80
+ percent = (time_pos / duration * 100) if duration else 0
81
+
82
+ bar = ProgressBar(total=100, completed=percent, width=None,
83
+ complete_style="green", pulse=duration == 0)
84
+ time_display = Text(f"{format_time(time_pos)} / {format_time(duration)}",
85
+ justify="right")
86
+ icon = "⏸" if player.pause else "▶"
87
+
88
+ if _progress_layout is None:
89
+ _progress_layout = Layout()
90
+ _progress_layout.split_row(
91
+ Layout(name="icon", size=3),
92
+ Layout(name="bar"),
93
+ Layout(name="time", size=15)
94
+ )
95
+
96
+ _progress_layout["icon"].update(Text(f" {icon} "))
97
+ _progress_layout["bar"].update(bar)
98
+ _progress_layout["time"].update(time_display)
99
+
100
+ return _progress_layout
101
+ except Exception:
102
+ return Layout(Text(""))
103
+
@@ -0,0 +1,10 @@
1
+ from .helpers import format_time, truncate_string
2
+ from .cache import SimpleCache, panel_cache, art_cache
3
+
4
+ __all__ = [
5
+ 'format_time',
6
+ 'truncate_string',
7
+ 'SimpleCache',
8
+ 'panel_cache',
9
+ 'art_cache'
10
+ ]
@@ -0,0 +1,23 @@
1
+ from functools import wraps
2
+ import logging
3
+
4
+
5
+ class SimpleCache:
6
+ def __init__(self, max_size=100):
7
+ self._cache = {}
8
+ self._max_size = max_size
9
+
10
+ def get(self, key):
11
+ return self._cache.get(key)
12
+
13
+ def set(self, key, value):
14
+ if len(self._cache) >= self._max_size:
15
+ first_key = next(iter(self._cache))
16
+ del self._cache[first_key]
17
+ self._cache[key] = value
18
+
19
+ def clear(self):
20
+ self._cache.clear()
21
+
22
+ panel_cache = SimpleCache(max_size=50)
23
+ art_cache = SimpleCache(max_size=32)
@@ -0,0 +1,15 @@
1
+ from functools import lru_cache
2
+
3
+
4
+ @lru_cache(maxsize=128)
5
+ def format_time(seconds):
6
+ if seconds is None or seconds < 0:
7
+ return "--:--"
8
+ m, s = divmod(int(seconds), 60)
9
+ return f"{m:02}:{s:02}"
10
+
11
+
12
+ def truncate_string(text, max_length, suffix="..."):
13
+ if len(text) <= max_length:
14
+ return text
15
+ return text[:max_length] + suffix