| # | Station | Track | Plays |
|---|---|---|---|
Loading… | |||
| Date | Status | Scraped | Matched | Added | Missing | Dupes | Skipped | |
|---|---|---|---|---|---|---|---|---|
Loading… | ||||||||
| Artist | Title | Album | |
|---|---|---|---|
Click Load to fetch your playlist from Plex | |||
import json import logging import queue import sqlite3 import threading import traceback import webbrowser from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from journeyfm.config_store import get_secret, load_runtime_config, save_runtime_config from journeyfm.paths import data_path from journeyfm.update_service import run_update_job DEFAULT_HOST = "127.0.0.1" DEFAULT_PORT = 8765 _sync_state = {"running": False} _sync_lock = threading.Lock() class _QueueLogHandler(logging.Handler): def __init__(self, q): super().__init__() self.q = q self.setFormatter(logging.Formatter("%(levelname)s %(name)s: %(message)s")) def emit(self, record): try: self.q.put_nowait(self.format(record)) except Exception: pass # ── Data loaders ────────────────────────────────────────────── 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(added_count), SUM(duplicate_count), SUM(skipped_count) FROM history" ) row = c.fetchone() or () stats["total_updates"] = int(row[0] or 0) stats["total_added"] = int(row[1] or 0) stats["total_duplicates"] = int(row[2] or 0) stats["total_skipped"] = int(row[3] 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 scraped_songs, missing_songs FROM history") scraped_seen = set() missing_seen = set() for scraped_songs_json, missing_songs_json in c.fetchall(): try: for song in json.loads(scraped_songs_json or "[]"): sname = song.get("source", "Unknown") title = song.get("title", "?") artist = song.get("artist", "?") scraped_seen.add((artist, title)) # accumulate play count every scrape (powers Top Songs ranking) stats["song_counts"].setdefault(sname, {}) dk = f"{artist} - {title}" stats["song_counts"][sname][dk] = stats["song_counts"][sname].get(dk, 0) + 1 except Exception: pass try: for song in json.loads(missing_songs_json or "[]"): artist = song.get("artist", "?") title = song.get("title", "?") missing_seen.add((artist, title)) except Exception: pass for sname, songs in stats["song_counts"].items(): stats["station_counts"][sname] = len(songs) stats["total_scraped"] = len(scraped_seen) stats["total_missing"] = len(missing_seen) # matched from most recent successful run (per-run count is accurate) c.execute( "SELECT matched_count FROM history WHERE status='success' ORDER BY date DESC LIMIT 1" ) r = c.fetchone() stats["total_matched"] = int(r[0] or 0) if r else 0 finally: conn.close() return stats def load_history_entries(db_path=None, limit=200): db_path = db_path or data_path("playlist_history.db") if not Path(db_path).exists(): return [] conn = sqlite3.connect(db_path) try: c = conn.cursor() c.execute( "SELECT id, date, status, scraped_count, matched_count, added_count," " missing_count, duplicate_count, skipped_count, station_breakdown" " FROM history ORDER BY date DESC LIMIT ?", (limit,) ) return [ {"id": r[0], "date": r[1], "status": r[2], "scraped_count": r[3], "matched_count": r[4], "added_count": r[5], "missing_count": r[6], "duplicate_count": r[7], "skipped_count": r[8], "station_breakdown": json.loads(r[9] or "[]")} for r in c.fetchall() ] except Exception: return [] finally: conn.close() def load_buy_list(buy_list_path=None): buy_list_path = buy_list_path or data_path("amazon_buy_list.txt") if not Path(buy_list_path).exists(): return [] try: with open(buy_list_path, "r", encoding="utf-8") as f: return [ln.strip() for ln in f if ln.strip() and not ln.startswith("http") and ln.strip() != "Songs not in your library - Amazon search links:"] except Exception: return [] def load_buy_list_rich(buy_list_path=None): buy_list_path = buy_list_path or data_path("amazon_buy_list.txt") if not Path(buy_list_path).exists(): return [] entries = [] try: with open(buy_list_path, "r", encoding="utf-8") as f: lines = [ln.strip() for ln in f if ln.strip()] i = 0 while i < len(lines): line = lines[i] if not line or line.startswith("Songs not in") or line.startswith("http"): i += 1 continue url = None if i + 1 < len(lines) and lines[i + 1].startswith("http"): url = lines[i + 1] i += 2 else: i += 1 entries.append({"song": line, "url": url}) except Exception: return [] return entries def dedup_buy_list(buy_list_path=None): buy_list_path = buy_list_path or data_path("amazon_buy_list.txt") entries = load_buy_list_rich(buy_list_path) seen = set() unique = [] for e in entries: key = e["song"].lower().strip() if key not in seen: seen.add(key) unique.append(e) removed = len(entries) - len(unique) with open(buy_list_path, "w", encoding="utf-8") as f: f.write("Songs not in your library - Amazon search links:\n\n") for e in unique: f.write(f"{e['song']}\n") if e["url"]: f.write(f"{e['url']}\n\n") else: f.write("\n") return {"original": len(entries), "removed": removed, "remaining": len(unique)} # ── Config ──────────────────────────────────────────────────── def get_web_config(): from journeyfm.config_store import _read_config_file, DEFAULT_CONFIG, _normalize_selected_stations # Read config.json directly so the form shows/saves the file values, # not the env-var-merged runtime config (env vars still win at sync time). file_cfg = _read_config_file() config = dict(DEFAULT_CONFIG) config.update({k: v for k, v in file_cfg.items() if k != "PLEX_TOKEN"}) config["_has_token"] = bool(get_secret("PLEX_TOKEN")) config["PLEX_TOKEN"] = "" config["SELECTED_STATIONS"] = _normalize_selected_stations(config.get("SELECTED_STATIONS", [])) # Normalise playlist names to a list raw = config.get("PLAYLIST_NAMES") or config.get("PLAYLIST_NAME", "") if isinstance(raw, list): playlists = [n.strip() for n in raw if str(n).strip()] else: playlists = [n.strip() for n in str(raw).split(",") if n.strip()] config["PLAYLIST_NAMES"] = playlists return config def save_web_config(data): config = {} config["SERVER_IP"] = str(data.get("SERVER_IP", "")).strip() # Accept PLAYLIST_NAMES as list or comma string; store as comma string raw = data.get("PLAYLIST_NAMES", data.get("PLAYLIST_NAME", "")) if isinstance(raw, list): playlists = [n.strip() for n in raw if str(n).strip()] else: playlists = [n.strip() for n in str(raw).split(",") if n.strip()] config["PLAYLIST_NAMES"] = ",".join(playlists) config["PLAYLIST_NAME"] = playlists[0] if playlists else "" stations = data.get("SELECTED_STATIONS", []) if isinstance(stations, str): stations = [s.strip() for s in stations.split(",") if s.strip()] config["SELECTED_STATIONS"] = stations config["AUTO_UPDATE"] = bool(data.get("AUTO_UPDATE", False)) try: config["UPDATE_INTERVAL"] = int(data.get("UPDATE_INTERVAL", 15)) except (ValueError, TypeError): config["UPDATE_INTERVAL"] = 15 config["UPDATE_UNIT"] = str(data.get("UPDATE_UNIT", "Minutes")) token = str(data.get("PLEX_TOKEN", "")).strip() config["PLEX_TOKEN"] = token if token else get_secret("PLEX_TOKEN") save_runtime_config(config) # ── Playlist ────────────────────────────────────────────────── def get_playlist_tracks(name=None): from journeyfm.plex_service import connect_to_plex_server config = load_runtime_config() plex = connect_to_plex_server(config.get("PLEX_TOKEN", ""), config.get("SERVER_IP", "")) if not name: cfg = get_web_config() names = cfg.get("PLAYLIST_NAMES") or [] name = names[0] if names else config.get("PLAYLIST_NAME", "") playlist = plex.playlist(name) tracks = [] for item in playlist.items(): tracks.append({ "title": item.title, "artist": getattr(item, "grandparentTitle", ""), "album": getattr(item, "parentTitle", ""), "rating_key": str(item.ratingKey), "duration_ms": item.duration or 0, }) return {"name": name, "tracks": tracks} def remove_playlist_tracks(rating_keys): from journeyfm.plex_service import connect_to_plex_server config = load_runtime_config() plex = connect_to_plex_server(config.get("PLEX_TOKEN", ""), config.get("SERVER_IP", "")) name = config.get("PLAYLIST_NAME", "") playlist = plex.playlist(name) keys = set(str(k) for k in rating_keys) to_remove = [item for item in playlist.items() if str(item.ratingKey) in keys] if to_remove: playlist.removeItems(to_remove) return len(to_remove) # ── HTML ────────────────────────────────────────────────────── DASHBOARD_HTML = r"""
| # | Station | Track | Plays |
|---|---|---|---|
Loading… | |||
| Date | Status | Scraped | Matched | Added | Missing | Dupes | Skipped | |
|---|---|---|---|---|---|---|---|---|
Loading… | ||||||||
| Artist | Title | Album | |
|---|---|---|---|
Click Load to fetch your playlist from Plex | |||