+
+
Station breakdown — all-time scrapes
+
+
+
No station data yet
+
-
-
-
-
-
-
-
-
-
+
-
-
-
Station totals
-
-
- | Station | Scraped this run |
-
-
-
-
-
-
-
Top tracks per station (historical play counts)
-
-
-
-
-
App Controls (desktop features)
-
-
-
-
-
-
-
-
-
-
-
Run History
-
-
- | Date | Status | Scraped | Matched | Added | Missing | Duplicates | Skipped |
-
-
-
-
-
-
-
Raw API snapshot
-
Loading…
-
-
-
-
-
-
-
Download JSON stats
+
-
+
+
+
+ Live Sync
+ Running…
+
+
+
+
+
+
+
+
"""
- 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 render_dashboard_html(stats):
+ return DASHBOARD_HTML
def _build_handler(stats_supplier):
- class DashboardHandler(BaseHTTPRequestHandler):
+ class Handler(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":
+ p = self.path.split("?")[0]
+ if p in ("/", "/index.html"):
+ self._html(DASHBOARD_HTML.encode("utf-8"))
+ elif p == "/api/stats":
+ self._json(stats_supplier())
+ elif p == "/api/config":
+ self._json(get_web_config())
+ elif p == "/api/test-connection":
+ try:
+ from journeyfm.plex_service import connect_to_plex_server
+ cfg = load_runtime_config()
+ connect_to_plex_server(cfg.get("PLEX_TOKEN", ""), cfg.get("SERVER_IP", ""))
+ self._json({"status": "ok", "message": "Connected to Plex successfully"})
+ except Exception as exc:
+ self._json({"status": "error", "message": str(exc)})
+ elif p == "/api/sync-stream":
+ self._sse_sync()
+ elif p == "/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)
+ self._json({"status": "ok", "result": result})
except Exception as exc:
- trace = traceback.format_exc()
- response = {
- "status": "error",
- "message": str(exc),
- "trace": trace,
- }
- 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)
- elif self.path == "/api/preview":
+ self._json({"status": "error", "message": str(exc), "trace": traceback.format_exc()}, 500)
+ elif p == "/api/preview":
try:
result = run_update_job(load_runtime_config(), dry_run=True, persist_history=False, write_buy_list=False)
- content = json.dumps(result, 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)
+ self._json(result)
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)
- elif self.path == "/api/buy-list":
+ self._json({"status": "error", "message": str(exc)}, 500)
+ elif p == "/api/buy-list":
+ self._json(load_buy_list())
+ elif p == "/api/buy-list-rich":
+ self._json(load_buy_list_rich())
+ elif p == "/api/buy-list-links":
+ self._json([e["url"] for e in load_buy_list_rich() if e.get("url")])
+ elif p == "/api/history":
+ self._json(load_history_entries())
+ elif p == "/api/playlist":
try:
- entries = load_buy_list()
- content = json.dumps(entries, 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)
+ self._json(get_playlist_tracks())
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)
- elif self.path == "/api/history":
- try:
- history = load_history_entries()
- content = json.dumps(history, 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)
+ self._json({"error": str(exc), "tracks": []})
else:
self.send_response(404)
- self.send_header("Content-Type", "text/plain; charset=utf-8")
+ self.send_header("Content-Type", "text/plain")
self.end_headers()
self.wfile.write(b"404 Not Found")
+ def do_POST(self):
+ p = self.path.split("?")[0]
+ body = self._read_body()
+ if p == "/api/config":
+ try:
+ save_web_config(json.loads(body))
+ self._json({"status": "ok"})
+ except Exception as exc:
+ self._json({"status": "error", "message": str(exc)}, 400)
+ elif p == "/api/playlist/remove":
+ try:
+ data = json.loads(body)
+ removed = remove_playlist_tracks(data.get("rating_keys", []))
+ self._json({"status": "ok", "removed": removed})
+ except Exception as exc:
+ self._json({"status": "error", "message": str(exc)}, 400)
+ elif p == "/api/buy-list-dedup":
+ try:
+ self._json(dedup_buy_list())
+ except Exception as exc:
+ self._json({"status": "error", "message": str(exc)}, 500)
+ else:
+ self.send_response(404)
+ self.end_headers()
+
+ def _sse_sync(self):
+ with _sync_lock:
+ if _sync_state["running"]:
+ self._json({"status": "error", "message": "A sync is already in progress"}, 409)
+ return
+ _sync_state["running"] = True
+
+ self.send_response(200)
+ self.send_header("Content-Type", "text/event-stream")
+ self.send_header("Cache-Control", "no-cache")
+ self.send_header("Connection", "keep-alive")
+ self.send_header("X-Accel-Buffering", "no")
+ self.end_headers()
+
+ q = queue.Queue()
+ handler = _QueueLogHandler(q)
+
+ def run():
+ root = logging.getLogger()
+ root.addHandler(handler)
+ try:
+ result = run_update_job(load_runtime_config())
+ q.put({"_done": True, "result": result})
+ except Exception as exc:
+ q.put(f"ERROR: {exc}")
+ q.put({"_done": True, "result": None, "error": str(exc)})
+ finally:
+ root.removeHandler(handler)
+ _sync_state["running"] = False
+
+ threading.Thread(target=run, daemon=True).start()
+
+ try:
+ while True:
+ try:
+ item = q.get(timeout=120)
+ except queue.Empty:
+ self.wfile.write(b": ping\n\n")
+ self.wfile.flush()
+ continue
+ if isinstance(item, dict) and item.get("_done"):
+ payload = json.dumps({"result": item.get("result"), "error": item.get("error")}, default=str)
+ self.wfile.write(f"event: done\ndata: {payload}\n\n".encode())
+ self.wfile.flush()
+ break
+ else:
+ payload = json.dumps({"msg": str(item)}, default=str)
+ self.wfile.write(f"data: {payload}\n\n".encode())
+ self.wfile.flush()
+ except (BrokenPipeError, ConnectionResetError, OSError):
+ pass
+ finally:
+ _sync_state["running"] = False
+
+ def _read_body(self):
+ length = int(self.headers.get("Content-Length", 0))
+ return self.rfile.read(length).decode("utf-8") if length else "{}"
+
+ def _json(self, data, status=200):
+ body = json.dumps(data, default=str).encode("utf-8")
+ self.send_response(status)
+ self.send_header("Content-Type", "application/json; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def _html(self, body):
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
def log_message(self, format, *args):
- # Keep server quiet; logging handled elsewhere
pass
- return DashboardHandler
+ return Handler
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))
+ httpd = ThreadingHTTPServer((host, port), _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