lushapp 2.0.0
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.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/bin/lush +62 -0
- package/bin/lush.js +48 -0
- package/package.json +61 -0
- package/pyproject.toml +52 -0
- package/scripts/postinstall.js +58 -0
- package/scripts/sync-version.js +38 -0
- package/setup.py +23 -0
- package/src/lush/__init__.py +30 -0
- package/src/lush/__main__.py +42 -0
- package/src/lush/audio.py +222 -0
- package/src/lush/cava.py +665 -0
- package/src/lush/constants.py +686 -0
- package/src/lush/data/cava.conf +19 -0
- package/src/lush/data/stations.json +3586 -0
- package/src/lush/modals.py +1382 -0
- package/src/lush/net.py +27 -0
- package/src/lush/notify.py +154 -0
- package/src/lush/search.py +185 -0
- package/src/lush/state.py +550 -0
- package/src/lush/stats.py +555 -0
- package/src/lush/ui.py +690 -0
- package/src/lush/ui_helpers.py +569 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# LUSH - Audio and Recording Engine
|
|
2
|
+
import time
|
|
3
|
+
import re
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import mpv
|
|
10
|
+
HAS_MPV_LIB = True
|
|
11
|
+
except Exception:
|
|
12
|
+
HAS_MPV_LIB = False
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CLIPlayer:
|
|
16
|
+
"""Universal subprocess-based audio player fallback (supports mpv, ffplay, vlc)."""
|
|
17
|
+
def __init__(self, volume: int = 100, user_agent: str = "LUSH/2.0 (Terminal Audio Player)"):
|
|
18
|
+
self.volume = volume
|
|
19
|
+
self.user_agent = user_agent
|
|
20
|
+
self.proc = None
|
|
21
|
+
self.pause = False
|
|
22
|
+
self.current_url = None
|
|
23
|
+
|
|
24
|
+
def play(self, url: str):
|
|
25
|
+
self.stop()
|
|
26
|
+
self.current_url = url
|
|
27
|
+
self.pause = False
|
|
28
|
+
|
|
29
|
+
if shutil.which("mpv"):
|
|
30
|
+
cmd = ["mpv", "--no-video", "--no-terminal", f"--volume={self.volume}", f"--user-agent={self.user_agent}", url]
|
|
31
|
+
elif shutil.which("ffplay"):
|
|
32
|
+
cmd = ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", str(int(self.volume)), url]
|
|
33
|
+
elif shutil.which("cvlc"):
|
|
34
|
+
cmd = ["cvlc", "--no-video", "--gain", str(self.volume / 100.0), url]
|
|
35
|
+
else:
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
self.proc = subprocess.Popen(
|
|
40
|
+
cmd,
|
|
41
|
+
stdin=subprocess.DEVNULL,
|
|
42
|
+
stdout=subprocess.DEVNULL,
|
|
43
|
+
stderr=subprocess.DEVNULL
|
|
44
|
+
)
|
|
45
|
+
except Exception:
|
|
46
|
+
self.proc = None
|
|
47
|
+
|
|
48
|
+
def stop(self):
|
|
49
|
+
if self.proc:
|
|
50
|
+
try:
|
|
51
|
+
self.proc.terminate()
|
|
52
|
+
self.proc.wait(timeout=0.2)
|
|
53
|
+
except Exception:
|
|
54
|
+
try:
|
|
55
|
+
self.proc.kill()
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
self.proc = None
|
|
59
|
+
|
|
60
|
+
def terminate(self):
|
|
61
|
+
self.stop()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AudioEngine:
|
|
65
|
+
def __init__(self, recordings_dir: Path):
|
|
66
|
+
self.recordings_dir = recordings_dir
|
|
67
|
+
self.recording = False
|
|
68
|
+
self.rec_proc = None
|
|
69
|
+
self.rec_start_time = 0
|
|
70
|
+
self.rec_file = None
|
|
71
|
+
|
|
72
|
+
# Main Station Player
|
|
73
|
+
self.player = None
|
|
74
|
+
if HAS_MPV_LIB:
|
|
75
|
+
try:
|
|
76
|
+
self.player = mpv.MPV(ytdl=True, video='no', user_agent='LUSH/2.0 (Terminal Audio Player)')
|
|
77
|
+
self.player.volume = 100
|
|
78
|
+
except Exception:
|
|
79
|
+
self.player = None
|
|
80
|
+
|
|
81
|
+
if self.player is None:
|
|
82
|
+
self.player = CLIPlayer(volume=100, user_agent='LUSH/2.0 (Terminal Audio Player)')
|
|
83
|
+
|
|
84
|
+
# Secondary Ambient Soundscape Player
|
|
85
|
+
self.ambient_player = None
|
|
86
|
+
if HAS_MPV_LIB:
|
|
87
|
+
try:
|
|
88
|
+
self.ambient_player = mpv.MPV(ytdl=False, video='no', user_agent='LUSH/2.0 (Ambient Player)')
|
|
89
|
+
self.ambient_player.volume = 40
|
|
90
|
+
except Exception:
|
|
91
|
+
self.ambient_player = None
|
|
92
|
+
|
|
93
|
+
if self.ambient_player is None:
|
|
94
|
+
self.ambient_player = CLIPlayer(volume=40, user_agent='LUSH/2.0 (Ambient Player)')
|
|
95
|
+
|
|
96
|
+
def play_station(self, url: str):
|
|
97
|
+
if not self.player: return
|
|
98
|
+
try:
|
|
99
|
+
self.player.pause = False
|
|
100
|
+
self.player.play(url)
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
def toggle_play(self, currently_playing: bool) -> bool:
|
|
105
|
+
if not self.player: return False
|
|
106
|
+
try:
|
|
107
|
+
if currently_playing:
|
|
108
|
+
self.player.pause = True
|
|
109
|
+
return False
|
|
110
|
+
else:
|
|
111
|
+
self.player.pause = False
|
|
112
|
+
return True
|
|
113
|
+
except Exception:
|
|
114
|
+
return currently_playing
|
|
115
|
+
|
|
116
|
+
def set_volume(self, vol: int):
|
|
117
|
+
if self.player:
|
|
118
|
+
try:
|
|
119
|
+
self.player.volume = max(0, min(100, int(vol)))
|
|
120
|
+
except Exception:
|
|
121
|
+
pass
|
|
122
|
+
|
|
123
|
+
def set_ambient_volume(self, vol: int):
|
|
124
|
+
if self.ambient_player:
|
|
125
|
+
try:
|
|
126
|
+
self.ambient_player.volume = max(0, min(100, int(vol)))
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
|
|
130
|
+
def play_ambient(self, url: str, vol: int):
|
|
131
|
+
if not self.ambient_player: return
|
|
132
|
+
try:
|
|
133
|
+
if not url:
|
|
134
|
+
self.ambient_player.stop()
|
|
135
|
+
else:
|
|
136
|
+
self.ambient_player.volume = vol
|
|
137
|
+
self.ambient_player.play(url)
|
|
138
|
+
except Exception:
|
|
139
|
+
pass
|
|
140
|
+
|
|
141
|
+
def toggle_recording(self, station_name: str, station_url: str, current_track: str = ""):
|
|
142
|
+
if not self.recording:
|
|
143
|
+
self.recordings_dir.mkdir(parents=True, exist_ok=True)
|
|
144
|
+
sanitized_st = re.sub(r'[^\w\-_\.]', '_', station_name)
|
|
145
|
+
sanitized_tr = re.sub(r'[^\w\-_\.]', '_', current_track) if current_track else ""
|
|
146
|
+
timestamp = time.strftime("%Y%m%d_%H%M%S")
|
|
147
|
+
filename = f"{sanitized_st}_{sanitized_tr}_{timestamp}.flac" if sanitized_tr else f"{sanitized_st}_{timestamp}.flac"
|
|
148
|
+
rec_path = self.recordings_dir / filename
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
cmd = [
|
|
152
|
+
"ffmpeg", "-y", "-nostdin",
|
|
153
|
+
"-i", station_url,
|
|
154
|
+
"-vn", "-c:a", "flac",
|
|
155
|
+
"-compression_level", "5",
|
|
156
|
+
"-metadata", f"title={current_track or station_name}",
|
|
157
|
+
"-metadata", f"artist={station_name}",
|
|
158
|
+
"-metadata", "album=LUSH Audiophile Live Recordings",
|
|
159
|
+
"-metadata", "comment=Lossless FLAC Master Capture",
|
|
160
|
+
"-metadata", "encoder=LUSH Audiophile FLAC Engine",
|
|
161
|
+
str(rec_path)
|
|
162
|
+
]
|
|
163
|
+
self.rec_proc = subprocess.Popen(
|
|
164
|
+
cmd,
|
|
165
|
+
stdin=subprocess.DEVNULL,
|
|
166
|
+
stdout=subprocess.DEVNULL,
|
|
167
|
+
stderr=subprocess.DEVNULL
|
|
168
|
+
)
|
|
169
|
+
self.recording = True
|
|
170
|
+
self.rec_start_time = time.time()
|
|
171
|
+
self.rec_file = rec_path
|
|
172
|
+
return True, f"● REC (Lossless FLAC): {rec_path.name}", rec_path
|
|
173
|
+
except Exception as e:
|
|
174
|
+
return False, f"Recording error: {e}", None
|
|
175
|
+
else:
|
|
176
|
+
if self.rec_proc:
|
|
177
|
+
try:
|
|
178
|
+
import signal
|
|
179
|
+
self.rec_proc.send_signal(signal.SIGINT)
|
|
180
|
+
self.rec_proc.wait(timeout=3.0)
|
|
181
|
+
except Exception:
|
|
182
|
+
try:
|
|
183
|
+
self.rec_proc.terminate()
|
|
184
|
+
self.rec_proc.wait(timeout=1.0)
|
|
185
|
+
except Exception:
|
|
186
|
+
try:
|
|
187
|
+
self.rec_proc.kill()
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
self.recording = False
|
|
191
|
+
saved_file = self.rec_file
|
|
192
|
+
size_mb = 0.0
|
|
193
|
+
if saved_file and saved_file.exists():
|
|
194
|
+
size_mb = saved_file.stat().st_size / (1024 * 1024)
|
|
195
|
+
return False, f"→ Saved Lossless FLAC ({size_mb:.2f} MB): {saved_file.name if saved_file else 'recording'}", saved_file
|
|
196
|
+
|
|
197
|
+
def shutdown(self):
|
|
198
|
+
if self.recording and self.rec_proc:
|
|
199
|
+
try:
|
|
200
|
+
self.rec_proc.terminate()
|
|
201
|
+
self.rec_proc.wait(timeout=0.2)
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
self.rec_proc = None
|
|
205
|
+
if self.player:
|
|
206
|
+
try:
|
|
207
|
+
self.player.terminate()
|
|
208
|
+
except Exception:
|
|
209
|
+
pass
|
|
210
|
+
self.player = None
|
|
211
|
+
if self.ambient_player:
|
|
212
|
+
try:
|
|
213
|
+
self.ambient_player.terminate()
|
|
214
|
+
except Exception:
|
|
215
|
+
pass
|
|
216
|
+
self.ambient_player = None
|
|
217
|
+
|
|
218
|
+
def quit(self):
|
|
219
|
+
self.shutdown()
|
|
220
|
+
|
|
221
|
+
def __del__(self):
|
|
222
|
+
self.shutdown()
|