From ff174a70ce9c6e100a581b6f0adb84193266046e Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 26 Aug 2026 08:57:00 -0400 Subject: [PATCH 1/2] Add multiple playlist support; fix settings save in container - PLAYLIST_NAMES config key (comma-separated list); falls back to PLAYLIST_NAME - run_update_job syncs songs to every configured playlist, merges results - Settings form: dynamic add/remove playlist rows instead of single input - get_web_config reads config.json directly so form is consistent in container environments where env vars override runtime config - Playlist tab: dropdown to pick which playlist to browse when multiple exist Co-Authored-By: Claude Sonnet 4.6 --- journeyfm/config_store.py | 1 + journeyfm/update_service.py | 24 ++++++++-- journeyfm/web_service.py | 89 +++++++++++++++++++++++++++++-------- 3 files changed, 91 insertions(+), 23 deletions(-) diff --git a/journeyfm/config_store.py b/journeyfm/config_store.py index f0c42a3..fd0d790 100644 --- a/journeyfm/config_store.py +++ b/journeyfm/config_store.py @@ -23,6 +23,7 @@ DEFAULT_CONFIG = { NON_SECRET_KEYS = { "SERVER_IP", "PLAYLIST_NAME", + "PLAYLIST_NAMES", "AUTO_UPDATE", "UPDATE_INTERVAL", "UPDATE_UNIT", diff --git a/journeyfm/update_service.py b/journeyfm/update_service.py index a84864c..b1f4561 100644 --- a/journeyfm/update_service.py +++ b/journeyfm/update_service.py @@ -102,9 +102,17 @@ def run_update_job(config=None, dry_run=False, persist_history=True, write_buy_l token = config.get("PLEX_TOKEN", "").strip() server_ip = config.get("SERVER_IP", "").strip() - playlist_name = config.get("PLAYLIST_NAME", "Journey FM Recently Played").strip() selected_stations = config.get("SELECTED_STATIONS", ["journey_fm", "spirit_fm"]) + # Support PLAYLIST_NAMES (list) or fall back to single PLAYLIST_NAME + raw_names = config.get("PLAYLIST_NAMES") or config.get("PLAYLIST_NAME", "Journey FM Recently Played") + if isinstance(raw_names, list): + playlist_names = [n.strip() for n in raw_names if str(n).strip()] + else: + playlist_names = [n.strip() for n in str(raw_names).split(",") if n.strip()] + if not playlist_names: + playlist_names = ["Journey FM Recently Played"] + if not token or not server_ip: result["status"] = "error" result["error_message"] = "Missing configuration: set Plex token and server IP before running updates" @@ -117,13 +125,21 @@ def run_update_job(config=None, dry_run=False, persist_history=True, write_buy_l songs = scrape_result["songs"] result["station_breakdown"] = scrape_result["station_results"] result["scraped_count"] = len(songs) - # Keep scraped songs for history analytics (song play counts per station) result["scraped_songs"] = songs try: plex = connect_to_plex_server(token, server_ip) - playlist_result = create_or_update_playlist(plex, songs, playlist_name, dry_run=dry_run) - result.update(playlist_result) + # Sync every configured playlist; aggregate results + merged = {} + for playlist_name in playlist_names: + pr = create_or_update_playlist(plex, songs, playlist_name, dry_run=dry_run) + if not merged: + merged = pr + else: + merged["added_count"] = merged.get("added_count", 0) + pr.get("added_count", 0) + merged["added_songs"] = merged.get("added_songs", []) + pr.get("added_songs", []) + merged["duplicate_count"] = merged.get("duplicate_count", 0) + pr.get("duplicate_count", 0) + result.update(merged) result["missing_count"] = len(result.get("missing_songs", [])) result["skipped_count"] = len(result.get("skipped_songs", [])) if write_buy_list and not dry_run: diff --git a/journeyfm/web_service.py b/journeyfm/web_service.py index 170113c..684a173 100644 --- a/journeyfm/web_service.py +++ b/journeyfm/web_service.py @@ -186,21 +186,36 @@ def dedup_buy_list(buy_list_path=None): # ── Config ──────────────────────────────────────────────────── def get_web_config(): - config = load_runtime_config() - token = config.get("PLEX_TOKEN", "") - config["_has_token"] = bool(token) + 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"] = "" - stations = config.get("SELECTED_STATIONS", []) - if isinstance(stations, str): - stations = [s.strip() for s in stations.split(",") if s.strip()] - config["SELECTED_STATIONS"] = stations + 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() - config["PLAYLIST_NAME"] = str(data.get("PLAYLIST_NAME", "")).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()] @@ -218,11 +233,14 @@ def save_web_config(data): # ── Playlist ────────────────────────────────────────────────── -def get_playlist_tracks(): +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", "")) - name = config.get("PLAYLIST_NAME", "") + 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(): @@ -538,6 +556,7 @@ tr:last-child td{border-bottom:none}tr:hover td{background:var(--sf2)}
Playlist +
-
Playlist
+
Playlists
-
- - -
+
Songs are synced to every playlist listed below.
+
+
@@ -658,6 +676,7 @@ function go(s){ if(s==='songs'&&!songsReady&&statsData)buildSongs(); if(s==='history'&&!histReady)loadHist(); if(s==='buylist'&&!buyReady)loadBuy(); + if(s==='playlist')initPlaylistSel(); if(s==='settings')loadCfg(); } @@ -887,9 +906,21 @@ function copyAllLinks(){ } // ── Playlist ────────────────────────────────────────────────── +function initPlaylistSel(){ + fetch('/api/config').then(r=>r.json()).then(cfg=>{ + const names=cfg.PLAYLIST_NAMES||[]; + const sel=document.getElementById('pl-sel'); + if(names.length>1){ + sel.innerHTML=names.map(n=>``).join(''); + sel.style.display=''; + }else{sel.style.display='none';} + }); +} function loadPlaylist(){ const btn=document.getElementById('pl-btn');ld(btn,true); - fetch('/api/playlist').then(r=>r.json()).then(d=>{ + const selEl=document.getElementById('pl-sel'); + const name=selEl.style.display!=='none'&&selEl.value?'?name='+encodeURIComponent(selEl.value):''; + fetch('/api/playlist'+name).then(r=>r.json()).then(d=>{ ld(btn,false); if(d.error){document.getElementById('pl-body').innerHTML=`
${esc(d.error)}
`;return;} plData=d.tracks||[]; @@ -936,10 +967,23 @@ function removeTrack(rk,btn){ } // ── Settings ────────────────────────────────────────────────── +function addPlaylistRow(val){ + const row=document.createElement('div'); + row.style.cssText='display:flex;gap:.5rem;align-items:center'; + const inp=document.createElement('input'); + inp.className='fi';inp.type='text';inp.placeholder='Playlist name…';inp.value=val;inp.style.flex='1'; + const del=document.createElement('button'); + del.className='btn btd';del.style.cssText='font-size:.74rem;padding:.3rem .55rem;flex-shrink:0'; + del.textContent='✕';del.onclick=()=>row.remove(); + row.appendChild(inp);row.appendChild(del); + document.getElementById('pl-list').appendChild(row); +} +function getPlaylistNames(){ + return Array.from(document.querySelectorAll('#pl-list input')).map(i=>i.value.trim()).filter(Boolean); +} function loadCfg(){ fetch('/api/config').then(r=>r.json()).then(cfg=>{ document.getElementById('f-sv').value=cfg.SERVER_IP||''; - document.getElementById('f-pl').value=cfg.PLAYLIST_NAME||''; document.getElementById('f-au').checked=!!cfg.AUTO_UPDATE; document.getElementById('f-in').value=cfg.UPDATE_INTERVAL||15; document.getElementById('f-un').value=cfg.UPDATE_UNIT||'Minutes'; @@ -950,6 +994,10 @@ function loadCfg(){ const h=document.getElementById('tk-hint'); h.textContent=cfg._has_token?'✓ Token saved — leave blank to keep it':'No token saved yet'; h.style.color=cfg._has_token?'var(--gr)':'var(--mt)'; + // populate playlist rows + const list=document.getElementById('pl-list');list.innerHTML=''; + const names=cfg.PLAYLIST_NAMES||[]; + if(names.length){names.forEach(n=>addPlaylistRow(n));}else{addPlaylistRow('');} }); } function doSave(){ @@ -961,7 +1009,7 @@ function doSave(){ fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ SERVER_IP:document.getElementById('f-sv').value.trim(), PLEX_TOKEN:document.getElementById('f-tk').value.trim(), - PLAYLIST_NAME:document.getElementById('f-pl').value.trim(), + PLAYLIST_NAMES:getPlaylistNames(), SELECTED_STATIONS:stations, AUTO_UPDATE:document.getElementById('f-au').checked, UPDATE_INTERVAL:parseInt(document.getElementById('f-in').value)||15, @@ -1095,7 +1143,10 @@ def _build_handler(stats_supplier): self._json(load_history_entries()) elif p == "/api/playlist": try: - self._json(get_playlist_tracks()) + from urllib.parse import urlparse, parse_qs + qs = parse_qs(urlparse(self.path).query) + pname = qs.get("name", [None])[0] + self._json(get_playlist_tracks(name=pname)) except Exception as exc: self._json({"error": str(exc), "tracks": []}) else: From 7efb9265e6b36999da79ecebd95869bf8c96504b Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 26 Aug 2026 09:04:34 -0400 Subject: [PATCH 2/2] Add per-playlist station selection Each playlist now has its own station checkboxes in Settings. Sync scrapes the union of all stations once, then filters songs by source before syncing each playlist separately. Config stored as PLAYLISTS JSON: [{name, stations}, ...]. Backward-compatible with flat PLAYLIST_NAME + SELECTED_STATIONS. Co-Authored-By: Claude Sonnet 4.6 --- journeyfm/config_store.py | 1 + journeyfm/update_service.py | 50 +++++++++---- journeyfm/web_service.py | 135 ++++++++++++++++++++---------------- 3 files changed, 112 insertions(+), 74 deletions(-) diff --git a/journeyfm/config_store.py b/journeyfm/config_store.py index fd0d790..c193086 100644 --- a/journeyfm/config_store.py +++ b/journeyfm/config_store.py @@ -24,6 +24,7 @@ NON_SECRET_KEYS = { "SERVER_IP", "PLAYLIST_NAME", "PLAYLIST_NAMES", + "PLAYLISTS", "AUTO_UPDATE", "UPDATE_INTERVAL", "UPDATE_UNIT", diff --git a/journeyfm/update_service.py b/journeyfm/update_service.py index b1f4561..12a6e2f 100644 --- a/journeyfm/update_service.py +++ b/journeyfm/update_service.py @@ -100,18 +100,35 @@ def run_update_job(config=None, dry_run=False, persist_history=True, write_buy_l "skipped_songs": [], } + import json as _json + + # station_key → display name used in song["source"] + STATION_SOURCE = {"journey_fm": "Journey FM", "spirit_fm": "Spirit FM", "klove": "K-LOVE"} + token = config.get("PLEX_TOKEN", "").strip() server_ip = config.get("SERVER_IP", "").strip() - selected_stations = config.get("SELECTED_STATIONS", ["journey_fm", "spirit_fm"]) - # Support PLAYLIST_NAMES (list) or fall back to single PLAYLIST_NAME - raw_names = config.get("PLAYLIST_NAMES") or config.get("PLAYLIST_NAME", "Journey FM Recently Played") - if isinstance(raw_names, list): - playlist_names = [n.strip() for n in raw_names if str(n).strip()] + # Build playlist configs: [{name, stations}] + playlists_raw = config.get("PLAYLISTS") + if playlists_raw: + try: + playlists = _json.loads(playlists_raw) if isinstance(playlists_raw, str) else playlists_raw + except Exception: + playlists = [] else: - playlist_names = [n.strip() for n in str(raw_names).split(",") if n.strip()] - if not playlist_names: - playlist_names = ["Journey FM Recently Played"] + playlists = [] + + if not playlists: + # Fall back to flat PLAYLIST_NAMES / PLAYLIST_NAME + SELECTED_STATIONS + raw_names = config.get("PLAYLIST_NAMES") or config.get("PLAYLIST_NAME", "Journey FM Recently Played") + if isinstance(raw_names, list): + names = raw_names + else: + names = [n.strip() for n in str(raw_names).split(",") if n.strip()] + stations = config.get("SELECTED_STATIONS", ["journey_fm"]) + if isinstance(stations, str): + stations = [s.strip() for s in stations.split(",") if s.strip()] + playlists = [{"name": n, "stations": list(stations)} for n in (names or ["Journey FM Recently Played"])] if not token or not server_ip: result["status"] = "error" @@ -121,18 +138,21 @@ def run_update_job(config=None, dry_run=False, persist_history=True, write_buy_l if persist_history: init_history_db() - scrape_result = scrape_recently_played(selected_stations) - songs = scrape_result["songs"] + # Scrape the union of all stations across all playlists (no duplicate scrapes) + all_station_keys = list({s for pl in playlists for s in pl.get("stations", [])}) + scrape_result = scrape_recently_played(all_station_keys) + all_songs = scrape_result["songs"] result["station_breakdown"] = scrape_result["station_results"] - result["scraped_count"] = len(songs) - result["scraped_songs"] = songs + result["scraped_count"] = len(all_songs) + result["scraped_songs"] = all_songs try: plex = connect_to_plex_server(token, server_ip) - # Sync every configured playlist; aggregate results merged = {} - for playlist_name in playlist_names: - pr = create_or_update_playlist(plex, songs, playlist_name, dry_run=dry_run) + for pl in playlists: + pl_sources = {STATION_SOURCE.get(s, s) for s in pl.get("stations", [])} + pl_songs = [s for s in all_songs if s.get("source", "") in pl_sources] if pl_sources else all_songs + pr = create_or_update_playlist(plex, pl_songs, pl["name"], dry_run=dry_run) if not merged: merged = pr else: diff --git a/journeyfm/web_service.py b/journeyfm/web_service.py index 684a173..0299223 100644 --- a/journeyfm/web_service.py +++ b/journeyfm/web_service.py @@ -187,39 +187,49 @@ def dedup_buy_list(buy_list_path=None): 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). + # Read config.json directly so the form is consistent in container environments + # where env vars override the 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()] + # Parse PLAYLISTS JSON → list of {name, stations} + playlists_raw = config.get("PLAYLISTS") + if playlists_raw: + try: + playlists = json.loads(playlists_raw) if isinstance(playlists_raw, str) else playlists_raw + except Exception: + playlists = [] else: - playlists = [n.strip() for n in str(raw).split(",") if n.strip()] - config["PLAYLIST_NAMES"] = playlists + playlists = [] + if not playlists: + # Build from flat keys for backward compat + raw_names = config.get("PLAYLIST_NAMES") or config.get("PLAYLIST_NAME", "") + if isinstance(raw_names, list): + names = raw_names + else: + names = [n.strip() for n in str(raw_names).split(",") if n.strip()] + stations = list(_normalize_selected_stations(config.get("SELECTED_STATIONS", []))) + playlists = [{"name": n, "stations": stations} for n in names] if names else [] + config["PLAYLISTS"] = 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()] + # PLAYLISTS: list of {name, stations} + playlists = data.get("PLAYLISTS", []) + if isinstance(playlists, list): + clean = [{"name": str(p.get("name", "")).strip(), "stations": list(p.get("stations", []))} + for p in playlists if str(p.get("name", "")).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 + clean = [] + config["PLAYLISTS"] = json.dumps(clean) + config["PLAYLIST_NAME"] = clean[0]["name"] if clean else "" + all_stations = list({s for p in clean for s in p.get("stations", [])}) + config["SELECTED_STATIONS"] = all_stations config["AUTO_UPDATE"] = bool(data.get("AUTO_UPDATE", False)) try: config["UPDATE_INTERVAL"] = int(data.get("UPDATE_INTERVAL", 15)) @@ -597,21 +607,11 @@ tr:last-child td{border-bottom:none}tr:hover td{background:var(--sf2)}
-
Playlists
+
Playlists & Stations
-
Songs are synced to every playlist listed below.
-
- -
-
-
-
Stations
-
-
- - - -
+
Each playlist syncs songs from its own set of stations.
+
+
@@ -908,10 +908,10 @@ function copyAllLinks(){ // ── Playlist ────────────────────────────────────────────────── function initPlaylistSel(){ fetch('/api/config').then(r=>r.json()).then(cfg=>{ - const names=cfg.PLAYLIST_NAMES||[]; + const pls=cfg.PLAYLISTS||[]; const sel=document.getElementById('pl-sel'); - if(names.length>1){ - sel.innerHTML=names.map(n=>``).join(''); + if(pls.length>1){ + sel.innerHTML=pls.map(p=>``).join(''); sel.style.display=''; }else{sel.style.display='none';} }); @@ -967,19 +967,45 @@ function removeTrack(rk,btn){ } // ── Settings ────────────────────────────────────────────────── -function addPlaylistRow(val){ - const row=document.createElement('div'); - row.style.cssText='display:flex;gap:.5rem;align-items:center'; +const STATION_OPTS=[ + {key:'journey_fm',label:'Journey FM'}, + {key:'spirit_fm',label:'Spirit FM'}, + {key:'klove',label:'K-LOVE'}, +]; +function addPlaylistRow(pl){ + const name=pl.name||'',sts=pl.stations||[]; + const wrap=document.createElement('div'); + wrap.style.cssText='background:var(--sf2);border:1px solid var(--bd);border-radius:10px;padding:.7rem .9rem;display:flex;flex-direction:column;gap:.5rem'; + const top=document.createElement('div'); + top.style.cssText='display:flex;gap:.5rem;align-items:center'; const inp=document.createElement('input'); - inp.className='fi';inp.type='text';inp.placeholder='Playlist name…';inp.value=val;inp.style.flex='1'; + inp.className='fi pl-name';inp.type='text';inp.placeholder='Playlist name…';inp.value=name;inp.style.flex='1'; const del=document.createElement('button'); del.className='btn btd';del.style.cssText='font-size:.74rem;padding:.3rem .55rem;flex-shrink:0'; - del.textContent='✕';del.onclick=()=>row.remove(); - row.appendChild(inp);row.appendChild(del); - document.getElementById('pl-list').appendChild(row); + del.textContent='✕';del.onclick=()=>wrap.remove(); + top.appendChild(inp);top.appendChild(del); + const cks=document.createElement('div'); + cks.style.cssText='display:flex;gap:.5rem;flex-wrap:wrap'; + STATION_OPTS.forEach(opt=>{ + const lbl=document.createElement('label'); + lbl.style.cssText='display:inline-flex;align-items:center;gap:.35rem;font-size:.8rem;cursor:pointer;background:var(--sf);border:1px solid var(--bd);border-radius:6px;padding:.25rem .55rem;transition:border-color .15s'; + lbl.onmouseenter=()=>lbl.style.borderColor='var(--am)'; + lbl.onmouseleave=()=>lbl.style.borderColor=''; + const cb=document.createElement('input'); + cb.type='checkbox';cb.className='pl-st';cb.dataset.key=opt.key; + cb.style.accentColor='var(--am)';cb.checked=sts.includes(opt.key); + lbl.appendChild(cb);lbl.appendChild(document.createTextNode(opt.label)); + cks.appendChild(lbl); + }); + wrap.appendChild(top);wrap.appendChild(cks); + document.getElementById('pl-list').appendChild(wrap); } -function getPlaylistNames(){ - return Array.from(document.querySelectorAll('#pl-list input')).map(i=>i.value.trim()).filter(Boolean); +function getPlaylists(){ + return Array.from(document.querySelectorAll('#pl-list>div')).map(wrap=>{ + const name=wrap.querySelector('.pl-name')?.value.trim()||''; + const stations=Array.from(wrap.querySelectorAll('.pl-st:checked')).map(c=>c.dataset.key); + return name?{name,stations}:null; + }).filter(Boolean); } function loadCfg(){ fetch('/api/config').then(r=>r.json()).then(cfg=>{ @@ -987,30 +1013,21 @@ function loadCfg(){ document.getElementById('f-au').checked=!!cfg.AUTO_UPDATE; document.getElementById('f-in').value=cfg.UPDATE_INTERVAL||15; document.getElementById('f-un').value=cfg.UPDATE_UNIT||'Minutes'; - const sts=cfg.SELECTED_STATIONS||[]; - document.getElementById('st-j').checked=sts.includes('journey_fm'); - document.getElementById('st-s').checked=sts.includes('spirit_fm'); - document.getElementById('st-k').checked=sts.includes('klove'); const h=document.getElementById('tk-hint'); h.textContent=cfg._has_token?'✓ Token saved — leave blank to keep it':'No token saved yet'; h.style.color=cfg._has_token?'var(--gr)':'var(--mt)'; - // populate playlist rows const list=document.getElementById('pl-list');list.innerHTML=''; - const names=cfg.PLAYLIST_NAMES||[]; - if(names.length){names.forEach(n=>addPlaylistRow(n));}else{addPlaylistRow('');} + const pls=cfg.PLAYLISTS||[]; + if(pls.length){pls.forEach(p=>addPlaylistRow(p));} + else{addPlaylistRow({name:'',stations:['journey_fm']});} }); } function doSave(){ const btn=document.getElementById('save-btn');ld(btn,true); - const stations=[]; - if(document.getElementById('st-j').checked)stations.push('journey_fm'); - if(document.getElementById('st-s').checked)stations.push('spirit_fm'); - if(document.getElementById('st-k').checked)stations.push('klove'); fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({ SERVER_IP:document.getElementById('f-sv').value.trim(), PLEX_TOKEN:document.getElementById('f-tk').value.trim(), - PLAYLIST_NAMES:getPlaylistNames(), - SELECTED_STATIONS:stations, + PLAYLISTS:getPlaylists(), AUTO_UPDATE:document.getElementById('f-au').checked, UPDATE_INTERVAL:parseInt(document.getElementById('f-in').value)||15, UPDATE_UNIT:document.getElementById('f-un').value,