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.
@@ -0,0 +1,550 @@
1
+ # LUSH - State and Storage Management
2
+ import os
3
+ import json
4
+ import threading
5
+ import time
6
+ from pathlib import Path
7
+ from .constants import DEFAULT_THEMES, SHIMMER_EFFECTS, SHIMMER_EFFECT_LABELS, ALL_SIDEBAR_ITEMS, VISUALIZER_MODES, VISUALIZER_LABELS, AMBIENT_PRESETS
8
+ from .audio import AudioEngine
9
+ from .cava import CavaVisualizerEngine
10
+ from .net import get_net_bytes, format_speed
11
+ from .stats import StatsEngine
12
+ from .notify import NotificationManager
13
+ from .search import fuzzy_filter_stations
14
+
15
+ CONFIG_DIR = Path(os.getenv("XDG_CONFIG_HOME", Path.home() / ".config")) / "lush"
16
+ STATIONS_FILE = CONFIG_DIR / "stations.json"
17
+ CONFIG_FILE = CONFIG_DIR / "config.json"
18
+ THEMES_FILE = CONFIG_DIR / "themes.json"
19
+ FAVORITES_FILE = CONFIG_DIR / "favorites.json"
20
+ HISTORY_FILE = CONFIG_DIR / "history.json"
21
+ STATS_FILE = CONFIG_DIR / "stats.json"
22
+
23
+ def load_history():
24
+ try:
25
+ if HISTORY_FILE.exists():
26
+ with open(HISTORY_FILE, "r", encoding="utf-8") as f:
27
+ data = json.load(f)
28
+ if isinstance(data, list):
29
+ return data
30
+ except Exception:
31
+ pass
32
+ return []
33
+
34
+ def save_history(history):
35
+ try:
36
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
37
+ tmp_file = HISTORY_FILE.with_suffix(".tmp")
38
+ with open(tmp_file, "w", encoding="utf-8") as f:
39
+ json.dump(history[:150], f, ensure_ascii=False, indent=2)
40
+ tmp_file.replace(HISTORY_FILE)
41
+ except Exception:
42
+ pass
43
+
44
+ CAVA_CFG_FILE = CONFIG_DIR / "cava.conf"
45
+ RECORDINGS_DIR = Path.home() / "Music" / "Recordings"
46
+
47
+ def get_bundled_stations():
48
+ try:
49
+ bundled_path = Path(__file__).parent / "data" / "stations.json"
50
+ if bundled_path.exists():
51
+ with open(bundled_path, "r", encoding="utf-8") as f:
52
+ data = json.load(f)
53
+ if isinstance(data, list) and data:
54
+ return data
55
+ except Exception:
56
+ pass
57
+ return []
58
+
59
+ DEFAULT_STATIONS = get_bundled_stations()
60
+
61
+ def load_stations():
62
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
63
+ bundled = get_bundled_stations()
64
+ try:
65
+ if STATIONS_FILE.exists():
66
+ with open(STATIONS_FILE, "r", encoding="utf-8") as f:
67
+ data = json.load(f)
68
+ if isinstance(data, list) and len(data) >= 100:
69
+ return data
70
+ except Exception:
71
+ pass
72
+ if bundled:
73
+ save_stations(bundled)
74
+ return bundled
75
+ return DEFAULT_STATIONS
76
+
77
+ def save_stations(stations):
78
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
79
+ with open(STATIONS_FILE, "w", encoding="utf-8") as f:
80
+ json.dump(stations, f, ensure_ascii=False, indent=2)
81
+
82
+ def load_favorites():
83
+ try:
84
+ if FAVORITES_FILE.exists():
85
+ with open(FAVORITES_FILE, "r", encoding="utf-8") as f:
86
+ data = json.load(f)
87
+ if isinstance(data, list):
88
+ return set(data)
89
+ except Exception:
90
+ pass
91
+ return set()
92
+
93
+ def save_favorites(favs):
94
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
95
+ with open(FAVORITES_FILE, "w", encoding="utf-8") as f:
96
+ json.dump(list(favs), f, ensure_ascii=False, indent=2)
97
+
98
+ def load_config():
99
+ try:
100
+ if CONFIG_FILE.exists():
101
+ with open(CONFIG_FILE, "r", encoding="utf-8") as f:
102
+ data = json.load(f)
103
+ if isinstance(data, dict):
104
+ return data
105
+ except Exception:
106
+ pass
107
+ return {
108
+ "theme": "Plur1bus",
109
+ "section": "top_artists",
110
+ "visualizer": "spectrum",
111
+ "ambient_preset": "none",
112
+ "ambient_volume": 40,
113
+ "cava_sensitivity": 180,
114
+ "master_volume": 100,
115
+ "nsfw_enabled": False
116
+ }
117
+
118
+ def save_config(cfg):
119
+ try:
120
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
121
+ cur = load_config()
122
+ cur.update(cfg)
123
+ tmp_file = CONFIG_FILE.with_suffix(".tmp")
124
+ with open(tmp_file, "w", encoding="utf-8") as f:
125
+ json.dump(cur, f, ensure_ascii=False, indent=2)
126
+ tmp_file.replace(CONFIG_FILE)
127
+ except Exception:
128
+ pass
129
+
130
+ def load_themes():
131
+ themes = dict(DEFAULT_THEMES)
132
+ try:
133
+ if THEMES_FILE.exists():
134
+ with open(THEMES_FILE, "r", encoding="utf-8") as f:
135
+ custom = json.load(f)
136
+ if isinstance(custom, dict):
137
+ themes.update(custom)
138
+ except Exception:
139
+ pass
140
+ return themes
141
+
142
+ class PlayerState:
143
+ def __init__(self):
144
+ self.stations = load_stations()
145
+ self.favorites = load_favorites()
146
+ self.themes = load_themes()
147
+ cfg = load_config()
148
+
149
+ self.theme_name = cfg.get("theme", "Plur1bus")
150
+ if self.theme_name not in self.themes:
151
+ self.theme_name = "Plur1bus" if "Plur1bus" in self.themes else list(self.themes.keys())[0]
152
+
153
+ self.section_key = cfg.get("section", "top_artists")
154
+ if self.section_key in ("all", "lush"): self.section_key = "top_artists"
155
+ keys = [it["key"] for it in ALL_SIDEBAR_ITEMS]
156
+ if self.section_key not in keys:
157
+ self.section_key = "all"
158
+
159
+ self.sidebar_idx = keys.index(self.section_key)
160
+ self.region = "content"
161
+ self.search_active = False
162
+ self.search_query = ""
163
+
164
+ # Visualizer & Ambient
165
+ self.visualizer_mode = cfg.get("visualizer", "shimmer_string")
166
+ if self.visualizer_mode not in VISUALIZER_MODES:
167
+ self.visualizer_mode = "shimmer_string"
168
+
169
+ self.ambient_preset = cfg.get("ambient_preset", "none")
170
+ self.ambient_volume = cfg.get("ambient_volume", 40)
171
+ self.cava_sensitivity = cfg.get("cava_sensitivity", 180)
172
+ self.volume = cfg.get("master_volume", 100)
173
+ self.nsfw_enabled = cfg.get("nsfw_enabled", False)
174
+ self.headphone_warned_session = False
175
+ self.config = cfg
176
+ self.show_welcome = cfg.get("show_welcome", True)
177
+ self.deck_style = cfg.get("deck_style", "vinyl")
178
+ self.framerate = cfg.get("framerate", 144)
179
+ self.shimmer_effect = cfg.get("shimmer_effect", "cava_sync")
180
+ if self.shimmer_effect not in SHIMMER_EFFECTS: self.shimmer_effect = "cava_sync"
181
+
182
+ # Network and Bitrate
183
+ self.net_down_str = "↓ 0 B/s"
184
+ self.net_up_str = "↑ 0 B/s"
185
+ self.stream_bitrate = "128 kbps"
186
+
187
+ # Station selection: resume last station or pick random trending/legend artist on first launch
188
+ last_url = cfg.get("last_station_url")
189
+ last_name = cfg.get("last_station_name")
190
+
191
+ target_idx = None
192
+ if last_url:
193
+ for i, s in enumerate(self.stations):
194
+ if s.get("url") == last_url:
195
+ target_idx = i
196
+ break
197
+ if target_idx is None and last_name:
198
+ for i, s in enumerate(self.stations):
199
+ if s.get("name") == last_name:
200
+ target_idx = i
201
+ break
202
+
203
+ # First-time user boot experience: pick random trending or legends artist
204
+ if target_idx is None:
205
+ import random
206
+ popular_keywords = [
207
+ "taylor swift", "billie eilish", "olivia rodrigo", "the weeknd", "britney",
208
+ "daft punk", "lana del rey", "dua lipa", "queen", "michael jackson",
209
+ "eminem", "shakira", "justin timberlake", "timbaland", "nelly", "usher",
210
+ "ariana grande", "harry styles", "rihanna", "lady gaga", "k-pop", "sabrina carpenter",
211
+ "charli xcx", "post malone", "drake", "sza", "pink floyd", "david bowie"
212
+ ]
213
+ candidates = [
214
+ i for i, s in enumerate(self.stations)
215
+ if (s.get("category") == "music" or "exclusive.radio" in s.get("url", "").lower())
216
+ and any(k in s.get("name", "").lower() for k in popular_keywords)
217
+ ]
218
+ if candidates:
219
+ target_idx = random.choice(candidates)
220
+ else:
221
+ target_idx = 0
222
+
223
+ self.idx = target_idx
224
+ self.cursor_idx = target_idx
225
+ self.current_track = self.stations[self.idx]["name"]
226
+ self.history = load_history()
227
+ self.playing = True
228
+ self.quit = False
229
+ self.toast_msg = ""
230
+ self.toast_time = 0
231
+ self.lock = threading.RLock()
232
+
233
+ # SSD Wear Reduction: In-Memory Dirty Tracking
234
+ self._cfg_dirty = False
235
+ self._last_cfg_save = 0.0
236
+
237
+ # Subsystems
238
+ self.audio = AudioEngine(RECORDINGS_DIR)
239
+ self.cava = CavaVisualizerEngine(CAVA_CFG_FILE, sensitivity=self.cava_sensitivity, framerate=self.framerate)
240
+ self.stats = StatsEngine(STATS_FILE, HISTORY_FILE, RECORDINGS_DIR)
241
+ self.notify_mgr = NotificationManager(enabled=cfg.get("notifications_enabled", True))
242
+
243
+ # Initial playback
244
+ self.audio.set_volume(self.volume)
245
+ self.audio.play_station(self.stations[self.idx]["url"])
246
+ if self.ambient_preset != "none":
247
+ self.start_ambient_preset(self.ambient_preset, notify=False)
248
+
249
+ def add_history_entry(self, track_title, station_name, station_url, genre):
250
+ if not track_title:
251
+ return
252
+ entry = {
253
+ "name": track_title,
254
+ "station": station_name,
255
+ "genre": genre,
256
+ "url": station_url,
257
+ "time": time.strftime("%H:%M"),
258
+ "date": time.strftime("%Y-%m-%d"),
259
+ "timestamp": time.time()
260
+ }
261
+ if self.history and self.history[0].get("name") == track_title:
262
+ return
263
+ self.history.insert(0, entry)
264
+ if len(self.history) > 150:
265
+ self.history = self.history[:150]
266
+ save_history(self.history)
267
+ if hasattr(self, "stats") and self.stats:
268
+ self.stats.record_track(track_title, station_name, genre, station_url)
269
+
270
+ def get_current_sidebar_item(self):
271
+ for it in ALL_SIDEBAR_ITEMS:
272
+ if it["key"] == self.section_key:
273
+ return it
274
+ return ALL_SIDEBAR_ITEMS[0]
275
+
276
+ def get_filtered_stations_unlocked(self):
277
+ cur_item = self.get_current_sidebar_item()
278
+ k = cur_item["key"]
279
+
280
+ if k == "bookmarks":
281
+ filtered = [(i, s) for i, s in enumerate(self.stations) if s["name"] in self.favorites]
282
+ elif k == "playing":
283
+ filtered = [(self.idx, self.stations[self.idx])]
284
+ elif k == "history":
285
+ filtered = [(i, h) for i, h in enumerate(self.history)]
286
+ elif k == "lush":
287
+ if not self.nsfw_enabled:
288
+ filtered = []
289
+ else:
290
+ filtered = [(i, s) for i, s in enumerate(self.stations) if cur_item.get("match", lambda x: True)(s)]
291
+ elif "match" in cur_item:
292
+ match_fn = cur_item["match"]
293
+ filtered = [(i, s) for i, s in enumerate(self.stations) if match_fn(s)]
294
+ if "sort_key" in cur_item:
295
+ sort_fn = cur_item["sort_key"]
296
+ filtered.sort(key=lambda item: sort_fn(item[1]))
297
+ else:
298
+ filtered = [(i, s) for i, s in enumerate(self.stations)]
299
+
300
+ # Strictly filter out 18+ / sensual / ASMR content when 18+ is disabled
301
+ if not self.nsfw_enabled:
302
+ filtered = [(i, s) for i, s in filtered if not (
303
+ s.get("nsfw") is True or
304
+ s.get("category") == "lush" or
305
+ any(sens in s.get("genre", "").lower() for sens in ["sensual", "erotic", "adult", "18+", "nsfw"])
306
+ )]
307
+
308
+ if self.search_query:
309
+ matched = fuzzy_filter_stations(filtered, self.search_query)
310
+ if not matched and k not in ("playing", "history"):
311
+ # Fallback to search all available stations
312
+ all_indexed = [(i, s) for i, s in enumerate(self.stations)]
313
+ if not self.nsfw_enabled:
314
+ all_indexed = [(i, s) for i, s in all_indexed if not (
315
+ s.get("nsfw") is True or
316
+ s.get("category") == "lush" or
317
+ any(sens in s.get("genre", "").lower() for sens in ["sensual", "erotic", "adult", "18+", "nsfw"])
318
+ )]
319
+ matched = fuzzy_filter_stations(all_indexed, self.search_query)
320
+ filtered = matched
321
+
322
+ return filtered
323
+
324
+ def get_filtered_stations(self):
325
+ with self.lock:
326
+ return self.get_filtered_stations_unlocked()
327
+
328
+ def play_station(self, global_idx):
329
+ with self.lock:
330
+ if global_idx < 0 or global_idx >= len(self.stations): return
331
+ self.idx = global_idx
332
+ st = self.stations[self.idx]
333
+ self.current_track = st["name"]
334
+ self.add_history_entry(st["name"], st["name"], st["url"], st.get("genre", "Unknown"))
335
+ self.audio.play_station(st["url"])
336
+ self.playing = True
337
+ self.save_current_config()
338
+ self.set_toast(f"▶ Tuning into: {st['name']}")
339
+
340
+ def toggle_play(self):
341
+ with self.lock:
342
+ self.playing = self.audio.toggle_play(self.playing)
343
+ if self.playing:
344
+ self.set_toast("▶ Resumed")
345
+ else:
346
+ self.set_toast("→ PAUSED")
347
+
348
+ def adjust_volume(self, delta):
349
+ with self.lock:
350
+ new_v = max(0, min(100, int(self.volume + delta)))
351
+ self.volume = new_v
352
+ self.audio.set_volume(new_v)
353
+ save_config({"master_volume": new_v})
354
+ self.set_toast(f"Volume: {new_v}%")
355
+
356
+ def cycle_visualizer(self):
357
+ with self.lock:
358
+ cur_i = VISUALIZER_MODES.index(self.visualizer_mode) if self.visualizer_mode in VISUALIZER_MODES else 0
359
+ self.visualizer_mode = VISUALIZER_MODES[(cur_i + 1) % len(VISUALIZER_MODES)]
360
+ self.save_current_config()
361
+ self.set_toast(f"Visualizer: {VISUALIZER_LABELS[self.visualizer_mode]}")
362
+
363
+ def start_ambient_preset(self, preset_id, notify=True):
364
+ preset = next((p for p in AMBIENT_PRESETS if p["id"] == preset_id), None)
365
+ if not preset: return
366
+ self.ambient_preset = preset_id
367
+ save_config({"ambient_preset": preset_id, "ambient_volume": self.ambient_volume})
368
+ self.audio.play_ambient(preset["url"], self.ambient_volume)
369
+ if notify:
370
+ if not preset["url"]:
371
+ self.set_toast("Ambient FX: Muted")
372
+ else:
373
+ self.set_toast(f"Ambient FX: {preset['name']}")
374
+
375
+ def toggle_ambient(self):
376
+ with self.lock:
377
+ if self.ambient_preset != "none":
378
+ self.start_ambient_preset("none")
379
+ else:
380
+ self.start_ambient_preset("deep_rain")
381
+
382
+ def adjust_ambient_volume(self, delta):
383
+ with self.lock:
384
+ new_v = max(0, min(100, int(self.ambient_volume + delta)))
385
+ self.ambient_volume = new_v
386
+ self.audio.set_ambient_volume(new_v)
387
+ save_config({"ambient_volume": new_v})
388
+ self.set_toast(f"Ambient Vol: {new_v}%")
389
+
390
+ def toggle_recording(self):
391
+ with self.lock:
392
+ st = self.stations[self.idx]
393
+ ok, msg, path = self.audio.toggle_recording(st["name"], st["url"], getattr(self, "current_track", ""))
394
+ self.set_toast(msg, duration=3.0)
395
+
396
+ def toggle_favorite(self):
397
+ with self.lock:
398
+ filtered = self.get_filtered_stations_unlocked()
399
+ if not filtered or self.cursor_idx >= len(filtered): return
400
+ st_name = filtered[self.cursor_idx][1]["name"]
401
+ if st_name in self.favorites:
402
+ self.favorites.remove(st_name)
403
+ self.set_toast(f"Removed from Favs: {st_name}")
404
+ else:
405
+ self.favorites.add(st_name)
406
+ self.set_toast(f"Added to Favs: {st_name}")
407
+ save_favorites(self.favorites)
408
+
409
+ def select_section(self, section_key):
410
+ with self.lock:
411
+ keys = [it["key"] for it in ALL_SIDEBAR_ITEMS]
412
+ if section_key in keys:
413
+ self.section_key = section_key
414
+ self.sidebar_idx = keys.index(section_key)
415
+ self.cursor_idx = 0
416
+ self.save_current_config()
417
+ it = self.get_current_sidebar_item()
418
+ self.set_toast(f"Section: {it['label']}")
419
+
420
+ def save_current_config(self, force=False):
421
+ try:
422
+ self._cfg_dirty = True
423
+ t_now = time.time()
424
+ if not force and (t_now - self._last_cfg_save < 2.0):
425
+ return
426
+ cfg = {
427
+ "theme": self.theme_name,
428
+ "section": self.section_key,
429
+ "visualizer": self.visualizer_mode,
430
+ "ambient_preset": self.ambient_preset,
431
+ "ambient_volume": self.ambient_volume,
432
+ "cava_sensitivity": self.cava_sensitivity,
433
+ "master_volume": self.volume,
434
+ "nsfw_enabled": self.nsfw_enabled,
435
+ "show_welcome": getattr(self, "show_welcome", True),
436
+ "deck_style": getattr(self, "deck_style", "vinyl"),
437
+ "framerate": getattr(self, "framerate", 144),
438
+ "shimmer_effect": getattr(self, "shimmer_effect", "cava_sync"),
439
+ "notifications_enabled": self.notify_mgr.is_enabled() if hasattr(self, "notify_mgr") else True,
440
+ "last_station_url": self.stations[self.idx]["url"] if (self.stations and self.idx < len(self.stations)) else "",
441
+ "last_station_name": self.stations[self.idx]["name"] if (self.stations and self.idx < len(self.stations)) else ""
442
+ }
443
+ save_config(cfg)
444
+ self._cfg_dirty = False
445
+ self._last_cfg_save = t_now
446
+ except Exception:
447
+ pass
448
+
449
+ def toggle_notifications(self):
450
+ with self.lock:
451
+ new_state = not self.notify_mgr.is_enabled()
452
+ self.notify_mgr.set_enabled(new_state)
453
+ self.save_current_config()
454
+ status = "ON" if new_state else "OFF"
455
+ self.set_toast(f"Desktop Notifications: {status}")
456
+
457
+ def cycle_framerate(self, step=1):
458
+ rates = [144, 120, 60, 240]
459
+ with self.lock:
460
+ cur_i = rates.index(self.framerate) if self.framerate in rates else 0
461
+ self.framerate = rates[(cur_i + step) % len(rates)]
462
+ self.cava.update_settings(self.cava_sensitivity, self.framerate)
463
+ self.save_current_config()
464
+ self.set_toast(f"Display Refresh: {self.framerate} Hz")
465
+
466
+ def cycle_deck(self):
467
+ with self.lock:
468
+ self.deck_style = "cassette" if self.deck_style == "vinyl" else "vinyl"
469
+ self.save_current_config()
470
+ lbl = "Vinyl Turntable" if self.deck_style == "vinyl" else "Cassette Master Deck"
471
+ self.set_toast(f"Now Playing Deck: {lbl}")
472
+
473
+ def cycle_shimmer(self):
474
+ with self.lock:
475
+ cur_i = SHIMMER_EFFECTS.index(self.shimmer_effect) if self.shimmer_effect in SHIMMER_EFFECTS else 0
476
+ self.shimmer_effect = SHIMMER_EFFECTS[(cur_i + 1) % len(SHIMMER_EFFECTS)]
477
+ self.save_current_config()
478
+ self.set_toast(f"Logo Shimmer: {SHIMMER_EFFECT_LABELS.get(self.shimmer_effect, self.shimmer_effect)}")
479
+
480
+ def cycle_theme(self):
481
+ with self.lock:
482
+ names = list(self.themes.keys())
483
+ if not names: return
484
+ cur = names.index(self.theme_name) if self.theme_name in names else 0
485
+ self.theme_name = names[(cur + 1) % len(names)]
486
+ self.save_current_config()
487
+ self.set_toast(f"Theme: {self.theme_name}")
488
+
489
+ def set_theme(self, name):
490
+ with self.lock:
491
+ if name in self.themes:
492
+ self.theme_name = name
493
+ self.save_current_config()
494
+ self.set_toast(f"Theme: {self.theme_name}")
495
+
496
+ def set_toast(self, msg, duration=1.8):
497
+ self.toast_msg = msg
498
+ self.toast_time = time.time() + duration
499
+
500
+ def shutdown(self):
501
+ with self.lock:
502
+ self.quit = True
503
+ self.save_current_config(force=True)
504
+ if hasattr(self, 'stats') and self.stats:
505
+ self.stats.flush()
506
+ if hasattr(self, 'cava') and self.cava:
507
+ self.cava.stop()
508
+ if hasattr(self, 'audio') and self.audio:
509
+ self.audio.shutdown()
510
+
511
+ def metadata_loop(state: PlayerState):
512
+ last_rx, last_tx = get_net_bytes()
513
+ last_t = time.time()
514
+
515
+ while not state.quit:
516
+ try:
517
+ now = time.time()
518
+ dt = max(0.1, now - last_t)
519
+ cur_rx, cur_tx = get_net_bytes()
520
+
521
+ rx_spd = max(0, (cur_rx - last_rx) / dt)
522
+ tx_spd = max(0, (cur_tx - last_tx) / dt)
523
+ last_rx, last_tx = cur_rx, cur_tx
524
+ last_t = now
525
+
526
+ with state.lock:
527
+ state.net_down_str = f"↓ {format_speed(rx_spd)}"
528
+ state.net_up_str = f"↑ {format_speed(tx_spd)}"
529
+
530
+ # Periodic debounced write of dirty config
531
+ if getattr(state, "_cfg_dirty", False) and (now - getattr(state, "_last_cfg_save", 0.0) >= 2.0):
532
+ state.save_current_config(force=True)
533
+
534
+ if state.audio.player:
535
+ meta = state.audio.player.metadata
536
+ if meta:
537
+ title = meta.get("icy-title") or meta.get("title")
538
+ br = meta.get("icy-br")
539
+ with state.lock:
540
+ if br:
541
+ state.stream_bitrate = f"{br} kbps"
542
+ if title and title != state.current_track:
543
+ state.current_track = title
544
+ st = state.stations[state.idx]
545
+ state.add_history_entry(title, st["name"], st["url"], st.get("genre", "Unknown"))
546
+ if hasattr(state, "notify_mgr") and state.notify_mgr:
547
+ state.notify_mgr.send_track_notification(title, st.get("name", ""), st.get("genre", ""), st.get("category", ""))
548
+ except Exception:
549
+ pass
550
+ time.sleep(0.5)