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 = """
+
+
+
+
+
+
+
+
+
+
+
+
Journey FM Song Count Command Center
+
Live track count breakdown by station with overall totals and history-driven health metrics.
+
+
+
+
+
+ | Station | Scraped 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()