Add web dashboard with live refresh + fade transitions and CLI/GU integration

This commit is contained in:
nate.emmert committed 2026-03-26 08:00:24 -04:00
1 parent cb966beca2
commit 946e8cd92c
4 files changed
+327 -1

No files matched your search

+18
View File
@@ -134,6 +134,24 @@ Run one update cycle from CLI:
python main.py 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 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. 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.
+19
View File
@@ -9,6 +9,7 @@ Features system tray integration, log viewing, and easy setup.
import sys import sys
import os import os
import logging import logging
import webbrowser
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -659,6 +660,10 @@ class MainWindow(QMainWindow):
self.analytics_button = QPushButton("Analytics") self.analytics_button = QPushButton("Analytics")
self.analytics_button.clicked.connect(self.show_analytics) self.analytics_button.clicked.connect(self.show_analytics)
button_layout.addWidget(self.analytics_button, 2, 1) 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) status_layout.addLayout(button_layout)
@@ -1468,6 +1473,20 @@ class MainWindow(QMainWindow):
dialog.setLayout(layout) dialog.setLayout(layout)
dialog.exec() 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): def show_analytics(self):
"""Show analytics dashboard with charts""" """Show analytics dashboard with charts"""
import sqlite3 import sqlite3
+259
View File
@@ -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"<tr><td>{name}</td><td>{count}</td></tr>" for name, count in sorted(stats["station_counts"].items())
)
overall_count = sum(stats["station_counts"].values())
template = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta http-equiv="refresh" content="30" />
<title><!--TITLE--></title>
<style>
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; margin: 0; background: linear-gradient(135deg, #12163f 0%, #192a66 100%); color: #ffffff; }
.container { max-width: 960px; margin: 2.5rem auto; padding: 2rem; background: rgba(255, 255, 255, .08); border-radius: 20px; box-shadow: 0 15px 45px rgba(0,0,0,.35); }
h1 { margin-bottom: .25rem; font-size: 2.4rem; letter-spacing: 1px; }
p.subtitle { color: #e3e8ff; margin: 0 0 1.6rem; }
.stats-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 1rem; }
.stat-card { background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.28); padding: 1rem 1rem; border-radius: 12px; }
.stat-card h2 { margin: .2rem 0; font-size: 1.3rem; }
.table-wrap { margin-top: 1.5rem; overflow-x: auto; }
table { width: 100%; border-collapse: collapse; color: #f7f8ff; }
th, td { text-align: left; padding: .7rem 0.8rem; border-bottom: 1px solid rgba(255,255,255,0.2); }
th { text-transform: uppercase; font-size: .9rem; letter-spacing: .08em; color: #cdd5ff; }
.footer { margin-top: 2rem; font-size: .9rem; color: #d6d9ff; }
.button { background: linear-gradient(135deg,#34caff,#5f76ff); color:#fff; border:none; padding:.85rem 1.15rem; border-radius: 9px; text-decoration:none; font-weight:700; display:inline-block; margin-top:1rem; }
body { opacity: 0; transition: opacity 0.8s ease; }
body.loaded { opacity: 1; }
#refresh-status { display: inline-block; margin-left: 10px; color: #a6d8ff; }
</style>
</head>
<body>
<div class="container">
<h1>Journey FM Song Count Command Center</h1>
<p class="subtitle">Live track count breakdown by station with overall totals and history-driven health metrics.</p>
<div class="stats-grid">
<div class="stat-card"><h2>Overall Find</h2><p><!--OVERALL_COUNT--></p></div>
<div class="stat-card"><h2>Total Scraped</h2><p><!--TOTAL_SCRAPED--></p></div>
<div class="stat-card"><h2>Total Matched</h2><p><!--TOTAL_MATCHED--></p></div>
<div class="stat-card"><h2>Missing / Buy List</h2><p><!--TOTAL_MISSING--></p></div>
<div class="stat-card"><h2>Duplicates</h2><p><!--TOTAL_DUPLICATES--></p></div>
<div class="stat-card"><h2>Updates Run</h2><p><!--TOTAL_UPDATES--></p></div>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>Station</th><th>Scraped this run</th></tr></thead>
<tbody><!--STATION_ROWS--></tbody>
</table>
</div>
<div class="footer">
Last attempted run: <!--LAST_ATTEMPTED--><br>
Last successful run: <!--LAST_SUCCESS-->
</div>
<button class="button" id="refresh-button" onclick="refreshStats()">Refresh Now (scrape fresh data)</button>
<span id="refresh-status"></span>
<a class="button" href="/api/stats" style="margin-left:10px;">Download JSON stats</a>
</div>
<script>
window.addEventListener('DOMContentLoaded', function() {
document.body.classList.add('loaded');
});
function refreshStats() {
var status = document.getElementById('refresh-status');
status.textContent = 'Refreshing ...';
fetch('/api/refresh')
.then(function(response) {
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
return response.json();
})
.then(function(data) {
status.textContent = data.status === 'ok' ? 'Refresh complete' : 'Refresh failed';
setTimeout(function() { status.textContent = ''; }, 4500);
setTimeout(function() { location.reload(); }, 1000);
})
.catch(function(err) {
status.textContent = 'Error: ' + err;
setTimeout(function() { status.textContent = ''; }, 6500);
});
}
</script>
</body>
</html>
"""
return (
template
.replace("<!--TITLE-->", title)
.replace("<!--OVERALL_COUNT-->", f"{overall_count:,}")
.replace("<!--TOTAL_SCRAPED-->", f"{stats['total_scraped']:,}")
.replace("<!--TOTAL_MATCHED-->", f"{stats['total_matched']:,}")
.replace("<!--TOTAL_MISSING-->", f"{stats['total_missing']:,}")
.replace("<!--TOTAL_DUPLICATES-->", f"{stats['total_duplicates']:,}")
.replace("<!--TOTAL_UPDATES-->", f"{stats['total_updates']:,}")
.replace("<!--STATION_ROWS-->", station_rows or '<tr><td colspan="2">No station data yet</td></tr>')
.replace("<!--LAST_ATTEMPTED-->", stats['last_attempted'] or 'N/A')
.replace("<!--LAST_SUCCESS-->", 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}/"
+31 -1
View File
@@ -1,14 +1,17 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Journey FM Playlist Creator CLI entrypoint.""" """Journey FM Playlist Creator CLI entrypoint."""
import argparse
import logging import logging
import sys import sys
import time
from journeyfm.config_store import load_runtime_config from journeyfm.config_store import load_runtime_config
from journeyfm.history_service import init_history_db from journeyfm.history_service import init_history_db
from journeyfm.paths import data_path from journeyfm.paths import data_path
from journeyfm.plex_service import connect_to_plex_server from journeyfm.plex_service import connect_to_plex_server
from journeyfm.update_service import format_result_summary, run_update_job 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(): def configure_logging():
@@ -55,5 +58,32 @@ def update_playlist():
return main() 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__': if __name__ == '__main__':
main() main_cli()