import json import sqlite3 import threading import time import webbrowser from http.server import BaseHTTPRequestHandler, HTTPServer from pathlib import Path from journeyfm.paths import data_path from journeyfm.config_store import load_runtime_config from journeyfm.update_service import run_update_job DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8765 def load_recent_stats(db_path=None): db_path = db_path or data_path("playlist_history.db") stats = { "total_updates": 0, "total_scraped": 0, "total_matched": 0, "total_added": 0, "total_missing": 0, "total_duplicates": 0, "total_skipped": 0, "last_attempted": None, "last_success": None, "station_counts": {}, "song_counts": {}, } if not Path(db_path).exists(): return stats conn = sqlite3.connect(db_path) try: c = conn.cursor() c.execute( "SELECT COUNT(*), SUM(scraped_count), SUM(matched_count), SUM(added_count), SUM(missing_count), SUM(duplicate_count), SUM(skipped_count) FROM history" ) row = c.fetchone() or () stats["total_updates"] = int(row[0] or 0) stats["total_scraped"] = int(row[1] or 0) stats["total_matched"] = int(row[2] or 0) stats["total_added"] = int(row[3] or 0) stats["total_missing"] = int(row[4] or 0) stats["total_duplicates"] = int(row[5] or 0) stats["total_skipped"] = int(row[6] or 0) c.execute("SELECT MAX(date), MAX(CASE WHEN status='success' THEN date END) FROM history") last_row = c.fetchone() or () stats["last_attempted"] = last_row[0] stats["last_success"] = last_row[1] c.execute("SELECT station_breakdown, scraped_songs FROM history") station_rows = c.fetchall() for station_json, scraped_songs_json in station_rows: try: for station in json.loads(station_json or "[]"): if station.get("success"): key = station.get("display_name") or station.get("station") or "Unknown" stats["station_counts"][key] = stats["station_counts"].get(key, 0) + int(station.get("scraped_count", 0)) except Exception: pass try: songs = json.loads(scraped_songs_json or "[]") for song in songs: station_name = song.get("source", "Unknown") title = song.get("title", "?") artist = song.get("artist", "?") key = f"{artist} - {title}" stats["song_counts"].setdefault(station_name, {}) stats["song_counts"][station_name][key] = stats["song_counts"][station_name].get(key, 0) + 1 except Exception: pass finally: conn.close() return stats def render_dashboard_html(stats): title = "Journey FM Song Count Command Center" station_rows = "".join( f"{name}{count}" for name, count in sorted(stats["station_counts"].items()) ) overall_count = sum(stats["station_counts"].values()) # Build a flattened station-song ranking table for display top_song_rows = [] for station_name, songs in sorted(stats.get("song_counts", {}).items()): sorted_songs = sorted(songs.items(), key=lambda kv: kv[1], reverse=True) for song_name, count in sorted_songs[:10]: top_song_rows.append(f"{station_name}{song_name}{count}") top_song_rows_html = ''.join(top_song_rows) or 'No song counts yet' template = """ <!--TITLE-->

Journey FM Song Count Command Center

Live track count breakdown by station with overall totals and history-driven health metrics.

Overall Find

Total Scraped

Total Matched

Missing / Buy List

Duplicates

Updates Run

StationScraped this run

Top tracks per station (historical play counts)

StationSongCount
Download JSON stats
""" return ( template .replace("", title) .replace("", f"{overall_count:,}") .replace("", f"{stats['total_scraped']:,}") .replace("", f"{stats['total_matched']:,}") .replace("", f"{stats['total_missing']:,}") .replace("", f"{stats['total_duplicates']:,}") .replace("", f"{stats['total_updates']:,}") .replace("", station_rows or 'No station data yet') .replace("", top_song_rows_html) .replace("", stats['last_attempted'] or 'N/A') .replace("", stats['last_success'] or 'N/A') ) def _build_handler(stats_supplier): class DashboardHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path in ("/", "/stats", "/index.html"): stats = stats_supplier() content = render_dashboard_html(stats).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) elif self.path == "/api/stats": stats = stats_supplier() content = json.dumps(stats, default=str).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) elif self.path == "/api/refresh": try: result = run_update_job(load_runtime_config()) response = {"status": "ok", "result": result} content = json.dumps(response, default=str).encode("utf-8") self.send_response(200) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) except Exception as exc: response = {"status": "error", "message": str(exc)} content = json.dumps(response, default=str).encode("utf-8") self.send_response(500) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(content))) self.end_headers() self.wfile.write(content) else: self.send_response(404) self.send_header("Content-Type", "text/plain; charset=utf-8") self.end_headers() self.wfile.write(b"404 Not Found") def log_message(self, format, *args): # Keep server quiet; logging handled elsewhere pass return DashboardHandler def start_dashboard_server(host=DEFAULT_HOST, port=DEFAULT_PORT, open_browser_if_possible=True): server_address = (host, port) httpd = HTTPServer(server_address, _build_handler(load_recent_stats)) def serve(): try: if open_browser_if_possible: webbrowser.open(f"http://{host}:{port}") print(f"Dashboard available at http://{host}:{port}/") httpd.serve_forever() except KeyboardInterrupt: pass finally: httpd.server_close() thread = threading.Thread(target=serve, daemon=True) thread.start() return httpd, thread def stop_dashboard_server(httpd): try: httpd.shutdown() except Exception: pass def get_dashboard_url(host=DEFAULT_HOST, port=DEFAULT_PORT): return f"http://{host}:{port}/"