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.
@@ -0,0 +1,818 @@
1
+ import yt_dlp
2
+ import spotipy
3
+ import time
4
+ import random
5
+ import threading
6
+ import queue
7
+ import sys
8
+ import select
9
+ import os
10
+ import json
11
+ import logging
12
+ import requests
13
+ import io
14
+ from pathlib import Path
15
+ from PIL import Image
16
+ from functools import lru_cache
17
+ from spotipy.oauth2 import SpotifyClientCredentials
18
+ from spotipy.cache_handler import CacheFileHandler
19
+ from mpv import MPV
20
+ from rich.console import Console
21
+ from rich.layout import Layout
22
+ from rich.live import Live
23
+ from rich.panel import Panel
24
+ from rich.text import Text
25
+ from rich.progress_bar import ProgressBar
26
+ from rich.prompt import Prompt, Confirm
27
+
28
+ console = Console()
29
+
30
+ # --- Configuration & Logging Setup ---
31
+
32
+ cache_path = os.path.join(os.path.expanduser("~"), ".cache_headless_music")
33
+ cache_handler = CacheFileHandler(cache_path=cache_path)
34
+
35
+ CONFIG_FILE = Path.home() / ".headless_music_config.json"
36
+ LOG_FILE = Path.home() / ".headless_music.log"
37
+
38
+ logging.basicConfig(
39
+ filename=LOG_FILE,
40
+ level=logging.WARNING, # Changed from INFO to reduce I/O
41
+ format='%(asctime)s - %(levelname)s - %(message)s'
42
+ )
43
+
44
+ # --- Config & Setup ---
45
+
46
+ def load_config():
47
+ if CONFIG_FILE.exists():
48
+ try:
49
+ with open(CONFIG_FILE, 'r') as f:
50
+ return json.load(f)
51
+ except Exception as e:
52
+ logging.warning(f"Could not load config: {e}")
53
+ console.print(f"[yellow]Warning: Could not load config: {e}[/yellow]")
54
+ return {}
55
+
56
+ def save_config(config):
57
+ try:
58
+ with open(CONFIG_FILE, 'w') as f:
59
+ json.dump(config, f, indent=2)
60
+ return True
61
+ except Exception as e:
62
+ logging.error(f"Error saving config: {e}")
63
+ console.print(f"[red]Error saving config: {e}[/red]")
64
+ return False
65
+
66
+ def setup_wizard():
67
+ console.clear()
68
+ console.print("=" * 60, style="cyan")
69
+ console.print("šŸŽµ Welcome to headless_music Setup!", style="bold cyan", justify="center")
70
+ console.print("=" * 60, style="cyan")
71
+ console.print()
72
+
73
+ config = load_config()
74
+
75
+ console.print("šŸ“± [bold]Spotify API Credentials[/bold]")
76
+ console.print(" Get these from: https://developer.spotify.com/dashboard", style="dim")
77
+ console.print()
78
+
79
+ spotify_id = Prompt.ask(
80
+ " Spotify Client ID",
81
+ default=config.get('SPOTIFY_CLIENT_ID', '')
82
+ )
83
+ spotify_secret = Prompt.ask(
84
+ " Spotify Client Secret",
85
+ default=config.get('SPOTIFY_CLIENT_SECRET', ''),
86
+ password=True
87
+ )
88
+
89
+ console.print()
90
+ console.print("šŸŽ§ [bold]Playlist Source[/bold]")
91
+ console.print()
92
+
93
+ source_choice = Prompt.ask(
94
+ " Choose your playlist source",
95
+ choices=["spotify", "youtube"],
96
+ default=config.get('PLAYLIST_SOURCE', 'spotify')
97
+ )
98
+
99
+ console.print()
100
+
101
+ if source_choice == "spotify":
102
+ console.print(" Enter your Spotify playlist URL or URI", style="dim")
103
+ console.print(" Example: https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M", style="dim")
104
+ playlist_url = Prompt.ask(" Spotify Playlist URL/URI")
105
+ else:
106
+ console.print(" Enter your YouTube playlist URL", style="dim")
107
+ console.print(" Example: https://www.youtube.com/playlist?list=...", style="dim")
108
+ playlist_url = Prompt.ask(" YouTube Playlist URL")
109
+
110
+ console.print()
111
+
112
+ new_config = {
113
+ 'SPOTIFY_CLIENT_ID': spotify_id,
114
+ 'SPOTIFY_CLIENT_SECRET': spotify_secret,
115
+ 'PLAYLIST_SOURCE': source_choice,
116
+ 'PLAYLIST_URL': playlist_url
117
+ }
118
+
119
+ if save_config(new_config):
120
+ console.print("āœ“ Configuration saved!", style="bold green")
121
+ console.print(f" Config location: {CONFIG_FILE}", style="dim")
122
+ console.print(f" Log location: {LOG_FILE}", style="dim")
123
+ else:
124
+ console.print("āš ļø Could not save configuration. You'll need to re-enter it next time.", style="yellow")
125
+
126
+ console.print()
127
+ if Confirm.ask("Start headless_music now?", default=True):
128
+ return new_config
129
+ else:
130
+ console.print("šŸ‘‹ Run this script again when you're ready!", style="cyan")
131
+ sys.exit(0)
132
+
133
+ def validate_config(config):
134
+ required = ['SPOTIFY_CLIENT_ID', 'SPOTIFY_CLIENT_SECRET', 'PLAYLIST_SOURCE', 'PLAYLIST_URL']
135
+ missing = [key for key in required if not config.get(key)]
136
+
137
+ if missing:
138
+ console.print(f"[red]Missing configuration: {', '.join(missing)}[/red]")
139
+ return False
140
+ return True
141
+
142
+ # --- Globals ---
143
+
144
+ command_queue = queue.Queue()
145
+ master_playlist = []
146
+ current_index = 0
147
+ player = MPV(ytdl=True, video=False, keep_open=False,
148
+ keep_open_pause=False, # Prevent pausing when idle
149
+ cache=True, # Enable caching for smoother playback
150
+ demuxer_max_bytes='50M', # Reduce buffer size
151
+ cache_secs=10) # Reduce cache time
152
+ layout = Layout()
153
+ is_running = True
154
+ sp_client = None
155
+ config = {}
156
+ current_ascii_art = None
157
+ needs_ui_update = threading.Event()
158
+ last_progress_update = 0
159
+ cached_panels = {} # Cache for UI panels
160
+
161
+ # --- Helper Functions ---
162
+
163
+ @lru_cache(maxsize=128)
164
+ def format_time(seconds):
165
+ """Cached time formatter"""
166
+ if seconds is None or seconds < 0:
167
+ return "--:--"
168
+ m, s = divmod(int(seconds), 60)
169
+ return f"{m:02}:{s:02}"
170
+
171
+ # --- Data Fetching Functions ---
172
+
173
+ def get_youtube_playlist_titles(url):
174
+ ydl_opts = {
175
+ 'quiet': True,
176
+ 'extract_flat': True,
177
+ 'no_warnings': True,
178
+ 'ignoreerrors': True, # Skip broken videos
179
+ 'no_color': True # Reduce terminal output overhead
180
+ }
181
+ try:
182
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
183
+ info = ydl.extract_info(url, download=False)
184
+ return [(e['title'], e.get('uploader', 'Unknown'), "YouTube", None) for e in info['entries'] if e]
185
+ except Exception as e:
186
+ logging.error(f"Error fetching YouTube playlist: {e}")
187
+ return []
188
+
189
+ def get_spotify_playlist_tracks(sp, playlist_url):
190
+ try:
191
+ if 'spotify.com' in playlist_url:
192
+ playlist_id = playlist_url.split('playlist/')[-1].split('?')[0]
193
+ elif 'spotify:playlist:' in playlist_url:
194
+ playlist_id = playlist_url.split('spotify:playlist:')[-1]
195
+ else:
196
+ playlist_id = playlist_url
197
+
198
+ results = []
199
+ offset = 0
200
+
201
+ while True:
202
+ response = sp.playlist_tracks(playlist_id, offset=offset, limit=100)
203
+ for item in response['items']:
204
+ if item['track']:
205
+ track = item['track']
206
+ image_url = track['album']['images'][-1]['url'] if track['album']['images'] else None
207
+ results.append((
208
+ track['name'],
209
+ track['artists'][0]['name'],
210
+ "Spotify",
211
+ image_url
212
+ ))
213
+
214
+ if not response['next']:
215
+ break
216
+ offset += 100
217
+
218
+ logging.info(f"Fetched {len(results)} tracks from Spotify playlist")
219
+ return results
220
+ except Exception as e:
221
+ logging.error(f"Error fetching Spotify playlist: {e}")
222
+ return []
223
+
224
+ def spotify_setup():
225
+ global sp_client, config
226
+ if sp_client is None:
227
+ try:
228
+ sp_client = spotipy.Spotify(
229
+ auth_manager=SpotifyClientCredentials(
230
+ client_id=config['SPOTIFY_CLIENT_ID'],
231
+ client_secret=config['SPOTIFY_CLIENT_SECRET'],
232
+ cache_handler=cache_handler
233
+ ),
234
+ requests_timeout=5 # Add timeout to prevent hanging
235
+ )
236
+ logging.info("Spotify client initialized successfully")
237
+ except Exception as e:
238
+ logging.error(f"Failed to setup Spotify client: {e}")
239
+ return None
240
+ return sp_client
241
+
242
+ def get_spotify_tracks_by_search(sp, titles_artists, limit=20):
243
+ """Returns (name, artist, image_url, track_id)"""
244
+ results = []
245
+ seen_ids = set()
246
+
247
+ sample_size = min(len(titles_artists), 10)
248
+ sampled = random.sample(titles_artists, sample_size)
249
+
250
+ for title, artist, _, _ in sampled:
251
+ if len(results) >= limit:
252
+ break
253
+
254
+ try:
255
+ artist_results = sp.search(q=f"artist:{artist}", type="artist", limit=1)
256
+ if artist_results['artists']['items']:
257
+ artist_id = artist_results['artists']['items'][0]['id']
258
+ top_tracks = sp.artist_top_tracks(artist_id)
259
+ for track in top_tracks['tracks'][:3]:
260
+ track_id = track['id']
261
+ if track_id not in seen_ids:
262
+ image_url = track['album']['images'][-1]['url'] if track['album']['images'] else None
263
+ results.append((track['name'], track['artists'][0]['name'], image_url, track_id))
264
+ seen_ids.add(track_id)
265
+ if len(results) >= limit: return results
266
+ except Exception:
267
+ continue
268
+
269
+ try:
270
+ track_results = sp.search(q=f"{title} {artist}", type="track", limit=3)
271
+ for track in track_results['tracks']['items']:
272
+ track_id = track['id']
273
+ if track_id not in seen_ids:
274
+ image_url = track['album']['images'][-1]['url'] if track['album']['images'] else None
275
+ results.append((track['name'], track['artists'][0]['name'], image_url, track_id))
276
+ seen_ids.add(track_id)
277
+ if len(results) >= limit: return results
278
+ except Exception:
279
+ continue
280
+
281
+ return results
282
+
283
+ def spotify_recommendations_with_fallback(sp, titles_artists, limit=20):
284
+ """Returns (name, artist, source, image_url)"""
285
+ if not sp: return []
286
+ results = []
287
+ seen_ids = set()
288
+
289
+ sample_size = min(len(titles_artists), 5)
290
+ sampled = random.sample(titles_artists, sample_size)
291
+
292
+ for title, artist, _, _ in sampled:
293
+ if len(results) >= limit:
294
+ break
295
+
296
+ try:
297
+ search_results = sp.search(q=f"{title} {artist}", type="track", limit=1)
298
+ if search_results['tracks']['items']:
299
+ seed_id = search_results['tracks']['items'][0]['id']
300
+ try:
301
+ recs = sp.recommendations(seed_tracks=[seed_id], limit=5)
302
+ for track in recs['tracks']:
303
+ track_id = track['id']
304
+ if track_id not in seen_ids:
305
+ image_url = track['album']['images'][-1]['url'] if track['album']['images'] else None
306
+ results.append((track['name'], track['artists'][0]['name'], "Spotify", image_url))
307
+ seen_ids.add(track_id)
308
+ except Exception:
309
+ pass
310
+ except Exception:
311
+ continue
312
+
313
+ if len(results) < limit:
314
+ for title, artist, _, _ in sampled:
315
+ if len(results) >= limit:
316
+ break
317
+
318
+ try:
319
+ artist_results = sp.search(q=f"artist:{artist}", type="artist", limit=1)
320
+ if artist_results['artists']['items']:
321
+ artist_id = artist_results['artists']['items'][0]['id']
322
+ related = sp.artist_related_artists(artist_id)
323
+ for rel_artist in related['artists'][:3]:
324
+ top_tracks = sp.artist_top_tracks(rel_artist['id'])
325
+ for track in top_tracks['tracks'][:2]:
326
+ track_id = track['id']
327
+ if track_id not in seen_ids:
328
+ image_url = track['album']['images'][-1]['url'] if track['album']['images'] else None
329
+ results.append((track['name'], track['artists'][0]['name'], "Spotify", image_url))
330
+ seen_ids.add(track_id)
331
+ if len(results) >= limit: return results
332
+ except Exception:
333
+ continue
334
+
335
+ if len(results) < limit // 2:
336
+ search_results = get_spotify_tracks_by_search(sp, titles_artists, limit - len(results))
337
+ for name, artist, image_url, track_id in search_results:
338
+ if track_id not in seen_ids:
339
+ results.append((name, artist, "Spotify", image_url))
340
+ seen_ids.add(track_id)
341
+
342
+ logging.info(f"Generated {len(results)} recommended tracks")
343
+ return results
344
+
345
+ def fetch_more_youtube_tracks():
346
+ if not master_playlist: return []
347
+
348
+ results = []
349
+ sample = random.sample(master_playlist, min(3, len(master_playlist)))
350
+ ydl_opts = {
351
+ 'quiet': True,
352
+ 'extract_flat': False,
353
+ 'no_warnings': True,
354
+ 'ignoreerrors': True,
355
+ 'no_color': True
356
+ }
357
+
358
+ for title, artist, _, _ in sample:
359
+ try:
360
+ search_query = f"{artist} {title} audio"
361
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
362
+ search_results = ydl.extract_info(f"ytsearch5:{search_query}", download=False)
363
+ if search_results and 'entries' in search_results:
364
+ for entry in search_results['entries']:
365
+ if entry:
366
+ image_url = entry.get('thumbnail')
367
+ results.append((
368
+ entry.get('title', 'Unknown'),
369
+ entry.get('uploader', 'Unknown'),
370
+ "YouTube",
371
+ image_url
372
+ ))
373
+ except Exception:
374
+ continue
375
+
376
+ return results
377
+
378
+ # --- ASCII Art Generation ---
379
+
380
+ @lru_cache(maxsize=32) # Cache generated art
381
+ def generate_ascii_art(image_url, height):
382
+ """Generate ASCII art from image URL using colored blocks. CACHED."""
383
+ try:
384
+ height = max(1, min(height, 30))
385
+ width = height * 2
386
+
387
+ response = requests.get(image_url, timeout=3) # Reduced timeout
388
+ response.raise_for_status()
389
+
390
+ with Image.open(io.BytesIO(response.content)) as img:
391
+ img = img.resize((width, height), Image.Resampling.LANCZOS)
392
+ img = img.convert("RGB")
393
+
394
+ # Pre-allocate list for better performance
395
+ ascii_lines = [None] * height
396
+
397
+ for y in range(height):
398
+ line_parts = []
399
+ for x in range(width):
400
+ r, g, b = img.getpixel((x, y))
401
+ line_parts.append(f"[rgb({r},{g},{b}) on rgb({r},{g},{b})]ā–€[/]")
402
+ ascii_lines[y] = ''.join(line_parts)
403
+
404
+ return Text.from_markup("\n".join(ascii_lines), justify="center")
405
+
406
+ except Exception as e:
407
+ logging.warning(f"Failed to generate ASCII art: {e}")
408
+ return None
409
+
410
+ # Cache placeholder art - it never changes
411
+ _PLACEHOLDER_CACHE = {}
412
+
413
+ def get_placeholder_art(height=8):
414
+ """Returns cached placeholder art."""
415
+ if height in _PLACEHOLDER_CACHE:
416
+ return _PLACEHOLDER_CACHE[height]
417
+
418
+ base_art = [
419
+ "╔══════════════╗",
420
+ "ā•‘ ā•‘",
421
+ "ā•‘ [bold]šŸŽµ[/bold] ā•‘",
422
+ "ā•‘ ā•‘",
423
+ "ā•‘ [dim]headless[/dim] ā•‘",
424
+ "ā•‘ [dim]_music[/dim] ā•‘",
425
+ "ā•‘ ā•‘",
426
+ "ā•šā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•ā•",
427
+ ]
428
+ base_height = len(base_art)
429
+ scaled_art_lines = []
430
+
431
+ if height <= 0:
432
+ result = Text("")
433
+ elif height < base_height:
434
+ start_index = (base_height - height) // 2
435
+ for i in range(height):
436
+ scaled_art_lines.append(f" [green]{base_art[start_index + i]}[/green]")
437
+ result = Text.from_markup("\n".join(scaled_art_lines), justify="center")
438
+ else:
439
+ top_padding = (height - base_height) // 2
440
+ bottom_padding = height - base_height - top_padding
441
+ scaled_art_lines.extend([""] * top_padding)
442
+ for line in base_art:
443
+ scaled_art_lines.append(f" [green]{line}[/green]")
444
+ scaled_art_lines.extend([""] * bottom_padding)
445
+ result = Text.from_markup("\n".join(scaled_art_lines), justify="center")
446
+
447
+ _PLACEHOLDER_CACHE[height] = result
448
+ return result
449
+
450
+ # --- UI Panel Creation ---
451
+
452
+ def create_ascii_art_panel(image_url, height):
453
+ """Creates the panel, trying to generate art, falling back to placeholder."""
454
+ global current_ascii_art
455
+
456
+ # Check cache first
457
+ cache_key = f"{image_url}_{height}"
458
+ if cache_key in cached_panels:
459
+ current_ascii_art = cached_panels[cache_key]
460
+ return current_ascii_art
461
+
462
+ if image_url:
463
+ try:
464
+ art = generate_ascii_art(image_url, height)
465
+ if art:
466
+ current_ascii_art = Panel(art, border_style="dim", padding=(0, 0))
467
+ cached_panels[cache_key] = current_ascii_art
468
+ return current_ascii_art
469
+ except Exception:
470
+ pass
471
+
472
+ # Fallback to placeholder
473
+ current_ascii_art = Panel(get_placeholder_art(height), border_style="dim")
474
+ return current_ascii_art
475
+
476
+ def create_now_playing_panel(title, artist, source):
477
+ """Create now playing panel with minimal string operations."""
478
+ source_color = "red" if source == "YouTube" else "green"
479
+
480
+ # Pre-truncate strings before formatting
481
+ if len(title) > 60:
482
+ title = title[:60] + "..."
483
+ if len(artist) > 40:
484
+ artist = artist[:40] + "..."
485
+
486
+ display_text = Text.from_markup(
487
+ f"\n[bold]{title}[/bold]\n[dim]{artist}[/dim]\nSource: [{source_color}]{source}[/{source_color}]\n"
488
+ )
489
+ return Panel(display_text, title="Now Playing", padding=(0, 2, 0, 2), border_style="dim")
490
+
491
+ def create_queue_panel():
492
+ """Optimized queue panel with pre-allocated lists."""
493
+ lines = []
494
+
495
+ if 0 <= current_index < len(master_playlist):
496
+ title, artist, _, _ = master_playlist[current_index]
497
+ title = title[:30] + "..." if len(title) > 30 else title
498
+ artist = artist[:30] + "..." if len(artist) > 30 else artist
499
+
500
+ lines.append(Text.from_markup(f"[bold green] > {title}[/bold green]"))
501
+ lines.append(Text.from_markup(f" [dim]{artist}[/dim]"))
502
+ lines.append(Text.from_markup("---"))
503
+
504
+ end_idx = min(current_index + 11, len(master_playlist))
505
+
506
+ if end_idx == current_index + 1 and len(master_playlist) > 0:
507
+ lines.append(Text.from_markup(" [dim]Fetching more tracks...[/dim]"))
508
+ else:
509
+ for i in range(current_index + 1, end_idx):
510
+ title, artist, _, _ = master_playlist[i]
511
+ title = title[:30] + "..." if len(title) > 30 else title
512
+ artist = artist[:30] + "..." if len(artist) > 30 else artist
513
+
514
+ lines.append(Text.from_markup(f" {i - current_index}. {title}"))
515
+ lines.append(Text.from_markup(f" [dim]{artist}[/dim]"))
516
+
517
+ if not lines:
518
+ return Panel(Text(" [dim]Loading queue...[/dim]"), title="Queue", border_style="dim", padding=(1,1))
519
+
520
+ return Panel(Text("\n").join(lines), title="Queue", border_style="dim", padding=(1,1))
521
+
522
+ # Reusable progress layout to avoid recreation
523
+ _progress_layout = None
524
+
525
+ def create_progress_panel():
526
+ """Optimized progress panel with cached layout."""
527
+ global _progress_layout
528
+
529
+ try:
530
+ time_pos = player.time_pos or 0
531
+ duration = player.duration or 0
532
+ percent = (time_pos / duration * 100) if duration else 0
533
+
534
+ bar = ProgressBar(total=100, completed=percent, width=None, complete_style="green", pulse=duration == 0)
535
+ time_display = Text(f"{format_time(time_pos)} / {format_time(duration)}", justify="right")
536
+ icon = "āø" if player.pause else "ā–¶"
537
+
538
+ if _progress_layout is None:
539
+ _progress_layout = Layout()
540
+ _progress_layout.split_row(
541
+ Layout(name="icon", size=3),
542
+ Layout(name="bar"),
543
+ Layout(name="time", size=15)
544
+ )
545
+
546
+ _progress_layout["icon"].update(Text(f" {icon} "))
547
+ _progress_layout["bar"].update(bar)
548
+ _progress_layout["time"].update(time_display)
549
+
550
+ return _progress_layout
551
+ except Exception:
552
+ return Layout(Text(""))
553
+
554
+ # Create controls panel ONCE - it never changes
555
+ _CONTROLS_PANEL = None
556
+
557
+ def create_controls_panel():
558
+ """Returns cached controls panel."""
559
+ global _CONTROLS_PANEL
560
+ if _CONTROLS_PANEL is None:
561
+ _CONTROLS_PANEL = Panel(
562
+ Text.from_markup(
563
+ "[bold cyan]n[/bold cyan] next • [bold cyan]p[/bold cyan] prev • [bold cyan]space[/bold cyan] pause/play • [bold cyan]q[/bold cyan] quit • [bold cyan]c[/bold cyan] config",
564
+ justify="center"
565
+ ),
566
+ border_style="dim"
567
+ )
568
+ return _CONTROLS_PANEL
569
+
570
+ def create_layout():
571
+ """Creates the Spotify-esque layout."""
572
+ layout.split_row(
573
+ Layout(name="sidebar", size=40),
574
+ Layout(name="main", ratio=1)
575
+ )
576
+
577
+ layout["main"].split_column(
578
+ Layout(name="art", ratio=1),
579
+ Layout(name="now_playing", size=5),
580
+ Layout(name="progress", size=1),
581
+ Layout(name="footer", size=3)
582
+ )
583
+
584
+ return layout
585
+
586
+ # --- Playback & Input Logic ---
587
+
588
+ def play_track(index):
589
+ global current_index
590
+ if index < 0 or index >= len(master_playlist):
591
+ if index >= len(master_playlist) and len(master_playlist) > 0:
592
+ index = 0
593
+ else:
594
+ return
595
+
596
+ current_index = index
597
+ (title, artist, source, image_url) = master_playlist[current_index]
598
+
599
+ available_height = max(1, console.height - 11)
600
+
601
+ # Update UI panels
602
+ layout["art"].update(create_ascii_art_panel(image_url, available_height))
603
+ layout["now_playing"].update(create_now_playing_panel(title, artist, source))
604
+ layout["sidebar"].update(create_queue_panel())
605
+
606
+ try:
607
+ query = f"{title} {artist} audio"
608
+ player.play(f"ytdl://ytsearch1:{query}")
609
+ player.pause = False
610
+ except Exception as e:
611
+ logging.error(f"Error playing track: {e}")
612
+ command_queue.put("next")
613
+
614
+ needs_ui_update.set()
615
+
616
+ def check_and_refresh_queue():
617
+ """Optimized queue refresh - runs in background to avoid blocking."""
618
+ global master_playlist
619
+
620
+ if len(master_playlist) - current_index < 5:
621
+ seed_tracks = master_playlist[max(0, current_index - 10) : current_index + 1]
622
+ sp = spotify_setup()
623
+
624
+ if not sp:
625
+ return
626
+
627
+ new_tracks = spotify_recommendations_with_fallback(sp, seed_tracks, limit=15)
628
+
629
+ if len(new_tracks) < 5:
630
+ yt_tracks = fetch_more_youtube_tracks()
631
+ new_tracks.extend(yt_tracks[:10])
632
+
633
+ if new_tracks:
634
+ master_playlist.extend(new_tracks)
635
+ layout["sidebar"].update(create_queue_panel())
636
+ else:
637
+ master_playlist.extend(master_playlist[:20])
638
+
639
+ needs_ui_update.set()
640
+
641
+ @player.property_observer('idle-active')
642
+ def handle_song_end(_name, value):
643
+ if value and is_running:
644
+ command_queue.put("next")
645
+
646
+ def input_thread():
647
+ """Ultra-efficient input thread with adaptive polling."""
648
+ global is_running
649
+ poll_interval = 0.5 # Start with longer interval
650
+
651
+ while is_running:
652
+ try:
653
+ readable, _, _ = select.select([sys.stdin], [], [], poll_interval)
654
+ if readable:
655
+ char = sys.stdin.read(1)
656
+ poll_interval = 0.1
657
+
658
+ if char == 'n':
659
+ command_queue.put("next")
660
+ elif char == 'p':
661
+ command_queue.put("prev")
662
+ elif char == ' ':
663
+ command_queue.put("pause")
664
+ needs_ui_update.set()
665
+ elif char == 'q':
666
+ command_queue.put("quit")
667
+ is_running = False
668
+ elif char == 'c':
669
+ command_queue.put("config")
670
+ else:
671
+ poll_interval = min(poll_interval * 1.5, 0.5)
672
+
673
+ except Exception as e:
674
+ logging.error(f"Error in input thread: {e}")
675
+ is_running = False
676
+
677
+ def main():
678
+ global master_playlist, current_index, layout, is_running, config, current_ascii_art, last_progress_update
679
+ logging.info("headless_music starting")
680
+ config = load_config()
681
+
682
+ if not validate_config(config):
683
+ config = setup_wizard()
684
+
685
+ console.print("šŸŽµ Initialising headless_music...", style="bold cyan")
686
+ console.print(f"šŸ“” Fetching {config['PLAYLIST_SOURCE'].title()} playlist...", style="bold green")
687
+
688
+ sp = spotify_setup()
689
+ if config['PLAYLIST_SOURCE'] == 'spotify':
690
+ if not sp:
691
+ console.print("[red]Failed to initialize Spotify client. Check credentials. Exiting.[/red]")
692
+ return
693
+ playlist_tracks = get_spotify_playlist_tracks(sp, config['PLAYLIST_URL'])
694
+ if not playlist_tracks:
695
+ console.print("[red]Failed to fetch Spotify playlist. Exiting.[/red]")
696
+ return
697
+ else:
698
+ playlist_tracks = get_youtube_playlist_titles(config['PLAYLIST_URL'])
699
+ if not playlist_tracks:
700
+ console.print("[red]Failed to fetch YouTube playlist. Exiting.[/red]")
701
+ return
702
+
703
+ console.print(f"āœ“ Found {len(playlist_tracks)} tracks from {config['PLAYLIST_SOURCE'].title()}", style="green")
704
+ console.print("šŸŽ§ Fetching additional tracks...", style="bold green")
705
+
706
+ if not sp:
707
+ console.print("[yellow]āš ļø Spotify client not available, cannot fetch additional tracks.[/yellow]")
708
+ additional_tracks = []
709
+ else:
710
+ additional_tracks = spotify_recommendations_with_fallback(sp, playlist_tracks, limit=30)
711
+ if additional_tracks:
712
+ console.print(f"āœ“ Added {len(additional_tracks)} additional tracks", style="green")
713
+ else:
714
+ console.print("[yellow]āš ļø Could not fetch additional tracks, using playlist only[/yellow]")
715
+
716
+ master_playlist = playlist_tracks + additional_tracks
717
+ if not master_playlist:
718
+ console.print("[red]No tracks found. Exiting.[/red]")
719
+ return
720
+
721
+ current_index = 0
722
+ layout = create_layout()
723
+ current_ascii_art = Panel(get_placeholder_art(), border_style="dim")
724
+
725
+ try:
726
+ import tty
727
+ old_settings = tty.tcgetattr(sys.stdin)
728
+ tty.setcbreak(sys.stdin.fileno())
729
+ except Exception:
730
+ old_settings = None
731
+
732
+ input_handler = threading.Thread(target=input_thread, daemon=True)
733
+ input_handler.start()
734
+
735
+ console.print("\n✨ Starting playback...\n", style="bold magenta")
736
+ time.sleep(0.5) # Reduced from 1 second
737
+
738
+ try:
739
+ # ULTRA LOW refresh rate - only 0.5 FPS when idle
740
+ with Live(layout, console=console, screen=True, refresh_per_second=0.5) as live:
741
+ play_track(current_index)
742
+
743
+ # Initialize static elements ONCE
744
+ layout["footer"].update(create_controls_panel())
745
+ last_progress_update = time.time()
746
+
747
+ # Track if we need forced refresh
748
+ force_refresh_counter = 0
749
+
750
+ while is_running:
751
+ try:
752
+ # Long timeout - we wake up on events
753
+ cmd = command_queue.get(timeout=2.0)
754
+ except queue.Empty:
755
+ cmd = None
756
+
757
+ if cmd == "next":
758
+ check_and_refresh_queue()
759
+ play_track(current_index + 1)
760
+ force_refresh_counter = 0
761
+ elif cmd == "prev":
762
+ play_track(max(0, current_index - 1))
763
+ force_refresh_counter = 0
764
+ elif cmd == "pause":
765
+ player.pause = not player.pause
766
+ needs_ui_update.set()
767
+ force_refresh_counter = 0
768
+ elif cmd == "config":
769
+ is_running = False
770
+ player.pause = True
771
+ live.stop()
772
+ console.clear()
773
+ setup_wizard()
774
+ console.print("\n[yellow]Please restart headless_music to apply new settings.[/yellow]")
775
+ break
776
+ elif cmd == "quit":
777
+ break
778
+
779
+ # Update progress only every 2 seconds (not 1)
780
+ current_time = time.time()
781
+ if current_time - last_progress_update >= 2.0:
782
+ try:
783
+ layout["progress"].update(create_progress_panel())
784
+ last_progress_update = current_time
785
+ except Exception:
786
+ pass
787
+
788
+ # Only force refresh occasionally to save CPU
789
+ force_refresh_counter += 1
790
+ if force_refresh_counter >= 10:
791
+ live.refresh()
792
+ force_refresh_counter = 0
793
+
794
+ # Sleep aggressively when idle
795
+ if cmd is None:
796
+ time.sleep(0.2)
797
+
798
+ except KeyboardInterrupt:
799
+ is_running = False
800
+ finally:
801
+ is_running = False
802
+ try:
803
+ if old_settings:
804
+ import termios
805
+ termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_settings)
806
+ except Exception:
807
+ pass
808
+
809
+ try:
810
+ player.quit()
811
+ except Exception:
812
+ pass
813
+
814
+ console.clear()
815
+ console.print("\nheadless_music stopped. goodbye! šŸ‘‹ \n", style="bold yellow")
816
+
817
+ if __name__ == "__main__":
818
+ main()