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,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