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
+112 -74

No files matched your search

+1
View File
@@ -24,6 +24,7 @@ NON_SECRET_KEYS = {
"SERVER_IP",
"PLAYLIST_NAME",
"PLAYLIST_NAMES",
"PLAYLISTS",
"AUTO_UPDATE",
"UPDATE_INTERVAL",
"UPDATE_UNIT",
+35 -15
View File
@@ -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:
+76 -59
View File
@@ -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)}
</div>
</div>
<div class="sgrp">
<div class="sgh">Playlists</div>
<div class="sgh">Playlists &amp; Stations</div>
<div class="sgb">
<div class="fl-h" style="margin-bottom:.4rem">Songs are synced to every playlist listed below.</div>
<div id="pl-list" style="display:flex;flex-direction:column;gap:.45rem"></div>
<button class="btn btg" style="align-self:flex-start;margin-top:.2rem;font-size:.8rem" onclick="addPlaylistRow('')">+ 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 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:.7rem"></div>
<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">
@@ -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=>`<option value="${esc(n)}">${esc(n)}</option>`).join('');
if(pls.length>1){
sel.innerHTML=pls.map(p=>`<option value="${esc(p.name)}">${esc(p.name)}</option>`).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,