PrintLyrics finds songs through LRCLIB and turns their lyrics into shareable, print-optimized lyric sheets. Manual lyric entry is also supported. It is a Rails 8.1 application built with Hotwire, Tailwind CSS, SQLite, and a small amount of Stimulus for preview controls.
- Ruby 4.0.5
- SQLite 3
bin/setup
bin/devThe application runs at http://localhost:3000.
bin/rails test
bin/rubocop
bin/brakeman --no-pager
bin/rails assets:precompileSong search uses the public LRCLIB API. Selecting a result fills the editable form without creating a database record. A record is persisted only after the user generates the print page.
A generated page can be extended into a songbook: an ordered, token-addressed set of lyric sheets that prints as one job. A songbook keeps the generated-page contract — unlisted link, no account, and excluded from indexing.
Generated pages and songbooks expire 180 days after their last visit, and visits
renew the retention window. Visiting a songbook renews it and every song in it.
Nothing purges expired rows on its own: cleanup is the explicit task below, which
the kamal purge alias runs against production.
bin/rails lyrics:purge_expiredVisitors can describe what they were printing and what got in the way, from the
prompt under a search that found nothing, from the line under a sheet they just
generated, or from the /feedback page. Submissions are stored in the
feedbacks table and read with:
bin/rails feedback:demand # the songs asked for, most requested first
bin/rails feedback:list # newest first, 50 by default
LIMIT=200 bin/rails feedback:listfeedback:demand is where a review starts: the same title typed twice counts as
one request, and each row carries its surface, whether it came with a note, and
when it was last asked for. Every row also carries the answer the visitor chose
(reason, from Feedback::REASONS) and how many sheets the visit had already
made (visit_sheet_count) — a request with no sheet behind it is a visitor who
left empty-handed. The kamal feedback and kamal feedback-demand aliases run
those two tasks against the deployed container, which is where production
submissions are read.
A stored submission is also reported to the site's own analytics service as
Feedback Submitted, carrying the song query, the note, the surface it came
from, and the answer, so the demand can be read beside the counts around it.
That report is Umami's alone — never Plausible's — and the reply address is not
part of it. The measurement contract owns the
rule and the event's properties.
The form is gated by Cloudflare Turnstile. The site key and the accepted
hostnames are public and live in config/deploy.yml; the widget secret is a
credential, with the rest of this project's secrets:
TURNSTILE_SITE_KEY=... # config/deploy.yml, public
TURNSTILE_HOSTNAMES=... # config/deploy.yml, public; never localhost in production
bin/rails credentials:edit # turnstile.secret_key, unlocked by RAILS_MASTER_KEYA submission is stored only when Cloudflare confirms the token for the
feedback action on one of TURNSTILE_HOSTNAMES. A declined token, a token
minted for another surface or host, an unreachable siteverify, or a host missing
any of these values is refused: an unjudged challenge is not a pass, so the form
stays closed until the deployment is configured.
Cloudflare's test keys cannot satisfy those checks: their reply carries no
action and reports hostname: "example.com", so a test-key submission is
refused. To exercise the happy path locally, add localhost and 127.0.0.1 to
the widget's domains in the Cloudflare dashboard and run with the real sitekey
and TURNSTILE_HOSTNAMES=localhost,127.0.0.1.
The Dockerfile builds assets without Node.js. Kamal deploys one web container to the existing Hetzner host, with SQLite stored on a persistent Docker volume. Deployments require:
RAILS_MASTER_KEY=...
KAMAL_REGISTRY_PASSWORD=...Analytics runs beside that container as two Kamal accessories — Umami and its PostgreSQL database. A deploy neither starts, stops, nor updates them; booting them for the first time needs two more secrets:
UMAMI_DB_PASSWORD=... # openssl rand -hex 24
UMAMI_APP_SECRET=... # openssl rand -hex 32An ordinary deploy does not need those two, and the runbook linked under Operations owns the setup.
The printlyrics-prod GitHub environment also requires KAMAL_SSH_KEY.
CI runs for pull requests and pushes to main. Release Please maintains a
release pull request and updates version.txt; merging that pull request
publishes a release and dispatches the production deployment. The application
shows that version in its footer.
Use the organic search operations runbook to configure Search Console, boot the self-hosted Umami analytics service and its MCP endpoint, run the two-week parallel measurement beside Plausible, record launch baselines, and run the 30- and 90-day reviews. The measurement contract holds the event vocabulary, how a surface reports an event, and what must never reach analytics.