WICN, outta woostah, ma: https://wicn-ice.streamguys1.com/live-mp3 (90.5)
I even have a cronjob setup to record some of their shows, they are so good. IN fact, tonight at 19:00: Soul Serenade. They don’t broadcast headers so I record the stream and the script fills in the tags and downloads the playlist from spinitron and saves it into a nicely formatted text file.
SO handy, then I copy the mp3 onto an old ipod so I can groove in the ride. I mean, I used to do that, and I will again, once I actually take the ipod out of the car. A 2 or 3 hour show in a 145mb mp3 file. Nice.
share the bits…
here’s the script:
#!/usr/bin/env bash
# wicn-show — cron record a WICN show, tag it, save the spinitron playlist.
# usage: wicn-show <slug> "Show Name" <end HH:MM> "Comment"
# wicn-show new-orleans-grooves "New Orleans Grooves" 20:31 "With Tony Baglio"
# cron fires at 18:59 (1 min early); recorder hard-stops at END.
# If the box is asleep/off at cron time the job never fires (quiet fail, free).
# Forwards to: $HOME/.local/state/wicn-show.log
set -euo pipefail
URL="https://wicn-ice.streamguys1.com/live-mp3"
UA="Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0"
STREAM_HOST="wicn-ice.streamguys1.com"
[[ $# -eq 4 ]] || { echo "usage: wicn-show <slug> <\"Show Name\"> <end HH:MM> <comment>" >&2; exit 2; }
SLUG=$1 NAME=$2 END=$3 COMMENT=$4
DATE=$(date +%F)
DAY=$(date +%-d) MONTH=$(date +%B) YEAR=$(date +%Y)
YM=$(date +%Y-%m)
WICN_HOME="${WICN_MUSIC:-$HOME/Music}" # WICN_MUSIC overrides the base (for tests)
DIR="$WICN_HOME/WICN/$SLUG/$YM"
FETCH_DELAY="${WICN_FETCH_DELAY:-75}" # seconds after recording ends before 1st playlist try
log() { echo "$(date '+%F %T') [$SLUG] $*"; }
# --- 1) network pre-flight: reach the stream host or quietly fail
if ! timeout 8 bash -c "exec 3<>/dev/tcp/$STREAM_HOST/443" 2>/dev/null; then
log "network unavailable - quietly failing"
exit 0
fi
# --- 2) disk guard: need a couple GB of slack
mkdir -p "$DIR"
FREE=$(df -BG --output=avail "$DIR" | tail -1 | tr -dc '0-9')
if (( ${FREE:-0} < 2 )); then
log "only ${FREE:-0}G free on /home - quietly failing"
exit 0
fi
# --- 3) record (reconnects until END through network drops)
log "recording $NAME until $END ($DATE)"
"$HOME/bin/streamrec" --url "$URL" --show "$SLUG" \
--base "$WICN_HOME/WICN/$SLUG" --end "$END" --no-tracks \
|| log "streamrec exited nonzero - finalizing what we have"
# --- 4) find + rename the show file
shopt -s nullglob
BEST="" BESTSZ=0
for f in "$DIR"/"$SLUG"_"$DATE"_*.mp3; do
SZ=$(stat -c%s "$f" 2>/dev/null || echo 0)
if (( SZ > BESTSZ )); then BEST=$f BESTSZ=$SZ; fi
done
if [[ -z $BEST ]]; then
log "no show file found - nothing to tag; done"
else
OUT="$DIR/$SLUG-$DATE.mp3"
if [[ -e $OUT ]]; then
HH=$(stat -c %y "$BEST" | awk '{print substr($2,1,5)}' | tr -d ':')
OUT="$DIR/$SLUG-$DATE-$HH.mp3"
mv "$BEST" "$OUT"
log "target existed, kept both: new copy -> $(basename "$OUT")"
else
mv "$BEST" "$OUT"
fi
if (( BESTSZ < 1000000 )); then
log "recording under 1 MB ($BESTSZ bytes) - likely no broadcast; leaving file, no tags"
else
# --- 5) tag: Title = "WICN <Name> <day> <Month> <Year>", Album, Comment (ID3v2.3)
python3 - "$OUT" "WICN $NAME $DAY $MONTH $YEAR" "$NAME" "$COMMENT" <<'PY'
import sys
from mutagen.id3 import ID3, ID3NoHeaderError
from mutagen.id3 import TIT2, TALB, COMM
path, title, album, comment = sys.argv[1:5]
try:
tags = ID3(path)
except ID3NoHeaderError:
tags = ID3()
tags.add(TIT2(encoding=3, text=title))
tags.add(TALB(encoding=3, text=album))
tags.add(COMM(encoding=3, lang="eng", desc="", text=comment))
tags.save(path, v2_version=3)
print(f"tagged {path}: Title={title!r} Album={album!r} Comment={comment!r}")
PY
log "saved $(basename "$OUT") ($(( BESTSZ / 1048576 )) MB)"
fi
fi
# --- 6) spinitron playlist: raw page as title-named .html (like the manual
# save-as) + a clean .txt spin list. Two tries: at +FETCH_DELAY, then
# +4 min (catches DJs entering the last song late).
sleep "$FETCH_DELAY"
save_playlist() {
local feed page tmp1 tmp2 plurl rc
mkdir -p "$HOME/.cache"
feed=$(mktemp -p "$HOME/.cache") page=$(mktemp -p "$HOME/.cache")
if ! curl -sS -m 30 -A "$UA" \
"https://spinitron.com/WICN/calendar-feed?timeslot=15&start=$DATE&end=$DATE" \
-o "$feed"; then
rm -f "$feed" "$page"; return 1
fi
plurl=$(python3 - "$feed" "$NAME" 2>/dev/null <<'PY'
import json, re, sys
norm = lambda s: re.sub(r"[^a-z0-9]", "", s.lower()) # "Soul Serenade!" == "Soul Serenade"
d = json.load(open(sys.argv[1]))
evs = d.get("events", []) if isinstance(d, dict) else d
want = norm(sys.argv[2])
for e in evs:
if norm(str(e.get("title", ""))) == want and e.get("url"):
print(e["url"]); raise SystemExit(0)
raise SystemExit(3)
PY
) \
&& rc=0 || rc=$?
if [[ $rc -ne 0 ]]; then rm -f "$feed" "$page"; return $rc; fi
if ! curl -sS -m 40 -A "$UA" "https://spinitron.com$plurl" -o "$page"; then
rm -f "$feed" "$page"; return 1
fi
# html+t txt named after the page title (spinitron's own save-as convention)
if ! python3 - "$page" "$DIR" "$NAME" "$DATE" <<'PY'
import sys, re, html, urllib.parse
from html.parser import HTMLParser
page, outdir, name, date = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4]
src = open(page, encoding="utf-8", errors="replace").read()
m = re.search(r"<title>(.*?)</title>", src, re.S)
title = html.unescape(m.group(1)).strip() if m else name
fname = re.sub(r"[\x00-\x1f/\\:*?\"<>|].*$", " ", title)
fname = re.sub(r"\s+", " ", fname).strip() or f"{name} {date}"
class Spins(HTMLParser):
FIELDS = {"artist", "song", "release", "label", "released", "genre"}
def __init__(self):
super().__init__()
self.rows, self.row, self.key, self.time = [], None, None, None
def handle_starttag(self, tag, attrs):
c = dict(attrs).get("class", "")
if tag == "tr" and "spin-item" in c:
self.row = {}
elif self.row is not None:
if tag == "td" and "spin-time" in c:
self.key = "time"
elif tag == "span" and c in self.FIELDS:
self.key = c
def handle_data(self, data):
if self.key and self.row is not None:
self.row[self.key] = (self.row.get(self.key, "") + data).strip()
def handle_endtag(self, tag):
if tag in ("span", "td") and self.row is not None:
self.key = None
if tag == "tr" and self.row is not None:
self.rows.append(self.row); self.row = None; self.key = None
p = Spins(); p.feed(src)
lines = []
for r in p.rows:
if not r.get("song") and not r.get("artist"):
continue
who = " - ".join(x for x in (r.get("artist"), r.get("song")) if x)
extra = " - ".join(x for x in (r.get("release"),) if x)
pub = " ".join(x for x in (r.get("label"), r.get("released")) if x)
bits = [f'{r.get("time",""):>7}', who]
if extra: bits.append(extra)
if pub: bits.append(f"({pub})")
lines.append(" | ".join(bits))
with open(f"{outdir}/{fname}.txt", "x", encoding="utf-8") as f:
f.write(title + "\n" + "=" * len(title) + "\n")
f.write("\n".join(lines) + "\n")
print(f"{len(p.rows)} spins saved to {outdir}/{fname}.txt")
PY
then rm -f "$feed" "$page"; return 4 # playlist present but empty/parse failed
else
rm -f "$feed" "$page"; return 0
fi
}
PTRC=0
if save_playlist; then
log "playlist saved"
else
PTRC=$?
log "playlist not ready yet (rc=$PTRC) - retrying once at +4 min"
sleep 225
if save_playlist; then
log "playlist saved (retry)"
else
log "playlist quietly failed (rc=$?) - the manual browser save still works"
fi
fi
log "done"
exit 0
Here’s the cronjobs:
59 18 * * 1 $HOME/bin/wicn-show soul-serenade "Soul Serenade" 22:01 "with Tom Shaker" >> $HOME/.local/state/wicn-show.log 2>&1
59 18 * * 5 $HOME/bin/wicn-show latin-jazz-now "Latin Jazz Now" 22:01 "with Jim Benitez" >> $HOME/.local/state/wicn-show.log 2>&1
59 18 * * 6 $HOME/bin/wicn-show new-orleans-grooves "New Orleans Grooves" 21:01 "With Tony Baglio" >> $HOME/.local/state/wicn-show.log 2>&1
Everything lands in ~/Music/WICN/$SHOW/$DATE