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
package/src/lush/net.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# LUSH - Network Bandwidth Monitor
|
|
2
|
+
import time
|
|
3
|
+
|
|
4
|
+
def get_net_bytes():
|
|
5
|
+
rx = 0
|
|
6
|
+
tx = 0
|
|
7
|
+
try:
|
|
8
|
+
with open('/proc/net/dev') as f:
|
|
9
|
+
for line in f.readlines()[2:]:
|
|
10
|
+
parts = line.split(':')
|
|
11
|
+
if len(parts) == 2:
|
|
12
|
+
iface = parts[0].strip()
|
|
13
|
+
if iface != 'lo':
|
|
14
|
+
cols = parts[1].split()
|
|
15
|
+
rx += int(cols[0])
|
|
16
|
+
tx += int(cols[8])
|
|
17
|
+
except Exception:
|
|
18
|
+
pass
|
|
19
|
+
return rx, tx
|
|
20
|
+
|
|
21
|
+
def format_speed(bps: float) -> str:
|
|
22
|
+
if bps < 1024:
|
|
23
|
+
return f"{bps:.0f} B/s"
|
|
24
|
+
elif bps < 1024 * 1024:
|
|
25
|
+
return f"{bps/1024:.1f} KB/s"
|
|
26
|
+
else:
|
|
27
|
+
return f"{bps/(1024*1024):.2f} MB/s"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# LUSH - Cross-Platform Desktop Track Notifications
|
|
2
|
+
import platform
|
|
3
|
+
import subprocess
|
|
4
|
+
import threading
|
|
5
|
+
import shutil
|
|
6
|
+
import time
|
|
7
|
+
import re
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
class NotificationManager:
|
|
11
|
+
"""
|
|
12
|
+
Lightweight, thread-safe desktop notification manager supporting
|
|
13
|
+
Linux (notify-send), macOS (osascript), and Windows (PowerShell Toast).
|
|
14
|
+
|
|
15
|
+
Includes smart filtering to ignore soundscapes, ambient noise, station bumpers,
|
|
16
|
+
and duplicate metadata events.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self, enabled: bool = True):
|
|
20
|
+
self.enabled = enabled
|
|
21
|
+
self._last_track = ""
|
|
22
|
+
self._last_time = 0.0
|
|
23
|
+
self._lock = threading.Lock()
|
|
24
|
+
self._system = platform.system()
|
|
25
|
+
|
|
26
|
+
def set_enabled(self, enabled: bool):
|
|
27
|
+
with self._lock:
|
|
28
|
+
self.enabled = enabled
|
|
29
|
+
|
|
30
|
+
def is_enabled(self) -> bool:
|
|
31
|
+
with self._lock:
|
|
32
|
+
return self.enabled
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def should_suppress(track_title: str, station_name: str, genre: str = "", category: str = "") -> bool:
|
|
36
|
+
"""
|
|
37
|
+
Smart filter heuristic to determine if a notification should be suppressed.
|
|
38
|
+
Mutes soundscapes, ambient overlays, station identifiers, and promotional bumpers.
|
|
39
|
+
"""
|
|
40
|
+
if not track_title or not track_title.strip():
|
|
41
|
+
return True
|
|
42
|
+
|
|
43
|
+
clean_track = track_title.strip().lower()
|
|
44
|
+
clean_station = station_name.strip().lower() if station_name else ""
|
|
45
|
+
clean_genre = genre.strip().lower() if genre else ""
|
|
46
|
+
clean_cat = category.strip().lower() if category else ""
|
|
47
|
+
|
|
48
|
+
# 1. Ignore pure soundscapes and ambient FX
|
|
49
|
+
if clean_cat in ("soundscape", "ambient", "fx", "nature"):
|
|
50
|
+
return True
|
|
51
|
+
if any(amb in clean_genre for amb in ["ambient", "soundscape", "rain", "spa", "drone", "whitenoise", "sleep"]):
|
|
52
|
+
return True
|
|
53
|
+
|
|
54
|
+
# 2. Ignore when track title is identical to station name (common on radio startup)
|
|
55
|
+
if clean_station and (clean_track == clean_station or clean_track in clean_station):
|
|
56
|
+
return True
|
|
57
|
+
|
|
58
|
+
# 3. Ignore common radio bumpers and commercial placeholders
|
|
59
|
+
bumper_phrases = [
|
|
60
|
+
"station will continue",
|
|
61
|
+
"this break",
|
|
62
|
+
"commercial break",
|
|
63
|
+
"after this",
|
|
64
|
+
"stream offline",
|
|
65
|
+
"connecting",
|
|
66
|
+
"buffering",
|
|
67
|
+
"continuous music",
|
|
68
|
+
"music mix",
|
|
69
|
+
"various artists"
|
|
70
|
+
]
|
|
71
|
+
if any(phrase in clean_track for phrase in bumper_phrases):
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
# 4. Ignore pure soundscape track names
|
|
75
|
+
soundscape_names = ["deep rain", "pink rain", "ocean waves", "campfire", "space drone", "soma drone"]
|
|
76
|
+
if any(s_name in clean_track for s_name in soundscape_names):
|
|
77
|
+
return True
|
|
78
|
+
|
|
79
|
+
return False
|
|
80
|
+
|
|
81
|
+
def send_track_notification(self, track_title: str, station_name: str, genre: str = "", category: str = ""):
|
|
82
|
+
"""Dispatches a non-blocking desktop notification in a daemon thread."""
|
|
83
|
+
with self._lock:
|
|
84
|
+
if not self.enabled:
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
if self.should_suppress(track_title, station_name, genre, category):
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
now = time.time()
|
|
91
|
+
clean = track_title.strip()
|
|
92
|
+
# Debounce duplicate notifications within 20 seconds
|
|
93
|
+
if clean == self._last_track and (now - self._last_time < 20.0):
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
self._last_track = clean
|
|
97
|
+
self._last_time = now
|
|
98
|
+
|
|
99
|
+
threading.Thread(
|
|
100
|
+
target=self._dispatch,
|
|
101
|
+
args=(clean, station_name.strip() if station_name else "", genre.strip() if genre else ""),
|
|
102
|
+
daemon=True
|
|
103
|
+
).start()
|
|
104
|
+
|
|
105
|
+
def _dispatch(self, track: str, station: str, genre: str):
|
|
106
|
+
"""Worker thread to run native OS notification command with strict timeout."""
|
|
107
|
+
app_title = "LUSH ♫"
|
|
108
|
+
body = f"{track}"
|
|
109
|
+
if station:
|
|
110
|
+
body += f"\n[{station}]"
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
if self._system == "Linux":
|
|
114
|
+
self._dispatch_linux(app_title, body)
|
|
115
|
+
elif self._system == "Darwin":
|
|
116
|
+
self._dispatch_macos(app_title, track, station)
|
|
117
|
+
elif self._system == "Windows":
|
|
118
|
+
self._dispatch_windows(app_title, body)
|
|
119
|
+
except Exception:
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
def _dispatch_linux(title: str, body: str):
|
|
124
|
+
if shutil.which("notify-send"):
|
|
125
|
+
subprocess.run(
|
|
126
|
+
["notify-send", "-a", "LUSH", "-i", "audio-headphones", title, body],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
timeout=2.0
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _dispatch_macos(title: str, track: str, station: str):
|
|
133
|
+
subtitle = f"[{station}]" if station else "Now Playing"
|
|
134
|
+
# Escape quotes for AppleScript
|
|
135
|
+
safe_track = track.replace('"', '\\"')
|
|
136
|
+
safe_sub = subtitle.replace('"', '\\"')
|
|
137
|
+
script = f'display notification "{safe_track}" with title "{title}" subtitle "{safe_sub}"'
|
|
138
|
+
subprocess.run(["osascript", "-e", script], capture_output=True, timeout=2.0)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
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)
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
# LUSH - In-Memory Fuzzy Search & Relevance Ranking Engine
|
|
2
|
+
import re
|
|
3
|
+
import difflib
|
|
4
|
+
from typing import List, Tuple, Dict, Any
|
|
5
|
+
|
|
6
|
+
STOPWORDS = {
|
|
7
|
+
'the', 'a', 'an', 'and', 'or', 'in', 'on', 'of', 'one', 'that', 'this',
|
|
8
|
+
'to', 'for', 'with', 'by', 'at', 'from', 'is', 'it', 'station', 'radio'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
def _extract_tokens(text: str) -> List[str]:
|
|
12
|
+
"""
|
|
13
|
+
Extracts search tokens from text, handling whitespace, punctuation,
|
|
14
|
+
CamelCase word splitting, and compound 2-word combinations (e.g. 'Anne-Marie' -> 'annemarie').
|
|
15
|
+
"""
|
|
16
|
+
raw_words = re.findall(r'[a-zA-Z0-9]+', text)
|
|
17
|
+
tokens = set()
|
|
18
|
+
for w in raw_words:
|
|
19
|
+
tokens.add(w.lower())
|
|
20
|
+
camel_parts = re.findall(r'[A-Z]?[a-z]+|[A-Z]+(?=[A-Z][a-z]|\d|\W|$)|\d+', w)
|
|
21
|
+
if len(camel_parts) > 1:
|
|
22
|
+
for cp in camel_parts:
|
|
23
|
+
tokens.add(cp.lower())
|
|
24
|
+
|
|
25
|
+
for i in range(len(raw_words) - 1):
|
|
26
|
+
combo = (raw_words[i] + raw_words[i+1]).lower()
|
|
27
|
+
if len(combo) <= 20:
|
|
28
|
+
tokens.add(combo)
|
|
29
|
+
|
|
30
|
+
return list(tokens)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _token_similarity(q_tok: str, cand_tok: str) -> float:
|
|
34
|
+
"""
|
|
35
|
+
Computes token-level similarity between query word and candidate word.
|
|
36
|
+
Handles exact, prefix, substring, SequenceMatcher ratio, sub-sequence acronyms,
|
|
37
|
+
and conversational synonyms (e.g. 'sister' <-> 'sis').
|
|
38
|
+
"""
|
|
39
|
+
if not q_tok or not cand_tok:
|
|
40
|
+
return 0.0
|
|
41
|
+
if q_tok == cand_tok:
|
|
42
|
+
return 1.0
|
|
43
|
+
if cand_tok.startswith(q_tok):
|
|
44
|
+
return 0.95
|
|
45
|
+
if q_tok in cand_tok:
|
|
46
|
+
return 0.88
|
|
47
|
+
if len(cand_tok) >= 3 and cand_tok in q_tok:
|
|
48
|
+
return 0.50 + 0.35 * (len(cand_tok) / len(q_tok))
|
|
49
|
+
|
|
50
|
+
# Common colloquial abbreviations
|
|
51
|
+
if (q_tok == 'sister' and cand_tok == 'sis') or (q_tok == 'sis' and cand_tok == 'sister'):
|
|
52
|
+
return 0.95
|
|
53
|
+
|
|
54
|
+
r = difflib.SequenceMatcher(None, q_tok, cand_tok).ratio()
|
|
55
|
+
|
|
56
|
+
# If first letter differs on short words, apply typo penalty
|
|
57
|
+
if q_tok[0] != cand_tok[0]:
|
|
58
|
+
if len(q_tok) <= 5 or len(cand_tok) <= 5:
|
|
59
|
+
r = r * 0.75
|
|
60
|
+
|
|
61
|
+
# Subsequence ratio within token (e.g. 'swft' in 'swift', 'sntra' in 'sinatra')
|
|
62
|
+
if len(q_tok) >= 3 and len(cand_tok) >= len(q_tok):
|
|
63
|
+
sub_idx = 0
|
|
64
|
+
for ch in cand_tok:
|
|
65
|
+
if ch == q_tok[sub_idx]:
|
|
66
|
+
sub_idx += 1
|
|
67
|
+
if sub_idx == len(q_tok):
|
|
68
|
+
sub_r = len(q_tok) / len(cand_tok)
|
|
69
|
+
if q_tok[0] == cand_tok[0]:
|
|
70
|
+
return max(r, 0.75 + 0.25 * sub_r)
|
|
71
|
+
else:
|
|
72
|
+
return max(r, 0.60 + 0.20 * sub_r)
|
|
73
|
+
return r
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def score_station_match(query: str, station: Dict[str, Any]) -> float:
|
|
77
|
+
"""
|
|
78
|
+
Computes an in-memory fuzzy relevance score (0.0 to 100.0) between a user query
|
|
79
|
+
and a station's metadata (name, genre, category, description).
|
|
80
|
+
"""
|
|
81
|
+
if not query:
|
|
82
|
+
return 1.0
|
|
83
|
+
|
|
84
|
+
q = query.strip().lower()
|
|
85
|
+
if not q:
|
|
86
|
+
return 1.0
|
|
87
|
+
|
|
88
|
+
name = station.get("name", "").lower()
|
|
89
|
+
genre = station.get("genre", "").lower()
|
|
90
|
+
cat = station.get("category", "").lower()
|
|
91
|
+
full_text = f"{name} {genre} {cat}"
|
|
92
|
+
|
|
93
|
+
# 1. Exact string match
|
|
94
|
+
if q == name:
|
|
95
|
+
return 100.0
|
|
96
|
+
|
|
97
|
+
# 2. Exact prefix match on name
|
|
98
|
+
if name.startswith(q):
|
|
99
|
+
return 95.0 - (len(name) - len(q)) * 0.02
|
|
100
|
+
|
|
101
|
+
# 3. Whole query substring in name
|
|
102
|
+
if q in name:
|
|
103
|
+
return 90.0
|
|
104
|
+
|
|
105
|
+
# 4. Token-to-Token Multi-Word Alignment
|
|
106
|
+
q_all_words = [w.lower() for w in re.findall(r'[a-zA-Z0-9]+', q)]
|
|
107
|
+
q_words = [w for w in q_all_words if w not in STOPWORDS]
|
|
108
|
+
if not q_words:
|
|
109
|
+
q_words = q_all_words
|
|
110
|
+
|
|
111
|
+
name_tokens = _extract_tokens(station.get("name", ""))
|
|
112
|
+
full_tokens = _extract_tokens(full_text)
|
|
113
|
+
|
|
114
|
+
if q_words:
|
|
115
|
+
# Match against name tokens
|
|
116
|
+
token_scores = []
|
|
117
|
+
for qw in q_words:
|
|
118
|
+
best_t = 0.0
|
|
119
|
+
for nt in name_tokens:
|
|
120
|
+
s = _token_similarity(qw, nt)
|
|
121
|
+
if s > best_t:
|
|
122
|
+
best_t = s
|
|
123
|
+
token_scores.append(best_t)
|
|
124
|
+
|
|
125
|
+
avg_score = sum(token_scores) / len(token_scores)
|
|
126
|
+
min_score = min(token_scores)
|
|
127
|
+
|
|
128
|
+
if min_score >= 0.55 or (len(q_words) > 1 and avg_score >= 0.70 and min_score >= 0.40):
|
|
129
|
+
return 60.0 + avg_score * 30.0
|
|
130
|
+
|
|
131
|
+
# Match against full metadata (name + genre + category)
|
|
132
|
+
full_token_scores = []
|
|
133
|
+
for qw in q_words:
|
|
134
|
+
best_ft = 0.0
|
|
135
|
+
for ft in full_tokens:
|
|
136
|
+
s = _token_similarity(qw, ft)
|
|
137
|
+
if s > best_ft:
|
|
138
|
+
best_ft = s
|
|
139
|
+
full_token_scores.append(best_ft)
|
|
140
|
+
|
|
141
|
+
avg_full = sum(full_token_scores) / len(full_token_scores)
|
|
142
|
+
min_full = min(full_token_scores)
|
|
143
|
+
if min_full >= 0.55 or (len(q_words) > 1 and avg_full >= 0.70 and min_full >= 0.40):
|
|
144
|
+
return 45.0 + avg_full * 25.0
|
|
145
|
+
|
|
146
|
+
# 5. Full-string character stream alignment (handles squished names)
|
|
147
|
+
clean_q = "".join(q_words)
|
|
148
|
+
clean_name = "".join(name_tokens)
|
|
149
|
+
if clean_q and clean_name:
|
|
150
|
+
if clean_q == clean_name:
|
|
151
|
+
return 93.0
|
|
152
|
+
if clean_name.startswith(clean_q):
|
|
153
|
+
return 88.0
|
|
154
|
+
if clean_q in clean_name:
|
|
155
|
+
return 83.0
|
|
156
|
+
full_r = difflib.SequenceMatcher(None, clean_q, clean_name).ratio()
|
|
157
|
+
if full_r >= 0.65:
|
|
158
|
+
return 50.0 + full_r * 30.0
|
|
159
|
+
|
|
160
|
+
# 6. Acronym / Initialism
|
|
161
|
+
if len(q_words) == 1 and len(q) >= 2:
|
|
162
|
+
initials = "".join(w[0] for w in name_tokens if w)
|
|
163
|
+
if q == initials or q in initials:
|
|
164
|
+
return 75.0
|
|
165
|
+
|
|
166
|
+
return 0.0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def fuzzy_filter_stations(indexed_stations: List[Tuple[int, Dict[str, Any]]], query: str) -> List[Tuple[int, Dict[str, Any]]]:
|
|
170
|
+
"""
|
|
171
|
+
Filters and sorts a list of (global_index, station_dict) tuples using
|
|
172
|
+
in-memory fuzzy scoring. Preserves original order if query is empty.
|
|
173
|
+
"""
|
|
174
|
+
if not query or not query.strip():
|
|
175
|
+
return indexed_stations
|
|
176
|
+
|
|
177
|
+
scored_items = []
|
|
178
|
+
for orig_idx, st in indexed_stations:
|
|
179
|
+
score = score_station_match(query, st)
|
|
180
|
+
if score > 0.0:
|
|
181
|
+
scored_items.append((score, orig_idx, st))
|
|
182
|
+
|
|
183
|
+
# Sort primarily by score (descending), secondarily by original order
|
|
184
|
+
scored_items.sort(key=lambda item: (-item[0], item[1]))
|
|
185
|
+
return [(orig_idx, st) for _, orig_idx, st in scored_items]
|