Files
radio-free-elsewhere/CLAUDE.md
T

11 KiB
Executable File

Radio Free Elsewhere

Project Overview

Radio Free Elsewhere is an Internet radio station streaming eclectic music (Celtic folk, ambient/space, surf rock, and more). It runs on Icecast hosted on a Yunohost server. Streams are live and occasional — a few times per week in the evenings, typically around 7:30pm Eastern, running roughly two hours. This is not a 24/7 automated station.

The shows have distinct themes and identities:

  • O'Morain's Pub — Celtic folk
  • Cosmoeuphoria — ambient and space music
  • Bisexual Surfers From the Twilight of Time — surf rock and rockabilly
  • The Bollocks - punk rock
  • The Spin - eclectic tunes from all genres

Scope

This repo covers the website — the public pages, the admin backend, and the site's MariaDB database. Icecast configuration, Yunohost server administration, and streaming software are out of scope. Do not look for or suggest changes to server-side streaming config.

The one approved exception is cli/poll_icecast.php, a cron job that reads the Icecast status API to record track history. It only reads from Icecast; it never configures it.

Key Files

Public pages:

  • index.php — Main page. Contains the audio player, now-playing widget, upcoming schedule section, and about section. Show information is updated here manually before each broadcast.
  • live.php — Page to send listeners to while streaming. Player on the left, recently played list on the right; the sidebar background and the heading change with the current show.
  • now_playing.php — Returns the currently playing track as HTML. Called by index.php and live.php every 15 seconds.
  • recently_played.php — Returns the current broadcast's track history as table rows for live.php.

Admin backend (admin/):

Shared includes:

Command-line tools (run over SSH from the site root):

Other:

  • sql/schema.sql — Full database schema plus seed data for the five shows. Safe to re-run.
  • config/db_credentials.example.php — Template for the gitignored config/db_credentials.php.
  • SETUP.md — Step-by-step guide to installing the site on a Yunohost server, including the poller cron job.

Architecture

  • Backend: PHP only. No Composer, no package manager, no build step.
  • Frontend: HTML5 UP Strata template with custom CSS additions. No npm, no webpack, no preprocessor — edit CSS directly in assets/css/main.css or inline <style> blocks.
  • Now-playing: index.php and live.php fetch now_playing.php via JavaScript every 15 seconds and inject the result into #now-playing. The fetch uses arrayBuffer + TextDecoder to handle the UTF-8 encoding correctly on the client side. live.php fetches recently_played.php the same way.
  • Duplicated sidebar: live.php was copied from index.php, so the sidebar markup, now-playing styles and fetch script exist in both. Changes to one usually need making in the other.
  • Sidebar background: #header in main.css uses images/bg.jpg with background-size: cover. Don't switch it back to bg.png — that file has an alpha fade at the bottom that lets the brown background colour show through.

Database

MariaDB on localhost. Credentials live in config/db_credentials.php, which is gitignored and uploaded to the server separately. Tables (see sql/schema.sql):

  • shows — the shows, with a unique slug and an optional background_image (filename in images/backgrounds/).
  • broadcasts — one row per streaming session: pending (started_at NULL, queued from the dashboard or cli/set_show.php), live (ended_at NULL), or ended.
  • played_tracks — tracks per broadcast. Cleared ten minutes after the broadcast ends; no long-term history is kept.
  • admins — admin accounts (username + password hash).
  • login_attempts — failed logins by IP, used for throttling.

All DATETIME values are UTC. recently_played.php outputs <time datetime="...Z"> and live.php reformats them in the listener's local time zone.

Broadcasts and Track History

cli/poll_icecast.php runs from cron every minute and polls Icecast four times, 15 seconds apart (with a lock file so runs never overlap). Rules, implemented in recordPoll() in includes/broadcasts.php:

  • When the stream comes online, the queued broadcast starts. If nothing was queued, a broadcast with no show is created so tracks are still recorded.
  • A new track is recorded whenever title/artist/album differ from the last one, with its album art URL looked up once.
  • A broadcast ends once it has been unseen for five minutes, so brief encoder drop-outs don't split a show.
  • Tracks are deleted ten minutes after their broadcast ends.
  • If the status API can't be reached, the poll is skipped entirely so an outage isn't mistaken for the stream ending.

