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/__init__.py +2 -0
- headless_music/config.py +123 -0
- headless_music/fetchers/__init__.py +4 -0
- headless_music/fetchers/spotify.py +166 -0
- headless_music/fetchers/youtube.py +59 -0
- headless_music/headless_music.py +818 -0
- headless_music/main.py +279 -0
- headless_music/player.py +75 -0
- headless_music/ui/__init__.py +17 -0
- headless_music/ui/art.py +95 -0
- headless_music/ui/layout.py +19 -0
- headless_music/ui/panels.py +103 -0
- headless_music/utils/__init__.py +10 -0
- headless_music/utils/cache.py +23 -0
- headless_music/utils/helpers.py +15 -0
- headless_music-1.1.1.dist-info/METADATA +115 -0
- headless_music-1.1.1.dist-info/RECORD +21 -0
- headless_music-1.1.1.dist-info/WHEEL +5 -0
- headless_music-1.1.1.dist-info/entry_points.txt +2 -0
- headless_music-1.1.1.dist-info/licenses/LICENSE +21 -0
- headless_music-1.1.1.dist-info/top_level.txt +1 -0
|
@@ -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()
|