From 946e8cd92c44651e9d841b3cbc9e01ffd6b377fd Mon Sep 17 00:00:00 2001 From: "nate.emmert" Date: Thu, 26 Mar 2026 08:00:24 -0400 Subject: [PATCH] Add web dashboard with live refresh + fade transitions and CLI/GU integration --- README.md | 18 +++ journey_fm_app.py | 19 +++ journeyfm/web_service.py | 259 +++++++++++++++++++++++++++++++++++++++ main.py | 32 ++++- 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 journeyfm/web_service.py diff --git a/README.md b/README.md index 17c4f36..a694bcc 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,24 @@ Run one update cycle from CLI: python main.py ``` +Run one update cycle and start a local web dashboard for live counts and station metrics: + +```bash +python main.py --serve-web +``` + +Advanced options: + +- `--host`: Web dashboard listen host (default `127.0.0.1`) +- `--port`: Web dashboard listen port (default `8765`) +- `--no-open`: Do not open default browser automatically + +Example: + +```bash +python main.py --serve-web --host 0.0.0.0 --port 8765 --no-open +``` + ## Container Deployment Container mode is intended for scheduled/headless playlist updates. The container now runs as a long-lived loop and sleeps between syncs instead of relying on restart churn. diff --git a/journey_fm_app.py b/journey_fm_app.py index eed7778..4db7e45 100644 --- a/journey_fm_app.py +++ b/journey_fm_app.py @@ -9,6 +9,7 @@ Features system tray integration, log viewing, and easy setup. import sys import os import logging +import webbrowser from datetime import datetime from pathlib import Path @@ -659,6 +660,10 @@ class MainWindow(QMainWindow): self.analytics_button = QPushButton("Analytics") self.analytics_button.clicked.connect(self.show_analytics) button_layout.addWidget(self.analytics_button, 2, 1) + + self.web_dashboard_button = QPushButton("Open Web Dashboard") + self.web_dashboard_button.clicked.connect(self.open_web_dashboard) + button_layout.addWidget(self.web_dashboard_button, 2, 2) status_layout.addLayout(button_layout) @@ -1468,6 +1473,20 @@ class MainWindow(QMainWindow): dialog.setLayout(layout) dialog.exec() + def open_web_dashboard(self): + """Launch local web dashboard URL in default browser.""" + try: + from journeyfm.web_service import get_dashboard_url, start_dashboard_server + + host = '127.0.0.1' + port = 8765 + start_dashboard_server(host=host, port=port, open_browser_if_possible=False) + qs = get_dashboard_url(host=host, port=port) + webbrowser.open(qs) + QMessageBox.information(self, 'Web Dashboard', f'Web dashboard started at:\n{qs}') + except Exception as exc: + QMessageBox.warning(self, 'Web Dashboard Failed', f'Unable to start web dashboard: {exc}') + def show_analytics(self): """Show analytics dashboard with charts""" import sqlite3 diff --git a/journeyfm/web_service.py b/journeyfm/web_service.py new file mode 100644 index 0000000..5816ff3 --- /dev/null +++ b/journeyfm/web_service.py @@ -0,0 +1,259 @@ +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": {}, + } + + 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 FROM history") + station_rows = c.fetchall() + for (station_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: + continue + 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()) + + 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
+
+ + + + + + 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("", 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}/" diff --git a/main.py b/main.py index 101cad5..ca08798 100644 --- a/main.py +++ b/main.py @@ -1,14 +1,17 @@ #!/usr/bin/env python3 """Journey FM Playlist Creator CLI entrypoint.""" +import argparse import logging import sys +import time from journeyfm.config_store import load_runtime_config from journeyfm.history_service import init_history_db from journeyfm.paths import data_path from journeyfm.plex_service import connect_to_plex_server from journeyfm.update_service import format_result_summary, run_update_job +from journeyfm.web_service import get_dashboard_url, start_dashboard_server def configure_logging(): @@ -55,5 +58,32 @@ def update_playlist(): return main() +def main_cli(): + parser = argparse.ArgumentParser(description='Journey FM Playlist CLI') + parser.add_argument('--serve-web', action='store_true', help='Start local web dashboard server') + parser.add_argument('--host', default='127.0.0.1', help='Dashboard host (default 127.0.0.1)') + parser.add_argument('--port', type=int, default=8765, help='Dashboard port (default 8765)') + parser.add_argument('--no-open', action='store_true', help='Do not automatically open browser when serving web dashboard') + args = parser.parse_args() + + if args.serve_web: + init_history_db() + result = run_update_job(load_runtime_config()) + summary = format_result_summary(result) + print(summary) + httpd, thread = start_dashboard_server(host=args.host, port=args.port, open_browser_if_possible=not args.no_open) + print('Web dashboard URL:', get_dashboard_url(args.host, args.port)) + print('Press Ctrl+C to stop the web server.') + try: + while thread.is_alive(): + time.sleep(0.5) + except KeyboardInterrupt: + print('Shutting down dashboard server...') + httpd.shutdown() + return result + + return main() + + if __name__ == '__main__': - main() + main_cli()