lushapp 2.0.2 → 2.0.4

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/README.md CHANGED
@@ -44,6 +44,18 @@ lush
44
44
  lushapp
45
45
  ```
46
46
 
47
+ ### Update to Latest Version
48
+ ```bash
49
+ lush update
50
+ # or
51
+ lushapp update
52
+
53
+ # Manual package updates:
54
+ npm install -g lushapp@latest
55
+ # or
56
+ pip install --upgrade lushapp
57
+ ```
58
+
47
59
  ### Clone & Run from Source
48
60
  ```bash
49
61
  git clone https://github.com/pseudoshell/lush.git
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lushapp",
3
- "version": "2.0.2",
3
+ "version": "2.0.4",
4
4
  "description": "A hyper-aesthetic terminal radio player with 560+ live stations, real-time CAVA DSP audio visualizers, ambient soundscapes, and artist discographies.",
5
5
  "main": "bin/lush.js",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lushapp"
7
- version = "2.0.2"
7
+ version = "2.0.4"
8
8
  description = "A hyper-aesthetic terminal radio player with 560+ live stations, real-time CAVA DSP visualizers, and artist discographies."
9
9
  readme = "README.md"
10
10
  authors = [{ name = "pseudoshell" }]
package/setup.py CHANGED
@@ -2,7 +2,7 @@ from setuptools import setup, find_packages
2
2
 
