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 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 4.6 committed 2026-08-26 09:04:34 -04:00
1 parent ff174a70ce
commit 7efb9265e6
3 files changed
+110 -72

No files matched your search

+1
View File
@@ -24,6 +24,7 @@ NON_SECRET_KEYS = {
"SERVER_IP", "SERVER_IP",
"PLAYLIST_NAME", "PLAYLIST_NAME",
"PLAYLIST_NAMES", "PLAYLIST_NAMES",
"PLAYLISTS",
"AUTO_UPDATE", "AUTO_UPDATE",
"UPDATE_INTERVAL", "UPDATE_INTERVAL",
"UPDATE_UNIT", "UPDATE_UNIT",
+33 -13
View File
@@ -100,18 +100,35 @@ def run_update_job(config=None, dry_run=False, persist_history=True, write_buy_l
"skipped_songs": [], "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() token = config.get("PLEX_TOKEN", "").strip()
server_ip = config.get("SERVER_IP", "").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 # 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:
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") raw_names = config.get("PLAYLIST_NAMES") or config.get("PLAYLIST_NAME", "Journey FM Recently Played")
if isinstance(raw_names, list): if isinstance(raw_names, list):
playlist_names = [n.strip() for n in raw_names if str(n).strip()] names = raw_names
else: else:
playlist_names = [n.strip() for n in str(raw_names).split(",") if n.strip()] names = [n.strip() for n in str(raw_names).split(",") if n.strip()]
if not playlist_names: stations = config.get("SELECTED_STATIONS", ["journey_fm"])
playlist_names = ["Journey FM Recently Played"] 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: if not token or not server_ip:
result["status"] = "error" 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: if persist_history:
init_history_db() init_history_db()
scrape_result = scrape_recently_played(selected_stations) # Scrape the union of all stations across all playlists (no duplicate scrapes)
songs = scrape_result["songs"] 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["station_breakdown"] = scrape_result["station_results"]
result["scraped_count"] = len(songs) result["scraped_count"] = len(all_songs)
result["scraped_songs"] = songs result["scraped_songs"] = all_songs
try: try:
plex = connect_to_plex_server(token, server_ip) plex = connect_to_plex_server(token, server_ip)
# Sync every configured playlist; aggregate results
merged = {} merged = {}
for playlist_name in playlist_names: for pl in playlists:
pr = create_or_update_playlist(plex, songs, playlist_name, dry_run=dry_run) 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: if not merged:
merged = pr merged = pr
else: else:
+76 -59
View File
@@ -187,39 +187,49 @@ def dedup_buy_list(buy_list_path=None):
def get_web_config(): def get_web_config():
from journeyfm.config_store import _read_config_file, DEFAULT_CONFIG, _normalize_selected_stations 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, # Read config.json directly so the form is consistent in container environments
# not the env-var-merged runtime config (env vars still win at sync time). # where env vars override the runtime config (env vars still win at sync time).
file_cfg = _read_config_file() file_cfg = _read_config_file()
config = dict(DEFAULT_CONFIG) config = dict(DEFAULT_CONFIG)
config.update({k: v for k, v in file_cfg.items() if k != "PLEX_TOKEN"}) config.update({k: v for k, v in file_cfg.items() if k != "PLEX_TOKEN"})
config["_has_token"] = bool(get_secret("PLEX_TOKEN")) config["_has_token"] = bool(get_secret("PLEX_TOKEN"))
config["PLEX_TOKEN"] = "" config["PLEX_TOKEN"] = ""
config["SELECTED_STATIONS"] = _normalize_selected_stations(config.get("SELECTED_STATIONS", [])) # Parse PLAYLISTS JSON → list of {name, stations}
# Normalise playlist names to a list playlists_raw = config.get("PLAYLISTS")
raw = config.get("PLAYLIST_NAMES") or config.get("PLAYLIST_NAME", "") if playlists_raw:
if isinstance(raw, list): try:
playlists = [n.strip() for n in raw if str(n).strip()] playlists = json.loads(playlists_raw) if isinstance(playlists_raw, str) else playlists_raw
except Exception:
playlists = []
else: else:
playlists = [n.strip() for n in str(raw).split(",") if n.strip()] playlists = []
config["PLAYLIST_NAMES"] = 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 return config
def save_web_config(data): def save_web_config(data):
config = {} config = {}
config["SERVER_IP"] = str(data.get("SERVER_IP", "")).strip() config["SERVER_IP"] = str(data.get("SERVER_IP", "")).strip()
# Accept PLAYLIST_NAMES as list or comma string; store as comma string # PLAYLISTS: list of {name, stations}
raw = data.get("PLAYLIST_NAMES", data.get("PLAYLIST_NAME", "")) playlists = data.get("PLAYLISTS", [])
if isinstance(raw, list): if isinstance(playlists, list):
playlists = [n.strip() for n in raw if str(n).strip()] clean = [{"name": str(p.get("name", "")).strip(), "stations": list(p.get("stations", []))}
for p in playlists if str(p.get("name", "")).strip()]
else: else:
playlists = [n.strip() for n in str(raw).split(",") if n.strip()] clean = []
config["PLAYLIST_NAMES"] = ",".join(playlists) config["PLAYLISTS"] = json.dumps(clean)
config["PLAYLIST_NAME"] = playlists[0] if playlists else "" config["PLAYLIST_NAME"] = clean[0]["name"] if clean else ""
stations = data.get("SELECTED_STATIONS", []) all_stations = list({s for p in clean for s in p.get("stations", [])})
if isinstance(stations, str): config["SELECTED_STATIONS"] = all_stations
stations = [s.strip() for s in stations.split(",") if s.strip()]
config["SELECTED_STATIONS"] = stations
config["AUTO_UPDATE"] = bool(data.get("AUTO_UPDATE", False)) config["AUTO_UPDATE"] = bool(data.get("AUTO_UPDATE", False))
try: try:
config["UPDATE_INTERVAL"] = int(data.get("UPDATE_INTERVAL", 15)) 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)}
</div> </div>
</div> </div>
<div class="sgrp"> <div class="sgrp">
<div class="sgh">Playlists</div> <div class="sgh">Playlists &amp; Stations</div>
<div class="sgb"> <div class="sgb">
<div class="fl-h" style="margin-bottom:.4rem">Songs are synced to every playlist listed below.</div> <div class="fl-h" style="margin-bottom:.5rem">Each playlist syncs songs from its own set of stations.</div>
<div id="pl-list" style="display:flex;flex-direction:column;gap:.45rem"></div> <div id="pl-list" style="display:flex;flex-direction:column;gap:.7rem"></div>
<button class="btn btg" style="align-self:flex-start;margin-top:.2rem;font-size:.8rem" onclick="addPlaylistRow('')">+ Add Playlist</button> <button class="btn btg" style="align-self:flex-start;margin-top:.3rem;font-size:.8rem" onclick="addPlaylistRow({name:'',stations:[]})">+ Add Playlist</button>
</div>
</div>
<div class="sgrp">
<div class="sgh">Stations</div>
<div class="sgb">
<div class="stc">
<label class="ck"><input type="checkbox" id="st-j" value="journey_fm"><span>Journey FM</span><span class="ck-m">myjourneyfm.com</span></label>
<label class="ck"><input type="checkbox" id="st-s" value="spirit_fm"><span>Spirit FM</span><span class="ck-m">spiritfm.com</span></label>
<label class="ck"><input type="checkbox" id="st-k" value="klove"><span>K-LOVE</span><span class="ck-m">klove.com</span></label>
</div>
</div> </div>
</div> </div>
<div class="sgrp"> <div class="sgrp">
@@ -908,10 +908,10 @@ function copyAllLinks(){
// ── Playlist ────────────────────────────────────────────────── // ── Playlist ──────────────────────────────────────────────────
function initPlaylistSel(){ function initPlaylistSel(){
fetch('/api/config').then(r=>r.json()).then(cfg=>{ fetch('/api/config').then(r=>r.json()).then(cfg=>{
const names=cfg.PLAYLIST_NAMES||[]; const pls=cfg.PLAYLISTS||[];
const sel=document.getElementById('pl-sel'); const sel=document.getElementById('pl-sel');
if(names.length>1){ if(pls.length>1){
sel.innerHTML=names.map(n=>`<option value="${esc(n)}">${esc(n)}</option>`).join(''); sel.innerHTML=pls.map(p=>`<option value="${esc(p.name)}">${esc(p.name)}</option>`).join('');
sel.style.display=''; sel.style.display='';
}else{sel.style.display='none';} }else{sel.style.display='none';}
}); });
@@ -967,19 +967,45 @@ function removeTrack(rk,btn){
} }
// ── Settings ────────────────────────────────────────────────── // ── Settings ──────────────────────────────────────────────────
function addPlaylistRow(val){ const STATION_OPTS=[
const row=document.createElement('div'); {key:'journey_fm',label:'Journey FM'},
row.style.cssText='display:flex;gap:.5rem;align-items:center'; {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'); 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'); const del=document.createElement('button');
del.className='btn btd';del.style.cssText='font-size:.74rem;padding:.3rem .55rem;flex-shrink:0'; del.className='btn btd';del.style.cssText='font-size:.74rem;padding:.3rem .55rem;flex-shrink:0';
del.textContent='✕';del.onclick=()=>row.remove(); del.textContent='✕';del.onclick=()=>wrap.remove();
row.appendChild(inp);row.appendChild(del); top.appendChild(inp);top.appendChild(del);
document.getElementById('pl-list').appendChild(row); 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(){ function getPlaylists(){
return Array.from(document.querySelectorAll('#pl-list input')).map(i=>i.value.trim()).filter(Boolean); 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(){ function loadCfg(){
fetch('/api/config').then(r=>r.json()).then(cfg=>{ 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-au').checked=!!cfg.AUTO_UPDATE;
document.getElementById('f-in').value=cfg.UPDATE_INTERVAL||15; document.getElementById('f-in').value=cfg.UPDATE_INTERVAL||15;
document.getElementById('f-un').value=cfg.UPDATE_UNIT||'Minutes'; 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'); const h=document.getElementById('tk-hint');
h.textContent=cfg._has_token?'✓ Token saved — leave blank to keep it':'No token saved yet'; 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)'; h.style.color=cfg._has_token?'var(--gr)':'var(--mt)';
// populate playlist rows
const list=document.getElementById('pl-list');list.innerHTML=''; const list=document.getElementById('pl-list');list.innerHTML='';
const names=cfg.PLAYLIST_NAMES||[]; const pls=cfg.PLAYLISTS||[];
if(names.length){names.forEach(n=>addPlaylistRow(n));}else{addPlaylistRow('');} if(pls.length){pls.forEach(p=>addPlaylistRow(p));}
else{addPlaylistRow({name:'',stations:['journey_fm']});}
}); });
} }
function doSave(){ function doSave(){
const btn=document.getElementById('save-btn');ld(btn,true); 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({ fetch('/api/config',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({
SERVER_IP:document.getElementById('f-sv').value.trim(), SERVER_IP:document.getElementById('f-sv').value.trim(),
PLEX_TOKEN:document.getElementById('f-tk').value.trim(), PLEX_TOKEN:document.getElementById('f-tk').value.trim(),
PLAYLIST_NAMES:getPlaylistNames(), PLAYLISTS:getPlaylists(),
SELECTED_STATIONS:stations,
AUTO_UPDATE:document.getElementById('f-au').checked, AUTO_UPDATE:document.getElementById('f-au').checked,
UPDATE_INTERVAL:parseInt(document.getElementById('f-in').value)||15, UPDATE_INTERVAL:parseInt(document.getElementById('f-in').value)||15,
UPDATE_UNIT:document.getElementById('f-un').value, UPDATE_UNIT:document.getElementById('f-un').value,