commit 9fff6fbf7f6aa6c1e88e7818d56945b36da63f42 Author: Priec Date: Sun May 17 13:12:08 2026 +0200 loco rewrite diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..5caf6b1 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,10 @@ +[alias] +loco = "run --" +loco-tool = "run --" + +playground = "run --example playground" + +# https://github.com/rust-lang/rust/issues/141626 +# (can be removed once link.exe is fixed) +[target.x86_64-pc-windows-msvc] +linker = "rust-lld" diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..75ba8a5 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,102 @@ +name: CI +on: + push: + branches: + - master + - main + pull_request: + +env: + RUST_TOOLCHAIN: stable + TOOLCHAIN_PROFILE: minimal + +jobs: + rustfmt: + name: Check Style + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + components: rustfmt + - name: Run cargo fmt + uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + + clippy: + name: Run Clippy + runs-on: ubuntu-latest + + permissions: + contents: read + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + - name: Run cargo clippy + uses: actions-rs/cargo@v1 + with: + command: clippy + args: --all-features -- -D warnings -W clippy::pedantic -W clippy::nursery -W rust-2018-idioms + + test: + name: Run Tests + runs-on: ubuntu-latest + + permissions: + contents: read + + services: + redis: + image: redis + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - "6379:6379" + postgres: + image: postgres + env: + POSTGRES_DB: postgres_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - "5432:5432" + # Set health checks to wait until postgres has started + options: --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - name: Checkout the code + uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_TOOLCHAIN }} + - name: Setup Rust cache + uses: Swatinem/rust-cache@v2 + - name: Run cargo test + uses: actions-rs/cargo@v1 + with: + command: test + args: --all-features --all + env: + REDIS_URL: redis://localhost:${{job.services.redis.ports[6379]}} + DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres_test + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..51ceb31 --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +**/config/local.yaml +**/config/*.local.yaml +**/config/production.yaml + +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# include cargo lock +!Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +*.sqlite +*.sqlite-* +.env +.env.production diff --git a/.rustfmt.toml b/.rustfmt.toml new file mode 100644 index 0000000..d862e08 --- /dev/null +++ b/.rustfmt.toml @@ -0,0 +1,2 @@ +max_width = 100 +use_small_heuristics = "Default" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..aa0b331 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,53 @@ +[workspace] + +[package] +name = "universal_web" +version = "0.1.0" +edition = "2021" +publish = false +default-run = "universal_web-cli" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[workspace.dependencies] +loco-rs = { version = "0.16" } + +[dependencies] +loco-rs = { workspace = true } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1" } +tokio = { version = "1.45", default-features = false, features = [ + "rt-multi-thread", +] } +async-trait = { version = "0.1" } +axum = { version = "0.8" } +tracing = { version = "0.1" } +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } +regex = { version = "1.11" } +migration = { path = "migration" } +sea-orm = { version = "1.1", features = [ + "sqlx-sqlite", + "sqlx-postgres", + "runtime-tokio-rustls", + "macros", +] } +chrono = { version = "0.4" } +validator = { version = "0.20" } +uuid = { version = "1.6", features = ["v4"] } +include_dir = { version = "0.7" } +# view engine i18n +fluent-templates = { version = "0.13", features = ["tera"] } +unic-langid = { version = "0.9" } +# /view engine +axum-extra = { version = "0.10", features = ["form"] } + +[[bin]] +name = "universal_web-cli" +path = "src/bin/main.rs" +required-features = [] + +[dev-dependencies] +loco-rs = { workspace = true, features = ["testing"] } +serial_test = { version = "3.1.1" } +rstest = { version = "0.25" } +insta = { version = "1.34", features = ["redactions", "yaml", "filters"] } diff --git a/README.md b/README.md new file mode 100644 index 0000000..43b9bdd --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# Welcome to Loco :train: + +[Loco](https://loco.rs) is a web and API framework running on Rust. + +This is the **SaaS starter** which includes a `User` model and authentication based on JWT. +It also include configuration sections that help you pick either a frontend or a server-side template set up for your fullstack server. + + +## Quick Start + +```sh +cargo loco start +``` + +```sh +$ cargo loco start +Finished dev [unoptimized + debuginfo] target(s) in 21.63s + Running `target/debug/myapp start` + + : + : + : + +controller/app_routes.rs:203: [Middleware] Adding log trace id + + ▄ ▀ + ▀ ▄ + ▄ ▀ ▄ ▄ ▄▀ + ▄ ▀▄▄ + ▄ ▀ ▀ ▀▄▀█▄ + ▀█▄ +▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄▄▄ ▄▄▄▄▄▄▄▄▄ ▀▀█ + ██████ █████ ███ █████ ███ █████ ███ ▀█ + ██████ █████ ███ █████ ▀▀▀ █████ ███ ▄█▄ + ██████ █████ ███ █████ █████ ███ ████▄ + ██████ █████ ███ █████ ▄▄▄ █████ ███ █████ + ██████ █████ ███ ████ ███ █████ ███ ████▀ + ▀▀▀██▄ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ██▀ + ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ + https://loco.rs + +environment: development + database: automigrate + logger: debug +compilation: debug + modes: server + +listening on http://localhost:5150 +``` + +## Full Stack Serving + +You can check your [configuration](config/development.yaml) to pick either frontend setup or server-side rendered template, and activate the relevant configuration sections. + + +## Getting help + +Check out [a quick tour](https://loco.rs/docs/getting-started/tour/) or [the complete guide](https://loco.rs/docs/getting-started/guide/). diff --git a/REWRITE_SPEC.md b/REWRITE_SPEC.md new file mode 100644 index 0000000..708fb68 --- /dev/null +++ b/REWRITE_SPEC.md @@ -0,0 +1,399 @@ +# Universal Web — Loco.rs Rewrite Specification + +This document is the implementation spec for re-building the existing **Axum** app +(parent directory `../`) on top of **Loco.rs**. It captures every feature so the +rewrite reaches parity. The GUI is being rewritten as part of this effort, so +templates/markup are *not* meant to be ported verbatim — only behaviour and routes. + +- **Source app:** `../` — Axum 0.8, sqlx, Askama, axum-login, OpenDAL. +- **Target app:** this directory — Loco 0.16 (SaaS, server-side rendered), SeaORM 1.1, Tera. +- **Scaffold already generated by `loco new`:** auth controller, `users` model + migration, + auth mailer, background worker example, Tera view engine, i18n. + +--- + +## 1. Stack mapping (old → Loco) + +| Concern | Old (Axum) | Loco target | +|--------------------|---------------------------------------------|----------------------------------------------------------| +| HTTP framework | Axum 0.8 + tower | Loco (Axum under the hood) — `controllers/` | +| DB access | sqlx 0.8 (raw SQL, `query_as`) | SeaORM 1.1 — entities in `models/_entities/` | +| Migrations | `migrations/*.sql` (sqlx-cli) | `migration/` crate (SeaORM, Rust DSL) | +| Templating | Askama (compile-time) | Tera (`assets/views/`) via `view_engine` initializer | +| Auth/session | axum-login + tower-sessions (PG store) | **Decision needed — see §3.1.** Loco ships JWT auth. | +| Password hashing | argon2 0.5 | Loco built-in (`AuthnUser`/`hash_password`) — argon2 | +| File storage | OpenDAL 0.50 (fs/S3/Azure/GCS) | Loco `storage` module (local/S3/multi-mirror) | +| Background jobs | none (manual) | Loco workers (`workers/`) — bg mode `async` selected | +| Email | none (verification stored, not sent) | Loco mailers (`mailers/`) — scaffold already present | +| API docs | utoipa + Swagger UI + Scalar | Manual — see §13 | +| Validation | garde | `validator` crate (already a dep) | +| Config | `.env` + `config.rs` singleton | `config/{development,production,test}.yaml` | +| Logging | tracing-subscriber | Loco logger (configured in yaml) | + +--- + +## 2. Feature inventory (parity checklist) + +The old app ships these feature modules. Each must exist in the rewrite: + +- [ ] **Auth** — register, login, logout, current-user, email verification +- [ ] **RBAC** — 4 roles, ~14 permissions, per-request permission loading +- [ ] **Admin** — dashboard, user management, role assignment, audit log +- [ ] **Blog** — articles CRUD, publish workflow, public listing, view counts +- [ ] **Audio dashboard** — albums + tracks + tags CRUD, publish workflow +- [ ] **Audio streaming** — range-aware track/file streaming, raw upload +- [ ] **Audio player** — persistent bottom-bar player (frontend) +- [ ] **Images** — upload + serve, used as cover/featured images +- [ ] **Theme** — per-user light/dark preference +- [ ] **Storage** — pluggable backend (fs default, S3/Azure/GCS capable) +- [ ] **Home + layout** — landing page, dynamic navbar, footer +- [ ] **Swagger/OpenAPI** — API docs (optional, lower priority) + +--- + +## 3. Key migration decisions / gotchas + +### 3.1 Sessions vs JWT + +The old app uses **cookie sessions** (axum-login + `tower-sessions-sqlx-store`, +1-day inactivity timeout, PG-backed `session` table). Loco's SaaS starter ships +**JWT bearer auth** by default. + +Because this is a **server-side rendered** app (forms post HTML, navbar reflects +auth state), bearer tokens are awkward. Recommended path: + +- Keep Loco's `users` model + password hashing + the `auth` controller for the + *API-style* endpoints. +- Add **cookie-session middleware** for the HTML pages. Loco exposes the Axum + router, so `tower-sessions` can be layered in an initializer + (`src/initializers/`). Store the user id in the session. +- Alternatively store the JWT in an `HttpOnly` cookie and add an extractor that + reads it from the cookie instead of the `Authorization` header. + +Pick one and apply it consistently. The old behaviour to match: login sets a +cookie, navbar/`/auth/me` reflect it, logout clears it, ~1-day expiry. + +### 3.2 sqlx → SeaORM + +Every raw query becomes a SeaORM entity + `ActiveModel`. Generate entities from +migrations with `cargo loco db entities`. Slug-uniqueness and `ON CONFLICT DO +NOTHING` semantics must be re-expressed (unique index + handle the error). + +### 3.3 Askama → Tera + +Templates are NOT ported. Re-author markup as Tera under `assets/views/`. The new +GUI may keep HTMX + Alpine + DaisyUI (still works with server-rendered Tera) or go +a different direction — that is a GUI decision, not a backend one. + +### 3.4 OpenDAL → Loco storage + +Loco has its own `storage` abstraction (local, S3, multi-backend mirror/replica). +Map the old `Storage`/`ImageStorage`/`AudioStorage` wrappers onto it. Default to +local (`uploads/`); keep S3/Azure/GCS reachable via config only. + +### 3.5 Range requests for audio + +The old `stream_track` forwards the request so `ServeFile` honours `Range` +headers (seeking/progressive playback). The Loco equivalent must also emit +`Accept-Ranges`/`Content-Range` — use `tower-http` `ServeFile`/`ServeDir` or a +range-aware handler. Do not regress this; it is required for the player's seek bar. + +--- + +## 4. Database schema + +Old app: 8 migrations under `../migrations/`. Re-create as SeaORM migrations in +`migration/src/` (the `loco new` scaffold already created `m20220101_000001_users`). +Prefer Loco generators: `cargo loco generate model ...`. + +### 4.1 `users` +| column | type | notes | +|-------------------|--------------|----------------------------------------| +| id | UUID PK | `gen_random_uuid()` | +| email | VARCHAR(255) | UNIQUE NOT NULL | +| password_hash | TEXT | NOT NULL (argon2) | +| email_verified | BOOLEAN | default FALSE | +| email_verified_at | TIMESTAMPTZ | nullable | +| theme | VARCHAR(10) | default `'light'` | +| created_at | TIMESTAMPTZ | default NOW | +| updated_at | TIMESTAMPTZ | default NOW | + +> Loco's scaffold `users` table differs (has `pid`, `api_key`, reset/verification +> token columns). Reconcile: either adopt Loco's columns and add `theme`, or align +> Loco's model to this schema. Adopting Loco's is less work — it already wires +> tokens for verification/reset. + +### 4.2 `email_verifications` +`id UUID PK`, `user_id UUID FK→users ON DELETE CASCADE`, `token VARCHAR(255) UNIQUE`, +`expires_at TIMESTAMPTZ`, `created_at TIMESTAMPTZ`. Indexes on `token`, `user_id`. +> Loco's `users` table already carries verification tokens — this table may become +> redundant if you adopt Loco's auth columns. + +### 4.3 `user_roles` +`user_id UUID FK→users CASCADE`, `role VARCHAR(50)` (`user`|`moderator`|`admin`| +`super_admin`), `assigned_by UUID FK→users ON DELETE SET NULL`, `assigned_at +TIMESTAMPTZ`. **PK = (user_id, role).** Index on `user_id`. Migration back-fills +`user` role for every existing user. + +### 4.4 `blog_articles` +`id UUID PK`, `title VARCHAR(500)`, `slug VARCHAR(500) UNIQUE`, `content TEXT`, +`excerpt VARCHAR(1000) NULL`, `published BOOL default false`, `author_id UUID +FK→users CASCADE`, `featured_image_id VARCHAR(500) NULL`, `view_count INT default 0`, +`created_at`, `updated_at` (trigger-updated), `published_at TIMESTAMPTZ NULL`. +Indexes: `slug`, `(published, published_at DESC)`, `author_id`. + +### 4.5 `audit_logs` +`id UUID PK`, `admin_user_id UUID FK→users CASCADE`, `action VARCHAR(100)`, +`target_type VARCHAR(50) NULL`, `target_id UUID NULL`, `details JSONB NULL`, +`ip_address INET NULL`, `user_agent TEXT NULL`, `created_at TIMESTAMPTZ`. +Indexes: `admin_user_id`, `action`, `(target_type,target_id)`, `created_at DESC`. + +### 4.6 `audio_albums` +`id UUID PK`, `title VARCHAR(500)`, `slug VARCHAR(500) UNIQUE`, `description TEXT +NULL`, `cover_image_id VARCHAR(500) NULL`, `artist VARCHAR(500) NULL`, `release_date +DATE NULL`, `published BOOL default false`, `uploader_id UUID FK→users CASCADE`, +`view_count INT default 0`, `created_at`, `updated_at` (trigger), `published_at NULL`. +Indexes: `slug`, `published`, `uploader_id`. + +### 4.7 `audio_tracks` +`id UUID PK`, `album_id UUID FK→audio_albums CASCADE`, `title VARCHAR(500)`, `slug +VARCHAR(500)`, `audio_file_id VARCHAR(500) NOT NULL`, `track_number INT NULL`, +`duration INT NULL` (seconds), `featured BOOL default false`, `play_count INT +default 0`, `created_at`, `updated_at` (trigger). **UNIQUE(album_id, slug).** + +### 4.8 `audio_tags` + `audio_track_tags` +`audio_tags`: `id UUID PK`, `name VARCHAR(100) UNIQUE`, `slug VARCHAR(100) UNIQUE`, +`created_at`. `audio_track_tags` (M:N junction): `track_id UUID FK→audio_tracks +CASCADE`, `tag_id UUID FK→audio_tags CASCADE`, `created_at`, **PK=(track_id,tag_id)**. + +> The old schema uses `updated_at` triggers. SeaORM convention is to set +> `updated_at` in the `ActiveModel` (`before_save` hook) instead — either works. + +--- + +## 5. Routes (parity table) + +`method path → behaviour (auth requirement)`. Loco controllers live in +`src/controllers/`; register each with `.add_route(...)` in `app.rs`. + +### Auth (`/auth`) +- `GET /auth/login` → login page (public) +- `POST /auth/login` → login, HTML/HTMX response; sets session cookie +- `POST /auth/login/json` → login, JSON response +- `GET /auth/register` → register page (public) +- `POST /auth/register` → register, HTML/HTMX +- `POST /auth/register/json` → register, JSON +- `POST /auth/logout` → clear session +- `GET /auth/me` → current user (auth required) +- `GET /auth/verify?token=` → verify email +- `POST /auth/verify/resend` → resend verification (auth required) + +### RBAC (`/rbac`) +- `GET /rbac/permissions` → list all permissions (public) +- `GET /rbac/roles` → list all roles (public) +- `GET /rbac/me` → current user roles+permissions (auth) +- `GET /rbac/users/{id}/roles` → (auth) +- `GET /rbac/users/{id}/permissions` → (auth) +- `POST /rbac/users/{id}/roles` → assign role (SuperAdmin) +- `DELETE /rbac/users/{id}/roles/{role}` → remove role (SuperAdmin) + +### Blog +- `GET /blog` → published articles (public) +- `GET /blog/{slug}` → article, increments `view_count` (public) +- `GET /admin/blog` & `/admin/blog/articles` → admin list (Moderator+) +- `GET|POST /admin/blog/articles/create` → create form / submit (Moderator+) +- `GET|POST /admin/blog/articles/{id}/edit` → edit form / submit (Moderator+) +- `GET /admin/blog/articles/{id}/delete` → delete (Moderator+) + +### Admin (`/admin`) +- `GET /admin` → redirect to `/admin/dashboard` +- `GET /admin/dashboard` → dashboard + system stats (Moderator+) +- `GET /admin/users` & `/admin/users/list` → user list, filter+paginate (Admin+) +- `GET /admin/users/{id}` → user detail (Admin+) +- `GET /admin/users/{id}/roles` → roles form (Admin+) +- `POST /admin/users/{id}/roles/assign` → assign (Admin+) +- `POST /admin/users/{id}/roles/{role}/remove` → remove (Admin+) +- `GET /admin/audit-logs` → audit log list (Moderator+) + +### Audio (public + admin) +- `POST /audio/upload` → raw audio upload (auth) +- `GET /audio/stream/{filename}` → stream raw file, **range-aware** (public) +- `GET /audio/albums` → published albums (public) +- `GET /audio/albums/{slug}` → album + tracks (public) +- `GET /audio/tracks/{id}/stream` → stream track, **range-aware** (public) +- `GET /admin/audio/albums` → admin album list (Moderator+) +- `GET|POST /admin/audio/albums/create` → (Moderator+) +- `GET|POST /admin/audio/albums/{id}/edit` → (Moderator+) +- `GET /admin/audio/albums/{id}/delete` → (Moderator+) +- `GET /admin/audio/albums/{id}/tracks` → track list (Moderator+) +- `GET /admin/audio/albums/{id}/tracks/upload` → upload form (Moderator+) +- `POST /admin/audio/albums/{id}/tracks/upload-file` → multipart upload, 50 MB (Moderator+) +- `GET|POST /admin/audio/tracks/{id}/edit` → (Moderator+) +- `GET /admin/audio/tracks/{id}/delete` → (Moderator+) +- `GET /admin/audio/tags` → tag list (Moderator+) +- `POST /admin/audio/tags` → create tag (Moderator+) + +### Images +- `POST /images/upload` → upload, validate ext+MIME, max 10 MB (auth) +- `GET /images/serve/{filename}` → serve with MIME + cache headers (public) + +### Misc +- `GET /` → home (public) +- `GET /layout/navbar` → navbar fragment reflecting auth state (public) +- `GET /theme` → current theme; `GET /theme/set?...` → set theme (public) +- `GET /static/*` → static files +- `GET /swagger-ui/`, `GET /api-docs/openapi.json` → API docs + +--- + +## 6. Feature details + +### 6.1 Auth +- Register: validate email + password (8–64 chars), reject duplicate email, + argon2-hash password, insert user, assign default `user` role. +- Login: look up by email, verify password (constant-time), create session. +- **Admin bootstrap:** on successful login, if `user.email` (case-insensitive) + equals config `admin_email`, assign `SuperAdmin` role (idempotent) and write an + `audit_logs` row with `action="admin_bootstrap"`. This is how the first admin is + created — there is no seed user. Keep this behaviour. +- Email verification: token table + `email_verified`/`email_verified_at`. The old + app stored tokens but never emailed them — the Loco rewrite **should actually + send** the email via the scaffolded `mailers/auth`. +- Every response that changes auth state should let the navbar refresh + (`GET /layout/navbar`). + +### 6.2 RBAC +- **Roles:** `SuperAdmin`, `Admin` (same perms as SuperAdmin), `Moderator`, `User`. +- **Permissions (~14):** `users.{create,read,update,delete,manage_roles}`, + `roles.assign`, `content.{create,read,update,delete,moderate}`, + `images.{create,read,update,delete}`. +- Role→permission mapping: + - SuperAdmin / Admin → all permissions. + - Moderator → `content.read/update/moderate`, `users.read`, `images.read/delete`. + - User (default) → `content.read`, `images.create/read`. +- A **middleware** loads the current user's `UserPermissions` into request + extensions on every request so handlers/views can check access. In Loco, do this + with a middleware layer or a custom extractor. Provide guard helpers equivalent + to "Moderator+", "Admin+", "SuperAdmin". + +### 6.3 Admin +- Dashboard with system stats (user counts, content counts) — Moderator+. +- User management: list with search filter + pagination, detail view — Admin+. +- Role assign/remove per user, each writing an `audit_logs` entry — Admin+. +- Audit log viewer — Moderator+. +- `AuditLog` fields: action, target_type, target_id, JSONB details, ip_address, + user_agent. Populate IP + user-agent from request headers. + +### 6.4 Blog +- Public: list published articles (paginate), view by slug (increments `view_count`). +- Admin (Moderator+): create / edit / delete, draft↔publish (`published_at` set on + publish), drafts visible only in admin. +- Slug auto-generated from title: lowercase, alphanumeric + hyphens, collapse + repeats; uniqueness enforced by DB — surface a clear "already exists" error. +- `author_id` = current user. `featured_image_id` references an uploaded image id. + +### 6.5 Audio dashboard +- **Albums:** CRUD, publish workflow, public list shows published only, slug URLs, + `cover_image_id` references an image, `view_count`. +- **Tracks:** belong to an album, multipart upload (50 MB cap, extensions + `mp3 wav ogg flac aac m4a webm`), `track_number`, `duration` (seconds, nullable — + upload does not extract it; the player reads it from the audio element instead), + `featured`, `play_count`, slug unique per album. +- **Tags:** create + assign to tracks (M:N). Tag has unique name + slug. + +### 6.6 Audio streaming & player +- `stream_track` / `stream_handler` must honour `Range` requests (see §3.5). +- The old app has a **persistent bottom-bar player** (`templates/player/player.html`): + hidden `