3
3
  setup(
4
4
  name="lushapp",
5
- version="2.0.2",
5
+ version="2.0.4",
6
6
  package_dir={"": "src"},
7
7
  packages=find_packages(where="src"),
8
8
  include_package_data=True,
@@ -2,7 +2,7 @@ import os
2
2
  import sys
3
3
  os.environ["ESCDELAY"] = "25"
4
4
 
5
- __version__ = "2.0.2"
5
+ __version__ = "2.0.4"
6
6
 
7
7
  def run_app():
8
8
  try:
@@ -3,7 +3,66 @@ import sys
3
3
  import argparse
4
4
  from . import __version__, run_app
5
5
 
6
+ def update_lush():
7
+ """Detects installation method and automatically updates the lushapp package."""
8
+ import shutil
9
+ import subprocess
10
+ from . import __version__
11
+
12
+ print("\033[1;95m┌────────────────────────────────────────────────────────────────────────┐\033[0m")
13
+ print(f"\033[1;95m│\033[0m \033[1;96mLUSH (Audiophile Edition)\033[0m — Automated Package Updater \033[1;95m│\033[0m")
14
+ print(f"\033[1;95m│\033[0m Current Version: \033[1;92mv{__version__:<10}\033[0m Package Name: \033[1;93mlushapp\033[0m \033[1;95m│\033[0m")
15
+ print("\033[1;95m└────────────────────────────────────────────────────────────────────────┘\033[0m\n")
16
+
17
+ # 1. Check if installed via npm
18
+ if shutil.which("npm"):
19
+ try:
20
+ check_npm = subprocess.run(["npm", "ls", "-g", "lushapp", "--depth=0"], capture_output=True, text=True)
21
+ if "lushapp@" in check_npm.stdout or "lushapp" in check_npm.stdout:
22
+ print("\033[1;96m> Detected global npm installation. Updating lushapp...\033[0m")
23
+ print("Running: \033[1;92mnpm install -g lushapp@latest\033[0m\n")
24
+ res = subprocess.run(["npm", "install", "-g", "lushapp@latest"])
25
+ if res.returncode == 0:
26
+ print("\n\033[1;92m✓ Successfully updated lushapp to the latest version via npm!\033[0m")
27
+ print("Run \033[1;96mlush\033[0m or \033[1;96mlushapp\033[0m to launch.\n")
28
+ sys.exit(0)
29
+ except Exception:
30
+ pass
31
+
32
+ # 2. Check if installed via pipx
33
+ if shutil.which("pipx"):
34
+ try:
35
+ check_pipx = subprocess.run(["pipx", "list"], capture_output=True, text=True)
36
+ if "lushapp" in check_pipx.stdout:
37
+ print("\033[1;96m> Detected pipx installation. Updating lushapp...\033[0m")
38
+ print("Running: \033[1;92mpipx upgrade lushapp\033[0m\n")
39
+ res = subprocess.run(["pipx", "upgrade", "lushapp"])
40
+ if res.returncode == 0:
41
+ print("\n\033[1;92m✓ Successfully updated lushapp via pipx!\033[0m\n")
42
+ sys.exit(0)
43
+ except Exception:
44
+ pass
45
+
46
+ # 3. Check if installed via pip / Python
47
+ pip_cmd = [sys.executable, "-m", "pip", "install", "--upgrade", "lushapp"]
48
+ print("\033[1;96m> Updating lushapp via pip...\033[0m")
49
+ print(f"Running: \033[1;92m{' '.join(pip_cmd)}\033[0m\n")
50
+ res = subprocess.run(pip_cmd)
51
+ if res.returncode == 0:
52
+ print("\n\033[1;92m✓ Successfully updated lushapp to the latest version!\033[0m")
53
+ print("Run \033[1;96mlush\033[0m or \033[1;96mlushapp\033[0m to launch.\n")
54
+ sys.exit(0)
55
+
56
+ # 4. Fallback info
57
+ print("\n\033[1;93m[NOTICE]\033[0m To manually update LUSH, run:")
58
+ print(" • npm: \033[1;96mnpm install -g lushapp@latest\033[0m")
59
+ print(" • pip: \033[1;96mpip install --upgrade lushapp\033[0m\n")
60
+ sys.exit(0)
61
+
6
62
  def main():
63
+ if any(arg in sys.argv[1:] for arg in ("update", "upgrade", "--update", "-U")):
64
+ update_lush()
65
+
7
66
  parser = argparse.ArgumentParser(
8
67
  prog="lush",
9
68
  description="LUSH - A hyper-aesthetic terminal radio player with 560+ live stations and real-time CAVA DSP visualizers.",
@@ -11,10 +70,15 @@ def main():
11
70
  )
12
71
  parser.add_argument("-v", "--version", action="version", version=f"LUSH v{__version__} (Audiophile Edition)")
13
72
  parser.add_argument("--test", action="store_true", help="Run internal self-test diagnostics")
73
+ parser.add_argument("--update", action="store_true", help="Update lushapp package to the latest release")
74
+ parser.add_argument("command", nargs="?", help="Optional subcommand (e.g. 'update', 'test')")
14
75
 
15
76
  args, unknown = parser.parse_known_args()
16
77
 
17
- if args.test:
78
+ if args.command in ("update", "upgrade") or args.update:
79
+ update_lush()
80
+
81
+ if args.test or args.command == "test":
18
82
  from pathlib import Path
19
83
  test_cands = [
20
84
  Path(__file__).resolve().parent.parent.parent / "tests" / "run_tests.py",
package/src/lush/audio.py CHANGED
@@ -12,40 +12,147 @@ except Exception:
12
12
  HAS_MPV_LIB = False
13
13
 
14
14
 
15
+ import threading
16
+ import requests
17
+
15
18
  class CLIPlayer:
16
19
  """Universal subprocess-based audio player fallback (supports mpv, ffplay, vlc)."""
17
20
  def __init__(self, volume: int = 100, user_agent: str = "LUSH/2.0 (Terminal Audio Player)"):
18
- self.volume = volume
21
+ self._volume = volume
19
22
  self.user_agent = user_agent
20
23
  self.proc = None
21
- self.pause = False
24
+ self._pause = False
22
25
  self.current_url = None
26
+ self.metadata = {}
27
+ self._meta_thread = None
28
+ self._stop_meta = False
29
+
30
+ @property
31
+ def pause(self) -> bool:
32
+ return self._pause
33
+
34
+ @pause.setter
35
+ def pause(self, val: bool):
36
+ self._pause = val
37
+ if val:
38
+ # Pause playback
39
+ if self.proc and self.proc.poll() is None:
40
+ if shutil.which("mpv") and self.proc.stdin:
41
+ try:
42
+ self.proc.stdin.write(b"set pause yes\n")
43
+ self.proc.stdin.flush()
44
+ return
45
+ except Exception:
46
+ pass
47
+ self.stop()
48
+ else:
49
+ # Resume playback
50
+ if self.proc and self.proc.poll() is None:
51
+ if shutil.which("mpv") and self.proc.stdin:
52
+ try:
53
+ self.proc.stdin.write(b"set pause no\n")
54
+ self.proc.stdin.flush()
55
+ return
56
+ except Exception:
57
+ pass
58
+ if self.current_url:
59
+ self.play(self.current_url)
60
+
61
+ @property
62
+ def volume(self) -> int:
63
+ return self._volume
64
+
65
+ @volume.setter
66
+ def volume(self, val: int):
67
+ self._volume = max(0, min(100, int(val)))
68
+ if self.proc and self.proc.poll() is None and self.proc.stdin and shutil.which("mpv"):
69
+ try:
70
+ self.proc.stdin.write(f"set volume {self._volume}\n".encode())
71
+ self.proc.stdin.flush()
72
+ except Exception:
73
+ pass
74
+
75
+ def _meta_worker(self, url: str):
76
+ try:
77
+ r = requests.get(
78
+ url,
79
+ headers={"Icy-MetaData": "1", "User-Agent": self.user_agent},
80
+ stream=True,
81
+ timeout=5
82
+ )
83
+ icy_br = r.headers.get("icy-br") or r.headers.get("ice-bitrate")
84
+ if icy_br:
85
+ self.metadata["icy-br"] = str(icy_br)
86
+
87
+ metaint = int(r.headers.get("icy-metaint", 0))
88
+ if metaint > 0:
89
+ while self.current_url == url and not self._stop_meta:
90
+ data = r.raw.read(metaint)
91
+ if not data:
92
+ break
93
+ len_byte = r.raw.read(1)
94
+ if not len_byte:
95
+ break
96
+ meta_len = len_byte[0] * 16
97
+ if meta_len > 0:
98
+ meta_bytes = r.raw.read(meta_len)
99
+ try:
100
+ meta_str = meta_bytes.decode("utf-8", errors="ignore")
101
+ except Exception:
102
+ meta_str = meta_bytes.decode("latin-1", errors="ignore")
103
+ m = re.search(r"StreamTitle='(.*?)';", meta_str)
104
+ if m:
105
+ stream_title = m.group(1).strip()
106
+ if stream_title:
107
+ self.metadata["icy-title"] = stream_title
108
+ self.metadata["title"] = stream_title
109
+ r.close()
110
+ except Exception:
111
+ pass
23
112
 
24
113
  def play(self, url: str):
25
114
  self.stop()
26
115
  self.current_url = url
27
- self.pause = False
116
+ self._pause = False
117
+ self.metadata = {}
118
+ self._stop_meta = False
28
119
 
29
120
  if shutil.which("mpv"):
30
- cmd = ["mpv", "--no-video", "--no-terminal", f"--volume={self.volume}", f"--user-agent={self.user_agent}", url]
121
+ cmd = [
122
+ "mpv",
123
+ "--no-video",
124
+ "--no-terminal",
125
+ "--input-terminal=no",
126
+ f"--volume={self._volume}",
127
+ f"--user-agent={self.user_agent}",
128
+ url
129
+ ]
130
+ stdin_mode = subprocess.PIPE
31
131
  elif shutil.which("ffplay"):
32
- cmd = ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", str(int(self.volume)), url]
132
+ cmd = ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", "-volume", str(int(self._volume)), url]
133
+ stdin_mode = subprocess.DEVNULL
33
134
  elif shutil.which("cvlc"):
34
- cmd = ["cvlc", "--no-video", "--gain", str(self.volume / 100.0), url]
135
+ cmd = ["cvlc", "--no-video", "--gain", str(self._volume / 100.0), url]
136
+ stdin_mode = subprocess.DEVNULL
35
137
  else:
36
138
  return
37
139
 
38
140
  try:
39
141
  self.proc = subprocess.Popen(
40
142
  cmd,
41
- stdin=subprocess.DEVNULL,
143
+ stdin=stdin_mode,
42
144
  stdout=subprocess.DEVNULL,
43
145
  stderr=subprocess.DEVNULL
44
146
  )
45
147
  except Exception:
46
148
  self.proc = None
47
149
 
150
+ if url.startswith("http://") or url.startswith("https://"):
151
+ self._meta_thread = threading.Thread(target=self._meta_worker, args=(url,), daemon=True)
152
+ self._meta_thread.start()
153
+
48
154
  def stop(self):
155
+ self._stop_meta = True
49
156
  if self.proc:
50
157
  try:
51
158
  self.proc.terminate()
@@ -2,8 +2,8 @@ import re
2
2
  # LUSH - Constants and Presets
3
3
 
4
4
  LOGO_LINES = [
5
- " █ █ █ █▀▀ █ █ █▀▀█",
6
- " █▄▄ █▄█ ▄██ █▀█ ██ ██"
5
+ " █ █ █ █▀▀ █ █ █▀▀▀█",
6
+ " █▄▄ █▄█ ▄██ █▀█ ██ ██"
7
7
  ]
8
8
 
9
9
  COMPACT_LOGO = "LUSH ♫"
@@ -139,16 +139,30 @@ class NotificationManager:
139
139
 
140
140
  @staticmethod
141
141
  def _dispatch_windows(title: str, body: str):
142
- safe_title = title.replace('"', '`"')
143
- safe_body = body.replace('"', '`"')
144
- ps_script = f'''
145
- [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] > $null
146
- $template = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)
147
- $textNodes = $template.GetElementsByTagName("text")
148
- $textNodes.Item(0).AppendChild($template.CreateTextNode("{safe_title} - Now Playing")) > $null
149
- $textNodes.Item(1).AppendChild($template.CreateTextNode("{safe_body}")) > $null
150
- $notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("LUSH")
151
- $notification = [Windows.UI.Notifications.ToastNotification]::new($template)
152
- $notifier.Show($notification)
153
- '''
154
- subprocess.run(["powershell", "-NoProfile", "-Command", ps_script], capture_output=True, timeout=3.0)
142
+ import base64
143
+ import html
144
+ safe_title = html.escape(f"{title} - Now Playing")
145
+ safe_body = html.escape(body)
146
+ ps_script = f"""[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
147
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
148
+ $template = @"
149
+ <toast>
150
+ <visual>
151
+ <binding template="ToastGeneric">
152
+ <text>{safe_title}</text>
153
+ <text>{safe_body}</text>
154
+ </binding>
155
+ </visual>
156
+ </toast>
157
+ "@
158
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
159
+ $xml.LoadXml($template)
160
+ $toast = [Windows.UI.Notifications.ToastNotification]::new($xml)
161
+ $appId = '{{1AC14E77-02E7-4E5D-B744-2EB1AE5198B7}}\\WindowsPowerShell\\v1.0\\powershell.exe'
162
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier($appId).Show($toast)
163
+ """
164
+ try:
165
+ enc = base64.b64encode(ps_script.encode("utf-16le")).decode("ascii")
166
+ subprocess.run(["powershell", "-NoProfile", "-NonInteractive", "-EncodedCommand", enc], capture_output=True, timeout=3.0)
167
+ except Exception:
168
+ pass
package/src/lush/ui.py CHANGED
@@ -179,7 +179,10 @@ def curses_main(stdscr, state: PlayerState):
179
179
  cava_bars = list(state.cava.bars) if (hasattr(state, "cava") and state.cava) else None
180
180
  p_id = get_shimmer_color_pair(shimmer_eff, x_norm, c_idx, row_i, t_now, cava_bars=cava_bars, is_playing=state.playing)
181
181
  attr = curses.color_pair(p_id) | curses.A_BOLD
182
- stdscr.addstr(1 + row_i, 2 + c_idx, ch, attr)
182
+ try:
183
+ stdscr.addstr(1 + row_i, 2 + c_idx, ch, attr)
184
+ except Exception:
185
+ pass
183
186
  main_start_y = 4
184
187
  else:
185
188
  for c_idx, ch in enumerate(COMPACT_LOGO):
@@ -187,7 +190,10 @@ def curses_main(stdscr, state: PlayerState):
187
190
  cava_bars = list(state.cava.bars) if (hasattr(state, "cava") and state.cava) else None
188
191
  p_id = get_shimmer_color_pair(shimmer_eff, x_norm, c_idx, 0, t_now, cava_bars=cava_bars, is_playing=state.playing)
189
192
  attr = curses.color_pair(p_id) | curses.A_BOLD
190
- stdscr.addstr(0, 2 + c_idx, ch, attr)
193
+ try:
194
+ stdscr.addstr(0, 2 + c_idx, ch, attr)
195
+ except Exception:
196
+ pass
191
197
  main_start_y = 2
192
198
 
193
199
  # Right side stats: Real Live Network Speeds (↓ Down / ↑ Up) + Bitrate + Rec
@@ -23,9 +23,21 @@ def trim(text: str, width: int) -> str:
23
23
  if width <= 3: return text[:width]
24
24
  return text[:width-3] + "..."
25
25
 
26
- def apply_theme(theme_name: str, themes: dict):
26
+ _CURRENT_THEME = None
27
+
28
+ def apply_theme(theme_name: str, themes: dict, force: bool = False):
29
+ global _CURRENT_THEME
27
30
  if not curses.has_colors():
28
31
  return
32
+ if not force and _CURRENT_THEME == theme_name:
33
+ return
34
+ _CURRENT_THEME = theme_name
35
+
36
+ try:
37
+ curses.use_default_colors()
38
+ except Exception:
39
+ pass
40
+
29
41
  t = themes.get(theme_name, themes.get("Plur1bus", list(themes.values())[0]))
30
42
  is_256 = curses.COLORS >= 256
31
43