SQLite replaces Postgres, and two fixes from using the thing. - The database is a file under ./storage instead of a second container. Ten members never needed a database server, and the driver is pure Go, so the build stays CGO_ENABLED=0 and the dependency count is unchanged. One bind mount is now the whole backup: no pgdata, no healthcheck-gated depends_on, no startup retry loop. Timestamps are UTC text, idle_ttl is seconds, the divisive/unified boards carry their own stddev, and foreign keys are on by pragma. Tests get a database file each and run without any setup - Invites are copied, not clicked. An invite is something to send, and the anchor opened the join form in the admin's own browser - Feedback asks for more than faults: the footer reads "Ongelmia? Ideoita? Palautetta?" and the page behind it invites ideas rather than only bugs - Kuuntele YouTubessa opens in a new tab, so a half-typed review survives it
208 lines
11 KiB
Markdown
208 lines
11 KiB
Markdown
# Later
|
||
|
||
Ideas with their reasoning, kept so the thinking isn't redone from scratch. **None of this is v1
|
||
work**, and none of it should shape v1 code beyond what the API contract already allows.
|
||
|
||
---
|
||
|
||
## Email: password reset and announcements
|
||
|
||
Wanted, blocked on not having a reliable SMTP server. Members already have addresses in their profile
|
||
(decisions 31), so the data is there when the transport is.
|
||
|
||
Two things to remember when it happens:
|
||
|
||
- **v1 cannot verify addresses.** Some of them will be typos, and a password reset to a typo'd
|
||
address is a locked-out member. The admin already resets passwords, so that is the fallback — but
|
||
the reset flow should not assume the address works.
|
||
- Announcements need an opt-out, or they become the reason someone stops reading them.
|
||
|
||
---
|
||
|
||
## Lyrics with scaled autoscroll
|
||
|
||
Fetch lyrics and scroll them in time with the audio. Designed and decided (decisions 45), not built.
|
||
|
||
**Coverage, measured 2026-07-31** rather than assumed. An earlier version of this page guessed
|
||
LRCLIB would miss nearly all Finnish music. It does not:
|
||
|
||
| Search | Results | With `syncedLyrics` |
|
||
|---|---|---|
|
||
| Nightwish | 20 | 20 |
|
||
| Eppu Normaali | 20 | 13 |
|
||
| CMX | 15 | 12 |
|
||
| Popeda | 20 | 8 |
|
||
|
||
**LRCLIB** (`lrclib.net`) needs no API key. `/api/get` matches on artist, track and duration within
|
||
±2 s and returns `syncedLyrics` — real LRC with `[mm:ss.xx]` per line — alongside `plainLyrics`;
|
||
`/api/search?q=` is the looser fallback. Go's side is `net/http` and `encoding/json`, so the
|
||
dependency budget survives, and it is treated exactly like ffmpeg and yt-dlp: a timeout, allowed to
|
||
fail, never blocking anything.
|
||
|
||
**Where it happens: at submission, as a suggestion.**
|
||
|
||
- The worker attempts one automatic lookup after conversion, using whatever metadata exists.
|
||
- The waiting page has a **Hae sanoitukset** button that re-queries with whatever is currently typed
|
||
in the title and artist fields. That is the answer for messy tags — `Sentenced Noose` from a
|
||
YouTube upload will not match until the submitter fixes it, and the automatic attempt would
|
||
otherwise just look broken.
|
||
- Neither ever overwrites text the submitter has typed. They can accept the suggestion, edit it, or
|
||
leave the field empty.
|
||
|
||
**Storage:** one nullable `lyrics text` column on both `submissions` and `songs`, copied across at
|
||
publish. LRC or plain is told apart by whether the first line starts with `[`, so no second column
|
||
and no flag. **Nothing has launched, so this goes into `001_init.sql` rather than a migration 002.**
|
||
|
||
**Lyrics stay editable after the song locks** — the lock exists so the thing people reviewed stops
|
||
changing, and nobody reviewed the lyrics. It also means someone can paste them for an old song a
|
||
year later, which is when this feature is most useful.
|
||
|
||
**Playback:**
|
||
|
||
- **Synced hit** → highlight the current line properly, driven by the transport's `timeupdate`.
|
||
- **Plain hit or manual paste** → distribute lines evenly across `duration_seconds` and scroll the
|
||
block *continuously without highlighting a line*. Highlighting makes every second of drift read as
|
||
a bug, and drift is guaranteed — intros and outros alone break a uniform mapping.
|
||
- The Web Animations API does the whole thing including seeking: build the scroll animation with
|
||
`duration_seconds`, `pause()` it, and bind `play`/`pause`/`seeked` on the audio element. No timers,
|
||
no drift accumulation.
|
||
- **Leave a nudge knob** — a ±10 s offset slider, remembered per song in `localStorage`. Uniform
|
||
distribution models a song no real song obeys, and one drag while listening beats any heuristic.
|
||
|
||
**Still open:** where the panel lives on the song page. That page's job is now *listen and write*,
|
||
and a scrolling lyrics panel competes with the review textarea for both space and attention — a
|
||
collapsed panel under the player is the starting guess, not a decision.
|
||
|
||
Copyright posture is the same as the YouTube note: private app, ten people, written down
|
||
deliberately.
|
||
|
||
---
|
||
|
||
## AI features (Gemma-class local model)
|
||
|
||
The constraint that shapes every idea: **E2B-class multimodal models are speech models.** The audio
|
||
encoder targets ASR and spoken-audio QA. Music is out of distribution — genre calls are near
|
||
coin-flips, "describe this track" produces beige copy, and singing over instrumentation is a worst
|
||
case for ASR. Encoders also work in ~30 s windows, so a four-minute song is a chunk loop, and on CPU
|
||
beside ffmpeg that is minutes per submission.
|
||
|
||
So the ideas that use the model to be *correct* are the weak ones, and the idea that uses it to be
|
||
*entertaining* is the strong one:
|
||
|
||
- **A bot reviewer** — a member account that listens to each published song and posts a score and a
|
||
few sentences in character. The model's weaknesses become the feature; nobody files a bug because
|
||
the robot was wrong about a friend's doom metal demo. It is a `users` row and a `reviews` row, no
|
||
new domain concepts, and the reveal rule already makes its take something you unlock by reviewing —
|
||
which quietly rewards the behaviour the whole app exists for. Needs: a sharp persona, a
|
||
~3-sentence cap, exclusion from the reviewer leaderboards and from the ≥3-review threshold.
|
||
- Weak: genre suggestion (buys one click, and a confident wrong default is worse than an empty
|
||
dropdown), auto-drafted introductions (same objection as YouTube descriptions), "is this actually
|
||
music" screening (ffmpeg already proves it is audio, and the admin can delete).
|
||
- If **duplicate detection** is ever the real want, that is Chromaprint/AcoustID, not an LLM.
|
||
- Architecture, when it happens: an Ollama or llama.cpp sidecar called over HTTP from the worker with
|
||
a timeout — treated exactly like ffmpeg and yt-dlp, an external process allowed to fail. Go's side
|
||
is `net/http` and `encoding/json`, so the dependency budget survives. Never block publish on
|
||
inference.
|
||
|
||
---
|
||
|
||
## Native client
|
||
|
||
Deferred; the shape depends entirely on what it is *for*.
|
||
|
||
- **Launcher icon and media keys** → a PWA. `manifest.json` plus ~10 lines of
|
||
`navigator.mediaSession` gets media-key control, and on Linux the browser exposes it over MPRIS, so
|
||
the song appears in the GNOME/KDE media widget with artwork. Zero packaging.
|
||
- **Playback surviving navigation** → a webview shell, and this is *not* free even then: a webview is
|
||
one browser context, so in-app navigation tears down `<audio>` exactly like a tab does. The fixes
|
||
are an outer shell page with the app in a same-origin `<iframe>`, fetch-based navigation, or audio
|
||
played by the native layer with the webview as UI only.
|
||
- **Offline listening** → the only motivation nothing else covers, and a genuinely different project:
|
||
download endpoint, local cache management, sync.
|
||
- **Not Electron.** It bundles Chromium and Node — ~150 MB on disk, 200–400 MB idle RAM, and Node
|
||
back in the build, which is the exact dependency surface the rewrite exists to escape. The
|
||
system-webview equivalents are ~5–10 MB: **Wails** (Go, same language as the server) is the pick,
|
||
with Tauri as the Rust alternative.
|
||
- Packaging inverts with that choice: a plain Go binary → **AppImage** (one `appimagetool` step, one
|
||
file to hand out). A webview shell links WebKitGTK, which AppImage bundles badly and the GNOME
|
||
**Flatpak** runtime provides for free.
|
||
- Worth settling first: the usage pattern this app implies is opening a song, listening, and
|
||
reviewing it right there — one page, playback never interrupted. If that is the real behaviour, the
|
||
entire rationale reduces to wanting a launcher icon, and that is a `.desktop` file with
|
||
`chromium --app=…`.
|
||
|
||
---
|
||
|
||
## Custom audio player
|
||
|
||
Native `<audio controls>` is plain, but it is also correct — keyboard-operable, screen-reader
|
||
labelled, media-key aware. Replacing it is cheap and purely client-side: drop the `controls`
|
||
attribute, keep the same element, and drive it through `play()`, `pause()`, `currentTime`,
|
||
`duration`, and `timeupdate`. Playback, buffering, seeking and Range requests keep working untouched,
|
||
and the server changes not at all.
|
||
|
||
Two things keep it cheap and both are already in v1: the player lives in one template partial, and
|
||
`duration_seconds` is on the song row. Skip precomputed waveform peaks — if a SoundCloud-style
|
||
waveform is ever wanted, backfilling a couple hundred `.ogg` files is a ten-line script.
|
||
|
||
When building it, re-earn what the native controls gave away for free: keyboard operation, visible
|
||
focus states, and `aria-label`s on the buttons. That is the part custom players routinely drop, and
|
||
it is not optional.
|
||
|
||
---
|
||
|
||
## Stats not built
|
||
|
||
Wanted at some point, deliberately out of v1: Top 10 by genre, average given versus received per
|
||
member, a reviewer agreement matrix, genre distribution, activity over time. The agreement matrix in
|
||
particular is an O(n²) query and a UI nobody has asked for twice.
|
||
|
||
Also here: pruning the count-based leaderboards once the queue has drained and they are all ties
|
||
(decisions 35).
|
||
|
||
---
|
||
|
||
## Review form as a mixer channel
|
||
|
||
Idea for the UI polish pass, not now: put the score slider and the review textarea **on one row**,
|
||
with the slider **vertical** like a channel fader on a mixing desk. The score stops being a form
|
||
field and becomes the instrument the app is actually about, and the two things you do at once —
|
||
decide a number, write why — stop being stacked a screenful apart.
|
||
|
||
Notes for whoever builds it:
|
||
|
||
- A vertical `<input type="range">` is native now: `writing-mode: vertical-lr; direction: rtl` gives
|
||
bottom-to-top travel with no JS and no custom widget, so keyboard support and the value stay free.
|
||
- Keep the live `<output>` — on a fader it wants to sit at the top of the track, reading like a
|
||
channel's gain display.
|
||
- The row needs a mobile answer: below ~640px, either keep the fader and shrink the textarea beside
|
||
it, or fall back to the current stacked layout. A short vertical fader is worse than a horizontal
|
||
one, so measure before choosing.
|
||
- Tick marks along the track (1 / 25 / 50 / 75 / 100) replace today's `.scorescale` row, and are
|
||
what make it read as equipment rather than decoration.
|
||
|
||
---
|
||
|
||
## The JSON API
|
||
|
||
Designed and specified in [spec.md §8](./spec.md) — object shapes, endpoints, error codes,
|
||
pagination — and deliberately not implemented, because nothing calls it (decision 43).
|
||
|
||
When something does:
|
||
|
||
- Build only the endpoints that consumer needs, as `jsonOf(...)` adapters over the same data
|
||
functions the pages already use, so the domain rules cannot diverge between the surfaces.
|
||
- Add the golden-file tests at the same time, one per object shape. They are what makes a renamed
|
||
field a test failure rather than a silent break in a client you cannot update.
|
||
- CORS is a one-line middleware, added the day the consumer is on a different origin. Not before.
|
||
- The most likely first consumer is a native client (see above), and the endpoints it needs are
|
||
login, the queue, a song with its reviews, and posting a review — four routes, not twenty-one.
|
||
|
||
---
|
||
|
||
## Filters on the browse list
|
||
|
||
`/songs` is newest-first with no filters. Once there are a couple of hundred songs, "which ones
|
||
haven't I reviewed" and "show me the metal" become real questions — one `WHERE` each, additive to the
|
||
API contract as query parameters. Worth doing when the scrolling annoys someone, not before.
|