In the recently played list, album art and album title link to the album's Last.fm page; the now-playing widget links to the track's Last.fm page.

Admin Authentication

Username and password only; the site sends no email. Accounts are created and passwords reset over SSH with php cli/create_admin.php <username>. Passwords are hashed with password_hash() (PASSWORD_DEFAULT — bcrypt, chosen for portability over Argon2). Sessions use a rfe_admin cookie scoped to the admin directory (HttpOnly, SameSite=Strict, Secure over HTTPS) and expire after four hours idle. Five failed logins from one IP within 15 minutes locks that IP out for the rest of the window.

When adding admin pages:

  • Call requireAdmin() first and render with adminHeader($title, $admin) / adminFooter().
  • Every POST form must include csrfField(), and its handler must call verifyCsrf() before doing anything else.
  • Use post-redirect-get: handle the POST, call flash(), then redirect.

Background Image Uploads

Uploaded through admin/show_edit.php: JPEG, PNG or WebP up to 5 MB (PHP's post_max_size is 8 MB). Files are validated by content (finfo + getimagesize), not extension, and saved to images/backgrounds/ as <slug>-<random>.<ext>. Replaced or removed images are deleted from disk. The web server user must be able to write to that directory. Uploads are gitignored — they belong to each server's database, so they're uploaded per server through the admin page.

Conventions

  • Files in includes/ and config/ return 403 if requested directly; files in cli/ refuse to run outside the command line (PHP_SAPI !== 'cli'). Follow the same pattern for new files.
  • Use prepared statements for all queries with user input, and escape all output with htmlspecialchars (the e() helper in admin pages).
  • index.php and live.php use CRLF line endings; the PHP includes and scripts use LF. Preserve whichever a file already has.
  • PHP files use 4-space indentation; the HTML templates use tabs, matching Strata.

Icecast Integration

  • Stream URL: https://radio.cyberpunklibrarian.nohost.me/rfe
  • Status API: https://radio.cyberpunklibrarian.nohost.me/status-json.xsl

The status API returns JSON with track metadata under icestats.source; when the stream is offline there is no source key. The source key may be a single object or an array depending on how many mountpoints are active; fetchCurrentTrack() in includes/icecast.php handles both cases. Track titles arrive as Title - Artist - Album.

Known Encoding Quirk

Icecast double-encodes track metadata as Latin-1 interpreted as UTF-8. The fix in decodeMetaField() in includes/icecast.php deliberately converts the string from UTF-8 to ISO-8859-1 to strip the outer encoding layer and recover the correct UTF-8 byte sequence. Do not remove or "simplify" this conversion — it is intentional and necessary for non-ASCII characters (accented names, etc.) to display correctly.

Deployment

There is no CI/CD pipeline. Deployment is manual:

  1. Commit and push changes to the self-hosted Gitea repository.
  2. Upload changed files to the Yunohost web server via SFTP. Never overwrite config/db_credentials.php or the contents of images/backgrounds/ on the server.

There is no staging environment — changes go straight to production.

Server-only setup (one-time, or when the schema changes):

  • Apply the schema: mysql -u <db_user> -p <db_name> < sql/schema.sql (use sudo mysql <db_name> < sql/schema.sql if the database user lacks CREATE privileges).
  • Upload config/db_credentials.php with the server's credentials.
  • Add the cron entry in /etc/cron.d (see SETUP.md): * * * * * <app_user> /usr/bin/php8.x /path/to/rfe/cli/poll_icecast.php, run as a user that can read the credentials file. Without it the recently played list stays empty and queued shows never start.
  • Make images/backgrounds/ writable by the user PHP runs as.
  • Create an admin account with php cli/create_admin.php <username>.

Updating Show Information

Show info lives in the #one section of index.php. Previous shows are commented out rather than deleted, so there's a history of past broadcasts in the markup. When updating for a new show, comment out the current show block and add the new one above it.

Before going live, queue the show on the admin dashboard (or php cli/set_show.php <slug>) and point listeners to live.php.

Planned Work

  • index.php will be reworked into more evergreen content (About, schedules, etc.) and will link to live.php. Until then, nothing on the site links to live.php.
  • The admin backend will grow to manage more of the site.