Browse newly released albums on Spotify and save them for later — an Rdio-style
new-release browser. Albums are ingested nightly from Spotify's tag:new
search, enriched with track counts, artwork, and popularity, and presented by
release week (new music drops Friday). Log in with Spotify to add a whole
album's tracks to your "Play Later" playlist with one click.
This is the modernized rewrite of Play-Later (now archived; treat it as the behavior reference, not running code).
Stack: PHP ^8.3, Slim 4, Twig, MariaDB 11.4, Phinx migrations, Symfony Console/Cache/Lock, nginx + php-fpm + supercronic under Docker Compose.
Requirements: Docker (Compose v2) and PHP 8.3+ with Composer on the host.
cp .env.example .env # fill in Spotify credentials + DB_PASS
cp compose.override.yaml.example compose.override.yaml
composer install
docker compose up -d
docker compose exec app vendor/bin/phinx migrateThe site is at http://localhost:8080. MariaDB is exposed on localhost:3306
for GUI clients (dev override only).
Spotify credentials: use the grandfathered Spotify app (rotate the secret in
the developer dashboard if needed — never create a new app, new client IDs lose
grandfathered API access). The redirect URI for dev is
http://localhost:8080/spotify/.
Seeding: a fresh database is empty. Either restore a production dump
(gunzip -c dump.sql.gz | docker compose exec -T db mariadb -u... play_later),
run app:migrate:legacy against a copy of the legacy database, or just run the
ingest + enrich commands below and wait — you'll have the current week's
releases within a few minutes.
All commands run through bin/console (inside the app or cron container:
docker compose exec app php bin/console <command>). Production schedule lives
in docker/cron/crontab, executed by supercronic in the cron container
(TZ America/New_York).
| Command | Schedule | What it does |
|---|---|---|
app:ingest:spotify-tag-new |
00:05 daily | Discover new releases via Spotify tag:new search |
app:enrich:albums --limit=500 |
01:05 daily | Fill in track counts, artwork, genres for new rows |
app:refresh:popularity |
02:05 daily | Refresh popularity scores for recent releases |
app:enrich:albums --backfill --budget=3000 |
03:15 daily | Drain the legacy-archive enrichment queue (remove once drained) |
scripts/backup.sh |
03:30 daily | DB dump + prune + optional offsite copy |
app:cache:prune |
04:00 Sunday | Prune expired filesystem cache entries |
app:migrate:legacy |
manual, once | Import the legacy database into the new schema |
Each cron job pings its healthchecks.io URL (see HEALTHCHECKS_* in
.env.example); leave those empty in dev to disable pings.
Pushing to master runs .github/workflows/deploy.yml: checks (cs-fixer,
phpstan, phpunit) → composer install --no-dev → rsync to
/srv/play-later/releases/<sha>/ → phinx migrate → flip the
/srv/play-later/current symlink → reload php-fpm → smoke-check
https://play-later.com/, rolling the symlink back on failure. Branch pushes
and PRs run .github/workflows/ci.yml only.
GitHub secrets: SSH_PRIVATE_KEY (deploy user), DEPLOY_HOST.
The application .env lives only on the server at
/srv/play-later/shared/.env (symlinked into each release) and is never in
git. To rotate a secret (Spotify client secret, DB password): update the
provider/database, edit the server .env, and docker compose up -d --force-recreate app cron — no deploy needed. (restart does not re-read
env_file; only recreate does.) TLS is a Cloudflare Origin CA cert mounted from
docker/nginx/certs/ (gitignored); Cloudflare fronts the public edge.
The shared var/ directory on the server (/srv/play-later/shared/var) must be
owned by uid 82 (www-data) — both php-fpm and the cron jobs run as that user:
chown -R 82:82 /srv/play-later/shared/var.
scripts/backup.sh (nightly via cron container) writes
var/backups/play_later_YYYY-MM-DD.sql.gz, prunes dumps older than 7 days,
copies offsite when RCLONE_REMOTE is set (e.g. a Backblaze B2 remote), and
pings HEALTHCHECKS_BACKUP_URL.
scripts/restore-test.sh is the monthly restore drill: it boots a throwaway
MariaDB container, imports the latest dump, and asserts the row count and
newest release date look sane. Run it — a backup that has never been restored
is a hope, not a backup.