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,555 @@
|
|
|
1
|
+
"""
|
|
2
|
+
LUSH Audiophile Listening Diary & Analytics Engine
|
|
3
|
+
Tracks local scrobbles, listening habits, 24-hour rhythms, FLAC recordings,
|
|
4
|
+
and generates GitHub-style contribution heatmaps. 100% private & offline.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class StatsEngine:
|
|
18
|
+
def __init__(self, stats_file: Path, history_file: Path, recordings_dir: Path):
|
|
19
|
+
self.stats_file = Path(stats_file)
|
|
20
|
+
self.history_file = Path(history_file)
|
|
21
|
+
self.recordings_dir = Path(recordings_dir)
|
|
22
|
+
self.lock = threading.RLock()
|
|
23
|
+
|
|
24
|
+
# Analytics Storage
|
|
25
|
+
self.total_scrobbles = 0
|
|
26
|
+
self.daily_counts = {} # "YYYY-MM-DD" -> count
|
|
27
|
+
self.hourly_counts = [0] * 24 # 0..23 -> count
|
|
28
|
+
self.artist_counts = Counter()
|
|
29
|
+
self.station_counts = Counter()
|
|
30
|
+
self.genre_counts = Counter()
|
|
31
|
+
self.recent_events = [] # list of dicts (last 500)
|
|
32
|
+
self.total_seconds_listened = 0.0
|
|
33
|
+
|
|
34
|
+
# SSD Wear Reduction & In-Memory Caching
|
|
35
|
+
self._dirty = False
|
|
36
|
+
self._last_save_time = time.time()
|
|
37
|
+
self._cached_vault = None
|
|
38
|
+
self._last_vault_time = 0.0
|
|
39
|
+
self._cached_heatmap = None
|
|
40
|
+
self._last_heatmap_time = 0.0
|
|
41
|
+
self._cached_rhythm = None
|
|
42
|
+
self._last_rhythm_time = 0.0
|
|
43
|
+
|
|
44
|
+
# Load existing stats or backfill from history
|
|
45
|
+
self._load_or_backfill()
|
|
46
|
+
|
|
47
|
+
def _clean_artist_name(self, track_title: str, station_name: str) -> str:
|
|
48
|
+
"""Extract clean artist name from 'Artist - Title' or station."""
|
|
49
|
+
if not track_title:
|
|
50
|
+
return station_name or "Unknown Artist"
|
|
51
|
+
|
|
52
|
+
# If 'Artist - Title' pattern exists
|
|
53
|
+
if " - " in track_title:
|
|
54
|
+
parts = track_title.split(" - ", 1)
|
|
55
|
+
candidate = parts[0].strip()
|
|
56
|
+
candidate = re.sub(r'^[0-9\.\s]+', '', candidate)
|
|
57
|
+
if candidate and len(candidate) > 1:
|
|
58
|
+
# Check for existing case-insensitive match to merge duplicates
|
|
59
|
+
for existing in self.artist_counts:
|
|
60
|
+
if candidate.lower() == existing.lower():
|
|
61
|
+
return existing
|
|
62
|
+
return candidate
|
|
63
|
+
|
|
64
|
+
# If station represents an artist channel
|
|
65
|
+
if station_name and not any(k in station_name.lower() for k in ["somafm", "radio", "chill", "def con", "nightride", "stream"]):
|
|
66
|
+
cand = station_name.strip()
|
|
67
|
+
for existing in self.artist_counts:
|
|
68
|
+
if cand.lower() == existing.lower():
|
|
69
|
+
return existing
|
|
70
|
+
return cand
|
|
71
|
+
|
|
72
|
+
return track_title.strip()
|
|
73
|
+
|
|
74
|
+
def _load_or_backfill(self):
|
|
75
|
+
with self.lock:
|
|
76
|
+
# 1. Load saved stats if exists
|
|
77
|
+
if self.stats_file.exists():
|
|
78
|
+
try:
|
|
79
|
+
with open(self.stats_file, "r", encoding="utf-8") as f:
|
|
80
|
+
data = json.load(f)
|
|
81
|
+
self.total_scrobbles = data.get("total_scrobbles", 0)
|
|
82
|
+
self.daily_counts = data.get("daily_counts", {})
|
|
83
|
+
self.hourly_counts = data.get("hourly_counts", [0] * 24)
|
|
84
|
+
self.artist_counts = Counter(data.get("artist_counts", {}))
|
|
85
|
+
self.station_counts = Counter(data.get("station_counts", {}))
|
|
86
|
+
self.genre_counts = Counter(data.get("genre_counts", {}))
|
|
87
|
+
self.recent_events = data.get("recent_events", [])
|
|
88
|
+
self.total_seconds_listened = data.get("total_seconds_listened", 0.0)
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# 2. Ingest / backfill from history.json if stats are empty or sparse
|
|
93
|
+
if self.history_file.exists():
|
|
94
|
+
try:
|
|
95
|
+
with open(self.history_file, "r", encoding="utf-8") as f:
|
|
96
|
+
history_records = json.load(f)
|
|
97
|
+
|
|
98
|
+
# Backfill records not already in stats
|
|
99
|
+
if self.total_scrobbles < len(history_records):
|
|
100
|
+
today_str = datetime.now().strftime("%Y-%m-%d")
|
|
101
|
+
for rec in reversed(history_records):
|
|
102
|
+
track_name = rec.get("name", "")
|
|
103
|
+
station = rec.get("station", "Live Stream")
|
|
104
|
+
genre = rec.get("genre", "Radio")
|
|
105
|
+
t_str = rec.get("time", "12:00")
|
|
106
|
+
d_str = rec.get("date", today_str)
|
|
107
|
+
|
|
108
|
+
# Hourly inference
|
|
109
|
+
try:
|
|
110
|
+
hour = int(t_str.split(":")[0])
|
|
111
|
+
except Exception:
|
|
112
|
+
hour = 14
|
|
113
|
+
|
|
114
|
+
artist = self._clean_artist_name(track_name, station)
|
|
115
|
+
self.daily_counts[d_str] = self.daily_counts.get(d_str, 0) + 1
|
|
116
|
+
if 0 <= hour <= 23:
|
|
117
|
+
self.hourly_counts[hour] += 1
|
|
118
|
+
self.artist_counts[artist] += 1
|
|
119
|
+
self.station_counts[station] += 1
|
|
120
|
+
self.genre_counts[genre] += 1
|
|
121
|
+
|
|
122
|
+
self.recent_events.append({
|
|
123
|
+
"track": track_name,
|
|
124
|
+
"artist": artist,
|
|
125
|
+
"station": station,
|
|
126
|
+
"genre": genre,
|
|
127
|
+
"date": d_str,
|
|
128
|
+
"time": t_str,
|
|
129
|
+
"timestamp": rec.get("timestamp", time.time())
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
self.total_scrobbles = len(self.recent_events)
|
|
133
|
+
self.total_seconds_listened = self.total_scrobbles * 210.0 # ~3.5 min average per song
|
|
134
|
+
self._save_unlocked()
|
|
135
|
+
except Exception:
|
|
136
|
+
pass
|
|
137
|
+
|
|
138
|
+
def record_track(self, track_name: str, station: str, genre: str, url: str = ""):
|
|
139
|
+
"""Record a live track event in thread-safe, non-blocking fashion."""
|
|
140
|
+
if not track_name:
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
with self.lock:
|
|
144
|
+
now = datetime.now()
|
|
145
|
+
d_str = now.strftime("%Y-%m-%d")
|
|
146
|
+
t_str = now.strftime("%H:%M")
|
|
147
|
+
hour = now.hour
|
|
148
|
+
|
|
149
|
+
artist = self._clean_artist_name(track_name, station)
|
|
150
|
+
|
|
151
|
+
# Prevent duplicate immediate consecutive counts
|
|
152
|
+
if self.recent_events and self.recent_events[-1].get("track") == track_name:
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
self.total_scrobbles += 1
|
|
156
|
+
self.total_seconds_listened += 210.0 # Average track length
|
|
157
|
+
self.daily_counts[d_str] = self.daily_counts.get(d_str, 0) + 1
|
|
158
|
+
self.hourly_counts[hour] += 1
|
|
159
|
+
self.artist_counts[artist] += 1
|
|
160
|
+
self.station_counts[station] += 1
|
|
161
|
+
self.genre_counts[genre] += 1
|
|
162
|
+
|
|
163
|
+
self.recent_events.append({
|
|
164
|
+
"track": track_name,
|
|
165
|
+
"artist": artist,
|
|
166
|
+
"station": station,
|
|
167
|
+
"genre": genre,
|
|
168
|
+
"url": url,
|
|
169
|
+
"date": d_str,
|
|
170
|
+
"time": t_str,
|
|
171
|
+
"timestamp": time.time()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
# Trim history to 500 events
|
|
175
|
+
if len(self.recent_events) > 500:
|
|
176
|
+
self.recent_events = self.recent_events[-500:]
|
|
177
|
+
|
|
178
|
+
self._dirty = True
|
|
179
|
+
# Invalidate transient in-memory caches
|
|
180
|
+
self._cached_heatmap = None
|
|
181
|
+
self._cached_rhythm = None
|
|
182
|
+
|
|
183
|
+
# SSD Protection: Debounce disk writes to at most once per 60s of active playback
|
|
184
|
+
t_now = time.time()
|
|
185
|
+
if t_now - self._last_save_time >= 60.0:
|
|
186
|
+
self._last_save_time = t_now
|
|
187
|
+
threading.Thread(target=self._save_async, daemon=True).start()
|
|
188
|
+
|
|
189
|
+
def _save_unlocked(self):
|
|
190
|
+
"""Atomically saves stats to disk with tempfile rename to eliminate NAND write amplification."""
|
|
191
|
+
try:
|
|
192
|
+
self.stats_file.parent.mkdir(parents=True, exist_ok=True)
|
|
193
|
+
data = {
|
|
194
|
+
"total_scrobbles": self.total_scrobbles,
|
|
195
|
+
"total_seconds_listened": self.total_seconds_listened,
|
|
196
|
+
"daily_counts": self.daily_counts,
|
|
197
|
+
"hourly_counts": self.hourly_counts,
|
|
198
|
+
"artist_counts": dict(self.artist_counts.most_common(100)),
|
|
199
|
+
"station_counts": dict(self.station_counts.most_common(100)),
|
|
200
|
+
"genre_counts": dict(self.genre_counts.most_common(50)),
|
|
201
|
+
"recent_events": self.recent_events[-100:]
|
|
202
|
+
}
|
|
203
|
+
tmp_file = self.stats_file.with_suffix(".tmp")
|
|
204
|
+
with open(tmp_file, "w", encoding="utf-8") as f:
|
|
205
|
+
json.dump(data, f, indent=2)
|
|
206
|
+
tmp_file.replace(self.stats_file)
|
|
207
|
+
self._dirty = False
|
|
208
|
+
self._last_save_time = time.time()
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
def flush(self):
|
|
213
|
+
"""Force flush dirty in-memory stats to SSD on demand or shutdown."""
|
|
214
|
+
with self.lock:
|
|
215
|
+
if self._dirty:
|
|
216
|
+
self._save_unlocked()
|
|
217
|
+
|
|
218
|
+
def _save_async(self):
|
|
219
|
+
with self.lock:
|
|
220
|
+
self._save_unlocked()
|
|
221
|
+
|
|
222
|
+
# -------------------------------------------------------------
|
|
223
|
+
# Heatmap Matrix (GitHub-Style 7 rows x N weeks)
|
|
224
|
+
# -------------------------------------------------------------
|
|
225
|
+
def get_heatmap_matrix(self, num_weeks: int = 16, force: bool = False):
|
|
226
|
+
"""
|
|
227
|
+
Returns a 7-row (Monday=0 to Sunday=6) by num_weeks matrix
|
|
228
|
+
of days ending on the current week. Cached in RAM to prevent CPU cycles.
|
|
229
|
+
"""
|
|
230
|
+
with self.lock:
|
|
231
|
+
t_now = time.time()
|
|
232
|
+
if not force and self._cached_heatmap is not None and (t_now - self._last_heatmap_time < 4.0):
|
|
233
|
+
return self._cached_heatmap
|
|
234
|
+
today = datetime.now().date()
|
|
235
|
+
# Find the Sunday of the current week (or end of week)
|
|
236
|
+
# Monday is 0, Sunday is 6
|
|
237
|
+
start_date = today - timedelta(days=today.weekday() + (num_weeks - 1) * 7)
|
|
238
|
+
|
|
239
|
+
matrix = [[] for _ in range(7)] # 7 days: Mon(0) to Sun(6)
|
|
240
|
+
month_labels = []
|
|
241
|
+
last_month = None
|
|
242
|
+
|
|
243
|
+
cur_date = start_date
|
|
244
|
+
col_idx = 0
|
|
245
|
+
|
|
246
|
+
for week in range(num_weeks):
|
|
247
|
+
# Check for month label at start of week
|
|
248
|
+
mid_week_date = cur_date + timedelta(days=3)
|
|
249
|
+
m_str = mid_week_date.strftime("%b")
|
|
250
|
+
if m_str != last_month:
|
|
251
|
+
month_labels.append((week, m_str))
|
|
252
|
+
last_month = m_str
|
|
253
|
+
|
|
254
|
+
for day_row in range(7):
|
|
255
|
+
d_str = cur_date.strftime("%Y-%m-%d")
|
|
256
|
+
count = self.daily_counts.get(d_str, 0)
|
|
257
|
+
is_today = (cur_date == today)
|
|
258
|
+
is_future = (cur_date > today)
|
|
259
|
+
|
|
260
|
+
# Intensity scale: 0..4
|
|
261
|
+
if is_future:
|
|
262
|
+
intensity = -1
|
|
263
|
+
elif count == 0:
|
|
264
|
+
intensity = 0
|
|
265
|
+
elif count <= 5:
|
|
266
|
+
intensity = 1
|
|
267
|
+
elif count <= 15:
|
|
268
|
+
intensity = 2
|
|
269
|
+
elif count <= 30:
|
|
270
|
+
intensity = 3
|
|
271
|
+
else:
|
|
272
|
+
intensity = 4
|
|
273
|
+
|
|
274
|
+
matrix[day_row].append({
|
|
275
|
+
"date": d_str,
|
|
276
|
+
"date_obj": cur_date,
|
|
277
|
+
"count": count,
|
|
278
|
+
"intensity": intensity,
|
|
279
|
+
"is_today": is_today,
|
|
280
|
+
"is_future": is_future,
|
|
281
|
+
"day_name": cur_date.strftime("%a")
|
|
282
|
+
})
|
|
283
|
+
cur_date += timedelta(days=1)
|
|
284
|
+
|
|
285
|
+
# Streaks
|
|
286
|
+
streaks = self.get_streaks()
|
|
287
|
+
result = (matrix, month_labels, streaks)
|
|
288
|
+
self._cached_heatmap = result
|
|
289
|
+
self._last_heatmap_time = t_now
|
|
290
|
+
return result
|
|
291
|
+
|
|
292
|
+
def get_streaks(self):
|
|
293
|
+
"""Calculates current streak, longest streak, and total active days."""
|
|
294
|
+
with self.lock:
|
|
295
|
+
if not self.daily_counts:
|
|
296
|
+
return {"current_streak": 0, "longest_streak": 0, "total_active_days": 0}
|
|
297
|
+
|
|
298
|
+
active_dates = sorted([datetime.strptime(k, "%Y-%m-%d").date() for k, v in self.daily_counts.items() if v > 0])
|
|
299
|
+
if not active_dates:
|
|
300
|
+
return {"current_streak": 0, "longest_streak": 0, "total_active_days": 0}
|
|
301
|
+
|
|
302
|
+
total_active_days = len(active_dates)
|
|
303
|
+
today = datetime.now().date()
|
|
304
|
+
yesterday = today - timedelta(days=1)
|
|
305
|
+
|
|
306
|
+
# Current streak
|
|
307
|
+
current_streak = 0
|
|
308
|
+
check_date = today if (today in active_dates) else yesterday
|
|
309
|
+
while check_date in active_dates:
|
|
310
|
+
current_streak += 1
|
|
311
|
+
check_date -= timedelta(days=1)
|
|
312
|
+
|
|
313
|
+
# Longest streak
|
|
314
|
+
longest_streak = 0
|
|
315
|
+
cur_seq = 0
|
|
316
|
+
for i in range(len(active_dates)):
|
|
317
|
+
if i == 0:
|
|
318
|
+
cur_seq = 1
|
|
319
|
+
else:
|
|
320
|
+
if active_dates[i] == active_dates[i-1] + timedelta(days=1):
|
|
321
|
+
cur_seq += 1
|
|
322
|
+
else:
|
|
323
|
+
cur_seq = 1
|
|
324
|
+
longest_streak = max(longest_streak, cur_seq)
|
|
325
|
+
|
|
326
|
+
return {
|
|
327
|
+
"current_streak": max(1, current_streak) if (today in active_dates or yesterday in active_dates) else 0,
|
|
328
|
+
"longest_streak": max(longest_streak, current_streak),
|
|
329
|
+
"total_active_days": total_active_days
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
# -------------------------------------------------------------
|
|
333
|
+
# Top Charts & Leaderboards
|
|
334
|
+
# -------------------------------------------------------------
|
|
335
|
+
def get_top_artists(self, limit: int = 10, timeframe: str = "all"):
|
|
336
|
+
with self.lock:
|
|
337
|
+
if timeframe == "all":
|
|
338
|
+
return self.artist_counts.most_common(limit)
|
|
339
|
+
|
|
340
|
+
now = datetime.now()
|
|
341
|
+
if timeframe == "today":
|
|
342
|
+
d_match = now.strftime("%Y-%m-%d")
|
|
343
|
+
else: # this month
|
|
344
|
+
d_match = now.strftime("%Y-%m")
|
|
345
|
+
|
|
346
|
+
filtered_counts = Counter()
|
|
347
|
+
for ev in self.recent_events:
|
|
348
|
+
if ev.get("date", "").startswith(d_match):
|
|
349
|
+
filtered_counts[ev.get("artist", "Unknown")] += 1
|
|
350
|
+
return filtered_counts.most_common(limit) or self.artist_counts.most_common(limit)
|
|
351
|
+
|
|
352
|
+
def get_top_stations(self, limit: int = 10, timeframe: str = "all"):
|
|
353
|
+
with self.lock:
|
|
354
|
+
if timeframe == "all":
|
|
355
|
+
return self.station_counts.most_common(limit)
|
|
356
|
+
|
|
357
|
+
now = datetime.now()
|
|
358
|
+
if timeframe == "today":
|
|
359
|
+
d_match = now.strftime("%Y-%m-%d")
|
|
360
|
+
else:
|
|
361
|
+
d_match = now.strftime("%Y-%m")
|
|
362
|
+
|
|
363
|
+
filtered_counts = Counter()
|
|
364
|
+
for ev in self.recent_events:
|
|
365
|
+
if ev.get("date", "").startswith(d_match):
|
|
366
|
+
filtered_counts[ev.get("station", "Radio")] += 1
|
|
367
|
+
return filtered_counts.most_common(limit) or self.station_counts.most_common(limit)
|
|
368
|
+
|
|
369
|
+
def get_top_genres(self, limit: int = 6):
|
|
370
|
+
with self.lock:
|
|
371
|
+
total = sum(self.genre_counts.values()) or 1
|
|
372
|
+
items = []
|
|
373
|
+
for g, count in self.genre_counts.most_common(limit):
|
|
374
|
+
pct = int((count / total) * 100)
|
|
375
|
+
items.append((g, count, pct))
|
|
376
|
+
return items
|
|
377
|
+
|
|
378
|
+
# -------------------------------------------------------------
|
|
379
|
+
# 24-Hour Listening Rhythm & Sonic Persona
|
|
380
|
+
# -------------------------------------------------------------
|
|
381
|
+
def get_hourly_rhythm(self, force: bool = False):
|
|
382
|
+
with self.lock:
|
|
383
|
+
t_now = time.time()
|
|
384
|
+
if not force and self._cached_rhythm is not None and (t_now - self._last_rhythm_time < 4.0):
|
|
385
|
+
return self._cached_rhythm
|
|
386
|
+
max_val = max(self.hourly_counts) or 1
|
|
387
|
+
peak_hour = self.hourly_counts.index(max_val)
|
|
388
|
+
|
|
389
|
+
if 22 <= peak_hour or peak_hour <= 4:
|
|
390
|
+
persona = "The Midnight Coder (Night Owl)"
|
|
391
|
+
desc = f"Peak listening late at night ({peak_hour:02d}:00). Optimal for deep coding & chill sessions."
|
|
392
|
+
elif 5 <= peak_hour <= 11:
|
|
393
|
+
persona = "The Morning Scholar (Focused & Alert)"
|
|
394
|
+
desc = f"Peak listening in the morning ({peak_hour:02d}:00). Tuned for daily planning & flow."
|
|
395
|
+
elif 12 <= peak_hour <= 17:
|
|
396
|
+
persona = "The Afternoon Flow (Studio State)"
|
|
397
|
+
desc = f"Peak listening midday ({peak_hour:02d}:00). Sustained high-energy focus."
|
|
398
|
+
else:
|
|
399
|
+
persona = "The Sunset Voyager (Twilight Groove)"
|
|
400
|
+
desc = f"Peak listening evening ({peak_hour:02d}:00). Unwinding with ambient & beats."
|
|
401
|
+
|
|
402
|
+
res = {
|
|
403
|
+
"counts": list(self.hourly_counts),
|
|
404
|
+
"peak_hour": peak_hour,
|
|
405
|
+
"persona": persona,
|
|
406
|
+
"desc": desc
|
|
407
|
+
}
|
|
408
|
+
self._cached_rhythm = res
|
|
409
|
+
self._last_rhythm_time = t_now
|
|
410
|
+
return res
|
|
411
|
+
|
|
412
|
+
# -------------------------------------------------------------
|
|
413
|
+
# Audiophile Vault (Lossless FLAC recordings)
|
|
414
|
+
# -------------------------------------------------------------
|
|
415
|
+
def get_flac_vault(self, force: bool = False):
|
|
416
|
+
with self.lock:
|
|
417
|
+
t_now = time.time()
|
|
418
|
+
if not force and self._cached_vault is not None and (t_now - self._last_vault_time < 5.0):
|
|
419
|
+
return self._cached_vault
|
|
420
|
+
|
|
421
|
+
files = []
|
|
422
|
+
tot_bytes = 0
|
|
423
|
+
if self.recordings_dir.exists():
|
|
424
|
+
for p in self.recordings_dir.glob("*.*"):
|
|
425
|
+
if p.suffix.lower() in (".flac", ".mp3", ".m4a", ".ogg"):
|
|
426
|
+
try:
|
|
427
|
+
sz = p.stat().st_size
|
|
428
|
+
tot_bytes += sz
|
|
429
|
+
mtime = p.stat().st_mtime
|
|
430
|
+
d_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M")
|
|
431
|
+
files.append({
|
|
432
|
+
"name": p.name,
|
|
433
|
+
"path": p,
|
|
434
|
+
"size_mb": sz / (1024 * 1024),
|
|
435
|
+
"format": "24-Bit FLAC" if p.suffix.lower() == ".flac" else p.suffix.upper()[1:],
|
|
436
|
+
"date": d_str,
|
|
437
|
+
"mtime": mtime
|
|
438
|
+
})
|
|
439
|
+
except Exception:
|
|
440
|
+
pass
|
|
441
|
+
|
|
442
|
+
files.sort(key=lambda x: x["mtime"], reverse=True)
|
|
443
|
+
self._cached_vault = {
|
|
444
|
+
"files": files,
|
|
445
|
+
"total_files": len(files),
|
|
446
|
+
"total_mb": tot_bytes / (1024 * 1024)
|
|
447
|
+
}
|
|
448
|
+
self._last_vault_time = t_now
|
|
449
|
+
return self._cached_vault
|
|
450
|
+
|
|
451
|
+
# -------------------------------------------------------------
|
|
452
|
+
# Earned Badges
|
|
453
|
+
# -------------------------------------------------------------
|
|
454
|
+
def get_earned_badges(self, state=None):
|
|
455
|
+
with self.lock:
|
|
456
|
+
vault = self.get_flac_vault()
|
|
457
|
+
streaks = self.get_streaks()
|
|
458
|
+
badges = []
|
|
459
|
+
|
|
460
|
+
# 1. Pop Royalty Starrer
|
|
461
|
+
brit_count = sum(c for a, c in self.artist_counts.items() if "britney" in a.lower() or "pop" in a.lower())
|
|
462
|
+
if brit_count >= 5 or "Britney Spears" in self.artist_counts:
|
|
463
|
+
badges.append(("→", "Pop Royalty Starrer", "Tuned into Britney Spears & Pop Royalty"))
|
|
464
|
+
|
|
465
|
+
# 2. 24-Bit FLAC Archivist
|
|
466
|
+
flac_count = sum(1 for f in vault["files"] if f["format"] == "24-Bit FLAC")
|
|
467
|
+
if flac_count >= 1:
|
|
468
|
+
badges.append(("◆", "Lossless FLAC Master", f"Archived {flac_count} bit-perfect master tracks"))
|
|
469
|
+
|
|
470
|
+
# 3. Night Owl Certified
|
|
471
|
+
night_plays = sum(self.hourly_counts[22:]) + sum(self.hourly_counts[:5])
|
|
472
|
+
if night_plays >= 5:
|
|
473
|
+
badges.append(("◈", "Night Owl Certified", f"{night_plays} sessions during late night hours"))
|
|
474
|
+
|
|
475
|
+
# 4. Stream Streaker
|
|
476
|
+
if streaks["current_streak"] >= 3 or streaks["longest_streak"] >= 3:
|
|
477
|
+
badges.append(("▲", "Stream Streaker", f"{streaks['longest_streak']}-day listening streak"))
|
|
478
|
+
|
|
479
|
+
# 5. 144Hz Fluid Purist
|
|
480
|
+
if state and getattr(state, "framerate", 60) >= 120:
|
|
481
|
+
badges.append(("→", "144Hz Fluid Purist", "Configured for ultra-high refresh rate"))
|
|
482
|
+
|
|
483
|
+
# 6. Lo-Fi Scholar
|
|
484
|
+
lofi_count = sum(c for g, c in self.genre_counts.items() if "lofi" in g.lower() or "chill" in g.lower())
|
|
485
|
+
if lofi_count >= 10:
|
|
486
|
+
badges.append(("◈", "Lo-Fi Scholar", f"{lofi_count} study & chill sessions logged"))
|
|
487
|
+
|
|
488
|
+
# Fallback badges if new
|
|
489
|
+
if len(badges) < 4:
|
|
490
|
+
badges.append(("▶", "Audiophile Initiate", f"Logged {self.total_scrobbles} lifetime tracks"))
|
|
491
|
+
badges.append(("→", "Global Radio Explorer", f"Explored {len(self.station_counts)} distinct stations"))
|
|
492
|
+
|
|
493
|
+
return badges
|
|
494
|
+
|
|
495
|
+
# -------------------------------------------------------------
|
|
496
|
+
# Export ASCII Diary Report
|
|
497
|
+
# -------------------------------------------------------------
|
|
498
|
+
def export_report(self, dest_path: Path = None) -> Path:
|
|
499
|
+
with self.lock:
|
|
500
|
+
if not dest_path:
|
|
501
|
+
dest_dir = Path.home() / "Music"
|
|
502
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
503
|
+
dest_path = dest_dir / f"lush_insights_{datetime.now().strftime('%Y%m%d_%H%M%S')}.txt"
|
|
504
|
+
|
|
505
|
+
vault = self.get_flac_vault()
|
|
506
|
+
streaks = self.get_streaks()
|
|
507
|
+
rhythm = self.get_hourly_rhythm()
|
|
508
|
+
|
|
509
|
+
hours_listened = self.total_seconds_listened / 3600.0
|
|
510
|
+
data_mb = (self.total_scrobbles * 210.0 * 16.0) / 1024.0 # ~128kbps approx
|
|
511
|
+
|
|
512
|
+
lines = [
|
|
513
|
+
"═══════════════════════════════════════════════════════════════════",
|
|
514
|
+
" LUSH AUDIOPHILE LISTENING DIARY & INSIGHTS REPORT ",
|
|
515
|
+
"═══════════════════════════════════════════════════════════════════",
|
|
516
|
+
f"Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
|
|
517
|
+
"",
|
|
518
|
+
"▶ LIFETIME AUDIOPHILE TOTALS",
|
|
519
|
+
f" • Total Tracks Scrobbled : {self.total_scrobbles:,}",
|
|
520
|
+
f" • Estimated Listening Time: {hours_listened:.1f} hours",
|
|
521
|
+
f" • FLAC Master Captures : {vault['total_files']} files ({vault['total_mb']:.2f} MB)",
|
|
522
|
+
f" • Data Streamed : ~{data_mb:.1f} MB",
|
|
523
|
+
f" • Current Streak : {streaks['current_streak']} days (Best: {streaks['longest_streak']} days)",
|
|
524
|
+
f" • Sonic Persona : {rhythm['persona']}",
|
|
525
|
+
"",
|
|
526
|
+
"▶ TOP 10 ARTISTS",
|
|
527
|
+
]
|
|
528
|
+
|
|
529
|
+
for idx, (artist, count) in enumerate(self.get_top_artists(10), 1):
|
|
530
|
+
pct = int((count / max(1, self.total_scrobbles)) * 100)
|
|
531
|
+
bar = "█" * min(20, max(1, int(pct * 0.4)))
|
|
532
|
+
lines.append(f" {idx:>2}. {artist:<24} {bar:<20} {count:>3} plays ({pct:>2}%)")
|
|
533
|
+
|
|
534
|
+
lines.append("")
|
|
535
|
+
lines.append("▶ TOP 10 STATIONS & STREAMS")
|
|
536
|
+
for idx, (st, count) in enumerate(self.get_top_stations(10), 1):
|
|
537
|
+
pct = int((count / max(1, self.total_scrobbles)) * 100)
|
|
538
|
+
bar = "█" * min(20, max(1, int(pct * 0.4)))
|
|
539
|
+
lines.append(f" {idx:>2}. {st:<24} {bar:<20} {count:>3} plays ({pct:>2}%)")
|
|
540
|
+
|
|
541
|
+
lines.append("")
|
|
542
|
+
lines.append("▶ GENRE BREAKDOWN")
|
|
543
|
+
for g, count, pct in self.get_top_genres(6):
|
|
544
|
+
bar = "█" * min(20, max(1, int(pct * 0.4)))
|
|
545
|
+
lines.append(f" • {g:<22} {bar:<20} {pct:>2}% ({count} plays)")
|
|
546
|
+
|
|
547
|
+
lines.append("")
|
|
548
|
+
lines.append("═══════════════════════════════════════════════════════════════════")
|
|
549
|
+
lines.append("Generated by LUSH Terminal Audiophile Player (github.com/pseudoshell/lush)")
|
|
550
|
+
|
|
551
|
+
report_text = "\n".join(lines)
|
|
552
|
+
with open(dest_path, "w", encoding="utf-8") as f:
|
|
553
|
+
f.write(report_text)
|
|
554
|
+
|
|
555
|
+
return dest_path
|