Merge pull request #1 from narrrl/rust-backend

Rust backend
This commit is contained in:
nvrl authored and GitHub committed 2026-11-01 13:50:20 +01:00
commit 396bea7af5
59 files changed
+11366 -806

No files matched your search

+4
View File
@@ -3,7 +3,11 @@
*.db
*.db-*
__pycache__/
*.py
docs/
README.md
compose*.yaml
Dockerfile
backend/target/
web/node_modules/
web/dist/
+19 -1
View File
@@ -2,5 +2,23 @@
# HOST=127.0.0.1
# PORT=8790
# PL8_DB=/data/pl8list.db
# CACHE_DIR=/data/cache # audio segments and images, default: next to the DB
# CACHE_MAX_MB=2048
# Server half of the key for "stay connected" pl8list sessions. Default: a key file
# created next to the DB on first start. Keep it out of DB backups for an extra layer;
# changing it disconnects every device.
# LINK_KEY= # base64 of 32 random bytes: openssl rand -base64 32
# LINK_KEY_FILE=/data/link.key
# Set by compose.override.yaml when behind Traefik:
# TRUST_PROXY=1
# TRUSTED_PROXY_HOPS=1 # proxies that append to X-Forwarded-For (TRUST_PROXY=1 also means 1)
# PUBLIC_ORIGIN=https://pl8.narl.io
# Only for plain-http development from another machine (drops Secure, __Host-, HSTS):
# INSECURE_COOKIES=1
# Impressum and privacy policy. Required for a public site in Germany.
# Separate address lines with "|".
IMPRESSUM_NAME=
IMPRESSUM_ADDRESS=
IMPRESSUM_EMAIL=
IMPRESSUM_PHONE=
HOSTING_PROVIDER=
+3
View File
@@ -2,3 +2,6 @@
*.db
*.db-*
__pycache__/
backend/target/
web/node_modules/
web/dist/
+29 -17
View File
@@ -1,22 +1,34 @@
# syntax=docker/dockerfile:1
FROM python:3.14-alpine
FROM node:24-alpine AS web
WORKDIR /src/web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web ./
RUN npm run build
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
HOST=0.0.0.0 \
FROM rust:1-alpine AS build
RUN apk add --no-cache musl-dev cmake make perl clang ca-certificates
WORKDIR /src/backend
COPY backend/Cargo.toml backend/Cargo.lock ./
# Build the dependencies once, so code changes don't rebuild them.
RUN mkdir src && echo 'fn main() {}' > src/main.rs \
&& cargo build --release --locked \
&& rm -rf src
COPY --from=web /src/web/dist /src/web/dist
COPY backend ./
RUN touch src/main.rs && cargo build --release --locked \
&& mkdir -p /out/data && cp target/release/pl8inf /out/pl8inf
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/pl8inf /pl8inf
COPY --from=build --chown=10001:10001 /out/data /data
ENV HOST=0.0.0.0 \
PORT=8790 \
PL8_DB=/data/pl8list.db
WORKDIR /app
COPY *.py ./
COPY static ./static
RUN python -m compileall -q . \
&& adduser -D -H -u 10001 app \
&& mkdir /data && chown app:app /data
USER app
PL8_DB=/data/pl8list.db \
SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt
USER 10001:10001
VOLUME /data
EXPOSE 8790
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8790/healthz', timeout=2)"
CMD ["python", "server.py"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s CMD ["/pl8inf", "--healthcheck"]
ENTRYPOINT ["/pl8inf"]
+123 -15
View File
@@ -23,12 +23,27 @@ Not affiliated with pl8list.
docker compose up -d --build # http://127.0.0.1:8790
```
or without Docker (Python 3.11+, no dependencies):
or without Docker (Rust 1.85+, Node 22+):
```sh
python3 server.py
cd web && npm ci && npm run build # front end into web/dist
cd ../backend && cargo run --release # embeds web/dist at build time
cargo test # unit tests + API tests against a fake pl8list
```
For front-end work run the server, then `npm run dev` in `web/`: Vite serves the page with
hot reload on http://127.0.0.1:5173/static/ and passes API calls to the server (`BACKEND=` to point it
elsewhere). Debug builds of the server read `web/dist` from disk, so `npm run build` is enough
to see changes on :8790 too.
The server is the Rust crate in `backend/`, the front end is TypeScript in `web/` (Vite, no
framework). The old Python server (`server.py` and friends) still works on the same database
but not with this front end; to roll back, check out `54a436a`.
Before going public in Germany, set the Impressum variables below. `/impressum` and
`/datenschutz` are filled in from them at startup; the texts are in `backend/templates/`.
Have them checked: they are a careful draft, not legal advice.
`compose.override.yaml` puts it behind Traefik at `pl8.narl.io` on the external `narl`
network, the same way as the other narl.io services. Docker Compose picks it up
automatically. Delete it, or change the host name, to run somewhere else.
@@ -40,7 +55,15 @@ Data (catalogue, users, listening history) lives in the `data` volume as one SQL
| `HOST` | `127.0.0.1` | bind address (`0.0.0.0` in the image) |
| `PORT` | `8790` | port |
| `PL8_DB` | `./pl8list.db` | SQLite path (`/data/pl8list.db` in the image) |
| `TRUST_PROXY` | `0` | `1` behind a reverse proxy: use `X-Forwarded-For` for rate limits and `X-Forwarded-Proto` for `Secure` cookies |
| `TRUSTED_PROXY_HOPS` | `0` | reverse proxies in front that append to `X-Forwarded-For` (`TRUST_PROXY=1` also means 1) |
| `PUBLIC_ORIGIN` | unset | `https://host` of the site; POSTs with any other `Origin` are refused |
| `CACHE_DIR` | `cache` next to DB | disk cache for audio segments and artist images |
| `CACHE_MAX_MB` | `2048` | cache size limit, least recently used files go first |
| `LINK_KEY` | unset | server half of the key for stored pl8list sessions (base64, 32 bytes) |
| `LINK_KEY_FILE` | `link.key` next to DB | used when `LINK_KEY` is unset; created on first start |
| `INSECURE_COOKIES` | `0` | `1` only for plain-http development from another machine |
| `IMPRESSUM_NAME`, `IMPRESSUM_ADDRESS`, `IMPRESSUM_EMAIL`, `IMPRESSUM_PHONE` | empty | operator details for `/impressum` and `/datenschutz`; separate address lines with `\|` |
| `HOSTING_PROVIDER` | empty | hosting company and address, named in the privacy policy |
## Accounts
@@ -50,12 +73,43 @@ There are no passwords here. Users sign in with the email of their pl8list accou
them a 6-digit code.
2. The user types the code. The server passes it to pl8list. If pl8list accepts it, the
server reads the user's pl8list id and handle, creates a local account, and sets its
own session cookie (`pl8inf`, HttpOnly, 90 days).
own session cookie (`__Host-pl8inf`, Secure, HttpOnly, SameSite=Lax). It ends after 30 days
without use and gets a new token every 7 days.
The pl8list session from step 2 is used once, in memory, to read the user's personal pl8list
feed (artists in their library). It is never written to disk. Only a SHA-256 hash of the
local session token is stored. Logins are rate-limited to 5 per IP and 3 per email per
15 minutes, with at most 5 code attempts per login.
By default the pl8list session from step 2 is used once, in memory, to sync with the user's
pl8list account: their personal feed, their saved tracks (they become likes here) and their
saved artists (they become follows). It is never written to disk, and when the sync is done
(or fails) the server logs that session out at pl8list. Only a SHA-256 hash of the local
session token is stored.
**Stay connected** is an opt-in checkbox at sign-in, per device. Then the pl8list session is
stored in that device's `sessions` row, encrypted with XChaCha20-Poly1305 under a key derived
(HKDF-SHA256) from the device's local session token and a server key (`LINK_KEY`). The
token only exists in the browser cookie, so a copy of the database, even with the server key,
opens nothing; the server can only decrypt during that device's own requests. With it, the
app resyncs on use every 6 hours and on the "resync with pl8list" button, and saves new
likes and follows to the user's pl8list library (`add-to-library?addOnly=true`, so it never
removes anything). If pl8list sends a new cookie it is stored again; if pl8list redirects to
its login, the stored session is deleted. Weekly token rotation re-encrypts it for the new
token. "stop staying connected", sign out and account deletion log it out at pl8list and
delete it. pl8list's `_session` is a signed cookie, so their logout may not invalidate a copy;
treat a stored session as long-lived.
Without stay connected, "resync with pl8list" in the account menu runs the email-code sign-in
again and syncs once.
Every login start and resend makes pl8list send a mail, so they are rate-limited: 5 per IP
and 3 per email per 15 minutes, 30 per 10 minutes for the whole server, at most 2 resends
and 5 code attempts per login, and at most 500 logins pending at once. Behind a proxy
(`TRUSTED_PROXY_HOPS=1`) the client IP is the last `X-Forwarded-For` entry, the one the proxy
added; IPv6 addresses are limited per /64.
The "account" menu shows the pl8list sync state and lets users resync, stop staying connected,
download everything stored about them (JSON), sign out on all devices, or delete their account
(likes, follows, history and sessions, right away). Expired sessions
are purged hourly and "served" events after 180 days. The browser only ever talks to this
server: hls.js is bundled into the front end and audio and images are proxied, so no
visitor address reaches pl8list or a CDN.
## Controls
@@ -65,14 +119,39 @@ local session token is stored. Logins are rate-limited to 5 per IP and 3 per ema
| double-tap | double-click · l | like |
| swipe up / down | ↓ j · ↑ k | next / previous |
| drag waveform | drag waveform · ← → | seek |
| follow button | f | follow the artist |
| radio button | r | artist radio (more like them) |
| ✕ button | x | not for me (skip, show less) |
| share button | s | share / copy link |
| | m | mute |
| | ? · Esc | help · close panels |
Lock-screen, notification and headphone buttons control playback too (Media Session API).
"likes" lists your likes newest first; ▶ plays one right away. Filters (free only, BPM range)
are remembered per browser. The site can be installed to the home screen as an app.
"likes" lists your likes newest first; ▶ plays one right away. The filters panel picks the
feed (for you, riddim, fresh, deep cuts); "radio" on a card starts an artist radio, and the chip
in the top bar goes back to your feed. Feed and filters (free only, BPM range) are remembered
per browser. The site can be installed to the home screen as an app.
## Front end
`web/src/`:
- `pager.ts`: the feed. One card fills the screen; drag, flick, wheel and keys turn the page,
with a spring that follows the finger's speed and a rubber band at the ends. Only the
current card and its neighbours are in the DOM. One wheel or trackpad gesture moves one
page.
- `player.ts`: two audio decks. The next track is buffered a few seconds after the current
one starts, so it plays at once; tracks crossfade (0.9 s, equal power) and the next one
starts just before the current one ends. Both decks run through a Web Audio graph with an
analyser. Not on iOS: its element volume is read-only and Web Audio stops with the screen
locked, so there tracks switch with a cut and lock-screen playback keeps working.
- `visual.ts`: per frame, the waveform progress and a bass level (`--energy`) that pulses the
artwork and its glow. Without the analyser the level comes from the track's waveform.
- `color.ts`: each card's accent colour (`--card-accent`) from the artist image.
- `cards.ts`, `api.ts`, `main.ts`: cards, server calls, and everything else.
Design tokens are CSS custom properties at the top of `style.css`. `prefers-reduced-motion`
turns off the spring, the pulse and the animations.
## How it works
@@ -91,8 +170,11 @@ pl8list has no public API, but it is a Remix app: every page loader returns JSON
| `/<handle>/<slug>/related?_data=routes/$handle_.$slug.related` | 3 related tracks |
| `/api/<handle>/<slug>/hls/playlist.m3u8?v=<uploadId>[&preview=true]` | HLS audio |
| `/login`, `/verify?email=…&mode=login` | email + 6-digit code login |
| `/library/tracks?page=N&pagination=true&_data=routes/library.tracks` | saved tracks (needs session) |
| `/library/artists?…&_data=routes/library.artists` | saved artists (needs session) |
| `POST /api/<handle>[/<slug>]/add-to-library?addOnly=true` | save artist or track (needs session) |
**Crawler** (`crawler.py`). There is no "all tracks" listing, so one shared catalogue grows by
**Crawler** (`backend/src/crawler.rs`). There is no "all tracks" listing, so one shared catalogue grows by
snowballing: home sections, the ticker, best-selling artists and artist search give artists;
artist pages give tracks; related pages pull in neighbours. Artists are re-checked daily for
new uploads. Requests are throttled to about 1.4 per second while someone has used the app in
@@ -100,12 +182,38 @@ the last 30 minutes, or while the catalogue has fewer than 500 tracks. Otherwise
one request every 20 seconds. When a user likes or finishes a track, its related tracks and
its artist's catalogue are crawled first.
**Ranking** (`ranker.py`). Each track the user has not seen yet gets a score from popularity,
freshness, artist affinity (from their likes, completions, skips and "not for me"), closeness to the BPM and
**Ranking** (`backend/src/ranker.rs`). Each track the user has not seen yet gets a score from popularity,
freshness, artist affinity (from their likes, completions, skips, "not for me" and follows), closeness to the BPM and
key of tracks they liked, whether it is related to something they liked, and some random
exploration. Artists are then spread out so you don't get five tracks from one person in a
row. Tune `WEIGHTS` at the top of the file.
row. Weights live in a `Ranker`, which can also narrow the candidates and add a bonus;
`?mode=` on `/local/feed` picks one:
| mode | what it plays |
| -------------------------- | ------------------------------------------------------------------------------ |
| (none) / `classic` | the mix above |
| `riddim` | 140 BPM (or 70) tracks, riddim first |
| `fresh` | uploads of the last 45 days, newest weighted up |
| `deep` | tracks with 15 saves or fewer; popularity counts against a track |
| `artist&artist=<handle>` | artist radio: the artist and their neighbours in the artist graph (two hops) |
**Tags** (`backend/src/tags.rs`). pl8list has no genre field, so tags come from words in the
title, description and artist name, and from the BPM: styles (`riddim`, `tearout`, `wonk`, `dnb`,
…), versions (`vip`, `dub`, `dubpack`, `remix`, `flip`, `special`, `collab`, `wip` for v1/v2…)
and tempo (`140`, `170`). They are stored in `track_tags`, sent with each track and shown on the
card. Changing the rules means bumping `tags::VERSION`; the catalogue is retagged on the next
start.
**Artist graph.** Every related page the crawler reads is stored in `track_related` (which
artists pl8list lists next to a track). Two artists are linked by how often that happens, in
either direction. The artist radio uses the graph directly. Riddim mode uses it to spread the
sparse `riddim` tag: an artist counts as riddim by their own tagged tracks (three count fully)
or by half of what the artists next to them have. Opening a radio crawls that artist and the
related pages of their top tracks first, so a new radio fills in within seconds. The crawler
also searches riddim-adjacent terms first and prefers related pages of riddim tracks.
**Audio.** The browser only talks to this server. HLS playlists and segments are proxied from
pl8list as a guest, so users hear previews of paid tracks and full free tracks, the same as a
logged-out visitor on pl8list.
logged-out visitor on pl8list. Segments and images are cached on disk, so a repeat play costs
pl8list nothing. Segment URLs carry a signed token; only tokens seen in a playlist we fetched
ourselves are used as cache keys, so a forged token can't pull another track out of the cache.
+2441
View File
File diff suppressed because it is too large. Load diff
+34
View File
@@ -0,0 +1,34 @@
[package]
name = "pl8inf"
version = "0.1.0"
edition = "2024"
[dependencies]
anyhow = "1.0.104"
axum = "0.8.9"
base64 = "0.23.1"
bytes = "1.12.1"
chacha20poly1305 = "0.11.0"
futures-util = "0.3.34"
hex = "0.4.3"
hkdf = "0.13.0"
percent-encoding = "2.3.2"
rand = "0.10.3"
reqwest = { version = "0.13.5", default-features = false, features = ["rustls", "http2", "stream", "form", "query", "json"] }
rusqlite = { version = "0.41.1", features = ["bundled"] }
rust-embed = "8.13.0"
secrecy = "0.10.3"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.154"
sha2 = "0.11.1"
time = { version = "0.3.55", features = ["parsing"] }
tokio = { version = "1.54.1", features = ["full"] }
tower = { version = "0.5.3", features = ["limit", "timeout", "util"] }
tower-http = { version = "0.7.1", features = ["timeout", "limit", "trace", "set-header"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = "2.5.8"
zeroize = "1.9.1"
[dev-dependencies]
wiremock = "0.6.5"
+11
View File
@@ -0,0 +1,11 @@
// The front end is built separately (`cd web && npm ci && npm run build`) and
// embedded from web/dist. Create the folder if it is missing so the backend still
// compiles (and its tests run) without Node; the pages are then empty.
fn main() {
let dist = std::path::Path::new("../web/dist");
println!("cargo:rerun-if-changed=../web/dist");
if !dist.join("index.html").exists() {
std::fs::create_dir_all(dist).expect("create ../web/dist");
println!("cargo:warning=web/dist has no index.html; run `npm run build` in web/ to include the front end");
}
}
+198
View File
@@ -0,0 +1,198 @@
//! Disk cache for immutable upstream content: audio segments and artist images.
//!
//! Files are named after a hash of their key, with an extension that records the
//! content type. The index lives in memory and is rebuilt from the directory at
//! startup. When the cache grows past its limit the least recently used files go.
use std::{
collections::HashMap,
path::PathBuf,
sync::Mutex,
time::SystemTime,
};
use bytes::Bytes;
use sha2::{Digest, Sha256};
/// Content types we pass on, with the file extension they are stored under.
pub const SEGMENT_TYPES: &[(&str, &str)] = &[
("video/mp2t", "ts"),
("audio/aac", "aac"),
("audio/mpeg", "mp3"),
("audio/mp4", "m4a"),
("video/mp4", "mp4"),
];
pub const IMAGE_TYPES: &[(&str, &str)] = &[
("image/jpeg", "jpg"),
("image/png", "png"),
("image/webp", "webp"),
("image/gif", "gif"),
("image/avif", "avif"),
];
fn type_for_ext(ext: &str) -> Option<&'static str> {
SEGMENT_TYPES.iter().chain(IMAGE_TYPES).find(|(_, e)| *e == ext).map(|(t, _)| *t)
}
/// The allowlisted type matching an upstream `Content-Type`, ignoring parameters.
pub fn allowed_type(upstream: Option<&str>, allow: &[(&'static str, &'static str)]) -> Option<(&'static str, &'static str)> {
let t = upstream?.split(';').next()?.trim().to_ascii_lowercase();
allow.iter().find(|(a, _)| *a == t).copied()
}
struct Entry {
ext: &'static str,
size: u64,
used: u64,
}
struct Index {
entries: HashMap<String, Entry>,
total: u64,
clock: u64,
}
pub struct Cache {
dir: Option<PathBuf>,
max: u64,
index: Mutex<Index>,
}
fn hash(key: &str) -> String {
hex::encode(Sha256::digest(key.as_bytes()))
}
impl Cache {
/// Opens (and creates) the directory. A cache that cannot be used is disabled, not fatal.
pub fn open(dir: PathBuf, max: u64) -> Self {
let mut index = Index { entries: HashMap::new(), total: 0, clock: 0 };
let dir = match std::fs::create_dir_all(&dir).and_then(|_| std::fs::read_dir(&dir)) {
Ok(read) => {
let mut found: Vec<(SystemTime, String, Entry)> = read
.flatten()
.filter_map(|f| {
let name = f.file_name().into_string().ok()?;
let (h, ext) = name.split_once('.')?;
let ext = SEGMENT_TYPES.iter().chain(IMAGE_TYPES).find(|(_, e)| *e == ext)?.1;
let meta = f.metadata().ok()?;
Some((meta.modified().ok()?, h.to_string(), Entry { ext, size: meta.len(), used: 0 }))
})
.collect();
found.sort_by_key(|(t, _, _)| *t);
for (_, h, mut e) in found {
index.clock += 1;
e.used = index.clock;
index.total += e.size;
index.entries.insert(h, e);
}
tracing::info!("cache: {} files, {} MB in {}", index.entries.len(), index.total >> 20, dir.display());
Some(dir)
}
Err(e) => {
tracing::warn!("cache disabled, {}: {e}", dir.display());
None
}
};
Cache { dir, max, index: Mutex::new(index) }
}
fn lock(&self) -> std::sync::MutexGuard<'_, Index> {
self.index.lock().unwrap_or_else(|e| e.into_inner())
}
pub async fn get(&self, key: &str) -> Option<(Bytes, &'static str)> {
let dir = self.dir.as_ref()?;
let h = hash(key);
let ext = {
let mut idx = self.lock();
idx.clock += 1;
let clock = idx.clock;
let e = idx.entries.get_mut(&h)?;
e.used = clock;
e.ext
};
match tokio::fs::read(dir.join(format!("{h}.{ext}"))).await {
Ok(b) => Some((Bytes::from(b), type_for_ext(ext)?)),
Err(_) => {
let mut idx = self.lock();
if let Some(e) = idx.entries.remove(&h) {
idx.total -= e.size;
}
None
}
}
}
pub async fn put(&self, key: &str, ext: &'static str, body: &Bytes) {
let Some(dir) = &self.dir else { return };
let h = hash(key);
let tmp = dir.join(format!(".{h}.tmp"));
let path = dir.join(format!("{h}.{ext}"));
if let Err(e) = async {
tokio::fs::write(&tmp, body).await?;
tokio::fs::rename(&tmp, &path).await
}
.await
{
tracing::warn!("cache write failed: {e}");
let _ = tokio::fs::remove_file(&tmp).await;
return;
}
let evict = {
let mut idx = self.lock();
idx.clock += 1;
let entry = Entry { ext, size: body.len() as u64, used: idx.clock };
idx.total += entry.size;
if let Some(old) = idx.entries.insert(h, entry) {
idx.total -= old.size;
}
let mut evict = vec![];
if idx.total > self.max {
let mut by_age: Vec<(u64, String)> = idx.entries.iter().map(|(k, e)| (e.used, k.clone())).collect();
by_age.sort();
let target = self.max / 10 * 9;
for (_, k) in by_age {
if idx.total <= target {
break;
}
if let Some(e) = idx.entries.remove(&k) {
idx.total -= e.size;
evict.push(format!("{k}.{}", e.ext));
}
}
}
evict
};
for name in evict {
let _ = tokio::fs::remove_file(dir.join(name)).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn evicts_least_recently_used() {
let dir = std::env::temp_dir().join(format!("pl8inf-cache-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let c = Cache::open(dir.clone(), 25);
let body = Bytes::from_static(&[0u8; 10]);
c.put("a", "ts", &body).await;
c.put("b", "ts", &body).await;
assert!(c.get("a").await.is_some()); // a is now newer than b
c.put("c", "ts", &body).await; // 30 > 25: evict down to 22, b goes first
assert!(c.get("b").await.is_none());
assert!(c.get("a").await.is_some() || c.get("c").await.is_some());
assert_eq!(c.get("c").await.unwrap().1, "video/mp2t");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn content_type_allowlist() {
assert_eq!(allowed_type(Some("video/MP2T; charset=x"), SEGMENT_TYPES), Some(("video/mp2t", "ts")));
assert_eq!(allowed_type(Some("text/html"), SEGMENT_TYPES), None);
assert_eq!(allowed_type(Some("image/svg+xml"), IMAGE_TYPES), None);
}
}
+104
View File
@@ -0,0 +1,104 @@
//! Configuration from the environment (or `.env` next to the working directory).
use std::{env, path::PathBuf};
pub struct Config {
pub host: String,
pub port: u16,
pub db_path: PathBuf,
/// Reverse proxies in front of us that append to `X-Forwarded-For`. 0 = none.
pub proxy_hops: usize,
/// `https://host` the site is served from. Used for the Origin check; when unset
/// the Origin must match the Host header.
pub public_origin: Option<String>,
/// Off only for plain-http development on another machine: drops `Secure`,
/// the `__Host-` cookie prefix and HSTS.
pub secure: bool,
pub cache_dir: PathBuf,
pub cache_max_bytes: u64,
/// Server half of the key for stored pl8list sessions: `LINK_KEY` (base64, 32
/// bytes), else the file `LINK_KEY_FILE` (created on first start).
pub link_key: Option<String>,
pub link_key_file: PathBuf,
/// pl8list itself; only changed by tests.
pub pl8_base: String,
pub legal: Legal,
}
/// Operator details for the Impressum and the privacy policy. Kept out of the repository.
#[derive(Clone, Default)]
pub struct Legal {
pub name: String,
pub address: String,
pub email: String,
pub phone: String,
pub hosting: String,
}
impl Legal {
pub fn is_complete(&self) -> bool {
!self.name.is_empty() && !self.address.is_empty() && !self.email.is_empty()
}
}
fn var(name: &str) -> Option<String> {
env::var(name).ok().map(|v| v.trim().to_string()).filter(|v| !v.is_empty())
}
fn flag(name: &str) -> Option<bool> {
var(name).map(|v| matches!(v.as_str(), "1" | "true" | "yes"))
}
pub fn load_dotenv(path: &str) {
let Ok(text) = std::fs::read_to_string(path) else { return };
for line in text.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((k, v)) = line.split_once('=') {
let k = k.trim();
if env::var_os(k).is_none() {
// SAFETY: called once at startup before any other thread exists.
unsafe { env::set_var(k, v.trim().trim_matches(|c| c == '"' || c == '\'')) };
}
}
}
}
impl Config {
pub fn from_env() -> Self {
let db_path = PathBuf::from(var("PL8_DB").unwrap_or_else(|| "pl8list.db".into()));
let link_key_file = var("LINK_KEY_FILE").map(PathBuf::from).unwrap_or_else(|| {
db_path.parent().map(|p| p.join("link.key")).unwrap_or_else(|| PathBuf::from("link.key"))
});
let cache_dir = var("CACHE_DIR").map(PathBuf::from).unwrap_or_else(|| {
db_path.parent().map(|p| p.join("cache")).unwrap_or_else(|| PathBuf::from("cache"))
});
// TRUST_PROXY=1 from the Python version means one proxy hop.
let proxy_hops = var("TRUSTED_PROXY_HOPS")
.and_then(|v| v.parse().ok())
.unwrap_or(if flag("TRUST_PROXY") == Some(true) { 1 } else { 0 });
Config {
host: var("HOST").unwrap_or_else(|| "127.0.0.1".into()),
port: var("PORT").and_then(|v| v.parse().ok()).unwrap_or(8790),
db_path,
proxy_hops,
public_origin: var("PUBLIC_ORIGIN").map(|o| o.trim_end_matches('/').to_string()),
secure: flag("INSECURE_COOKIES") != Some(true),
cache_dir,
cache_max_bytes: var("CACHE_MAX_MB").and_then(|v| v.parse().ok()).unwrap_or(2048u64) << 20,
link_key: var("LINK_KEY"),
link_key_file,
pl8_base: var("PL8_BASE").unwrap_or_else(|| "https://pl8list.com".into()),
legal: Legal {
name: var("IMPRESSUM_NAME").unwrap_or_default(),
// One line per address line; "\n" or "|" separate lines in .env.
address: var("IMPRESSUM_ADDRESS").unwrap_or_default().replace("\\n", "\n").replace('|', "\n"),
email: var("IMPRESSUM_EMAIL").unwrap_or_default(),
phone: var("IMPRESSUM_PHONE").unwrap_or_default(),
hosting: var("HOSTING_PROVIDER").unwrap_or_default().replace("\\n", "\n").replace('|', "\n"),
},
}
}
}
+435
View File
@@ -0,0 +1,435 @@
//! Background discovery crawler.
//!
//! There is no "all tracks" endpoint, so the pool grows by snowballing:
//! home page sections, the activity ticker, best-selling artists and user search
//! give us artists; artist pages give us their tracks; "related" pages of tracks
//! users liked or finished pull in neighbours. Everything goes through one priority
//! queue so feedback is crawled first.
//!
//! The catalogue is shared by all users and crawled as a guest. The crawler runs
//! at full pace only while someone has used the app recently.
//!
//! A user's pl8list session never enters the queue; account sync (`sync.rs`) reads
//! with it and only hands the artists it found to `discovered_artists`.
use std::{
cmp::Reverse,
collections::{BinaryHeap, HashSet},
sync::{Arc, Mutex},
time::Duration,
};
use serde_json::Value;
use tokio::{sync::Notify, time::Instant};
use crate::{
db::{self, Db},
pl8::{Pl8, normalize},
};
/// Search needs at least 3 characters. Genre-ish words find lots of artist handles.
/// Riddim and its neighbours are searched first.
const STYLE_TERMS: &[&str] = &["riddim", "dubstep", "tearout", "dubz", "wonk", "deathstep", "brostep", "halftime", "140"];
const SEARCH_TERMS: &[&str] = &[
"dub", "bass", "dnb", "jungle", "garage", "grime", "trap", "techno", "house", "beats", "music",
"sound", "prod", "audio", "records", "drill", "breaks", "wave", "deep", "dark", "the",
"official", "dj", "mc", "lofi", "neuro", "tek",
];
// Lower number = sooner.
const P_FEEDBACK: i64 = 0;
const P_SEED: i64 = 10;
const P_ARTIST_FIRST: i64 = 20;
const P_STYLE: i64 = 15;
const P_RELATED: i64 = 30;
const P_ARTIST_MORE: i64 = 40;
const P_SEARCH: i64 = 60;
const ACTIVE_WINDOW: f64 = 1800.0; // a user seen this recently counts as active
const IDLE_DELAY: Duration = Duration::from_secs(20); // extra wait between requests when nobody is active
const MIN_POOL: i64 = 500; // crawl at full pace until the catalogue has this many tracks
const REFRESH_AFTER: f64 = 86400.0; // re-check an artist's newest page for uploads this often
const RESEED_EVERY: Duration = Duration::from_secs(600);
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
enum Job {
Home,
Ticker,
BestArtists,
Search(String),
Artist(String),
ArtistRefresh(String),
Related(i64, String, String),
}
struct Queue {
heap: BinaryHeap<Reverse<(i64, u64, Job)>>,
queued: HashSet<Job>,
seq: u64,
}
pub struct Crawler {
pl8: Arc<Pl8>,
db: Db,
queue: Mutex<Queue>,
wake: Notify,
}
fn s(v: &Value, k: &str) -> Option<String> {
v.get(k).and_then(Value::as_str).filter(|s| !s.is_empty()).map(String::from)
}
impl Crawler {
pub fn new(pl8: Arc<Pl8>, db: Db) -> Arc<Self> {
Arc::new(Crawler {
pl8,
db,
queue: Mutex::new(Queue { heap: BinaryHeap::new(), queued: HashSet::new(), seq: 0 }),
wake: Notify::new(),
})
}
fn push(&self, priority: i64, job: Job) {
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
if !q.queued.insert(job.clone()) {
return;
}
q.seq += 1;
let seq = q.seq;
q.heap.push(Reverse((priority, seq, job)));
drop(q);
self.wake.notify_one();
}
fn try_pop(&self) -> Option<Job> {
let mut q = self.queue.lock().unwrap_or_else(|e| e.into_inner());
let Reverse((_, _, job)) = q.heap.pop()?;
q.queued.remove(&job);
Some(job)
}
async fn pop(&self) -> Job {
loop {
if let Some(job) = self.try_pop() {
return job;
}
if tokio::time::timeout(Duration::from_secs(30), self.wake.notified()).await.is_err()
&& let Err(e) = self.refill().await {
tracing::warn!("crawler refill: {e:#}");
}
}
}
// Feedback hooks, called from the web server ----------------------------
/// Liked or listened through: crawl its related tracks and its artist's catalogue.
pub async fn on_positive(&self, track_id: i64) {
let row = self
.db
.call(move |c| {
rusqlite::OptionalExtension::optional(c.query_row(
"SELECT handle, slug FROM tracks WHERE id=?",
[track_id],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
))
})
.await;
if let Ok(Some((handle, slug))) = row {
self.push(P_FEEDBACK, Job::Related(track_id, handle.clone(), slug));
self.push(P_FEEDBACK, Job::Artist(handle));
}
}
/// Artists found in a user's pl8list feed or library: crawl them soon.
pub fn discovered_artists(&self, handles: impl IntoIterator<Item = String>) {
for h in handles {
self.push(P_ARTIST_FIRST, Job::Artist(h));
}
}
/// An artist radio was opened: crawl the artist, and the related pages of their
/// best tracks that were not read yet, to fill in the artist graph around them.
pub async fn explore_artist(&self, handle: String) {
self.push(P_FEEDBACK, Job::Artist(handle.clone()));
let h = handle.clone();
let tracks = self
.db
.call(move |c| {
c.prepare("SELECT id, slug FROM tracks WHERE handle=? AND related_fetched=0 ORDER BY saves + downloads DESC LIMIT 3")?
.query_map([&h], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)))?
.collect::<Result<Vec<_>, _>>()
})
.await;
for (id, slug) in tracks.unwrap_or_default() {
self.push(P_FEEDBACK, Job::Related(id, handle.clone(), slug));
}
}
// Main loop -------------------------------------------------------------
pub fn start(self: &Arc<Self>) {
let me = self.clone();
tokio::spawn(async move { me.run().await });
}
async fn run(&self) {
self.seed();
let mut last_seed = Instant::now();
loop {
let job = self.pop().await;
if let Err(e) = self.run_job(job.clone()).await {
tracing::warn!("crawl {job:?} failed: {e:#}");
}
if !self.busy().await {
tokio::time::sleep(IDLE_DELAY).await;
}
if last_seed.elapsed() > RESEED_EVERY {
self.seed();
last_seed = Instant::now();
}
}
}
/// Crawl at full pace while someone is listening or the catalogue is still small.
async fn busy(&self) -> bool {
self.db
.call(|c| Ok(db::active_since(c, db::now() - ACTIVE_WINDOW)? > 0 || db::track_count(c)? < MIN_POOL))
.await
.unwrap_or(true)
}
fn seed(&self) {
self.push(P_SEED, Job::Home);
self.push(P_SEED, Job::Ticker);
self.push(P_SEED, Job::BestArtists);
for term in STYLE_TERMS {
self.push(P_STYLE, Job::Search(term.to_string()));
}
for term in SEARCH_TERMS {
self.push(P_SEARCH, Job::Search(term.to_string()));
}
}
/// Queue ran dry: queue more artist pages, related lookups and stale artists.
async fn refill(&self) -> anyhow::Result<()> {
let (more, related, stale) = self
.db
.call(|c| {
let col = |sql: &str, arg: Option<f64>| -> rusqlite::Result<Vec<String>> {
let mut st = c.prepare(sql)?;
match arg {
Some(a) => st.query_map([a], |r| r.get(0))?.collect(),
None => st.query_map([], |r| r.get(0))?.collect(),
}
};
let more = col(
"SELECT handle FROM artists WHERE total_pages IS NULL OR pages_fetched < total_pages \
ORDER BY verified DESC, pages_fetched ASC LIMIT 20",
None,
)?;
let related: Vec<(i64, String, String)> = c
.prepare(
"SELECT id, handle, slug FROM tracks t WHERE related_fetched=0 ORDER BY \
EXISTS (SELECT 1 FROM track_tags g WHERE g.track_id = t.id AND g.tag = 'riddim') DESC, \
saves + downloads DESC LIMIT 10",
)?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect::<Result<_, _>>()?;
let stale = col(
"SELECT handle FROM artists WHERE pages_fetched > 0 AND last_crawled < ? \
ORDER BY last_crawled LIMIT 20",
Some(db::now() - REFRESH_AFTER),
)?;
Ok((more, related, stale))
})
.await?;
for h in more {
self.push(P_ARTIST_MORE, Job::Artist(h));
}
for (id, h, s) in related {
self.push(P_RELATED, Job::Related(id, h, s));
}
for h in stale {
self.push(P_ARTIST_MORE, Job::ArtistRefresh(h));
}
Ok(())
}
// Jobs ------------------------------------------------------------------
async fn add_artist(&self, handle: String, source: &'static str, name: Option<String>, img: Option<String>, verified: bool, priority: i64) -> anyhow::Result<()> {
let h = handle.clone();
let known: Option<i64> = self
.db
.call(move |c| {
let known = rusqlite::OptionalExtension::optional(c.query_row(
"SELECT pages_fetched FROM artists WHERE handle=?",
[&h],
|r| r.get::<_, Option<i64>>(0),
))?
.flatten();
db::upsert_artist(c, &h, name.as_deref(), img.as_deref(), verified, source)?;
Ok(known)
})
.await?;
if known.unwrap_or(0) == 0 {
self.push(priority, Job::Artist(handle));
}
Ok(())
}
async fn upsert(&self, rows: Vec<db::Track>, related_of: Option<i64>) -> anyhow::Result<usize> {
self.db.call(move |c| db::upsert_tracks(c, &rows, related_of)).await
}
async fn ticker_events(&self, events: Option<&Value>) -> anyhow::Result<()> {
for e in events.and_then(Value::as_array).into_iter().flatten() {
if let Some(h) = s(e, "artistHandle") {
self.add_artist(h, "ticker", s(e, "artistName"), None, false, P_ARTIST_FIRST).await?;
}
}
Ok(())
}
async fn run_job(&self, job: Job) -> anyhow::Result<()> {
match job {
Job::Home => {
let d = self.pl8.home().await?.unwrap_or(Value::Null);
for section in ["bestSellers", "recommended"] {
self.upsert(normalize::items(d.get(section).and_then(|s| s.get("items"))), None).await?;
}
self.ticker_events(d.get("tickerEvents")).await
}
Job::Ticker => {
let d = self.pl8.activity_ticker().await?.unwrap_or(Value::Null);
self.ticker_events(d.get("events")).await
}
Job::BestArtists => {
let d = self.pl8.best_selling_artists().await?.unwrap_or(Value::Null);
for a in d.get("artists").and_then(Value::as_array).into_iter().flatten() {
if let Some(h) = s(a, "artist_handle") {
let verified = a.get("artist_isVerified").and_then(Value::as_bool).unwrap_or(false);
self.add_artist(h, "best_sellers", s(a, "artist_name"), s(a, "artist_img"), verified, P_SEED).await?;
}
}
self.upsert(normalize::items(d.get("recommended").and_then(|r| r.get("items"))), None).await?;
Ok(())
}
Job::ArtistRefresh(handle) => {
// Newest uploads are on page 1, so re-reading it catches new tracks.
if let Some(d) = self.pl8.artist(&handle, 1).await? {
let new = self.upsert(normalize::items(d.get("tracks")), None).await?;
if new > 0 {
tracing::info!("artist {handle}: {new} new uploads");
}
}
self.db
.call(move |c| c.execute("UPDATE artists SET last_crawled=? WHERE handle=?", rusqlite::params![db::now(), handle]))
.await?;
Ok(())
}
Job::Artist(handle) => self.artist_page(handle).await,
Job::Related(track_id, handle, slug) => {
let d = self.pl8.related(&handle, &slug).await?;
self.db.call(move |c| c.execute("UPDATE tracks SET related_fetched=1 WHERE id=?", [track_id])).await?;
let Some(d) = d else { return Ok(()) };
let rows = normalize::related(&d);
let artists: Vec<_> = rows.iter().map(|r| (r.handle.clone(), r.artist_name.clone(), r.artist_img.clone(), r.artist_verified)).collect();
self.upsert(rows, Some(track_id)).await?;
let handles: Vec<String> = artists.iter().map(|a| a.0.clone()).collect();
self.db.call(move |c| db::add_related(c, track_id, &handles)).await?;
for (h, name, img, verified) in artists {
self.add_artist(h, "related", Some(name), img, verified, P_RELATED).await?;
}
Ok(())
}
Job::Search(term) => self.search_page(term).await,
}
}
async fn artist_page(&self, handle: String) -> anyhow::Result<()> {
let h = handle.clone();
let (fetched, total) = self
.db
.call(move |c| {
Ok(rusqlite::OptionalExtension::optional(c.query_row(
"SELECT pages_fetched, total_pages FROM artists WHERE handle=?",
[&h],
|r| Ok((r.get::<_, Option<i64>>(0)?.unwrap_or(0), r.get::<_, Option<i64>>(1)?)),
))?
.unwrap_or((0, None)))
})
.await?;
if total.is_some_and(|t| fetched >= t) {
return Ok(());
}
let page = fetched + 1;
let h = handle.clone();
let Some(d) = self.pl8.artist(&handle, page).await? else {
self.db
.call(move |c| c.execute("UPDATE artists SET total_pages=0, last_crawled=? WHERE handle=?", rusqlite::params![db::now(), h]))
.await?;
return Ok(());
};
let new = self.upsert(normalize::items(d.get("tracks")), None).await?;
let total = d.get("totalPages").and_then(Value::as_i64).unwrap_or(0);
self.db
.call(move |c| {
c.execute(
"UPDATE artists SET pages_fetched=?, total_pages=?, last_crawled=? WHERE handle=?",
rusqlite::params![page, total, db::now(), h],
)
})
.await?;
tracing::info!("artist {handle} page {page}/{total}: {new} new tracks");
if page < total {
self.push(P_ARTIST_MORE + page, Job::Artist(handle));
}
Ok(())
}
async fn search_page(&self, term: String) -> anyhow::Result<()> {
let t = term.clone();
let (fetched, total) = self
.db
.call(move |c| {
Ok(rusqlite::OptionalExtension::optional(c.query_row(
"SELECT pages_fetched, total_pages FROM search_terms WHERE term=?",
[&t],
|r| Ok((r.get::<_, Option<i64>>(0)?.unwrap_or(0), r.get::<_, Option<i64>>(1)?)),
))?
.unwrap_or((0, None)))
})
.await?;
if total.is_some_and(|t| fetched >= t) {
return Ok(());
}
let page = fetched + 1;
let d = self.pl8.search_users(&term, page).await?.unwrap_or(Value::Null);
for item in d.get("items").and_then(Value::as_array).into_iter().flatten() {
let Some(a) = item.get("artist") else { continue };
if let Some(h) = s(a, "handle") {
let verified = a.get("isVerified").and_then(Value::as_bool).unwrap_or(false);
let riddim = s(a, "name").into_iter().chain([h.clone()]).any(|n| n.to_lowercase().contains("riddim"));
let prio = if verified || riddim { P_ARTIST_FIRST } else { P_SEARCH + 5 };
self.add_artist(h, "search", s(a, "name"), s(a, "img"), verified, prio).await?;
}
}
let total = d.get("totalPages").and_then(Value::as_i64).unwrap_or(0);
let t = term.clone();
self.db
.call(move |c| {
c.execute(
"INSERT INTO search_terms (term, pages_fetched, total_pages) VALUES (?,?,?) \
ON CONFLICT(term) DO UPDATE SET pages_fetched=excluded.pages_fetched, total_pages=excluded.total_pages",
rusqlite::params![t, page, total],
)
})
.await?;
if page < total {
self.push(P_SEARCH + page, Job::Search(term));
}
Ok(())
}
}
+836
View File
@@ -0,0 +1,836 @@
//! SQLite store: the shared track catalogue, plus per-user accounts and listening events.
//!
//! The schema is the one the Python version created, so both can open the same file.
//! Changes are additive and tracked with `PRAGMA user_version`.
use std::{
path::Path,
sync::{Arc, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
use base64::Engine;
use rand::Rng;
use rusqlite::{Connection, OptionalExtension, Row, params, params_from_iter};
use serde::Serialize;
use sha2::{Digest, Sha256};
/// Sessions end after this long without use.
pub const SESSION_IDLE_DAYS: f64 = 30.0;
/// "served" events only steer which tracks are new to a user; older ones are dropped.
pub const SERVED_RETENTION_DAYS: f64 = 180.0;
const SCHEMA_V1: &str = r#"
CREATE TABLE IF NOT EXISTS artists (
handle TEXT PRIMARY KEY,
name TEXT,
img TEXT,
verified INTEGER DEFAULT 0,
source TEXT,
pages_fetched INTEGER DEFAULT 0,
total_pages INTEGER,
last_crawled REAL
);
CREATE TABLE IF NOT EXISTS tracks (
id INTEGER PRIMARY KEY,
handle TEXT NOT NULL,
artist_name TEXT,
artist_img TEXT,
artist_verified INTEGER DEFAULT 0,
slug TEXT NOT NULL,
title TEXT,
description TEXT,
bpm REAL,
key TEXT,
duration REAL,
preview_duration REAL,
price INTEGER DEFAULT 0,
created_at TEXT,
upload_id TEXT,
preview_upload_id TEXT,
waveform TEXT,
preview_waveform TEXT,
full_access INTEGER DEFAULT 0,
saves INTEGER DEFAULT 0,
downloads INTEGER DEFAULT 0,
purchases INTEGER DEFAULT 0,
comments INTEGER DEFAULT 0,
related_of INTEGER,
related_fetched INTEGER DEFAULT 0,
updated_at REAL
);
CREATE INDEX IF NOT EXISTS tracks_handle ON tracks(handle);
CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id INTEGER NOT NULL,
kind TEXT NOT NULL,
seconds REAL,
ts REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY,
handle TEXT NOT NULL,
name TEXT,
img TEXT,
created_at REAL NOT NULL,
last_seen REAL
);
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at REAL NOT NULL,
expires_at REAL NOT NULL
);
CREATE TABLE IF NOT EXISTS search_terms (
term TEXT PRIMARY KEY,
pages_fetched INTEGER DEFAULT 0,
total_pages INTEGER
);
"#;
const SCHEMA_V2: &str = r#"
CREATE INDEX IF NOT EXISTS sessions_user ON sessions(user_id);
CREATE INDEX IF NOT EXISTS events_kind_ts ON events(kind, ts);
"#;
// pl8_link: the device's pl8list session, encrypted (see link.rs). NULL unless the
// user chose to stay connected on that device.
const SCHEMA_V3: &str = r#"
ALTER TABLE sessions ADD COLUMN pl8_link BLOB;
ALTER TABLE sessions ADD COLUMN pl8_synced_at REAL;
CREATE TABLE IF NOT EXISTS follows (
user_id INTEGER NOT NULL,
handle TEXT NOT NULL,
source TEXT NOT NULL,
ts REAL NOT NULL,
PRIMARY KEY (user_id, handle)
);
"#;
// track_tags: see tags.rs. track_related: the artists on a track's "related" page;
// the artist graph is read from it. meta: small settings, like the tagger version.
const SCHEMA_V4: &str = r#"
CREATE TABLE IF NOT EXISTS track_tags (
track_id INTEGER NOT NULL,
tag TEXT NOT NULL,
PRIMARY KEY (track_id, tag)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS track_tags_tag ON track_tags(tag, track_id);
CREATE TABLE IF NOT EXISTS track_related (
track_id INTEGER NOT NULL,
handle TEXT NOT NULL,
PRIMARY KEY (track_id, handle)
) WITHOUT ROWID;
CREATE INDEX IF NOT EXISTS track_related_handle ON track_related(handle);
INSERT OR IGNORE INTO track_related (track_id, handle)
SELECT related_of, handle FROM tracks WHERE related_of IS NOT NULL;
CREATE TABLE IF NOT EXISTS meta (
key TEXT PRIMARY KEY,
value
);
"#;
pub fn now() -> f64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64()
}
#[derive(Clone)]
pub struct Db(Arc<Mutex<Connection>>);
impl Db {
pub fn open(path: &Path) -> anyhow::Result<Self> {
let mut conn = Connection::open(path)?;
conn.pragma_update(None, "journal_mode", "WAL")?;
conn.pragma_update(None, "foreign_keys", "ON")?;
conn.busy_timeout(std::time::Duration::from_secs(5))?;
migrate(&conn)?;
let tagged = retag_if_stale(&mut conn)?;
if tagged > 0 {
tracing::info!("tagged {tagged} tracks");
}
Ok(Db(Arc::new(Mutex::new(conn))))
}
/// Run `f` on the connection off the async runtime.
pub async fn call<T, F>(&self, f: F) -> anyhow::Result<T>
where
T: Send + 'static,
F: FnOnce(&mut Connection) -> rusqlite::Result<T> + Send + 'static,
{
let db = self.0.clone();
let out = tokio::task::spawn_blocking(move || {
let mut conn = db.lock().unwrap_or_else(|e| e.into_inner());
f(&mut conn)
})
.await??;
Ok(out)
}
}
fn migrate(conn: &Connection) -> rusqlite::Result<()> {
let version: i64 = conn.pragma_query_value(None, "user_version", |r| r.get(0))?;
if version < 1 {
conn.execute_batch(SCHEMA_V1)?;
// Databases from the single-user version have events without an owner.
let has_user: bool = conn
.prepare("SELECT 1 FROM pragma_table_info('events') WHERE name='user_id'")?
.exists([])?;
if !has_user {
conn.execute("DELETE FROM events", [])?;
conn.execute("ALTER TABLE events ADD COLUMN user_id INTEGER", [])?;
}
conn.execute("CREATE INDEX IF NOT EXISTS events_user ON events(user_id, kind, track_id)", [])?;
conn.pragma_update(None, "user_version", 1)?;
}
if version < 2 {
conn.execute_batch(SCHEMA_V2)?;
conn.pragma_update(None, "user_version", 2)?;
}
if version < 3 {
conn.execute_batch(&format!("BEGIN; {SCHEMA_V3} PRAGMA user_version = 3; COMMIT;"))?;
}
if version < 4 {
conn.execute_batch(&format!("BEGIN; {SCHEMA_V4} PRAGMA user_version = 4; COMMIT;"))?;
}
Ok(())
}
/// An empty in-memory database, for tests.
#[cfg(test)]
pub fn memory() -> Connection {
let c = Connection::open_in_memory().unwrap();
migrate(&c).unwrap();
c
}
// Tags and the artist graph ---------------------------------------------------
fn tags_of(t: &Track) -> Vec<&'static str> {
crate::tags::of(&t.title, t.description.as_deref(), &t.artist_name, &t.handle, t.bpm)
}
fn set_tags(conn: &Connection, track_id: i64, tags: &[&str]) -> rusqlite::Result<()> {
conn.prepare_cached("DELETE FROM track_tags WHERE track_id=?")?.execute([track_id])?;
let mut insert = conn.prepare_cached("INSERT INTO track_tags (track_id, tag) VALUES (?,?)")?;
for tag in tags {
insert.execute(params![track_id, tag])?;
}
Ok(())
}
/// Tag the whole catalogue again if the rules in tags.rs changed. Returns how many
/// tracks were tagged.
pub fn retag_if_stale(conn: &mut Connection) -> rusqlite::Result<usize> {
let done: Option<i64> = conn.query_row("SELECT value FROM meta WHERE key='tagger'", [], |r| r.get(0)).optional()?;
if done == Some(crate::tags::VERSION) {
return Ok(0);
}
let tx = conn.transaction()?;
let tracks: Vec<Track> = tx.prepare("SELECT * FROM tracks")?.query_map([], Track::from_row)?.collect::<Result<_, _>>()?;
tx.execute("DELETE FROM track_tags", [])?;
for t in &tracks {
set_tags(&tx, t.id, &tags_of(t))?;
}
tx.execute("INSERT OR REPLACE INTO meta (key, value) VALUES ('tagger', ?)", [crate::tags::VERSION])?;
tx.commit()?;
Ok(tracks.len())
}
pub fn track_tags(conn: &Connection, track_id: i64) -> rusqlite::Result<Vec<String>> {
conn.prepare_cached("SELECT tag FROM track_tags WHERE track_id=? ORDER BY tag")?.query_map([track_id], |r| r.get(0))?.collect()
}
/// Remember which artists showed up on a track's related page.
pub fn add_related(conn: &mut Connection, track_id: i64, handles: &[String]) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
{
let mut insert = tx.prepare_cached("INSERT OR IGNORE INTO track_related (track_id, handle) VALUES (?,?)")?;
for h in handles {
insert.execute(params![track_id, h])?;
}
}
tx.commit()
}
/// Artist graph edges: (artist, related artist, how many of the first one's tracks
/// listed the second). Self links are left out.
pub fn artist_links(conn: &Connection) -> rusqlite::Result<Vec<(String, String, i64)>> {
conn.prepare_cached(
"SELECT t.handle, r.handle, COUNT(*) FROM track_related r JOIN tracks t ON t.id = r.track_id \
WHERE t.handle <> r.handle GROUP BY t.handle, r.handle",
)?
.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)))?
.collect()
}
/// Artists linked to `handle` in either direction, with the summed link counts.
pub fn artist_neighbours(conn: &Connection, handle: &str) -> rusqlite::Result<Vec<(String, i64)>> {
conn.prepare_cached(
"SELECT h, SUM(n) FROM ( \
SELECT r.handle AS h, COUNT(*) AS n FROM track_related r JOIN tracks t ON t.id = r.track_id \
WHERE t.handle = ?1 AND r.handle <> ?1 GROUP BY r.handle \
UNION ALL \
SELECT t.handle, COUNT(*) FROM track_related r JOIN tracks t ON t.id = r.track_id \
WHERE r.handle = ?1 AND t.handle <> ?1 GROUP BY t.handle \
) GROUP BY h",
)?
.query_map([handle], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect()
}
/// How many tracks each artist has with `tag`.
pub fn tag_counts(conn: &Connection, tag: &str) -> rusqlite::Result<Vec<(String, i64)>> {
conn.prepare_cached(
"SELECT t.handle, COUNT(*) FROM track_tags g JOIN tracks t ON t.id = g.track_id WHERE g.tag=? GROUP BY t.handle",
)?
.query_map([tag], |r| Ok((r.get(0)?, r.get(1)?)))?
.collect()
}
// Tracks ------------------------------------------------------------------
/// One row of `tracks`, as normalised from pl8list or read back.
#[derive(Clone, Debug, Default)]
pub struct Track {
pub id: i64,
pub handle: String,
pub artist_name: String,
pub artist_img: Option<String>,
pub artist_verified: bool,
pub slug: String,
pub title: String,
pub description: Option<String>,
pub bpm: Option<f64>,
pub key: Option<String>,
pub duration: Option<f64>,
pub preview_duration: Option<f64>,
pub price: f64,
pub created_at: Option<String>,
pub upload_id: Option<String>,
pub preview_upload_id: Option<String>,
pub waveform: Option<String>,
pub preview_waveform: Option<String>,
pub full_access: bool,
pub saves: i64,
pub downloads: i64,
pub purchases: i64,
pub comments: i64,
pub related_of: Option<i64>,
}
const TRACK_COLS: [&str; 23] = [
"id", "handle", "artist_name", "artist_img", "artist_verified", "slug", "title", "description",
"bpm", "key", "duration", "preview_duration", "price", "created_at", "upload_id",
"preview_upload_id", "waveform", "preview_waveform", "full_access", "saves", "downloads",
"purchases", "comments",
];
impl Track {
pub fn from_row(r: &Row) -> rusqlite::Result<Self> {
Ok(Track {
id: r.get("id")?,
handle: r.get("handle")?,
artist_name: r.get::<_, Option<String>>("artist_name")?.unwrap_or_default(),
artist_img: r.get("artist_img")?,
artist_verified: r.get::<_, Option<i64>>("artist_verified")?.unwrap_or(0) != 0,
slug: r.get("slug")?,
title: r.get::<_, Option<String>>("title")?.unwrap_or_default(),
description: r.get("description")?,
bpm: r.get("bpm")?,
key: r.get("key")?,
duration: r.get("duration")?,
preview_duration: r.get("preview_duration")?,
price: r.get::<_, Option<f64>>("price")?.unwrap_or(0.0),
created_at: r.get("created_at")?,
upload_id: r.get("upload_id")?,
preview_upload_id: r.get("preview_upload_id")?,
waveform: r.get("waveform")?,
preview_waveform: r.get("preview_waveform")?,
full_access: r.get::<_, Option<i64>>("full_access")?.unwrap_or(0) != 0,
saves: r.get::<_, Option<i64>>("saves")?.unwrap_or(0),
downloads: r.get::<_, Option<i64>>("downloads")?.unwrap_or(0),
purchases: r.get::<_, Option<i64>>("purchases")?.unwrap_or(0),
comments: r.get::<_, Option<i64>>("comments")?.unwrap_or(0),
related_of: r.get("related_of")?,
})
}
fn values(&self) -> Vec<rusqlite::types::Value> {
use rusqlite::types::Value;
let opt = |s: &Option<String>| s.clone().map(Value::Text).unwrap_or(Value::Null);
let optf = |f: Option<f64>| f.map(Value::Real).unwrap_or(Value::Null);
// Whole prices stay integers, as the Python version stored them.
let price = if self.price.fract() == 0.0 { Value::Integer(self.price as i64) } else { Value::Real(self.price) };
vec![
Value::Integer(self.id),
Value::Text(self.handle.clone()),
Value::Text(self.artist_name.clone()),
opt(&self.artist_img),
Value::Integer(self.artist_verified as i64),
Value::Text(self.slug.clone()),
Value::Text(self.title.clone()),
opt(&self.description),
optf(self.bpm),
opt(&self.key),
optf(self.duration),
optf(self.preview_duration),
price,
opt(&self.created_at),
opt(&self.upload_id),
opt(&self.preview_upload_id),
opt(&self.waveform),
opt(&self.preview_waveform),
Value::Integer(self.full_access as i64),
Value::Integer(self.saves),
Value::Integer(self.downloads),
Value::Integer(self.purchases),
Value::Integer(self.comments),
]
}
}
/// Insert or refresh tracks and their artists. Returns how many were new.
pub fn upsert_tracks(conn: &mut Connection, rows: &[Track], related_of: Option<i64>) -> rusqlite::Result<usize> {
let tx = conn.transaction()?;
let mut new = 0;
let now = now();
{
let sets = TRACK_COLS[1..].iter().map(|c| format!("{c}=?")).collect::<Vec<_>>().join(",");
let mut update = tx.prepare_cached(&format!("UPDATE tracks SET {sets}, updated_at=? WHERE id=?"))?;
let mut insert = tx.prepare_cached(&format!(
"INSERT INTO tracks ({}, related_of, updated_at) VALUES ({})",
TRACK_COLS.join(","),
vec!["?"; TRACK_COLS.len() + 2].join(",")
))?;
let mut artist = tx.prepare_cached(
"INSERT INTO artists (handle, name, img, verified, source) VALUES (?,?,?,?,'track') \
ON CONFLICT(handle) DO UPDATE SET name=excluded.name, img=excluded.img, verified=excluded.verified",
)?;
for r in rows {
let mut vals = r.values();
let id = vals[0].clone();
let changed = {
let mut v = vals[1..].to_vec();
v.push(rusqlite::types::Value::Real(now));
v.push(id);
update.execute(params_from_iter(v))?
};
if changed == 0 {
new += 1;
vals.push(related_of.map(rusqlite::types::Value::Integer).unwrap_or(rusqlite::types::Value::Null));
vals.push(rusqlite::types::Value::Real(now));
insert.execute(params_from_iter(vals))?;
}
artist.execute(params![r.handle, r.artist_name, r.artist_img, r.artist_verified as i64])?;
set_tags(&tx, r.id, &tags_of(r))?;
}
}
tx.commit()?;
Ok(new)
}
pub fn upsert_artist(
conn: &Connection,
handle: &str,
name: Option<&str>,
img: Option<&str>,
verified: bool,
source: &str,
) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO artists (handle, name, img, verified, source) VALUES (?,?,?,?,?) \
ON CONFLICT(handle) DO UPDATE SET name=COALESCE(excluded.name, name), \
img=COALESCE(excluded.img, img), verified=MAX(verified, excluded.verified)",
params![handle, name, img, verified as i64, source],
)?;
Ok(())
}
pub fn add_event(conn: &Connection, user_id: i64, track_id: i64, kind: &str, seconds: Option<f64>) -> rusqlite::Result<()> {
conn.execute(
"INSERT INTO events (user_id, track_id, kind, seconds, ts) VALUES (?,?,?,?,?)",
params![user_id, track_id, kind, seconds, now()],
)?;
Ok(())
}
pub fn track_path(conn: &Connection, track_id: i64) -> rusqlite::Result<Option<(String, String)>> {
conn.query_row("SELECT handle, slug FROM tracks WHERE id=?", [track_id], |r| Ok((r.get(0)?, r.get(1)?))).optional()
}
pub fn track_count(conn: &Connection) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM tracks", [], |r| r.get(0))
}
#[derive(Serialize)]
pub struct Stats {
pub tracks: i64,
pub artists: i64,
pub artists_crawled: i64,
pub served: i64,
pub liked: usize,
}
pub fn stats(conn: &Connection, user_id: i64) -> rusqlite::Result<Stats> {
conn.query_row(
"SELECT (SELECT COUNT(*) FROM tracks), (SELECT COUNT(*) FROM artists), \
(SELECT COUNT(*) FROM artists WHERE pages_fetched > 0), \
(SELECT COUNT(DISTINCT track_id) FROM events WHERE user_id=? AND kind='served')",
[user_id],
|r| Ok(Stats { tracks: r.get(0)?, artists: r.get(1)?, artists_crawled: r.get(2)?, served: r.get(3)?, liked: 0 }),
)
}
// Accounts ----------------------------------------------------------------
#[derive(Clone, Debug, Serialize)]
pub struct User {
pub id: i64,
pub handle: String,
pub name: Option<String>,
pub img: Option<String>,
pub created_at: f64,
pub last_seen: Option<f64>,
}
impl User {
fn from_row(r: &Row) -> rusqlite::Result<Self> {
Ok(User {
id: r.get("id")?,
handle: r.get("handle")?,
name: r.get("name")?,
img: r.get("img")?,
created_at: r.get("created_at")?,
last_seen: r.get("last_seen")?,
})
}
}
pub fn upsert_user(conn: &Connection, id: i64, handle: &str, name: Option<&str>, img: Option<&str>) -> rusqlite::Result<()> {
let now = now();
conn.execute(
"INSERT INTO users (id, handle, name, img, created_at, last_seen) VALUES (?,?,?,?,?,?) \
ON CONFLICT(id) DO UPDATE SET handle=excluded.handle, name=excluded.name, img=excluded.img, \
last_seen=excluded.last_seen",
params![id, handle, name, img, now, now],
)?;
Ok(())
}
pub fn hash_token(token: &str) -> String {
hex::encode(Sha256::digest(token.as_bytes()))
}
/// 32 random bytes, URL-safe base64, like Python's `secrets.token_urlsafe(32)`.
pub fn new_token() -> String {
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
/// Returns the raw token for the cookie; only its hash is stored.
pub fn create_session(conn: &Connection, user_id: i64) -> rusqlite::Result<String> {
let token = new_token();
let now = now();
conn.execute(
"INSERT INTO sessions (token_hash, user_id, created_at, expires_at) VALUES (?,?,?,?)",
params![hash_token(&token), user_id, now, now + SESSION_IDLE_DAYS * 86400.0],
)?;
Ok(token)
}
pub struct SessionHit {
pub user: User,
pub created_at: f64,
pub expires_at: f64,
pub link: Option<Vec<u8>>,
pub synced_at: Option<f64>,
}
pub fn session_user(conn: &Connection, token: &str) -> rusqlite::Result<Option<SessionHit>> {
let now = now();
let hit = conn
.query_row(
"SELECT u.*, s.created_at AS s_created, s.expires_at AS s_expires, s.pl8_link, s.pl8_synced_at \
FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash=? AND s.expires_at > ?",
params![hash_token(token), now],
|r| {
Ok(SessionHit {
user: User::from_row(r)?,
created_at: r.get("s_created")?,
expires_at: r.get("s_expires")?,
link: r.get("pl8_link")?,
synced_at: r.get("pl8_synced_at")?,
})
},
)
.optional()?;
if let Some(h) = &hit {
if h.user.last_seen.unwrap_or(0.0) < now - 60.0 {
conn.execute("UPDATE users SET last_seen=? WHERE id=?", params![now, h.user.id])?;
}
// Sliding expiry, written at most once a day.
if h.expires_at < now + (SESSION_IDLE_DAYS - 1.0) * 86400.0 {
conn.execute(
"UPDATE sessions SET expires_at=? WHERE token_hash=? AND expires_at > ?",
params![now + SESSION_IDLE_DAYS * 86400.0, hash_token(token), now + 120.0],
)?;
}
}
Ok(hit)
}
/// Replace a session with a new token. The old one keeps working for a minute so
/// requests already in flight do not fail.
pub fn rotate_session(conn: &Connection, old_token: &str, user_id: i64) -> rusqlite::Result<String> {
let token = create_session(conn, user_id)?;
conn.execute(
"UPDATE sessions SET expires_at=MIN(expires_at, ?) WHERE token_hash=?",
params![now() + 60.0, hash_token(old_token)],
)?;
Ok(token)
}
/// Store (or replace) the encrypted pl8list session of one device.
pub fn set_link(conn: &Connection, token_hash: &str, blob: &[u8], synced_at: Option<f64>) -> rusqlite::Result<()> {
conn.execute(
"UPDATE sessions SET pl8_link=?, pl8_synced_at=COALESCE(?, pl8_synced_at) WHERE token_hash=?",
params![blob, synced_at, token_hash],
)?;
Ok(())
}
pub fn clear_link(conn: &Connection, token_hash: &str) -> rusqlite::Result<()> {
conn.execute("UPDATE sessions SET pl8_link=NULL, pl8_synced_at=NULL WHERE token_hash=?", [token_hash])?;
Ok(())
}
pub fn mark_synced(conn: &Connection, token_hash: &str) -> rusqlite::Result<()> {
conn.execute("UPDATE sessions SET pl8_synced_at=? WHERE token_hash=? AND pl8_link IS NOT NULL", params![now(), token_hash])?;
Ok(())
}
pub fn delete_session(conn: &Connection, token: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM sessions WHERE token_hash=?", [hash_token(token)])?;
Ok(())
}
pub fn delete_user_sessions(conn: &Connection, user_id: i64) -> rusqlite::Result<()> {
conn.execute("DELETE FROM sessions WHERE user_id=?", [user_id])?;
Ok(())
}
// Library sync ---------------------------------------------------------------
/// Tracks saved on pl8list count as likes, unless the user already liked or unliked
/// them here. Returns how many were added.
pub fn import_likes(conn: &mut Connection, user_id: i64, track_ids: &[i64]) -> rusqlite::Result<usize> {
let tx = conn.transaction()?;
let mut added = 0;
{
let mut insert = tx.prepare_cached(
"INSERT INTO events (user_id, track_id, kind, seconds, ts) SELECT ?1, ?2, 'like', NULL, ?3 \
WHERE NOT EXISTS (SELECT 1 FROM events WHERE user_id=?1 AND track_id=?2 AND kind IN ('like', 'unlike'))",
)?;
let now = now();
for id in track_ids {
added += insert.execute(params![user_id, id, now])?;
}
}
tx.commit()?;
Ok(added)
}
pub fn add_follows(conn: &mut Connection, user_id: i64, handles: &[String], source: &str) -> rusqlite::Result<usize> {
let tx = conn.transaction()?;
let mut added = 0;
{
let mut insert = tx.prepare_cached("INSERT OR IGNORE INTO follows (user_id, handle, source, ts) VALUES (?,?,?,?)")?;
let now = now();
for h in handles {
added += insert.execute(params![user_id, h, source, now])?;
}
}
tx.commit()?;
Ok(added)
}
pub fn unfollow(conn: &Connection, user_id: i64, handle: &str) -> rusqlite::Result<()> {
conn.execute("DELETE FROM follows WHERE user_id=? AND handle=?", params![user_id, handle])?;
Ok(())
}
pub fn follows(conn: &Connection, user_id: i64) -> rusqlite::Result<std::collections::HashSet<String>> {
conn.prepare_cached("SELECT handle FROM follows WHERE user_id=?")?.query_map([user_id], |r| r.get(0))?.collect()
}
pub fn active_since(conn: &Connection, ts: f64) -> rusqlite::Result<i64> {
conn.query_row("SELECT COUNT(*) FROM users WHERE last_seen > ?", [ts], |r| r.get(0))
}
/// Everything stored about one user (Art. 15 / 20 GDPR).
pub fn export_user(conn: &Connection, user_id: i64) -> rusqlite::Result<serde_json::Value> {
let user = conn.query_row("SELECT * FROM users WHERE id=?", [user_id], User::from_row)?;
let sessions: Vec<serde_json::Value> = conn
.prepare(
"SELECT created_at, expires_at, pl8_link IS NOT NULL, pl8_synced_at FROM sessions \
WHERE user_id=? ORDER BY created_at",
)?
.query_map([user_id], |r| {
Ok(serde_json::json!({
"created_at": r.get::<_, f64>(0)?,
"expires_at": r.get::<_, f64>(1)?,
"pl8list_connected": r.get::<_, bool>(2)?,
"pl8list_synced_at": r.get::<_, Option<f64>>(3)?,
}))
})?
.collect::<Result<_, _>>()?;
let events: Vec<serde_json::Value> = conn
.prepare(
"SELECT e.ts, e.kind, e.seconds, e.track_id, t.handle, t.slug, t.title FROM events e \
LEFT JOIN tracks t ON t.id = e.track_id WHERE e.user_id=? ORDER BY e.ts",
)?
.query_map([user_id], |r| {
Ok(serde_json::json!({
"ts": r.get::<_, f64>(0)?,
"kind": r.get::<_, String>(1)?,
"seconds": r.get::<_, Option<f64>>(2)?,
"track_id": r.get::<_, i64>(3)?,
"track": r.get::<_, Option<String>>(4)?.zip(r.get::<_, Option<String>>(5)?)
.map(|(h, s)| format!("https://pl8list.com/{h}/{s}")),
"title": r.get::<_, Option<String>>(6)?,
}))
})?
.collect::<Result<_, _>>()?;
let follows: Vec<serde_json::Value> = conn
.prepare("SELECT handle, source, ts FROM follows WHERE user_id=? ORDER BY ts")?
.query_map([user_id], |r| {
Ok(serde_json::json!({ "artist": r.get::<_, String>(0)?, "source": r.get::<_, String>(1)?, "ts": r.get::<_, f64>(2)? }))
})?
.collect::<Result<_, _>>()?;
Ok(serde_json::json!({
"exported_at": now(),
"user": user,
"sessions": sessions,
"follows": follows,
"events": events,
"note": "Times are Unix timestamps in seconds. Session tokens are stored only as hashes and are not included. \
A connected session's pl8list login is stored encrypted with a key only your browser holds, so it \
cannot be included either.",
}))
}
/// Remove a user and everything linked to them (Art. 17 GDPR).
pub fn delete_user(conn: &mut Connection, user_id: i64) -> rusqlite::Result<()> {
let tx = conn.transaction()?;
tx.execute("DELETE FROM events WHERE user_id=?", [user_id])?;
tx.execute("DELETE FROM follows WHERE user_id=?", [user_id])?;
tx.execute("DELETE FROM sessions WHERE user_id=?", [user_id])?;
tx.execute("DELETE FROM users WHERE id=?", [user_id])?;
tx.commit()
}
/// Drop expired sessions and old "served" events. Returns (sessions, events) removed.
pub fn purge(conn: &Connection) -> rusqlite::Result<(usize, usize)> {
let now = now();
let s = conn.execute("DELETE FROM sessions WHERE expires_at <= ?", [now])?;
let e = conn.execute(
"DELETE FROM events WHERE kind='served' AND ts < ?",
[now - SERVED_RETENTION_DAYS * 86400.0],
)?;
Ok((s, e))
}
#[cfg(test)]
mod tests {
use super::*;
use super::memory as mem;
#[test]
fn upsert_tags_tracks_and_retag_follows_the_tagger_version() {
let mut c = mem();
let t = Track { id: 1, handle: "h".into(), artist_name: "H".into(), slug: "s".into(), title: "Big VIP".into(), bpm: Some(140.0), ..Default::default() };
upsert_tracks(&mut c, &[t], None).unwrap();
assert_eq!(track_tags(&c, 1).unwrap(), ["140", "vip"]);
c.execute("DELETE FROM track_tags", []).unwrap();
assert_eq!(retag_if_stale(&mut c).unwrap(), 1);
assert_eq!(retag_if_stale(&mut c).unwrap(), 0);
assert_eq!(track_tags(&c, 1).unwrap(), ["140", "vip"]);
}
#[test]
fn artist_graph_reads_related_pages_both_ways() {
let mut c = mem();
let track = |id, h: &str| Track { id, handle: h.into(), artist_name: h.into(), slug: format!("s{id}"), title: "T".into(), ..Default::default() };
upsert_tracks(&mut c, &[track(1, "a"), track(2, "a"), track(3, "b")], None).unwrap();
add_related(&mut c, 1, &["b".into(), "c".into(), "a".into()]).unwrap();
add_related(&mut c, 2, &["b".into()]).unwrap();
add_related(&mut c, 3, &["d".into()]).unwrap();
let mut n = artist_neighbours(&c, "a").unwrap();
n.sort();
assert_eq!(n, [("b".to_string(), 2), ("c".to_string(), 1)]);
let mut n = artist_neighbours(&c, "b").unwrap();
n.sort();
assert_eq!(n, [("a".to_string(), 2), ("d".to_string(), 1)]);
assert_eq!(artist_links(&c).unwrap().len(), 3);
}
#[test]
fn sessions_store_only_hashes_and_rotate() {
let c = mem();
upsert_user(&c, 7, "someone", None, None).unwrap();
let token = create_session(&c, 7).unwrap();
let stored: String = c.query_row("SELECT token_hash FROM sessions", [], |r| r.get(0)).unwrap();
assert_ne!(stored, token);
assert_eq!(stored, hash_token(&token));
assert_eq!(session_user(&c, &token).unwrap().unwrap().user.id, 7);
let new = rotate_session(&c, &token, 7).unwrap();
assert!(session_user(&c, &new).unwrap().is_some());
let old_expiry: f64 = c
.query_row("SELECT expires_at FROM sessions WHERE token_hash=?", [hash_token(&token)], |r| r.get(0))
.unwrap();
assert!(old_expiry <= now() + 61.0);
}
#[test]
fn delete_user_removes_everything() {
let mut c = mem();
upsert_user(&c, 1, "a", None, None).unwrap();
upsert_user(&c, 2, "b", None, None).unwrap();
create_session(&c, 1).unwrap();
create_session(&c, 2).unwrap();
add_event(&c, 1, 10, "like", None).unwrap();
add_event(&c, 2, 10, "like", None).unwrap();
delete_user(&mut c, 1).unwrap();
let count = |sql: &str| c.query_row(sql, [], |r| r.get::<_, i64>(0)).unwrap();
assert_eq!(count("SELECT COUNT(*) FROM users WHERE id=1"), 0);
assert_eq!(count("SELECT COUNT(*) FROM sessions WHERE user_id=1"), 0);
assert_eq!(count("SELECT COUNT(*) FROM events WHERE user_id=1"), 0);
assert_eq!(count("SELECT COUNT(*) FROM events WHERE user_id=2"), 1);
}
#[test]
fn imported_likes_do_not_override_choices_made_here() {
let mut c = mem();
upsert_user(&c, 1, "a", None, None).unwrap();
add_event(&c, 1, 10, "unlike", None).unwrap();
assert_eq!(import_likes(&mut c, 1, &[10, 11]).unwrap(), 1);
assert_eq!(import_likes(&mut c, 1, &[10, 11]).unwrap(), 0);
assert_eq!(add_follows(&mut c, 1, &["x".into(), "y".into()], "pl8list").unwrap(), 2);
assert_eq!(add_follows(&mut c, 1, &["x".into()], "local").unwrap(), 0);
unfollow(&c, 1, "x").unwrap();
assert_eq!(follows(&c, 1).unwrap().into_iter().collect::<Vec<_>>(), vec!["y".to_string()]);
}
#[test]
fn upsert_counts_new_tracks() {
let mut c = mem();
let t = Track { id: 1, handle: "h".into(), artist_name: "H".into(), slug: "s".into(), title: "T".into(), price: 4.5, ..Default::default() };
assert_eq!(upsert_tracks(&mut c, std::slice::from_ref(&t), None).unwrap(), 1);
assert_eq!(upsert_tracks(&mut c, &[t], None).unwrap(), 0);
let price: f64 = c.query_row("SELECT price FROM tracks", [], |r| r.get(0)).unwrap();
assert_eq!(price, 4.5);
}
}
+96
View File
@@ -0,0 +1,96 @@
//! Data export, account deletion and signing out everywhere (Art. 15, 17, 20 GDPR),
//! plus the pl8list connection of this device.
use axum::{
Extension, Json,
extract::State,
http::{StatusCode, header},
response::IntoResponse,
};
use serde_json::{Value, json};
use super::{CurrentUser, Res, St, auth::signed_out, json_error, no_store};
use crate::db;
pub async fn export(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
let user = match current.require() {
Ok(u) => u.clone(),
Err(r) => return Ok(r),
};
let uid = user.id;
let data = app.db.call(move |c| db::export_user(c, uid)).await?;
let body = serde_json::to_vec_pretty(&data)?;
let safe: String = user.handle.chars().filter(|c| c.is_ascii_alphanumeric() || "_.-".contains(*c)).collect();
let disposition = format!("attachment; filename=\"pl8inf-{safe}.json\"");
Ok((
[
(header::CONTENT_TYPE, "application/json".to_string()),
(header::CACHE_CONTROL, "no-store".to_string()),
(header::CONTENT_DISPOSITION, disposition),
],
body,
)
.into_response())
}
pub async fn delete(State(app): St, Extension(current): Extension<CurrentUser>, Json(body): Json<Value>) -> Res {
let user = match current.require() {
Ok(u) => u.clone(),
Err(r) => return Ok(r),
};
if body.get("confirm").and_then(Value::as_str) != Some(&user.handle) {
return Ok(json_error(StatusCode::BAD_REQUEST, "type your handle to confirm"));
}
let uid = user.id;
// Other devices' stored sessions can't be opened without their cookies; deleting
// the rows makes them unusable. This device's one is logged out at pl8list too.
if let Some((_, session)) = app.connected(&current).await {
app.pl8.logout(&session).await;
}
app.db.call(move |c| db::delete_user(c, uid)).await?;
tracing::info!("account deleted");
Ok(signed_out(&app, json!({ "ok": true })))
}
pub async fn logout_all(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
let uid = match current.require() {
Ok(u) => u.id,
Err(r) => return Ok(r),
};
if let Some((_, session)) = app.connected(&current).await {
app.pl8.logout(&session).await;
}
app.db.call(move |c| db::delete_user_sessions(c, uid)).await?;
Ok(signed_out(&app, json!({ "ok": true })))
}
/// The resync button. A connected device syncs with its stored session; any other
/// device has to sign in with pl8list again (409 tells the page to show the sign-in).
pub async fn sync(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
let uid = match current.require() {
Ok(u) => u.id,
Err(r) => return Ok(r),
};
let Some((sealer, session)) = app.connected(&current).await else {
return Ok(json_error(StatusCode::CONFLICT, "sign in with pl8list again to sync"));
};
if !app.limits.resync.allow(&uid.to_string()) {
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, "synced a moment ago, try again in two minutes"));
}
let started = app.sync.linked(sealer, session);
Ok((no_store(), Json(json!({ "ok": true, "started": started }))).into_response())
}
/// Stop staying connected on this device: log the session out at pl8list and delete it.
pub async fn disconnect(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
if let Err(r) = current.require() {
return Ok(r);
}
if let Some((_, session)) = app.connected(&current).await {
app.pl8.logout(&session).await;
}
if let Some(token) = current.token.clone() {
app.db.call(move |c| db::clear_link(c, &db::hash_token(&token))).await?;
}
Ok((no_store(), Json(json!({ "ok": true }))).into_response())
}
+179
View File
@@ -0,0 +1,179 @@
//! The feed, likes, follows and listening events.
use std::{collections::HashMap, time::Duration};
use axum::{
Extension, Json,
extract::{Query, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde_json::{Value, json};
use tokio::time::Instant;
use super::{App, CurrentUser, Res, St, json_error, no_store};
use crate::{db, ranker, sync};
const EVENT_KINDS: &[&str] = &["play", "like", "unlike", "skip", "complete", "dislike"];
/// On a cold start the crawler needs a few seconds before there is anything to serve.
const FEED_WAIT: Duration = Duration::from_secs(10);
pub async fn me(Extension(current): Extension<CurrentUser>) -> Response {
match current.user {
Some(u) => (
no_store(),
Json(json!({
"user": { "handle": u.handle, "name": u.name, "img": u.img },
"pl8list": { "connected": current.link.is_some(), "synced_at": current.synced_at },
})),
)
.into_response(),
None => json_error(StatusCode::UNAUTHORIZED, "not signed in"),
}
}
/// Mark tracks by artists the user follows.
async fn mark_followed(app: &App, uid: i64, tracks: &mut [Value]) -> anyhow::Result<()> {
let follows = app.db.call(move |c| db::follows(c, uid)).await?;
for t in tracks {
let followed = t["handle"].as_str().is_some_and(|h| follows.contains(h));
t["followed"] = json!(followed);
}
Ok(())
}
/// A connected device resyncs in the background once its last sync is old enough.
async fn resync_if_due(app: &App, current: &CurrentUser) {
if current.link.is_none() || current.synced_at.is_some_and(|t| t > db::now() - sync::RESYNC_AFTER) {
return;
}
if let Some((sealer, session)) = app.connected(current).await {
app.sync.linked(sealer, session);
}
}
fn num(q: &HashMap<String, String>, name: &str) -> Option<f64> {
q.get(name).and_then(|v| v.parse::<f64>().ok()).filter(|v| v.is_finite())
}
pub async fn feed(State(app): St, Extension(current): Extension<CurrentUser>, Query(q): Query<HashMap<String, String>>) -> Res {
let user = match current.require() {
Ok(u) => u.clone(),
Err(r) => return Ok(r),
};
let artist = q.get("artist").map(String::as_str).filter(|h| valid_handle(h));
let Some(mode) = ranker::Mode::parse(q.get("mode").map(String::as_str), artist) else {
return Ok(json_error(StatusCode::BAD_REQUEST, "unknown mode"));
};
// The artist graph is thin at first: crawl the radio's artist and their related pages.
if let ranker::Mode::Artist(h) = &mode {
app.crawler.explore_artist(h.clone()).await;
}
let n = num(&q, "n").map(|n| n as usize).unwrap_or(10).clamp(1, 30);
let filters = ranker::Filters {
free_only: q.get("free").map(String::as_str) == Some("1"),
bpm_min: num(&q, "bpm_min"),
bpm_max: num(&q, "bpm_max"),
};
resync_if_due(&app, &current).await;
let deadline = Instant::now() + FEED_WAIT;
let uid = user.id;
let mut tracks = loop {
let mode = mode.clone();
let tracks = app.db.call(move |c| ranker::pick(c, uid, n, filters, &*mode.ranker(c)?)).await?;
if !tracks.is_empty() || Instant::now() >= deadline {
break tracks;
}
tokio::time::sleep(Duration::from_millis(1500)).await;
};
mark_followed(&app, uid, &mut tracks).await?;
let ids: Vec<i64> = tracks.iter().filter_map(|t| t["id"].as_i64()).collect();
let stats = app
.db
.call(move |c| {
for id in ids {
db::add_event(c, uid, id, "served", None)?;
}
let mut s = db::stats(c, uid)?;
s.liked = ranker::taste(c, uid)?.liked_ids.len();
Ok(s)
})
.await?;
Ok((no_store(), Json(json!({ "tracks": tracks, "stats": stats }))).into_response())
}
pub async fn likes(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
let uid = match current.require() {
Ok(u) => u.id,
Err(r) => return Ok(r),
};
let mut tracks = app.db.call(move |c| ranker::likes(c, uid)).await?;
mark_followed(&app, uid, &mut tracks).await?;
Ok((no_store(), Json(json!({ "tracks": tracks }))).into_response())
}
pub async fn event(State(app): St, Extension(current): Extension<CurrentUser>, Json(body): Json<Value>) -> Res {
let uid = match current.require() {
Ok(u) => u.id,
Err(r) => return Ok(r),
};
let track_id = body.get("track_id").and_then(|v| v.as_i64().or_else(|| v.as_str()?.parse().ok()));
let kind = body.get("kind").and_then(Value::as_str).filter(|k| EVENT_KINDS.contains(k));
let seconds = match body.get("seconds") {
None | Some(Value::Null) => Some(None),
Some(v) => v.as_f64().filter(|s| s.is_finite() && *s >= 0.0).map(Some),
};
let (Some(track_id), Some(kind), Some(seconds)) = (track_id, kind, seconds) else {
return Ok(StatusCode::BAD_REQUEST.into_response());
};
if !app.limits.events.allow(&uid.to_string()) {
return Ok(StatusCode::TOO_MANY_REQUESTS.into_response());
}
let kind = kind.to_string();
let k = kind.clone();
app.db.call(move |c| db::add_event(c, uid, track_id, &k, seconds)).await?;
if kind == "like" || kind == "complete" {
app.crawler.on_positive(track_id).await;
}
if kind == "like" && app.limits.pl8_writes.allow(&uid.to_string()) {
let row = app.db.call(move |c| db::track_path(c, track_id)).await?;
if let (Some((handle, slug)), Some((sealer, session))) = (row, app.connected(&current).await) {
app.sync.push_like(sealer, session, handle, slug);
}
}
Ok((no_store(), Json(json!({ "ok": true }))).into_response())
}
/// Valid pl8list handles, as seen in every handle crawled so far.
fn valid_handle(h: &str) -> bool {
(1..=64).contains(&h.len()) && h.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) && h != "." && h != ".."
}
/// Follow or unfollow an artist. Unfollowing only affects this app; on a connected
/// device a follow is also saved to the user's pl8list library.
pub async fn follow(State(app): St, Extension(current): Extension<CurrentUser>, Json(body): Json<Value>) -> Res {
let uid = match current.require() {
Ok(u) => u.id,
Err(r) => return Ok(r),
};
let handle = body.get("handle").and_then(Value::as_str).filter(|h| valid_handle(h)).map(String::from);
let on = body.get("follow").and_then(Value::as_bool);
let (Some(handle), Some(on)) = (handle, on) else {
return Ok(StatusCode::BAD_REQUEST.into_response());
};
if !app.limits.events.allow(&uid.to_string()) {
return Ok(StatusCode::TOO_MANY_REQUESTS.into_response());
}
let h = handle.clone();
let added = app
.db
.call(move |c| if on { db::add_follows(c, uid, &[h], "local") } else { db::unfollow(c, uid, &h).map(|_| 0) })
.await?;
if added > 0 {
app.crawler.discovered_artists([handle.clone()]);
if app.limits.pl8_writes.allow(&uid.to_string()) && let Some((sealer, session)) = app.connected(&current).await {
app.sync.push_follow(sealer, session, handle);
}
}
Ok((no_store(), Json(json!({ "ok": true, "followed": on }))).into_response())
}
+226
View File
@@ -0,0 +1,226 @@
//! Sign-in through pl8list: email, emailed 6-digit code, then our own session cookie.
use std::{
collections::HashMap,
sync::{
Arc, Mutex,
atomic::{AtomicU32, Ordering},
},
time::{Duration, Instant},
};
use axum::{
Extension, Json,
extract::State,
http::{HeaderMap, StatusCode, header},
response::{IntoResponse, Response},
};
use serde_json::{Value, json};
use super::{App, CurrentUser, LOGIN_COOKIE, Peer, Res, SESSION_COOKIE, St, client_ip, json_error, no_store, security};
use crate::{db, pl8::login::LoginFlow};
const LOGIN_TTL: Duration = Duration::from_secs(900);
const MAX_PENDING: usize = 500;
const MAX_RESENDS: u32 = 2;
const MAX_CODE_ATTEMPTS: u32 = 5;
const TOO_MANY: &str = "too many attempts, wait a few minutes";
/// A login waiting for its emailed code, keyed by a random id in a short-lived cookie.
pub struct Pending {
created: Instant,
attempts: AtomicU32,
resends: AtomicU32,
/// Also serialises resend/verify calls of one login.
flow: tokio::sync::Mutex<LoginFlow>,
}
#[derive(Default)]
pub struct PendingLogins(Mutex<HashMap<String, Arc<Pending>>>);
impl PendingLogins {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Arc<Pending>>> {
self.0.lock().unwrap_or_else(|e| e.into_inner())
}
/// Returns the new flow id, or `None` when too many logins are pending.
fn add(&self, flow: LoginFlow) -> Option<(String, Arc<Pending>)> {
let mut map = self.lock();
map.retain(|_, p| p.created.elapsed() < LOGIN_TTL);
if map.len() >= MAX_PENDING {
return None;
}
let id = db::new_token();
let p = Arc::new(Pending {
created: Instant::now(),
attempts: AtomicU32::new(0),
resends: AtomicU32::new(0),
flow: tokio::sync::Mutex::new(flow),
});
map.insert(id.clone(), p.clone());
Some((id, p))
}
fn get(&self, id: Option<&str>) -> Option<Arc<Pending>> {
let p = self.lock().get(id?).cloned()?;
(p.created.elapsed() < LOGIN_TTL).then_some(p)
}
fn drop(&self, id: &str) {
self.lock().remove(id);
}
pub fn prune(&self) {
self.lock().retain(|_, p| p.created.elapsed() < LOGIN_TTL);
}
}
fn valid_email(e: &str) -> bool {
let Some((local, domain)) = e.split_once('@') else { return false };
e.len() <= 254
&& (1..=64).contains(&local.len())
&& !domain.contains('@')
&& domain.split('.').count() >= 2
&& domain.split('.').all(|p| !p.is_empty())
&& !e.chars().any(|c| c.is_whitespace() || c.is_control())
}
fn str_field(body: &Value, key: &str) -> String {
body.get(key).and_then(Value::as_str).unwrap_or_default().trim().to_string()
}
fn allow_login_mail(app: &App, ip: &str, email: &str) -> bool {
app.limits.login_ip.allow(ip) && app.limits.login_email.allow(email) && app.limits.login_global.allow("")
}
pub async fn start(State(app): St, peer: Peer, headers: HeaderMap, Json(body): Json<Value>) -> Res {
let email = str_field(&body, "email").to_lowercase();
if !valid_email(&email) {
return Ok(json_error(StatusCode::BAD_REQUEST, "enter a valid email"));
}
if !allow_login_mail(&app, &client_ip(&app, &headers, peer.0), &email) {
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, TOO_MANY));
}
let Some((id, pending)) = app.logins.add(LoginFlow::new(email.clone())) else {
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, "too many sign-ins right now, try again in a few minutes"));
};
if let Err(e) = pending.flow.lock().await.start(&app.pl8).await {
app.logins.drop(&id);
return Ok(json_error(StatusCode::BAD_REQUEST, &e.0));
}
let cookie = app.cookies.set(LOGIN_COOKIE, &id, LOGIN_TTL.as_secs() as i64);
Ok(([(header::SET_COOKIE, cookie)], no_store(), Json(json!({ "ok": true, "email": email }))).into_response())
}
fn login_cookie<'a>(app: &App, headers: &'a HeaderMap) -> Option<&'a str> {
security::cookie(headers, &app.cookies.name(LOGIN_COOKIE))
}
fn expired() -> Response {
json_error(StatusCode::BAD_REQUEST, "login expired, start again")
}
pub async fn resend(State(app): St, peer: Peer, headers: HeaderMap) -> Res {
let Some(pending) = app.logins.get(login_cookie(&app, &headers)) else { return Ok(expired()) };
if pending.resends.fetch_add(1, Ordering::SeqCst) >= MAX_RESENDS {
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, "no more resends, start again"));
}
let mut flow = pending.flow.lock().await;
if !allow_login_mail(&app, &client_ip(&app, &headers, peer.0), &flow.email) {
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, TOO_MANY));
}
if let Err(e) = flow.resend(&app.pl8).await {
return Ok(json_error(StatusCode::BAD_REQUEST, &e.0));
}
Ok((no_store(), Json(json!({ "ok": true }))).into_response())
}
pub async fn verify(State(app): St, Extension(current): Extension<CurrentUser>, headers: HeaderMap, Json(body): Json<Value>) -> Res {
let id = login_cookie(&app, &headers).map(String::from);
let Some(pending) = app.logins.get(id.as_deref()) else { return Ok(expired()) };
let id = id.unwrap_or_default();
let code = str_field(&body, "code");
if code.len() != 6 || !code.bytes().all(|b| b.is_ascii_digit()) {
return Ok(json_error(StatusCode::BAD_REQUEST, "the code has 6 digits"));
}
if pending.attempts.fetch_add(1, Ordering::SeqCst) >= MAX_CODE_ATTEMPTS {
app.logins.drop(&id);
return Ok(json_error(StatusCode::TOO_MANY_REQUESTS, "too many wrong codes, start again"));
}
let result = pending.flow.lock().await.verify(&app.pl8, &code).await;
let (user, pl8_session) = match result {
Ok(ok) => ok,
Err(e) => return Ok(json_error(StatusCode::BAD_REQUEST, &e.0)),
};
app.logins.drop(&id);
// "Stay connected" is opt-in per device; without it the session is used for one
// sync and then logged out at pl8list.
let stay = body.get("connect").and_then(Value::as_bool) == Some(true);
let u = user.clone();
// Signing in again on a device (the resync button) replaces its old session.
let previous = current.token.clone();
if let Some(old) = app.connected(&current).await {
app.pl8.logout(&old.1).await;
}
let token = app
.db
.call(move |c| {
if let Some(t) = previous {
db::delete_session(c, &t)?;
}
db::upsert_user(c, u.id, &u.handle, u.name.as_deref(), u.img.as_deref())?;
db::create_session(c, u.id)
})
.await?;
if stay {
let sealer = app.sync.key.sealer(&token, user.id);
let blob = sealer.seal(&pl8_session);
let hash = sealer.token_hash.clone();
app.db.call(move |c| db::set_link(c, &hash, &blob, Some(db::now()))).await?;
app.sync.linked(sealer, pl8_session);
} else {
app.sync.once(user.id, pl8_session);
}
tracing::info!("signed in: {}{}", user.handle, if stay { " (stays connected)" } else { "" });
let mut res = (no_store(), Json(json!({ "user": { "handle": user.handle, "name": user.name }, "connected": stay }))).into_response();
let h = res.headers_mut();
h.append(header::SET_COOKIE, app.cookies.set(SESSION_COOKIE, &token, (db::SESSION_IDLE_DAYS * 86400.0) as i64));
h.append(header::SET_COOKIE, app.cookies.clear(LOGIN_COOKIE));
Ok(res)
}
pub async fn logout(State(app): St, Extension(current): Extension<CurrentUser>) -> Res {
if let Some((_, session)) = app.connected(&current).await {
app.pl8.logout(&session).await;
}
if let Some(token) = current.token {
app.db.call(move |c| db::delete_session(c, &token)).await?;
}
Ok(signed_out(&app, json!({ "ok": true })))
}
/// Response that removes the session cookie (and the Python version's one).
pub fn signed_out(app: &App, body: Value) -> Response {
let mut res = (no_store(), Json(body)).into_response();
res.headers_mut().append(header::SET_COOKIE, app.cookies.clear(SESSION_COOKIE));
if app.cookies.secure {
res.headers_mut().append(header::SET_COOKIE, app.cookies.clear_legacy(SESSION_COOKIE));
}
res
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn email_validation() {
assert!(valid_email("a@b.co"));
assert!(!valid_email("a@b"));
assert!(!valid_email("a b@c.de"));
assert!(!valid_email("@c.de"));
assert!(!valid_email("a@@c.de"));
assert!(!valid_email("a@c..de"));
assert!(!valid_email(&format!("{}@c.de", "x".repeat(65))));
}
}
+85
View File
@@ -0,0 +1,85 @@
//! Impressum (§ 5 DDG, § 18 MStV) and Datenschutzerklärung (Art. 13 GDPR).
//!
//! The texts live in `templates/`; operator details come from the environment so
//! they are not committed. Pages are rendered once at startup.
use axum::{
http::header,
response::{IntoResponse, Response},
};
use crate::config::Legal;
const IMPRESSUM: &str = include_str!("../../templates/impressum.html");
const DATENSCHUTZ: &str = include_str!("../../templates/datenschutz.html");
const UPDATED: &str = "1. November 2026";
pub struct Pages {
impressum: String,
datenschutz: String,
}
fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;").replace('"', "&quot;").replace('\'', "&#39;")
}
fn lines(s: &str) -> String {
s.lines().map(|l| escape(l.trim())).filter(|l| !l.is_empty()).collect::<Vec<_>>().join("<br>\n")
}
fn fill(template: &str, l: &Legal) -> String {
let missing = "<strong>[nicht konfiguriert]</strong>";
let or_missing = |v: String| if v.is_empty() { missing.to_string() } else { v };
let email = if l.email.is_empty() {
missing.to_string()
} else {
format!("<a href=\"mailto:{0}\">{0}</a>", escape(&l.email))
};
let phone = if l.phone.is_empty() { String::new() } else { format!("<br>\nTelefon: {}", escape(&l.phone)) };
template
.replace("{{name}}", &or_missing(escape(&l.name)))
.replace("{{address}}", &or_missing(lines(&l.address)))
.replace("{{email}}", &email)
.replace("{{phone}}", &phone)
.replace("{{hosting}}", &or_missing(lines(&l.hosting)))
.replace("{{updated}}", UPDATED)
}
impl Pages {
pub fn render(l: &Legal) -> Self {
if !l.is_complete() {
tracing::warn!("IMPRESSUM_NAME, IMPRESSUM_ADDRESS and IMPRESSUM_EMAIL are not all set; the Impressum is incomplete");
}
if l.hosting.is_empty() {
tracing::warn!("HOSTING_PROVIDER is not set; the privacy policy names no hosting provider");
}
Pages { impressum: fill(IMPRESSUM, l), datenschutz: fill(DATENSCHUTZ, l) }
}
}
fn html(body: String) -> Response {
([(header::CONTENT_TYPE, "text/html; charset=utf-8"), (header::CACHE_CONTROL, "no-cache")], body).into_response()
}
pub async fn impressum(axum::extract::State(app): super::St) -> Response {
html(app.legal.impressum.clone())
}
pub async fn datenschutz(axum::extract::State(app): super::St) -> Response {
html(app.legal.datenschutz.clone())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn operator_details_are_escaped() {
let l = Legal { name: "A <b>".into(), address: "Str. 1\n12345 Ort".into(), email: "a@b.de".into(), ..Default::default() };
let p = Pages::render(&l);
assert!(p.impressum.contains("A &lt;b&gt;"));
assert!(p.impressum.contains("Str. 1<br>\n12345 Ort"));
assert!(!p.impressum.contains("{{"));
assert!(!p.datenschutz.contains("{{"));
}
}
+336
View File
@@ -0,0 +1,336 @@
//! Routes, shared state and the middleware every request goes through.
pub mod account;
pub mod app;
pub mod auth;
pub mod legal;
pub mod proxy;
pub mod security;
#[cfg(test)]
mod tests;
use std::{net::SocketAddr, sync::Arc, time::Duration};
use axum::{
Json, Router,
extract::{ConnectInfo, DefaultBodyLimit, Path, Request, State},
http::{HeaderMap, HeaderValue, Method, StatusCode, header},
middleware::{self, Next},
response::{IntoResponse, Response},
routing::{get, post},
};
use rust_embed::Embed;
use serde_json::json;
use tower::limit::GlobalConcurrencyLimitLayer;
use tower_http::timeout::TimeoutLayer;
use secrecy::SecretString;
use crate::{
cache::Cache,
config::Config,
crawler::Crawler,
db::{self, Db, User},
link::{LinkKey, Sealer},
pl8::Pl8,
ratelimit::RateLimiter,
sync::Syncer,
};
use security::Cookies;
pub const SESSION_COOKIE: &str = "pl8inf";
pub const LOGIN_COOKIE: &str = "pl8inf_login";
/// Sessions get a new token once they are this old.
const ROTATE_AFTER: f64 = 7.0 * 86400.0;
pub struct Limits {
pub login_ip: RateLimiter,
pub login_email: RateLimiter,
/// Every start and resend makes pl8list send a mail; cap them for the whole server.
pub login_global: RateLimiter,
pub events: RateLimiter,
pub proxy: RateLimiter,
/// Saves to a user's pl8list library (likes and follows pushed from here).
pub pl8_writes: RateLimiter,
/// The resync button.
pub resync: RateLimiter,
}
pub struct App {
pub cfg: Config,
pub db: Db,
pub pl8: Arc<Pl8>,
pub crawler: Arc<Crawler>,
pub sync: Arc<Syncer>,
pub cache: Cache,
pub logins: auth::PendingLogins,
pub limits: Limits,
pub cookies: Cookies,
pub segments: proxy::SegmentKeys,
pub legal: legal::Pages,
}
pub type St = State<Arc<App>>;
impl App {
pub fn new(cfg: Config, db: Db, pl8: Arc<Pl8>, crawler: Arc<Crawler>, link: LinkKey) -> Arc<Self> {
let cache = Cache::open(cfg.cache_dir.clone(), cfg.cache_max_bytes);
let sync = Syncer::new(pl8.clone(), db.clone(), crawler.clone(), link);
let legal = legal::Pages::render(&cfg.legal);
Arc::new(App {
cookies: Cookies { secure: cfg.secure },
limits: Limits {
login_ip: RateLimiter::new(5, 900),
login_email: RateLimiter::new(3, 900),
login_global: RateLimiter::new(30, 600),
events: RateLimiter::new(600, 600),
proxy: RateLimiter::new(1200, 60),
pl8_writes: RateLimiter::new(60, 600),
resync: RateLimiter::new(1, 120),
},
logins: auth::PendingLogins::default(),
segments: proxy::SegmentKeys::default(),
sync,
cache,
legal,
cfg,
db,
pl8,
crawler,
})
}
/// Hourly housekeeping.
pub async fn purge(&self) {
match self.db.call(|c| db::purge(c)).await {
Ok((s, e)) if s + e > 0 => tracing::info!("purged {s} expired sessions, {e} old served events"),
Ok(_) => {}
Err(e) => tracing::warn!("purge failed: {e:#}"),
}
self.logins.prune();
self.segments.prune();
}
/// This device's stored pl8list session, if it stays connected. A blob that no
/// longer opens (for example after the server key changed) is dropped.
pub async fn connected(&self, current: &CurrentUser) -> Option<(Sealer, SecretString)> {
let (Some(user), Some(token), Some(blob)) = (&current.user, &current.token, &current.link) else { return None };
let sealer = self.sync.key.sealer(token, user.id);
match sealer.open(blob) {
Some(session) => Some((sealer, session)),
None => {
let hash = sealer.token_hash.clone();
let _ = self.db.call(move |c| db::clear_link(c, &hash)).await;
None
}
}
}
}
// Errors ------------------------------------------------------------------
pub struct AppError(anyhow::Error);
impl<E: Into<anyhow::Error>> From<E> for AppError {
fn from(e: E) -> Self {
AppError(e.into())
}
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
tracing::error!("{:#}", self.0);
StatusCode::INTERNAL_SERVER_ERROR.into_response()
}
}
pub type Res = Result<Response, AppError>;
pub fn json_error(status: StatusCode, msg: &str) -> Response {
(status, no_store(), Json(json!({ "error": msg }))).into_response()
}
pub fn no_store() -> [(header::HeaderName, &'static str); 1] {
[(header::CACHE_CONTROL, "no-store")]
}
// Session -----------------------------------------------------------------
/// The signed-in user, put into request extensions by `session`.
#[derive(Clone)]
pub struct CurrentUser {
pub user: Option<User>,
pub token: Option<String>,
/// Encrypted pl8list session of this device (see `link.rs`).
pub link: Option<Vec<u8>>,
pub synced_at: Option<f64>,
}
impl CurrentUser {
pub fn require(&self) -> Result<&User, Response> {
self.user.as_ref().ok_or_else(|| StatusCode::UNAUTHORIZED.into_response())
}
}
/// Look up the session cookie, rotate old tokens and move the Python version's
/// unprefixed cookie to the `__Host-` one.
async fn session(State(app): St, mut req: Request, next: Next) -> Response {
let name = app.cookies.name(SESSION_COOKIE);
let headers = req.headers();
let (token, legacy) = match security::cookie(headers, &name) {
Some(t) => (Some(t.to_string()), false),
None if app.cookies.secure => (security::cookie(headers, SESSION_COOKIE).map(String::from), true),
None => (None, false),
};
let mut set_cookie: Vec<HeaderValue> = vec![];
let mut current = CurrentUser { user: None, token: token.clone(), link: None, synced_at: None };
if let Some(t) = token {
let lookup = t.clone();
if let Ok(Some(hit)) = app.db.call(move |c| db::session_user(c, &lookup)).await {
let mut new_token = None;
current.link = hit.link.clone();
current.synced_at = hit.synced_at;
if hit.created_at < db::now() - ROTATE_AFTER {
let (old, uid) = (t.clone(), hit.user.id);
// The stored pl8list session is encrypted for the old token; move it over.
let reopened = hit.link.as_deref().and_then(|b| app.sync.key.sealer(&t, uid).open(b));
let key = &app.sync.key;
let synced = hit.synced_at;
new_token = match app.db.call(move |c| db::rotate_session(c, &old, uid)).await {
Ok(nt) => {
if let Some(session) = reopened {
let new = key.sealer(&nt, uid);
let blob = new.seal(&session);
let old_hash = db::hash_token(&t);
let stored = blob.clone();
let _ = app
.db
.call(move |c| {
db::set_link(c, &new.token_hash, &stored, synced)?;
db::clear_link(c, &old_hash)
})
.await;
current.link = Some(blob);
} else {
current.link = None;
}
Some(nt)
}
Err(_) => None,
};
} else if legacy {
new_token = Some(t.clone());
}
if let Some(nt) = new_token {
set_cookie.push(app.cookies.set(SESSION_COOKIE, &nt, (db::SESSION_IDLE_DAYS * 86400.0) as i64));
current.token = Some(nt);
}
current.user = Some(hit.user);
}
if legacy {
set_cookie.push(app.cookies.clear_legacy(SESSION_COOKIE));
}
}
req.extensions_mut().insert(current);
let mut res = next.run(req).await;
// Handlers that set the session cookie themselves (sign-in, sign-out) win.
let handled = res.headers().get_all(header::SET_COOKIE).iter().any(|v| v.as_bytes().starts_with(format!("{name}=").as_bytes()));
if !handled {
for c in set_cookie {
res.headers_mut().append(header::SET_COOKIE, c);
}
}
res
}
/// POSTs must come from our own pages.
async fn origin_check(State(app): St, req: Request, next: Next) -> Response {
if req.method() == Method::POST && !security::same_origin(req.headers(), app.cfg.public_origin.as_deref()) {
return StatusCode::FORBIDDEN.into_response();
}
next.run(req).await
}
pub fn client_ip(app: &App, headers: &HeaderMap, peer: SocketAddr) -> String {
security::ip_key(security::client_ip(headers, peer, app.cfg.proxy_hops))
}
pub type Peer = ConnectInfo<SocketAddr>;
// Static files ------------------------------------------------------------
/// The built front end (`web/`, `npm run build`). Hashed asset names, so they
/// can be cached for good; index.html and fixed-name files are revalidated.
#[derive(Embed)]
#[folder = "../web/dist/"]
struct Static;
fn content_type(path: &str) -> &'static str {
match path.rsplit('.').next().unwrap_or_default() {
"html" => "text/html; charset=utf-8",
"js" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"png" => "image/png",
"svg" => "image/svg+xml",
"webmanifest" => "application/manifest+json",
"txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
fn static_file(path: &str) -> Response {
let cache = if path.starts_with("assets/") { "public, max-age=31536000, immutable" } else { "no-cache" };
match Static::get(path) {
Some(f) => ([(header::CONTENT_TYPE, content_type(path)), (header::CACHE_CONTROL, cache)], f.data).into_response(),
None => StatusCode::NOT_FOUND.into_response(),
}
}
async fn index() -> Response {
static_file("index.html")
}
async fn static_route(Path(path): Path<String>) -> Response {
static_file(&path)
}
async fn healthz() -> Response {
(no_store(), Json(json!({ "ok": true }))).into_response()
}
// Router ------------------------------------------------------------------
pub fn router(app: Arc<App>) -> Router {
let secure = app.cfg.secure;
Router::new()
.route("/", get(index))
.route("/static/{*path}", get(static_route))
.route("/healthz", get(healthz))
.route("/impressum", get(legal::impressum))
.route("/datenschutz", get(legal::datenschutz))
.route("/privacy", get(legal::datenschutz))
.route("/auth/start", post(auth::start))
.route("/auth/resend", post(auth::resend))
.route("/auth/verify", post(auth::verify))
.route("/auth/logout", post(auth::logout))
.route("/local/me", get(app::me))
.route("/local/feed", get(app::feed))
.route("/local/likes", get(app::likes))
.route("/local/event", post(app::event))
.route("/local/follow", post(app::follow))
.route("/account/export", get(account::export))
.route("/account/delete", post(account::delete))
.route("/account/logout-all", post(account::logout_all))
.route("/account/pl8list/sync", post(account::sync))
.route("/account/pl8list/disconnect", post(account::disconnect))
.route("/api/{handle}/{slug}/hls/playlist.m3u8", get(proxy::playlist))
.route("/api/{handle}/{slug}/hls/segment/{n}", get(proxy::segment))
.route("/img/{id}", get(proxy::image))
.layer(middleware::from_fn_with_state(app.clone(), session))
.layer(middleware::from_fn_with_state(app.clone(), origin_check))
.layer(DefaultBodyLimit::max(4096))
.layer(TimeoutLayer::with_status_code(StatusCode::REQUEST_TIMEOUT, Duration::from_secs(30)))
.layer(GlobalConcurrencyLimitLayer::new(512))
.layer(middleware::from_fn(move |req, next| security::headers(secure, req, next)))
.with_state(app)
}
+277
View File
@@ -0,0 +1,277 @@
//! Audio and image proxy.
//!
//! The browser only talks to us, so visitors' addresses never reach pl8list. Only
//! HLS playlists, their segments and artist images are proxied, each with a fixed
//! or allowlisted content type, so nothing pl8list returns can run as script on
//! our origin. Previews and free tracks stream fine as a guest.
//!
//! Segments and images are cached on disk. A segment URL carries a signed token
//! (`st`) that changes with every playlist, so the cache key is the upload id inside
//! that token. We cannot check pl8list's signature, so we only trust tokens that
//! came out of a playlist we fetched ourselves.
use std::{
collections::HashMap,
sync::Mutex,
time::{Duration, Instant},
};
use axum::{
Extension,
extract::{Path, Query, State},
http::{StatusCode, header},
response::{IntoResponse, Response},
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use bytes::{Bytes, BytesMut};
use futures_util::StreamExt;
use serde::Deserialize;
use serde_json::Value;
use super::{App, CurrentUser, Res, St};
use crate::cache::{self, IMAGE_TYPES, SEGMENT_TYPES};
const MAX_PLAYLIST: usize = 256 << 10;
const MAX_SEGMENT: usize = 16 << 20;
const MAX_IMAGE: usize = 5 << 20;
const TOKEN_TTL: Duration = Duration::from_secs(6 * 3600);
const MAX_TOKENS: usize = 50_000;
/// Segment tokens seen in playlists from pl8list, with the upload they belong to.
#[derive(Default)]
pub struct SegmentKeys(Mutex<HashMap<String, (String, Instant)>>);
impl SegmentKeys {
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, (String, Instant)>> {
self.0.lock().unwrap_or_else(|e| e.into_inner())
}
fn insert(&self, token: String, upload: String) {
let mut m = self.lock();
if m.len() >= MAX_TOKENS {
m.retain(|_, (_, t)| t.elapsed() < TOKEN_TTL);
if m.len() >= MAX_TOKENS {
m.clear();
}
}
m.insert(token, (upload, Instant::now()));
}
fn upload(&self, token: &str) -> Option<String> {
self.lock().get(token).filter(|(_, t)| t.elapsed() < TOKEN_TTL).map(|(u, _)| u.clone())
}
pub fn prune(&self) {
self.lock().retain(|_, (_, t)| t.elapsed() < TOKEN_TTL);
}
}
/// Handles, slugs and upload ids on pl8list only use these characters.
fn safe_segment(s: &str, max: usize) -> bool {
!s.is_empty()
&& s.len() <= max
&& s != "."
&& s != ".."
&& s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'.' || b == b'-')
}
/// The upload id pl8list put into a segment token (`base64url(json).signature`).
fn token_upload(token: &str) -> Option<String> {
let payload = URL_SAFE_NO_PAD.decode(token.split('.').next()?).ok()?;
let v: Value = serde_json::from_slice(&payload).ok()?;
v.get("u")?.as_str().filter(|u| safe_segment(u, 128)).map(String::from)
}
fn require_user(app: &App, current: &CurrentUser) -> Result<(), Response> {
let user = current.require()?;
if !app.limits.proxy.allow(&user.id.to_string()) {
return Err(StatusCode::TOO_MANY_REQUESTS.into_response());
}
Ok(())
}
/// Read an upstream body, giving up past `max` bytes.
async fn read_capped(res: reqwest::Response, max: usize) -> Result<Bytes, StatusCode> {
if res.content_length().is_some_and(|l| l as usize > max) {
return Err(StatusCode::BAD_GATEWAY);
}
let mut body = BytesMut::new();
let mut stream = res.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|_| StatusCode::BAD_GATEWAY)?;
if body.len() + chunk.len() > max {
return Err(StatusCode::BAD_GATEWAY);
}
body.extend_from_slice(&chunk);
}
Ok(body.freeze())
}
async fn fetch(app: &App, url: url::Url) -> Result<reqwest::Response, StatusCode> {
let res = app.pl8.request(url, None).send().await.map_err(|e| {
tracing::debug!("proxy: {}", e.without_url());
StatusCode::BAD_GATEWAY
})?;
match res.status().as_u16() {
200 => Ok(res),
403 => Err(StatusCode::FORBIDDEN),
404 => Err(StatusCode::NOT_FOUND),
_ => Err(StatusCode::BAD_GATEWAY),
}
}
fn media(ctype: &'static str, body: Bytes, cache_control: &'static str) -> Response {
([(header::CONTENT_TYPE, ctype), (header::CACHE_CONTROL, cache_control)], body).into_response()
}
#[derive(Deserialize)]
pub struct PlaylistQuery {
v: Option<String>,
preview: Option<String>,
}
pub async fn playlist(
State(app): St,
Extension(current): Extension<CurrentUser>,
Path((handle, slug)): Path<(String, String)>,
Query(q): Query<PlaylistQuery>,
) -> Res {
if let Err(r) = require_user(&app, &current) {
return Ok(r);
}
if !safe_segment(&handle, 64) || !safe_segment(&slug, 160) || q.v.as_deref().is_some_and(|v| !safe_segment(v, 128)) {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let mut url = app.pl8.url(&["api", &handle, &slug, "hls", "playlist.m3u8"]);
{
let mut qp = url.query_pairs_mut();
if let Some(v) = &q.v {
qp.append_pair("v", v);
}
if q.preview.as_deref() == Some("true") {
qp.append_pair("preview", "true");
}
}
let body = match fetch(&app, url).await {
Ok(res) => match read_capped(res, MAX_PLAYLIST).await {
Ok(b) => b,
Err(s) => return Ok(s.into_response()),
},
Err(s) => return Ok(s.into_response()),
};
let Ok(text) = std::str::from_utf8(&body) else { return Ok(StatusCode::BAD_GATEWAY.into_response()) };
if !text.starts_with("#EXTM3U") {
return Ok(StatusCode::BAD_GATEWAY.into_response());
}
for line in text.lines().filter(|l| l.starts_with("segment/")) {
if let Some(token) = line.split_once("st=").map(|(_, t)| t.split('&').next().unwrap_or_default())
&& let Some(upload) = token_upload(token) {
app.segments.insert(token.to_string(), upload);
}
}
Ok(media("application/vnd.apple.mpegurl", body, "no-store"))
}
#[derive(Deserialize)]
pub struct SegmentQuery {
st: Option<String>,
}
pub async fn segment(
State(app): St,
Extension(current): Extension<CurrentUser>,
Path((handle, slug, n)): Path<(String, String, String)>,
Query(q): Query<SegmentQuery>,
) -> Res {
if let Err(r) = require_user(&app, &current) {
return Ok(r);
}
let token = q.st.unwrap_or_default();
let valid_token = token.len() <= 2048 && token.bytes().all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b));
let Ok(n) = n.parse::<u32>() else { return Ok(StatusCode::NOT_FOUND.into_response()) };
if !safe_segment(&handle, 64) || !safe_segment(&slug, 160) || !valid_token {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let key = app.segments.upload(&token).map(|u| format!("seg:{u}:{n}"));
if let Some(k) = &key
&& let Some((body, ctype)) = app.cache.get(k).await {
return Ok(media(ctype, body, "private, max-age=86400"));
}
let mut url = app.pl8.url(&["api", &handle, &slug, "hls", "segment", &n.to_string()]);
if !token.is_empty() {
url.query_pairs_mut().append_pair("st", &token);
}
let res = match fetch(&app, url).await {
Ok(r) => r,
Err(s) => return Ok(s.into_response()),
};
let upstream_type = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
let Some((ctype, ext)) = cache::allowed_type(upstream_type, SEGMENT_TYPES) else {
return Ok(StatusCode::BAD_GATEWAY.into_response());
};
let body = match read_capped(res, MAX_SEGMENT).await {
Ok(b) => b,
Err(s) => return Ok(s.into_response()),
};
if let Some(k) = &key {
app.cache.put(k, ext, &body).await;
}
Ok(media(ctype, body, "private, max-age=86400"))
}
pub async fn image(State(app): St, Extension(current): Extension<CurrentUser>, Path(id): Path<String>) -> Res {
if let Err(r) = require_user(&app, &current) {
return Ok(r);
}
if id.len() > 64 || id.is_empty() || !id.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') {
return Ok(StatusCode::NOT_FOUND.into_response());
}
let key = format!("img:{id}");
if let Some((body, ctype)) = app.cache.get(&key).await {
return Ok(media(ctype, body, "private, max-age=86400"));
}
let res = match fetch(&app, app.pl8.url(&["img", &id])).await {
Ok(r) => r,
Err(s) => return Ok(s.into_response()),
};
let upstream_type = res.headers().get(header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
let Some((ctype, ext)) = cache::allowed_type(upstream_type, IMAGE_TYPES) else {
return Ok(StatusCode::BAD_GATEWAY.into_response());
};
let body = match read_capped(res, MAX_IMAGE).await {
Ok(b) => b,
Err(s) => return Ok(s.into_response()),
};
app.cache.put(&key, ext, &body).await;
Ok(media(ctype, body, "private, max-age=86400"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_segments() {
assert!(safe_segment("garp-chorus-flute-vip-final-v5", 160));
assert!(!safe_segment("..", 160));
assert!(!safe_segment(".", 160));
assert!(!safe_segment("a/b", 160));
assert!(!safe_segment("a%2Fb", 160));
assert!(!safe_segment("", 160));
}
#[test]
fn reads_upload_from_token() {
let t = "eyJ1IjoiMjU1YzhmZGUtYTUyYy00Njc2LTk2MzgtMGExOGZkNTRmN2UyLndhdiIsImgiOiJnYXJwYmUiLCJzIjoiZ2FycC1zY290dGlzY2gtc3F1YXJlLWZpbmFsLXYzLTIiLCJlIjoxNzkzNTMwMTgwfQ.nPGK9LdPDvmHDwhqlCbHbaEtJhLLqCwUIS26MlCvYR8";
assert_eq!(token_upload(t).as_deref(), Some("255c8fde-a52c-4676-9638-0a18fd54f7e2.wav"));
assert_eq!(token_upload("garbage"), None);
}
#[test]
fn unknown_tokens_get_no_cache_key() {
let keys = SegmentKeys::default();
keys.insert("known".into(), "u1".into());
assert_eq!(keys.upload("known").as_deref(), Some("u1"));
assert_eq!(keys.upload("forged"), None);
}
}
+181
View File
@@ -0,0 +1,181 @@
//! Client address, Origin check, cookies and response headers.
use std::net::{IpAddr, SocketAddr};
use axum::{
extract::Request,
http::{HeaderMap, HeaderValue, header},
middleware::Next,
response::Response,
};
/// The address rate limits use.
///
/// Each trusted proxy appends the address it saw to `X-Forwarded-For`, so with
/// `hops` proxies the client is the `hops`-th entry from the right. Everything to
/// the left of it was written by the client and is ignored.
pub fn client_ip(headers: &HeaderMap, peer: SocketAddr, hops: usize) -> IpAddr {
if hops == 0 {
return peer.ip();
}
let entries: Vec<&str> = headers
.get_all("x-forwarded-for")
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(','))
.map(str::trim)
.collect();
entries
.len()
.checked_sub(hops)
.and_then(|i| entries[i].parse().ok())
.unwrap_or(peer.ip())
}
/// Rate-limit key for an address. IPv6 users usually get a whole /64, so limit by that.
pub fn ip_key(ip: IpAddr) -> String {
match ip {
IpAddr::V4(v4) => v4.to_string(),
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
Some(v4) => v4.to_string(),
None => {
let s = v6.segments();
format!("{:x}:{:x}:{:x}:{:x}::/64", s[0], s[1], s[2], s[3])
}
},
}
}
/// Reject cross-site POSTs. Browsers send Origin on every POST and Sec-Fetch-Site
/// on every request; either one pointing elsewhere is enough to refuse.
pub fn same_origin(headers: &HeaderMap, public_origin: Option<&str>) -> bool {
if let Some(site) = headers.get("sec-fetch-site").and_then(|v| v.to_str().ok())
&& site != "same-origin" && site != "none" {
return false;
}
let Some(origin) = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok()) else { return true };
match public_origin {
Some(expected) => origin == expected,
None => {
let host = headers.get(header::HOST).and_then(|v| v.to_str().ok()).unwrap_or_default();
origin.split_once("://").map(|(_, h)| h) == Some(host)
}
}
}
pub fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
headers
.get_all(header::COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.flat_map(|v| v.split(';'))
.filter_map(|c| c.trim().split_once('='))
.find(|(k, _)| *k == name)
.map(|(_, v)| v)
.filter(|v| !v.is_empty())
}
pub struct Cookies {
pub secure: bool,
}
impl Cookies {
/// `__Host-` makes browsers refuse the cookie unless it is Secure, host-only and Path=/.
pub fn name(&self, base: &str) -> String {
if self.secure { format!("__Host-{base}") } else { base.to_string() }
}
pub fn set(&self, base: &str, value: &str, max_age: i64) -> HeaderValue {
let secure = if self.secure { "; Secure" } else { "" };
let v = format!("{}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}{secure}", self.name(base));
HeaderValue::from_str(&v).expect("cookie values are URL-safe base64")
}
pub fn clear(&self, base: &str) -> HeaderValue {
self.set(base, "", 0)
}
/// Remove a cookie that was set without the prefix by the Python version.
pub fn clear_legacy(&self, name: &str) -> HeaderValue {
HeaderValue::from_str(&format!("{name}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0")).expect("static")
}
}
const CSP: &str = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; \
media-src 'self' blob:; worker-src 'self' blob:; connect-src 'self'; object-src 'none'; \
base-uri 'none'; form-action 'self'; frame-ancestors 'none'";
const PERMISSIONS: &str = "camera=(), microphone=(), geolocation=(), payment=(), usb=(), \
browsing-topics=(), interest-cohort=()";
/// Headers on every response.
pub async fn headers(secure: bool, req: Request, next: Next) -> Response {
let mut res = next.run(req).await;
let h = res.headers_mut();
let set = |h: &mut HeaderMap, k: &'static str, v: &'static str| {
h.entry(k).or_insert(HeaderValue::from_static(v));
};
set(h, "content-security-policy", CSP);
set(h, "x-content-type-options", "nosniff");
set(h, "referrer-policy", "strict-origin-when-cross-origin");
set(h, "x-frame-options", "DENY");
set(h, "permissions-policy", PERMISSIONS);
set(h, "cross-origin-opener-policy", "same-origin");
set(h, "cross-origin-resource-policy", "same-origin");
if secure {
set(h, "strict-transport-security", "max-age=31536000; includeSubDomains");
}
res
}
#[cfg(test)]
mod tests {
use super::*;
fn h(pairs: &[(&'static str, &'static str)]) -> HeaderMap {
let mut m = HeaderMap::new();
for (k, v) in pairs {
m.append(*k, HeaderValue::from_static(v));
}
m
}
#[test]
fn xff_uses_entry_added_by_proxy() {
let peer: SocketAddr = "172.18.0.2:5000".parse().unwrap();
let spoofed = h(&[("x-forwarded-for", "6.6.6.6, 203.0.113.9")]);
assert_eq!(client_ip(&spoofed, peer, 1).to_string(), "203.0.113.9");
assert_eq!(client_ip(&spoofed, peer, 2).to_string(), "6.6.6.6");
assert_eq!(client_ip(&spoofed, peer, 0), peer.ip());
// Fewer entries than proxies, or garbage: fall back to the peer.
assert_eq!(client_ip(&spoofed, peer, 3), peer.ip());
assert_eq!(client_ip(&h(&[("x-forwarded-for", "nonsense")]), peer, 1), peer.ip());
assert_eq!(client_ip(&HeaderMap::new(), peer, 1), peer.ip());
}
#[test]
fn ipv6_is_grouped_by_64() {
let a: IpAddr = "2001:db8:1:2:aaaa::1".parse().unwrap();
let b: IpAddr = "2001:db8:1:2:bbbb::9".parse().unwrap();
assert_eq!(ip_key(a), ip_key(b));
}
#[test]
fn origin_check() {
assert!(same_origin(&h(&[("host", "pl8.narl.io"), ("origin", "https://pl8.narl.io")]), None));
assert!(!same_origin(&h(&[("host", "pl8.narl.io"), ("origin", "https://evil.example")]), None));
assert!(!same_origin(&h(&[("sec-fetch-site", "cross-site")]), None));
assert!(same_origin(&h(&[("origin", "https://pl8.narl.io")]), Some("https://pl8.narl.io")));
assert!(!same_origin(&h(&[("origin", "http://pl8.narl.io")]), Some("https://pl8.narl.io")));
}
#[test]
fn cookie_attributes() {
let c = Cookies { secure: true };
let v = c.set("pl8inf", "tok", 60);
assert_eq!(v.to_str().unwrap(), "__Host-pl8inf=tok; Path=/; HttpOnly; SameSite=Lax; Max-Age=60; Secure");
let jar = h(&[("cookie", "a=1; __Host-pl8inf=tok; b=")]);
assert_eq!(cookie(&jar, "__Host-pl8inf"), Some("tok"));
assert_eq!(cookie(&jar, "b"), None);
}
}
+419
View File
@@ -0,0 +1,419 @@
//! End-to-end tests of the HTTP API against a fake pl8list.
use std::{net::SocketAddr, path::PathBuf, sync::Arc, time::Duration};
use axum::{
Router,
body::Body,
extract::ConnectInfo,
http::{Request, StatusCode, header},
};
use base64::Engine;
use tower::ServiceExt;
use wiremock::{
Mock, MockServer, ResponseTemplate,
matchers::{body_string_contains, header as has_header, method, path, query_param},
};
use super::{App, router};
use crate::{config::{Config, Legal}, crawler::Crawler, db::Db, link::LinkKey, pl8::Pl8};
const LOGGED_IN: &str = "LOGGED-IN-PL8-SESSION-SECRET";
fn db_path(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pl8inf-test-{}-{name}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir.join("test.db")
}
async fn setup(name: &str, pl8_base: &str) -> (Router, Arc<App>, PathBuf) {
let db_path = db_path(name);
let cfg = Config {
host: "127.0.0.1".into(),
port: 0,
db_path: db_path.clone(),
proxy_hops: 1,
public_origin: Some("https://pl8.example".into()),
secure: true,
cache_dir: db_path.parent().unwrap().join("cache"),
cache_max_bytes: 1 << 20,
link_key: None,
link_key_file: db_path.parent().unwrap().join("link.key"),
pl8_base: pl8_base.into(),
legal: Legal::default(),
};
let db = Db::open(&db_path).unwrap();
let pl8 = Arc::new(Pl8::new(pl8_base, Duration::ZERO).unwrap());
let crawler = Crawler::new(pl8.clone(), db.clone());
let app = App::new(cfg, db, pl8, crawler, LinkKey::fixed(9));
(router(app.clone()), app, db_path)
}
fn req(method: &str, uri: &str, body: Option<&str>, cookie: Option<&str>, xff: &str) -> Request<Body> {
let mut b = Request::builder()
.method(method)
.uri(uri)
.header(header::HOST, "pl8.example")
.header("x-forwarded-for", xff);
if body.is_some() {
b = b.header(header::CONTENT_TYPE, "application/json").header(header::ORIGIN, "https://pl8.example");
}
if let Some(c) = cookie {
b = b.header(header::COOKIE, c);
}
let mut r = b.body(Body::from(body.unwrap_or_default().to_string())).unwrap();
r.extensions_mut().insert(ConnectInfo(SocketAddr::from(([172, 18, 0, 2], 4000))));
r
}
fn set_cookies(res: &axum::response::Response) -> Vec<String> {
res.headers().get_all(header::SET_COOKIE).iter().map(|v| v.to_str().unwrap().to_string()).collect()
}
fn cookie_pair(cookies: &[String], name: &str) -> String {
cookies.iter().find(|c| c.starts_with(&format!("{name}="))).unwrap().split(';').next().unwrap().to_string()
}
async fn fake_pl8list(logouts: u64) -> MockServer {
let server = MockServer::start().await;
let guest = base64::engine::general_purpose::STANDARD.encode(r#"{"pl8list_guest_id":"g-1"}"#);
Mock::given(method("GET")).and(path("/login"))
.respond_with(ResponseTemplate::new(200).insert_header("set-cookie", format!("_session={guest}.sig; Path=/; HttpOnly").as_str()))
.mount(&server).await;
Mock::given(method("POST")).and(path("/login")).and(body_string_contains("pl8list_guest_id=g-1"))
.respond_with(ResponseTemplate::new(204).insert_header("x-remix-redirect", "/verify?email=a%40b.de&mode=login"))
.mount(&server).await;
Mock::given(method("POST")).and(path("/verify")).and(body_string_contains("code=000000"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"error":"wrong code"}"#))
.mount(&server).await;
Mock::given(method("POST")).and(path("/verify")).and(body_string_contains("code=123456"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}")
.insert_header("set-cookie", format!("_session={LOGGED_IN}; Path=/; HttpOnly").as_str()))
.mount(&server).await;
Mock::given(method("GET")).and(path("/")).and(query_param("_data", "root"))
.and(has_header("cookie", format!("_session={LOGGED_IN}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"currentUser":{"id":42,"handle":"tester","name":"T"}}"#))
.mount(&server).await;
Mock::given(method("GET")).and(path("/api/feed"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"tracks":[],"totalPages":1}"#))
.mount(&server).await;
Mock::given(method("GET")).and(path("/logout"))
.and(has_header("cookie", format!("_session={LOGGED_IN}").as_str()))
.respond_with(ResponseTemplate::new(204))
.expect(logouts)
.named("logout of the user's pl8list session")
.mount(&server).await;
server
}
#[tokio::test]
async fn login_logs_pl8list_session_out_and_never_stores_it() {
let pl8 = fake_pl8list(1).await;
let (r, _app, db_path) = setup("login", &pl8.uri()).await;
let res = r.clone().oneshot(req("POST", "/auth/start", Some(r#"{"email":"A@b.de"}"#), None, "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let login = cookie_pair(&set_cookies(&res), "__Host-pl8inf_login");
let res = r.clone().oneshot(req("POST", "/auth/verify", Some(r#"{"code":"000000"}"#), Some(&login), "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let res = r.clone().oneshot(req("POST", "/auth/verify", Some(r#"{"code":"123456"}"#), Some(&login), "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let cookies = set_cookies(&res);
assert!(cookies.iter().any(|c| c.starts_with("__Host-pl8inf=") && c.contains("Secure") && c.contains("HttpOnly")));
let session = cookie_pair(&cookies, "__Host-pl8inf");
let res = r.clone().oneshot(req("GET", "/local/me", None, Some(&session), "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
// The feed crawl logs the pl8list session out in the background.
for _ in 0..50 {
let logged_out = pl8.received_requests().await.unwrap().iter().any(|q| q.url.path() == "/logout");
if logged_out {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
pl8.verify().await;
let raw = std::fs::read(&db_path).unwrap();
let wal = std::fs::read(db_path.with_extension("db-wal")).unwrap_or_default();
let needle = LOGGED_IN.as_bytes();
assert!(!raw.windows(needle.len()).any(|w| w == needle), "pl8list session found in database file");
assert!(!wal.windows(needle.len()).any(|w| w == needle), "pl8list session found in WAL");
assert!(!raw.windows(5).any(|w| w == b"a@b.d") && !wal.windows(5).any(|w| w == b"a@b.d"), "email stored");
}
#[tokio::test]
async fn spoofed_forwarded_for_does_not_reset_ip_limit() {
let pl8 = fake_pl8list(0).await;
let (r, _app, _) = setup("xff", &pl8.uri()).await;
let mut statuses = vec![];
for i in 0..7 {
let body = format!(r#"{{"email":"u{i}@b.de"}}"#);
let xff = format!("6.6.6.{i}, 198.51.100.7");
statuses.push(r.clone().oneshot(req("POST", "/auth/start", Some(&body), None, &xff)).await.unwrap().status());
}
assert_eq!(&statuses[..5], &[StatusCode::OK; 5]);
assert_eq!(&statuses[5..], &[StatusCode::TOO_MANY_REQUESTS; 2]);
}
#[tokio::test]
async fn cross_site_posts_and_anonymous_reads_are_refused() {
let (r, _app, _) = setup("csrf", "http://127.0.0.1:9").await;
let mut cross = req("POST", "/auth/logout", Some("{}"), None, "203.0.113.1");
cross.headers_mut().insert(header::ORIGIN, "https://evil.example".parse().unwrap());
assert_eq!(r.clone().oneshot(cross).await.unwrap().status(), StatusCode::FORBIDDEN);
for uri in ["/local/feed", "/local/likes", "/account/export", "/img/abc", "/api/a/b/hls/playlist.m3u8"] {
let res = r.clone().oneshot(req("GET", uri, None, None, "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "{uri}");
}
let res = r.clone().oneshot(req("GET", "/", None, None, "203.0.113.1")).await.unwrap();
let h = res.headers();
assert!(h["content-security-policy"].to_str().unwrap().contains("script-src 'self';"));
assert!(h.contains_key("strict-transport-security"));
assert_eq!(h["cross-origin-opener-policy"], "same-origin");
}
#[tokio::test]
async fn delete_account_needs_handle_and_removes_data() {
let (r, app, _) = setup("delete", "http://127.0.0.1:9").await;
let token = app
.db
.call(|c| {
crate::db::upsert_user(c, 5, "tester", None, None)?;
crate::db::add_event(c, 5, 1, "like", None)?;
crate::db::create_session(c, 5)
})
.await
.unwrap();
let cookie = format!("__Host-pl8inf={token}");
let res = r.clone().oneshot(req("POST", "/account/delete", Some(r#"{"confirm":"nope"}"#), Some(&cookie), "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let res = r.clone().oneshot(req("GET", "/account/export", None, Some(&cookie), "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let res = r.clone().oneshot(req("POST", "/account/delete", Some(r#"{"confirm":"tester"}"#), Some(&cookie), "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let left = app
.db
.call(|c| c.query_row("SELECT (SELECT COUNT(*) FROM users) + (SELECT COUNT(*) FROM sessions) + (SELECT COUNT(*) FROM events)", [], |r| r.get::<_, i64>(0)))
.await
.unwrap();
assert_eq!(left, 0);
let res = r.clone().oneshot(req("GET", "/local/me", None, Some(&cookie), "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn legacy_cookie_moves_to_host_prefix() {
let (r, app, _) = setup("legacy", "http://127.0.0.1:9").await;
let token = app
.db
.call(|c| {
crate::db::upsert_user(c, 5, "tester", None, None)?;
crate::db::create_session(c, 5)
})
.await
.unwrap();
let res = r.oneshot(req("GET", "/local/me", None, Some(&format!("pl8inf={token}")), "203.0.113.1")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let cookies = set_cookies(&res);
assert!(cookies.iter().any(|c| c.starts_with(&format!("__Host-pl8inf={token};"))));
assert!(cookies.iter().any(|c| c.starts_with("pl8inf=;") && c.contains("Max-Age=0")));
}
async fn wait_for(pl8: &MockServer, what: impl Fn(&wiremock::Request) -> bool) -> bool {
for _ in 0..50 {
if pl8.received_requests().await.unwrap().iter().any(&what) {
return true;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
false
}
async fn sign_in(r: &Router, body: &str) -> (String, serde_json::Value) {
let res = r.clone().oneshot(req("POST", "/auth/start", Some(r#"{"email":"a@b.de"}"#), None, "203.0.113.9")).await.unwrap();
let login = cookie_pair(&set_cookies(&res), "__Host-pl8inf_login");
let res = r.clone().oneshot(req("POST", "/auth/verify", Some(body), Some(&login), "203.0.113.9")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let session = cookie_pair(&set_cookies(&res), "__Host-pl8inf");
let bytes = axum::body::to_bytes(res.into_body(), 1 << 16).await.unwrap();
(session, serde_json::from_slice(&bytes).unwrap())
}
async fn json_body(res: axum::response::Response) -> serde_json::Value {
serde_json::from_slice(&axum::body::to_bytes(res.into_body(), 1 << 20).await.unwrap()).unwrap()
}
const REFRESHED: &str = "REFRESHED-PL8-SESSION-SECRET";
#[tokio::test]
async fn connected_device_syncs_library_and_pushes_likes_and_follows() {
use wiremock::matchers::path_regex;
let pl8 = fake_pl8list(0).await;
// pl8list hands out a new cookie with the feed; later calls must use it.
Mock::given(method("GET")).and(path("/api/feed"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"tracks":[],"totalPages":1}"#)
.insert_header("set-cookie", format!("_session={REFRESHED}; Max-Age=7776000; Path=/").as_str()))
.with_priority(1)
.mount(&pl8).await;
Mock::given(method("GET")).and(path("/library/tracks")).and(query_param("_data", "routes/library.tracks"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"{"items":[{"track":{"id":77,"slug":"song","title":"Song","user":{"handle":"artist1"}}}],"totalPages":1}"#))
.mount(&pl8).await;
Mock::given(method("GET")).and(path("/library/artists")).and(query_param("_data", "routes/library.artists"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"items":[{"handle":"artist2"}],"totalPages":1}"#))
.mount(&pl8).await;
Mock::given(method("POST")).and(path_regex(r"^/api/.+/add-to-library$")).and(query_param("addOnly", "true"))
.and(has_header("cookie", format!("_session={REFRESHED}").as_str()))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"success":true,"wasInLibrary":false}"#))
.expect(2)
.mount(&pl8).await;
Mock::given(method("GET")).and(path("/logout"))
.and(has_header("cookie", format!("_session={REFRESHED}").as_str()))
.respond_with(ResponseTemplate::new(204))
.expect(1)
.mount(&pl8).await;
let (r, app, db_path) = setup("connected", &pl8.uri()).await;
let (session, body) = sign_in(&r, r#"{"code":"123456","connect":true}"#).await;
assert_eq!(body["connected"], true);
assert!(wait_for(&pl8, |q| q.url.path() == "/library/artists").await, "library not synced");
tokio::time::sleep(Duration::from_millis(200)).await;
let me = json_body(r.clone().oneshot(req("GET", "/local/me", None, Some(&session), "203.0.113.9")).await.unwrap()).await;
assert_eq!(me["pl8list"]["connected"], true);
// Saved tracks became likes, saved artists follows.
let likes = json_body(r.clone().oneshot(req("GET", "/local/likes", None, Some(&session), "203.0.113.9")).await.unwrap()).await;
assert_eq!(likes["tracks"][0]["id"], 77);
let follows = app.db.call(|c| crate::db::follows(c, 42)).await.unwrap();
assert!(follows.contains("artist2"));
// The stored session is encrypted: neither cookie appears in the files.
let raw = [std::fs::read(&db_path).unwrap(), std::fs::read(db_path.with_extension("db-wal")).unwrap_or_default()].concat();
for secret in [LOGGED_IN, REFRESHED] {
assert!(!raw.windows(secret.len()).any(|w| w == secret.as_bytes()), "{secret} stored in plain text");
}
let res = r.clone().oneshot(req("POST", "/local/event", Some(r#"{"track_id":77,"kind":"like"}"#), Some(&session), "203.0.113.9")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let res = r.clone().oneshot(req("POST", "/local/follow", Some(r#"{"handle":"artist3","follow":true}"#), Some(&session), "203.0.113.9")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
assert!(wait_for(&pl8, |q| q.url.path() == "/api/artist1/song/add-to-library").await);
assert!(wait_for(&pl8, |q| q.url.path() == "/api/artist3/add-to-library").await);
// Resync works once, then is rate limited.
let res = r.clone().oneshot(req("POST", "/account/pl8list/sync", Some("{}"), Some(&session), "203.0.113.9")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let res = r.clone().oneshot(req("POST", "/account/pl8list/sync", Some("{}"), Some(&session), "203.0.113.9")).await.unwrap();
assert_eq!(res.status(), StatusCode::TOO_MANY_REQUESTS);
tokio::time::sleep(Duration::from_millis(300)).await;
let res = r.clone().oneshot(req("POST", "/account/pl8list/disconnect", Some("{}"), Some(&session), "203.0.113.9")).await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let me = json_body(r.clone().oneshot(req("GET", "/local/me", None, Some(&session), "203.0.113.9")).await.unwrap()).await;
assert_eq!(me["pl8list"]["connected"], false);
let res = r.clone().oneshot(req("POST", "/account/pl8list/sync", Some("{}"), Some(&session), "203.0.113.9")).await.unwrap();
assert_eq!(res.status(), StatusCode::CONFLICT);
pl8.verify().await;
}
#[tokio::test]
async fn expired_pl8list_session_disconnects_the_device() {
let pl8 = fake_pl8list(0).await;
Mock::given(method("GET")).and(path("/api/feed"))
.respond_with(ResponseTemplate::new(302).insert_header("location", "/login?continue=%2Fapi%2Ffeed"))
.with_priority(1)
.mount(&pl8).await;
let (r, _app, _) = setup("expired", &pl8.uri()).await;
let (session, _) = sign_in(&r, r#"{"code":"123456","connect":true}"#).await;
let mut connected = true;
for _ in 0..50 {
let me = json_body(r.clone().oneshot(req("GET", "/local/me", None, Some(&session), "203.0.113.9")).await.unwrap()).await;
connected = me["pl8list"]["connected"].as_bool().unwrap();
if !connected {
break;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
assert!(!connected, "expired session still stored");
}
#[tokio::test]
async fn token_rotation_keeps_the_pl8list_connection() {
let (r, app, _) = setup("rotate", "http://127.0.0.1:9").await;
let token = app
.db
.call(|c| {
crate::db::upsert_user(c, 5, "tester", None, None)?;
crate::db::create_session(c, 5)
})
.await
.unwrap();
let blob = app.sync.key.sealer(&token, 5).seal(&secrecy::SecretString::from("PL8".to_string()));
let hash = crate::db::hash_token(&token);
app.db
.call(move |c| {
crate::db::set_link(c, &hash, &blob, Some(crate::db::now()))?;
c.execute("UPDATE sessions SET created_at = created_at - 8 * 86400", [])
})
.await
.unwrap();
let res = r.oneshot(req("GET", "/local/me", None, Some(&format!("__Host-pl8inf={token}")), "203.0.113.1")).await.unwrap();
let new = cookie_pair(&set_cookies(&res), "__Host-pl8inf").trim_start_matches("__Host-pl8inf=").to_string();
assert_ne!(new, token);
assert_eq!(json_body(res).await["pl8list"]["connected"], true);
let (new_hash, old_hash) = (crate::db::hash_token(&new), crate::db::hash_token(&token));
let (new_blob, old_blob) = app
.db
.call(move |c| {
let get = |h: &str| c.query_row("SELECT pl8_link FROM sessions WHERE token_hash=?", [h], |r| r.get::<_, Option<Vec<u8>>>(0));
Ok((get(&new_hash)?, get(&old_hash)?))
})
.await
.unwrap();
use secrecy::ExposeSecret;
assert_eq!(app.sync.key.sealer(&new, 5).open(&new_blob.unwrap()).unwrap().expose_secret(), "PL8");
assert!(old_blob.is_none());
}
#[tokio::test]
async fn feed_modes_filter_and_tag_tracks() {
let (r, app, _) = setup("modes", "http://127.0.0.1:9").await;
let token = app
.db
.call(|c| {
let t = |id, title: &str, bpm| crate::db::Track {
id,
handle: "h".into(),
artist_name: "H".into(),
slug: format!("s{id}"),
title: title.into(),
bpm: Some(bpm),
full_access: true,
..Default::default()
};
crate::db::upsert_tracks(c, &[t(1, "Riddim VIP", 140.0), t(2, "Roller", 174.0)], None)?;
crate::db::upsert_user(c, 5, "tester", None, None)?;
crate::db::create_session(c, 5)
})
.await
.unwrap();
let cookie = format!("__Host-pl8inf={token}");
let get = |uri: &str| r.clone().oneshot(req("GET", uri, None, Some(&cookie), "203.0.113.1"));
let res = get("/local/feed?mode=riddim").await.unwrap();
assert_eq!(res.status(), StatusCode::OK);
let body = json_body(res).await;
let tracks = body["tracks"].as_array().unwrap();
assert_eq!(tracks.len(), 1);
assert_eq!(tracks[0]["tags"], serde_json::json!(["140", "riddim", "vip"]));
for bad in ["/local/feed?mode=nope", "/local/feed?mode=artist", "/local/feed?mode=artist&artist=..%2Fx"] {
assert_eq!(get(bad).await.unwrap().status(), StatusCode::BAD_REQUEST, "{bad}");
}
let res = get("/local/feed?mode=artist&artist=h").await.unwrap();
assert_eq!(json_body(res).await["tracks"].as_array().unwrap().len(), 1);
}
+153
View File
@@ -0,0 +1,153 @@
//! Opt-in "stay connected to pl8list": a device keeps its pl8list session, encrypted.
//!
//! Each stored session is encrypted with a key derived from two secrets:
//!
//! - the device's local session token, which only the browser holds (the database
//! has just its SHA-256 hash), and
//! - a server key kept outside the database (`LINK_KEY`, or a key file).
//!
//! A copy of the database, even together with the server key, opens nothing, and a
//! stolen browser cookie only reaches our API. The server can decrypt a session only
//! while that device is making a request; background jobs never can.
use std::{fs, io::Write, path::Path};
use base64::Engine;
use chacha20poly1305::{
XChaCha20Poly1305, XNonce,
aead::{Aead, KeyInit, Payload},
};
use hkdf::Hkdf;
use rand::Rng;
use secrecy::{ExposeSecret, SecretString};
use sha2::Sha256;
use zeroize::Zeroizing;
use crate::db;
const VERSION: u8 = 1;
const INFO: &[u8] = b"pl8inf pl8list link v1";
const NONCE_LEN: usize = 24;
pub struct LinkKey(Zeroizing<[u8; 32]>);
impl LinkKey {
/// `LINK_KEY` (base64, 32 bytes) if set, else the key file, created on first start.
pub fn load(env: Option<&str>, file: &Path) -> anyhow::Result<Self> {
let b64 = base64::engine::general_purpose::STANDARD;
let decode = |s: &str| -> anyhow::Result<Self> {
let raw = Zeroizing::new(b64.decode(s.trim())?);
let key: [u8; 32] = raw.as_slice().try_into().map_err(|_| anyhow::anyhow!("LINK_KEY must be 32 bytes"))?;
Ok(LinkKey(Zeroizing::new(key)))
};
if let Some(s) = env.filter(|s| !s.trim().is_empty()) {
return decode(s);
}
if let Ok(s) = fs::read_to_string(file) {
return decode(&s);
}
let mut key = Zeroizing::new([0u8; 32]);
rand::rng().fill_bytes(key.as_mut());
let mut opts = fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
std::os::unix::fs::OpenOptionsExt::mode(&mut opts, 0o600);
opts.open(file)?.write_all(b64.encode(key.as_ref()).as_bytes())?;
tracing::info!("created link key {}", file.display());
Ok(LinkKey(key))
}
#[cfg(test)]
pub fn fixed(byte: u8) -> Self {
LinkKey(Zeroizing::new([byte; 32]))
}
/// Everything needed to seal and open one device's pl8list session. Holds the
/// derived key, not the token.
pub fn sealer(&self, token: &str, user_id: i64) -> Sealer {
let mut key = Zeroizing::new([0u8; 32]);
Hkdf::<Sha256>::new(Some(self.0.as_ref()), token.as_bytes())
.expand(INFO, key.as_mut())
.expect("32 bytes is a valid HKDF-SHA256 output length");
Sealer { key, user_id, token_hash: db::hash_token(token) }
}
}
pub struct Sealer {
key: Zeroizing<[u8; 32]>,
pub user_id: i64,
pub token_hash: String,
}
impl Sealer {
/// Binds the ciphertext to this user and this session row.
fn aad(&self) -> Vec<u8> {
let mut aad = self.user_id.to_le_bytes().to_vec();
aad.extend_from_slice(self.token_hash.as_bytes());
aad
}
fn cipher(&self) -> XChaCha20Poly1305 {
XChaCha20Poly1305::new_from_slice(self.key.as_ref()).expect("key is 32 bytes")
}
pub fn seal(&self, session: &SecretString) -> Vec<u8> {
let mut nonce = [0u8; NONCE_LEN];
rand::rng().fill_bytes(&mut nonce);
let aad = self.aad();
let sealed = self
.cipher()
.encrypt(&XNonce::from(nonce), Payload { msg: session.expose_secret().as_bytes(), aad: &aad })
.expect("encryption with a valid key does not fail");
let mut out = Vec::with_capacity(1 + NONCE_LEN + sealed.len());
out.push(VERSION);
out.extend_from_slice(&nonce);
out.extend_from_slice(&sealed);
out
}
pub fn open(&self, blob: &[u8]) -> Option<SecretString> {
let (&version, rest) = blob.split_first()?;
if version != VERSION || rest.len() < NONCE_LEN {
return None;
}
let (nonce, sealed) = rest.split_at(NONCE_LEN);
let nonce: [u8; NONCE_LEN] = nonce.try_into().ok()?;
let aad = self.aad();
let plain = Zeroizing::new(self.cipher().decrypt(&XNonce::from(nonce), Payload { msg: sealed, aad: &aad }).ok()?);
let text = std::str::from_utf8(&plain).ok()?;
Some(SecretString::from(text.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn opens_only_with_the_same_token_key_and_user() {
let key = LinkKey::fixed(1);
let s = key.sealer("token-a", 7);
let blob = s.seal(&SecretString::from("cookie-value".to_string()));
assert!(!blob.windows(12).any(|w| w == b"cookie-value"));
assert_eq!(s.open(&blob).unwrap().expose_secret(), "cookie-value");
assert!(key.sealer("token-b", 7).open(&blob).is_none());
assert!(key.sealer("token-a", 8).open(&blob).is_none());
assert!(LinkKey::fixed(2).sealer("token-a", 7).open(&blob).is_none());
let mut tampered = blob.clone();
*tampered.last_mut().unwrap() ^= 1;
assert!(s.open(&tampered).is_none());
}
#[test]
fn key_file_is_created_once() {
let dir = std::env::temp_dir().join(format!("pl8inf-link-{}", std::process::id()));
fs::create_dir_all(&dir).unwrap();
let file = dir.join("link.key");
let _ = fs::remove_file(&file);
let a = LinkKey::load(None, &file).unwrap();
let b = LinkKey::load(None, &file).unwrap();
assert_eq!(a.0.as_ref(), b.0.as_ref());
fs::remove_dir_all(&dir).unwrap();
}
}
+103
View File
@@ -0,0 +1,103 @@
//! Infinite-scroll front end for pl8list, with sign-in through pl8list accounts.
//!
//! pl8inf # then open http://127.0.0.1:8790
//! pl8inf --healthcheck # exit 0 if the server on $PORT answers /healthz
//!
//! Users sign in with their pl8list email: pl8list mails them a code, they type it
//! here, and this server checks it with pl8list. Afterwards they get a session
//! cookie for this app only. Their pl8list session is used once to sync their
//! library, then logged out at pl8list, unless they opt in to stay connected on
//! that device; then it is stored encrypted with a key only their browser can
//! unlock (see `link.rs`).
//! See `config.rs` for the environment variables.
#![allow(clippy::result_large_err)] // handlers return axum `Response` as their error
mod cache;
mod config;
mod crawler;
mod db;
mod http;
mod link;
mod pl8;
mod ranker;
mod ratelimit;
mod sync;
mod tags;
use std::{
io::{Read, Write},
net::{SocketAddr, TcpStream},
sync::Arc,
time::Duration,
};
use config::Config;
fn healthcheck() -> std::process::ExitCode {
let port = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8790u16);
let ok = (|| -> std::io::Result<bool> {
let mut s = TcpStream::connect_timeout(&SocketAddr::from(([127, 0, 0, 1], port)), Duration::from_secs(2))?;
s.set_read_timeout(Some(Duration::from_secs(2)))?;
s.write_all(b"GET /healthz HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")?;
let mut head = [0u8; 12];
s.read_exact(&mut head)?;
Ok(head.ends_with(b"200"))
})();
if matches!(ok, Ok(true)) { std::process::ExitCode::SUCCESS } else { std::process::ExitCode::FAILURE }
}
async fn shutdown() {
let ctrl_c = tokio::signal::ctrl_c();
#[cfg(unix)]
{
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("signal handler");
tokio::select! { _ = ctrl_c => {}, _ = term.recv() => {} }
}
#[cfg(not(unix))]
let _ = ctrl_c.await;
}
#[tokio::main]
async fn main() -> anyhow::Result<std::process::ExitCode> {
if std::env::args().any(|a| a == "--healthcheck") {
return Ok(healthcheck());
}
config::load_dotenv(".env");
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "pl8inf=info".into()),
)
.with_target(false)
.init();
let cfg = Config::from_env();
if !cfg.secure {
tracing::warn!("INSECURE_COOKIES=1: cookies without Secure, no HSTS. Use only for local development.");
}
let db = db::Db::open(&cfg.db_path)?;
let link = link::LinkKey::load(cfg.link_key.as_deref(), &cfg.link_key_file)?;
let pl8 = Arc::new(pl8::Pl8::new(&cfg.pl8_base, Duration::from_millis(700))?);
let crawler = crawler::Crawler::new(pl8.clone(), db.clone());
crawler.start();
let addr: SocketAddr = format!("{}:{}", cfg.host, cfg.port).parse()?;
let app = http::App::new(cfg, db, pl8, crawler, link);
{
let app = app.clone();
tokio::spawn(async move {
let mut tick = tokio::time::interval(Duration::from_secs(3600));
loop {
tick.tick().await;
app.purge().await;
}
});
}
let listener = tokio::net::TcpListener::bind(addr).await?;
tracing::info!("listening on http://{addr}");
axum::serve(listener, http::router(app).into_make_service_with_connect_info::<SocketAddr>())
.with_graceful_shutdown(shutdown())
.await?;
Ok(std::process::ExitCode::SUCCESS)
}
+165
View File
@@ -0,0 +1,165 @@
//! pl8list's passwordless login: POST email, pl8list mails a 6-digit code, POST code.
//!
//! The site keeps the pending login in its `_session` cookie, so one flow holds that
//! cookie between the two steps. After `verify` succeeds the cookie is a fully
//! logged-in pl8list session. It is handed to the caller exactly once and is never
//! logged or kept in the flow; only an opted-in device stores it, encrypted
//! (see `link.rs`).
use base64::{
Engine,
alphabet,
engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig},
};
use percent_encoding::percent_decode_str;
use secrecy::{ExposeSecret, SecretString};
use serde_json::Value;
use super::{Pl8, UserSession};
#[derive(Debug)]
pub struct LoginError(pub String);
impl std::fmt::Display for LoginError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str(&self.0)
}
}
fn err(msg: impl Into<String>) -> LoginError {
LoginError(msg.into())
}
/// The pl8list account that just signed in.
#[derive(Clone, Debug)]
pub struct Pl8User {
pub id: i64,
pub handle: String,
pub name: Option<String>,
pub img: Option<String>,
}
pub struct LoginFlow {
pub email: String,
session: Option<SecretString>,
}
struct Reply {
status: u16,
remix_redirect: Option<String>,
payload: Option<Value>,
}
impl LoginFlow {
pub fn new(email: String) -> Self {
LoginFlow { email, session: None }
}
async fn call(&mut self, pl8: &Pl8, path: &str, query: &[(&str, &str)], route: &str, form: Option<&[(&str, &str)]>) -> Result<Reply, LoginError> {
let mut url = pl8.url(&[path]);
{
let mut q = url.query_pairs_mut();
for (k, v) in query {
q.append_pair(k, v);
}
q.append_pair("_data", route);
}
let mut req = match form {
Some(fields) => pl8.http().post(url).form(fields),
None => pl8.http().get(url),
};
if let Some(s) = &self.session {
req = req.header("Cookie", format!("_session={}", s.expose_secret()));
}
let res = req.send().await.map_err(|e| err(format!("pl8list unreachable: {}", e.without_url())))?;
for c in res.headers().get_all("set-cookie") {
if let Some(v) = c.to_str().ok().and_then(|c| c.strip_prefix("_session=")) {
let v = v.split(';').next().unwrap_or_default();
self.session = (!v.is_empty()).then(|| SecretString::from(v.to_string()));
}
}
let status = res.status().as_u16();
let remix_redirect = res.headers().get("x-remix-redirect").and_then(|v| v.to_str().ok()).map(String::from);
let payload = res.bytes().await.ok().and_then(|b| serde_json::from_slice(&b).ok());
Ok(Reply { status, remix_redirect, payload })
}
/// The guest id pl8list stores in its cookie; the login forms echo it back.
fn guest_id(&self) -> String {
let Some(s) = &self.session else { return String::new() };
let raw = percent_decode_str(s.expose_secret()).decode_utf8_lossy();
let first = raw.split('.').next().unwrap_or_default();
let engine = GeneralPurpose::new(
&alphabet::STANDARD,
GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent),
);
engine
.decode(first)
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v.get("pl8list_guest_id").and_then(Value::as_str).map(String::from))
.unwrap_or_default()
}
fn payload_str(reply: &Reply, key: &str) -> Option<String> {
reply.payload.as_ref()?.get(key)?.as_str().map(String::from)
}
/// Ask pl8list to mail a login code.
pub async fn start(&mut self, pl8: &Pl8) -> Result<(), LoginError> {
self.call(pl8, "login", &[], "routes/login", None).await?;
let guest = self.guest_id();
let email = self.email.clone();
let reply = self
.call(pl8, "login", &[], "routes/login", Some(&[("email", &email), ("pl8list_guest_id", &guest)]))
.await?;
if !reply.remix_redirect.as_deref().unwrap_or_default().contains("/verify") {
return Err(err(Self::payload_str(&reply, "error")
.unwrap_or_else(|| format!("pl8list refused the login (HTTP {})", reply.status))));
}
Ok(())
}
pub async fn resend(&mut self, pl8: &Pl8) -> Result<(), LoginError> {
let guest = self.guest_id();
let email = self.email.clone();
let query = [("email", email.as_str()), ("mode", "login")];
let reply = self
.call(pl8, "verify", &query, "routes/verify", Some(&[("action", "resend"), ("pl8list_guest_id", &guest)]))
.await?;
match Self::payload_str(&reply, "resendError") {
Some(e) => Err(err(e)),
None => Ok(()),
}
}
/// Submit the code. On success returns the account and its logged-in pl8list session;
/// the flow no longer holds the session afterwards.
pub async fn verify(&mut self, pl8: &Pl8, code: &str) -> Result<(Pl8User, SecretString), LoginError> {
let guest = self.guest_id();
let email = self.email.clone();
let query = [("email", email.as_str()), ("mode", "login")];
let reply = self
.call(pl8, "verify", &query, "routes/verify", Some(&[("action", "verify"), ("code", code), ("pl8list_guest_id", &guest)]))
.await?;
if let Some(e) = Self::payload_str(&reply, "error") {
return Err(err(e));
}
let session = self.session.take().ok_or_else(|| err("login failed, try again"))?;
let mut user_session = UserSession::new(session);
let root = pl8.root(Some(&mut user_session)).await.ok().flatten();
let session = user_session.into_cookie();
let user = root.as_ref().and_then(|r| r.get("currentUser")).filter(|u| u.is_object());
let Some(user) = user else {
// Not logged in after all; nothing to end at pl8list.
return Err(err("login failed, try again"));
};
let handle = user.get("handle").and_then(Value::as_str).filter(|h| !h.is_empty());
let (Some(id), Some(handle)) = (user.get("id").and_then(Value::as_i64), handle) else {
pl8.logout(&session).await;
return Err(err("finish setting up your account on pl8list.com first (choose a handle)"));
};
let text = |k: &str| user.get(k).and_then(Value::as_str).map(String::from);
Ok((Pl8User { id, handle: handle.to_string(), name: text("name"), img: text("img") }, session))
}
}
+264
View File
@@ -0,0 +1,264 @@
//! Client for pl8list.com.
//!
//! pl8list is a Remix app, so every page loader can be read as JSON by adding
//! `?_data=<route id>` to the page URL. Resource routes under /api/ return JSON
//! directly. All JSON requests share one throttle so we stay polite.
//!
//! Public data (artist pages, related tracks, previews, free tracks) works without
//! an account, so the crawler runs as a guest. A user's own session is only needed
//! for login and for syncing with their account (feed, library, saves).
pub mod login;
pub mod normalize;
use std::time::Duration;
use reqwest::{StatusCode, redirect};
use secrecy::{ExposeSecret, SecretString};
use serde_json::Value;
use tokio::{sync::Mutex, time::Instant};
use url::Url;
pub const USER_AGENT: &str = "pl8list-wrapper/0.3 (infinite-scroll client, https://pl8.narl.io)";
/// Signed storage URLs for audio and images point here.
const FILES_HOST: &str = "files.pl8list.com";
pub struct Pl8 {
http: reqwest::Client,
base: Url,
min_interval: Duration,
last: Mutex<Instant>,
}
impl Pl8 {
pub fn new(base: &str, min_interval: Duration) -> anyhow::Result<Self> {
let base = Url::parse(base)?;
let own_host = base.host_str().unwrap_or_default().to_string();
let own_scheme = base.scheme().to_string();
// Follow redirects only to pl8list and its storage host, never anywhere else.
let policy = redirect::Policy::custom(move |attempt| {
let url = attempt.url();
let host = url.host_str().unwrap_or_default();
let ok_host = host == own_host || host == FILES_HOST;
let ok_scheme = url.scheme() == "https" || url.scheme() == own_scheme;
if attempt.previous().len() >= 3 || !ok_host || !ok_scheme {
attempt.stop()
} else {
attempt.follow()
}
});
let http = reqwest::Client::builder()
.user_agent(USER_AGENT)
.redirect(policy)
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(30))
.build()?;
Ok(Pl8 { http, base, min_interval, last: Mutex::new(Instant::now() - min_interval) })
}
async fn throttle(&self) {
let mut last = self.last.lock().await;
let next = *last + self.min_interval;
tokio::time::sleep_until(next).await;
*last = Instant::now();
}
/// URL on pl8list from already-decoded path segments.
pub fn url(&self, segments: &[&str]) -> Url {
let mut url = self.base.clone();
url.path_segments_mut().expect("base is a URL with a path").clear().extend(segments);
url
}
pub fn request(&self, url: Url, session: Option<&SecretString>) -> reqwest::RequestBuilder {
with_session(self.http.get(url), session)
}
/// JSON GET with retries on overload. 403/404 give `None`. With a user session,
/// a redirect to the login page gives `SessionExpired`.
pub async fn get_json(
&self,
segments: &[&str],
params: &[(&str, String)],
route: Option<&str>,
mut session: Option<&mut UserSession>,
) -> anyhow::Result<Option<Value>> {
let mut url = self.url(segments);
{
let mut q = url.query_pairs_mut();
for (k, v) in params {
q.append_pair(k, v);
}
if let Some(r) = route {
q.append_pair("_data", r);
}
}
for attempt in 0..4u32 {
self.throttle().await;
let res = self.request(url.clone(), session.as_deref().map(|s| &s.cookie)).send().await?;
if let Some(s) = session.as_deref_mut() {
s.update(&res)?;
}
match res.status() {
s if s.is_success() => {
let body = res.bytes().await?;
return Ok(if body.is_empty() { None } else { Some(serde_json::from_slice(&body)?) });
}
StatusCode::TOO_MANY_REQUESTS | StatusCode::BAD_GATEWAY | StatusCode::SERVICE_UNAVAILABLE
| StatusCode::GATEWAY_TIMEOUT
if attempt < 3 =>
{
tokio::time::sleep(Duration::from_secs(3 << attempt)).await;
}
StatusCode::FORBIDDEN | StatusCode::NOT_FOUND => return Ok(None),
s => anyhow::bail!("pl8list {} for {}", s, url.path()),
}
}
Ok(None)
}
// Loaders -------------------------------------------------------------
//
// Paginated loaders ignore `page` unless `pagination=true` is also set,
// which is what the site's own useInfiniteLoading hook sends.
pub async fn root(&self, session: Option<&mut UserSession>) -> anyhow::Result<Option<Value>> {
self.get_json(&[], &[], Some("root"), session).await
}
pub async fn home(&self) -> anyhow::Result<Option<Value>> {
self.get_json(&[], &[], Some("routes/_index"), None).await
}
pub async fn feed(&self, session: &mut UserSession, page: i64) -> anyhow::Result<Option<Value>> {
let p = [("page", page.to_string()), ("pagination", "true".into())];
self.get_json(&["api", "feed"], &p, None, Some(session)).await
}
/// Tracks the user saved on pl8list (`items`, `totalPages`).
pub async fn library_tracks(&self, session: &mut UserSession, page: i64) -> anyhow::Result<Option<Value>> {
let p = [("page", page.to_string()), ("pagination", "true".into())];
self.get_json(&["library", "tracks"], &p, Some("routes/library.tracks"), Some(session)).await
}
/// Artists the user saved (follows) on pl8list (`items`, `totalPages`).
pub async fn library_artists(&self, session: &mut UserSession, page: i64) -> anyhow::Result<Option<Value>> {
let p = [("page", page.to_string()), ("pagination", "true".into())];
self.get_json(&["library", "artists"], &p, Some("routes/library.artists"), Some(session)).await
}
pub async fn artist(&self, handle: &str, page: i64) -> anyhow::Result<Option<Value>> {
let p = [("page", page.to_string()), ("pagination", "true".into())];
self.get_json(&[handle], &p, Some("routes/$handle"), None).await
}
pub async fn related(&self, handle: &str, slug: &str) -> anyhow::Result<Option<Value>> {
self.get_json(&[handle, slug, "related"], &[], Some("routes/$handle_.$slug.related"), None).await
}
pub async fn best_selling_artists(&self) -> anyhow::Result<Option<Value>> {
self.get_json(&["best-selling-artists"], &[], Some("routes/best-selling-artists"), None).await
}
pub async fn activity_ticker(&self) -> anyhow::Result<Option<Value>> {
self.get_json(&["api", "activity-ticker"], &[], None, None).await
}
pub async fn search_users(&self, q: &str, page: i64) -> anyhow::Result<Option<Value>> {
let p = [("q", q.to_string()), ("page", page.to_string()), ("pagination", "true".into())];
self.get_json(&["search", "users"], &p, Some("routes/search.users"), None).await
}
/// Save a track (`[handle, slug]`) or an artist (`[handle]`) to the user's pl8list
/// library. `addOnly` makes the toggle idempotent: it never removes anything.
pub async fn save(&self, session: &mut UserSession, target: &[&str]) -> anyhow::Result<()> {
let mut segments = vec!["api"];
segments.extend_from_slice(target);
segments.push("add-to-library");
let mut url = self.url(&segments);
url.query_pairs_mut().append_pair("addOnly", "true");
self.throttle().await;
let res = with_session(self.http.post(url), Some(&session.cookie)).send().await?;
session.update(&res)?;
let status = res.status();
let body: Value = res.json().await.unwrap_or(Value::Null);
if !status.is_success() || body.get("success").and_then(Value::as_bool) == Some(false) {
anyhow::bail!("pl8list refused the save (HTTP {status})");
}
Ok(())
}
/// End a session at pl8list. Errors are ignored: we drop the session either way.
pub async fn logout(&self, session: &SecretString) {
let mut url = self.url(&["logout"]);
url.query_pairs_mut().append_pair("_data", "routes/logout");
if let Err(e) = self.request(url, Some(session)).send().await {
tracing::warn!("pl8list logout failed: {}", e.without_url());
}
}
pub fn http(&self) -> &reqwest::Client {
&self.http
}
}
fn with_session(req: reqwest::RequestBuilder, session: Option<&SecretString>) -> reqwest::RequestBuilder {
match session {
Some(s) => req.header("Cookie", format!("_session={}", s.expose_secret())),
None => req,
}
}
/// pl8list no longer accepts the session; the user has to sign in again.
#[derive(Debug)]
pub struct SessionExpired;
impl std::fmt::Display for SessionExpired {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
f.write_str("pl8list session expired")
}
}
impl std::error::Error for SessionExpired {}
/// A signed-in pl8list session used on a user's behalf. pl8list may send a new
/// cookie with any response; `changed` tells the owner to store it again.
pub struct UserSession {
cookie: SecretString,
pub changed: bool,
}
impl UserSession {
pub fn new(cookie: SecretString) -> Self {
UserSession { cookie, changed: false }
}
pub fn cookie(&self) -> &SecretString {
&self.cookie
}
pub fn into_cookie(self) -> SecretString {
self.cookie
}
/// Take a refreshed cookie from `res`, and notice when pl8list sends us to its login.
fn update(&mut self, res: &reqwest::Response) -> Result<(), SessionExpired> {
let to_login = |p: &str| p == "/login" || p.starts_with("/login?");
let remix_redirect = res.headers().get("x-remix-redirect").and_then(|v| v.to_str().ok());
if to_login(res.url().path()) || remix_redirect.is_some_and(to_login) {
return Err(SessionExpired);
}
for c in res.headers().get_all("set-cookie") {
let Some(v) = c.to_str().ok().and_then(|c| c.strip_prefix("_session=")) else { continue };
let v = v.split(';').next().unwrap_or_default();
if v.is_empty() {
return Err(SessionExpired);
}
if v != self.cookie.expose_secret() {
self.cookie = SecretString::from(v.to_string());
self.changed = true;
}
}
Ok(())
}
}
+134
View File
@@ -0,0 +1,134 @@
//! Flatten pl8list loader JSON into `tracks` rows.
use serde_json::Value;
use crate::db::Track;
#[derive(Default)]
struct Extra {
full_access: bool,
saves: i64,
downloads: i64,
purchases: i64,
comments: i64,
}
fn int(v: Option<&Value>) -> i64 {
v.and_then(|v| v.as_i64().or_else(|| v.as_f64().map(|f| f as i64))).unwrap_or(0)
}
fn text(t: &Value, key: &str) -> Option<String> {
t.get(key).and_then(Value::as_str).filter(|s| !s.is_empty()).map(String::from)
}
fn num(t: &Value, key: &str) -> Option<f64> {
t.get(key).and_then(Value::as_f64)
}
fn json_text(t: &Value, key: &str) -> Option<String> {
t.get(key).filter(|v| !v.is_null() && v.as_array().is_none_or(|a| !a.is_empty())).map(Value::to_string)
}
/// A loader track item (`{track, hasFullAccess, saveCount, ...}`).
pub fn item(item: &Value) -> Option<Track> {
track(item.get("track")?, Extra {
full_access: item.get("hasFullAccess").and_then(Value::as_bool).unwrap_or(false),
saves: int(item.get("saveCount")),
downloads: int(item.get("downloadCount")),
purchases: int(item.get("purchaseCount")),
comments: int(item.get("commentCount")),
})
}
pub fn items(list: Option<&Value>) -> Vec<Track> {
list.and_then(Value::as_array).map(|a| a.iter().filter_map(item).collect()).unwrap_or_default()
}
/// The related loader returns bare tracks plus per-id maps for access and counts.
pub fn related(data: &Value) -> Vec<Track> {
let counts = data.get("counts");
let count = |name: &str, k: &str| int(counts.and_then(|c| c.get(name)).and_then(|m| m.get(k)));
let Some(tracks) = data.get("tracks").and_then(Value::as_array) else { return vec![] };
tracks
.iter()
.filter_map(|t| {
let k = t.get("id")?.as_i64()?.to_string();
track(t, Extra {
full_access: data.get("access").and_then(|a| a.get(&k)).and_then(Value::as_bool).unwrap_or(false),
saves: count("saveCount", &k),
downloads: count("downloadCount", &k),
purchases: count("purchaseCount", &k),
comments: count("commentCount", &k),
})
})
.collect()
}
fn track(t: &Value, extra: Extra) -> Option<Track> {
let user = t.get("user")?;
let handle = text(user, "handle")?;
if t.get("isDeleted").and_then(Value::as_bool) == Some(true) || t.get("isPublic").and_then(Value::as_bool) == Some(false) {
return None;
}
let slug = text(t, "slug")?;
Some(Track {
id: t.get("id")?.as_i64()?,
artist_name: text(user, "name").unwrap_or_else(|| handle.clone()),
artist_img: text(user, "img"),
artist_verified: user.get("isVerified").and_then(Value::as_bool).unwrap_or(false),
title: text(t, "title").or_else(|| text(t, "filename")).unwrap_or_else(|| slug.clone()),
description: text(t, "description"),
bpm: num(t, "bpm"),
key: text(t, "key"),
duration: num(t, "duration"),
preview_duration: num(t, "previewDuration"),
price: num(t, "price").unwrap_or(0.0),
created_at: text(t, "createdAt"),
upload_id: text(t, "uploadId"),
preview_upload_id: text(t, "previewUploadId"),
waveform: json_text(t, "waveform"),
preview_waveform: json_text(t, "previewWaveform"),
full_access: extra.full_access,
saves: extra.saves,
downloads: extra.downloads,
purchases: extra.purchases,
comments: extra.comments,
related_of: None,
handle,
slug,
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn item_flattens_and_skips_private() {
let i = json!({
"track": {"id": 5, "slug": "s", "title": "T", "price": 4.99, "bpm": 140,
"waveform": [1, 2], "user": {"handle": "h", "isVerified": true}},
"hasFullAccess": false, "saveCount": 3
});
let t = item(&i).unwrap();
assert_eq!((t.id, t.handle.as_str(), t.artist_name.as_str()), (5, "h", "h"));
assert_eq!((t.price, t.bpm, t.saves), (4.99, Some(140.0), 3));
assert_eq!(t.waveform.as_deref(), Some("[1,2]"));
let private = json!({"track": {"id": 6, "slug": "s", "isPublic": false, "user": {"handle": "h"}}});
assert!(item(&private).is_none());
}
#[test]
fn related_reads_count_maps() {
let d = json!({
"tracks": [{"id": 9, "slug": "x", "user": {"handle": "a"}}],
"access": {"9": true},
"counts": {"saveCount": {"9": 4}}
});
let r = related(&d);
assert_eq!(r.len(), 1);
assert!(r[0].full_access);
assert_eq!(r[0].saves, 4);
}
}
+551
View File
@@ -0,0 +1,551 @@
//! Pick the next batch of tracks for the scroll feed.
//!
//! Each unserved playable track gets a score:
//!
//! popularity log-scaled saves/downloads/purchases/comments, normalised to 0..1
//! freshness exp(-age / 21 days)
//! artist learned from your events on that artist's tracks (likes, full listens, skips)
//! and whether you follow them
//! tempo / key closeness to the BPM and key of tracks you liked or finished
//! related track came from the "related" page of something you liked
//! exploration Gumbel noise, so the feed never freezes into one niche
//!
//! The batch is then picked greedily with a penalty for repeating an artist.
//! A `Ranker` supplies the weights, may narrow the candidates and may add a bonus.
//! Discovery modes (`Mode`) are further `Ranker`s chosen with `?mode=`.
use std::collections::{HashMap, HashSet};
use rand::RngExt;
use rusqlite::{Connection, params_from_iter, types::Value as Sql};
use serde_json::{Value, json};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crate::db::Track;
pub struct Weights {
pub popularity: f64,
pub freshness: f64,
pub artist: f64,
pub tempo: f64,
pub key: f64,
pub related: f64,
pub unknown_artist: f64,
pub exploration: f64,
}
pub trait Ranker: Send + Sync {
fn weights(&self) -> &Weights;
/// Extra SQL condition on `tracks t`, for modes that only want part of the catalogue.
fn condition(&self) -> Option<&str> {
None
}
/// Drop candidates the SQL condition cannot express.
fn keep(&self, _track: &Track) -> bool {
true
}
/// Added to a candidate's score.
fn bonus(&self, _track: &Track) -> f64 {
0.0
}
}
/// The original mix.
pub struct Classic;
const CLASSIC: Weights = Weights {
popularity: 1.2,
freshness: 0.8,
artist: 1.5,
tempo: 1.0,
key: 0.3,
related: 1.0,
unknown_artist: 0.2,
exploration: 0.6,
};
impl Ranker for Classic {
fn weights(&self) -> &Weights {
&CLASSIC
}
}
/// Discovery modes, chosen with `?mode=` (and `&artist=` for an artist radio).
#[derive(Clone, Debug, PartialEq)]
pub enum Mode {
Classic,
/// 140 BPM tracks, riddim first: tagged riddim, or by artists who make it or sit
/// next to them in the artist graph.
Riddim,
/// Little-known tracks: popularity counts against a track.
Deep,
/// Uploads of the last few weeks.
Fresh,
/// "More like this artist": the artist and their neighbours in the artist graph.
Artist(String),
}
impl Mode {
pub fn parse(mode: Option<&str>, artist: Option<&str>) -> Option<Mode> {
Some(match mode.unwrap_or("") {
"" | "classic" => Mode::Classic,
"riddim" => Mode::Riddim,
"deep" => Mode::Deep,
"fresh" => Mode::Fresh,
"artist" => Mode::Artist(artist?.to_string()),
_ => return None,
})
}
pub fn ranker(&self, conn: &Connection) -> rusqlite::Result<Box<dyn Ranker>> {
Ok(match self {
Mode::Classic => Box::new(Classic),
Mode::Riddim => Box::new(Riddim { affinity: tag_affinity(conn, "riddim")? }),
Mode::Deep => Box::new(Deep),
Mode::Fresh => Box::new(Fresh),
Mode::Artist(h) => Box::new(ArtistRadio { seed: h.clone(), near: neighbourhood(conn, h)? }),
})
}
}
struct Riddim {
affinity: HashMap<String, f64>,
}
const RIDDIM: Weights = Weights { popularity: 0.8, ..CLASSIC };
impl Ranker for Riddim {
fn weights(&self) -> &Weights {
&RIDDIM
}
fn condition(&self) -> Option<&str> {
Some("EXISTS (SELECT 1 FROM track_tags g WHERE g.track_id = t.id AND g.tag = '140')")
}
fn bonus(&self, t: &Track) -> f64 {
2.0 * self.affinity.get(&t.handle).copied().unwrap_or(0.0)
}
}
struct Deep;
const DEEP: Weights = Weights {
popularity: -0.8,
freshness: 0.3,
artist: 0.6,
tempo: 1.0,
key: 0.3,
related: 0.5,
unknown_artist: 1.0,
exploration: 0.8,
};
impl Ranker for Deep {
fn weights(&self) -> &Weights {
&DEEP
}
fn condition(&self) -> Option<&str> {
Some("t.saves <= 15")
}
}
struct Fresh;
const FRESH: Weights = Weights { freshness: 3.0, popularity: 0.6, ..CLASSIC };
impl Ranker for Fresh {
fn weights(&self) -> &Weights {
&FRESH
}
// pl8list dates look like 2026-10-31T18:34:13.657Z, so they compare as text.
fn condition(&self) -> Option<&str> {
Some("t.created_at >= strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-45 days')")
}
}
struct ArtistRadio {
seed: String,
/// Neighbours, 0..1 by how closely they are linked.
near: HashMap<String, f64>,
}
const RADIO: Weights = Weights { artist: 0.5, related: 0.5, ..CLASSIC };
impl Ranker for ArtistRadio {
fn weights(&self) -> &Weights {
&RADIO
}
fn keep(&self, t: &Track) -> bool {
t.handle == self.seed || self.near.contains_key(&t.handle)
}
// The artist themselves now and then; mostly the ones around them.
fn bonus(&self, t: &Track) -> f64 {
if t.handle == self.seed { 1.0 } else { 2.0 * self.near.get(&t.handle).copied().unwrap_or(0.0) }
}
}
/// Neighbours of an artist up to two hops away, scored 0..1. A second-hop artist
/// scores at most 0.4 of the link that leads to it.
fn neighbourhood(conn: &Connection, seed: &str) -> rusqlite::Result<HashMap<String, f64>> {
let scaled = |links: Vec<(String, i64)>| {
let max = links.iter().map(|l| l.1).max().unwrap_or(1).max(1) as f64;
links.into_iter().map(|(h, n)| (h, (n as f64 / max).sqrt())).collect::<Vec<_>>()
};
let first = scaled(crate::db::artist_neighbours(conn, seed)?);
let mut near: HashMap<String, f64> = first.iter().cloned().collect();
let mut by_score = first;
by_score.sort_by(|a, b| b.1.total_cmp(&a.1));
for (h, w) in by_score.into_iter().take(10) {
for (h2, w2) in scaled(crate::db::artist_neighbours(conn, &h)?) {
if h2 != seed {
let s = near.entry(h2).or_default();
*s = s.max(0.4 * w * w2);
}
}
}
Ok(near)
}
/// How strongly each artist is linked to `tag`, 0..1: their own tagged tracks (three
/// or more count fully), or half of what the artists next to them in the graph have.
pub fn tag_affinity(conn: &Connection, tag: &str) -> rusqlite::Result<HashMap<String, f64>> {
let own: HashMap<String, f64> = crate::db::tag_counts(conn, tag)?.into_iter().map(|(h, n)| (h, (n as f64 / 3.0).min(1.0))).collect();
let mut spread: HashMap<String, (f64, f64)> = HashMap::new();
for (a, b, n) in crate::db::artist_links(conn)? {
let n = n as f64;
for (from, to) in [(&a, &b), (&b, &a)] {
let e = spread.entry(to.clone()).or_default();
e.0 += n * own.get(from).copied().unwrap_or(0.0);
e.1 += n;
}
}
let mut out = own;
for (h, (sum, total)) in spread {
let s = out.entry(h).or_default();
*s = s.max(0.5 * sum / total);
}
out.retain(|_, v| *v > 0.0);
Ok(out)
}
/// Per-event contributions to artist affinity.
fn event_signal(kind: &str) -> f64 {
match kind {
"like" => 3.0,
"unlike" => -3.0,
"complete" => 1.5,
"skip" => -1.0,
"dislike" => -3.0,
_ => 0.0,
}
}
/// Following an artist (here or on pl8list) counts like a few likes of their tracks.
const FOLLOW_SIGNAL: f64 = 4.0;
const REPEAT_ARTIST_PENALTY: f64 = 1.5;
const RECENT_ARTIST_WINDOW: i64 = 6;
fn age_days(iso: Option<&str>) -> f64 {
let Some(t) = iso.and_then(|s| OffsetDateTime::parse(s, &Rfc3339).ok()) else { return 365.0 };
((OffsetDateTime::now_utc() - t).as_seconds_f64() / 86400.0).max(0.0)
}
/// Gaussian closeness to any liked tempo, also counting half/double time as a match.
fn tempo_match(bpm: Option<f64>, liked: &[f64]) -> f64 {
let Some(bpm) = bpm.filter(|b| *b > 0.0) else { return 0.0 };
liked
.iter()
.map(|b| {
let d = (bpm - b).abs().min((bpm * 2.0 - b).abs()).min((bpm / 2.0 - b).abs());
(-(d / 6.0).powi(2)).exp()
})
.fold(0.0, f64::max)
}
pub struct Taste {
pub artist: HashMap<String, f64>,
pub bpms: Vec<f64>,
pub keys: HashSet<String>,
pub positive_ids: HashSet<i64>,
pub liked_ids: Vec<i64>,
}
/// Summarise a user's listening history into artist affinity, liked tempos/keys and liked track ids.
pub fn taste(conn: &Connection, user_id: i64) -> rusqlite::Result<Taste> {
let mut stmt = conn.prepare_cached(
"SELECT e.track_id, e.kind, t.handle, t.bpm, t.key FROM events e JOIN tracks t ON t.id = e.track_id \
WHERE e.user_id = ? AND e.kind IN ('like', 'unlike', 'complete', 'skip', 'dislike') ORDER BY e.ts",
)?;
let mut artist: HashMap<String, f64> = HashMap::new();
// (track id, bpm, key), in the order they became positive.
let mut liked: Vec<(i64, Option<f64>, Option<String>)> = vec![];
let mut completed: Vec<(i64, Option<f64>, Option<String>)> = vec![];
let rows = stmt.query_map([user_id], |r| {
Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?, r.get::<_, String>(2)?, r.get::<_, Option<f64>>(3)?, r.get::<_, Option<String>>(4)?))
})?;
for row in rows {
let (id, kind, handle, bpm, key) = row?;
*artist.entry(handle).or_default() += event_signal(&kind);
match kind.as_str() {
"like" => {
liked.retain(|l| l.0 != id);
liked.push((id, bpm, key));
}
"unlike" => liked.retain(|l| l.0 != id),
"complete" => {
completed.retain(|l| l.0 != id);
completed.push((id, bpm, key));
}
_ => {}
}
}
for handle in crate::db::follows(conn, user_id)? {
*artist.entry(handle).or_default() += FOLLOW_SIGNAL;
}
let liked_set: HashSet<i64> = liked.iter().map(|l| l.0).collect();
let positive: Vec<_> = completed.iter().filter(|c| !liked_set.contains(&c.0)).chain(liked.iter()).collect();
let bpms: Vec<f64> = positive.iter().filter_map(|p| p.1).collect();
Ok(Taste {
artist: artist.into_iter().map(|(h, v)| (h, (v / 4.0).tanh())).collect(),
bpms: bpms[bpms.len().saturating_sub(50)..].to_vec(),
keys: positive.iter().filter_map(|p| p.2.clone()).collect(),
positive_ids: positive.iter().map(|p| p.0).collect(),
liked_ids: liked.iter().rev().map(|l| l.0).collect(),
})
}
#[derive(Default, Clone, Copy)]
pub struct Filters {
pub free_only: bool,
pub bpm_min: Option<f64>,
pub bpm_max: Option<f64>,
}
pub fn pick(conn: &Connection, user_id: i64, n: usize, f: Filters, ranker: &dyn Ranker) -> rusqlite::Result<Vec<Value>> {
let mut sql = String::from(
"SELECT * FROM tracks t WHERE (t.full_access=1 OR t.preview_upload_id IS NOT NULL) \
AND NOT EXISTS (SELECT 1 FROM events e WHERE e.user_id=? AND e.kind='served' AND e.track_id=t.id)",
);
let mut args = vec![Sql::Integer(user_id)];
if f.free_only {
sql.push_str(" AND t.price = 0");
}
if let Some(b) = f.bpm_min {
sql.push_str(" AND t.bpm >= ?");
args.push(Sql::Real(b));
}
if let Some(b) = f.bpm_max {
sql.push_str(" AND t.bpm <= ?");
args.push(Sql::Real(b));
}
if let Some(c) = ranker.condition() {
sql.push_str(" AND (");
sql.push_str(c);
sql.push(')');
}
let mut cands: Vec<Track> = conn.prepare(&sql)?.query_map(params_from_iter(args), Track::from_row)?.collect::<Result<_, _>>()?;
cands.retain(|t| ranker.keep(t));
if cands.is_empty() {
return Ok(vec![]);
}
let t = taste(conn, user_id)?;
let recent: Vec<String> = conn
.prepare_cached(
"SELECT t.handle FROM events e JOIN tracks t ON t.id=e.track_id \
WHERE e.user_id=? AND e.kind='served' ORDER BY e.ts DESC LIMIT ?",
)?
.query_map(rusqlite::params![user_id, RECENT_ARTIST_WINDOW], |r| r.get(0))?
.collect::<Result<_, _>>()?;
let pop_raw = |r: &Track| {
(r.saves as f64).ln_1p() + 0.7 * (r.downloads as f64).ln_1p() + (r.purchases as f64).ln_1p()
+ 0.5 * (r.comments as f64).ln_1p()
};
let max_pop = cands.iter().map(pop_raw).fold(0.0, f64::max);
let max_pop = if max_pop > 0.0 { max_pop } else { 1.0 };
let w = ranker.weights();
let mut rng = rand::rng();
let mut scored: Vec<(f64, Track)> = cands
.into_iter()
.map(|r| {
let aff = t.artist.get(&r.handle).copied();
let u: f64 = rng.random::<f64>().max(1e-12);
let s = w.popularity * pop_raw(&r) / max_pop
+ w.freshness * (-age_days(r.created_at.as_deref()) / 21.0).exp()
+ w.artist * aff.unwrap_or(0.0)
+ w.tempo * tempo_match(r.bpm, &t.bpms)
+ w.key * if r.key.as_ref().is_some_and(|k| t.keys.contains(k)) { 1.0 } else { 0.0 }
+ w.related * if r.related_of.is_some_and(|id| t.positive_ids.contains(&id)) { 1.0 } else { 0.0 }
+ w.unknown_artist * if aff.is_none() { 1.0 } else { 0.0 }
+ w.exploration * -(-u.ln()).ln()
+ ranker.bonus(&r);
(s, r)
})
.collect();
scored.sort_by(|a, b| b.0.total_cmp(&a.0));
scored.truncate((n * 8).max(60));
let mut seen = recent;
let mut out = vec![];
while !scored.is_empty() && out.len() < n {
let penalised = |(s, r): &(f64, Track)| s - REPEAT_ARTIST_PENALTY * seen.iter().filter(|h| **h == r.handle).count() as f64;
let best = (0..scored.len()).max_by(|&a, &b| penalised(&scored[a]).total_cmp(&penalised(&scored[b]))).unwrap_or(0);
let (s, r) = scored.remove(best);
seen.push(r.handle.clone());
out.push(serialize(&r, Some(s)));
}
add_tags(conn, &mut out)?;
Ok(out)
}
fn add_tags(conn: &Connection, tracks: &mut [Value]) -> rusqlite::Result<()> {
for t in tracks {
if let Some(id) = t["id"].as_i64() {
t["tags"] = json!(crate::db::track_tags(conn, id)?);
}
}
Ok(())
}
pub fn serialize(r: &Track, score: Option<f64>) -> Value {
let preview = !r.full_access;
let wave = if preview && r.preview_waveform.is_some() { &r.preview_waveform } else { &r.waveform };
let price = if r.price.fract() == 0.0 { json!(r.price as i64) } else { json!(r.price) };
json!({
"id": r.id,
"handle": r.handle,
"slug": r.slug,
"title": r.title,
"description": r.description,
"artist_name": r.artist_name,
"artist_img": r.artist_img,
"artist_verified": r.artist_verified,
"bpm": r.bpm,
"key": r.key,
"duration": if preview && r.preview_duration.is_some() { r.preview_duration } else { r.duration },
"price": price,
"created_at": r.created_at,
"saves": r.saves,
"downloads": r.downloads,
"purchases": r.purchases,
"comments": r.comments,
"preview": preview,
"upload_id": if preview { &r.preview_upload_id } else { &r.upload_id },
"waveform": wave.as_deref().and_then(|w| serde_json::from_str::<Value>(w).ok()),
"score": score.map(|s| (s * 1000.0).round() / 1000.0),
})
}
/// Liked tracks, newest like first.
pub fn likes(conn: &Connection, user_id: i64) -> rusqlite::Result<Vec<Value>> {
let ids = taste(conn, user_id)?.liked_ids;
if ids.is_empty() {
return Ok(vec![]);
}
let mut stmt = conn.prepare_cached("SELECT * FROM tracks WHERE id=?")?;
let mut out = vec![];
for id in ids {
if let Some(t) = rusqlite::OptionalExtension::optional(stmt.query_row([id], Track::from_row))? {
out.push(serialize(&t, None));
}
}
add_tags(conn, &mut out)?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db;
fn track(id: i64, handle: &str, title: &str, bpm: f64, saves: i64) -> Track {
Track {
id,
handle: handle.into(),
artist_name: handle.into(),
slug: format!("s{id}"),
title: title.into(),
bpm: Some(bpm),
saves,
full_access: true,
created_at: Some("2020-01-01T00:00:00.000Z".into()),
..Default::default()
}
}
fn ids(conn: &Connection, mode: Mode, n: usize) -> Vec<i64> {
let r = mode.ranker(conn).unwrap();
let mut out: Vec<i64> = pick(conn, 1, n, Filters::default(), &*r).unwrap().iter().map(|t| t["id"].as_i64().unwrap()).collect();
out.sort();
out
}
#[test]
fn riddim_mode_keeps_140_and_ranks_riddim_artists_and_their_neighbours_first() {
let mut c = db::memory();
let rows = [
track(1, "rid", "Wobble riddim", 140.0, 0),
track(2, "rid", "Other", 140.0, 0),
track(3, "near", "Plain", 140.0, 0),
track(4, "far", "Plain", 140.0, 500),
track(5, "dnb", "Roller", 174.0, 900),
];
db::upsert_tracks(&mut c, &rows, None).unwrap();
db::add_related(&mut c, 3, &["rid".into()]).unwrap();
let aff = tag_affinity(&c, "riddim").unwrap();
assert!((aff["rid"] - 1.0 / 3.0).abs() < 1e-9);
assert!(aff["near"] > 0.0 && aff["near"] < aff["rid"]);
assert!(!aff.contains_key("far"));
assert_eq!(ids(&c, Mode::Riddim, 10), [1, 2, 3, 4]);
let r = Mode::Riddim.ranker(&c).unwrap();
assert!(r.bonus(&rows[0]) > r.bonus(&rows[2]) && r.bonus(&rows[2]) > r.bonus(&rows[3]));
}
#[test]
fn artist_radio_stays_near_the_artist() {
let mut c = db::memory();
let rows = [track(1, "a", "T", 140.0, 0), track(2, "b", "T", 140.0, 0), track(3, "c", "T", 140.0, 0), track(4, "z", "T", 140.0, 0)];
db::upsert_tracks(&mut c, &rows, None).unwrap();
db::add_related(&mut c, 1, &["b".into()]).unwrap();
db::add_related(&mut c, 2, &["c".into()]).unwrap();
let near = neighbourhood(&c, "a").unwrap();
assert_eq!(near["b"], 1.0);
assert!((near["c"] - 0.4).abs() < 1e-9);
assert_eq!(ids(&c, Mode::Artist("a".into()), 10), [1, 2, 3]);
}
#[test]
fn deep_and_fresh_narrow_the_catalogue() {
let mut c = db::memory();
let mut new = track(3, "c", "T", 140.0, 0);
new.created_at = Some(c.query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ', 'now', '-1 day')", [], |r| r.get(0)).unwrap());
db::upsert_tracks(&mut c, &[track(1, "a", "T", 140.0, 2), track(2, "b", "T", 140.0, 400), new], None).unwrap();
assert_eq!(ids(&c, Mode::Deep, 10), [1, 3]);
assert_eq!(ids(&c, Mode::Fresh, 10), [3]);
assert_eq!(ids(&c, Mode::Classic, 10), [1, 2, 3]);
}
#[test]
fn modes_parse() {
assert_eq!(Mode::parse(None, None), Some(Mode::Classic));
assert_eq!(Mode::parse(Some("riddim"), None), Some(Mode::Riddim));
assert_eq!(Mode::parse(Some("artist"), Some("x")), Some(Mode::Artist("x".into())));
assert_eq!(Mode::parse(Some("artist"), None), None);
assert_eq!(Mode::parse(Some("nope"), None), None);
}
#[test]
fn tempo_counts_half_and_double_time() {
assert!(tempo_match(Some(140.0), &[140.0]) > 0.99);
assert!(tempo_match(Some(70.0), &[140.0]) > 0.99);
assert!(tempo_match(Some(100.0), &[140.0]) < 0.01);
assert_eq!(tempo_match(None, &[140.0]), 0.0);
}
#[test]
fn unknown_dates_count_as_a_year_old() {
assert_eq!(age_days(None), 365.0);
assert_eq!(age_days(Some("garbage")), 365.0);
assert!(age_days(Some("2020-01-01T00:00:00.000Z")) > 365.0);
}
}
+51
View File
@@ -0,0 +1,51 @@
//! Sliding-window rate limits, kept in memory only.
use std::{
collections::{HashMap, VecDeque},
sync::Mutex,
time::{Duration, Instant},
};
/// At most `limit` hits per key within `window`.
pub struct RateLimiter {
limit: usize,
window: Duration,
hits: Mutex<HashMap<String, VecDeque<Instant>>>,
}
impl RateLimiter {
pub fn new(limit: usize, window_secs: u64) -> Self {
RateLimiter { limit, window: Duration::from_secs(window_secs), hits: Mutex::new(HashMap::new()) }
}
pub fn allow(&self, key: &str) -> bool {
let now = Instant::now();
let mut hits = self.hits.lock().unwrap_or_else(|e| e.into_inner());
if hits.len() > 10_000 {
hits.retain(|_, q| q.back().is_some_and(|t| now.duration_since(*t) < self.window));
}
let q = hits.entry(key.to_string()).or_default();
while q.front().is_some_and(|t| now.duration_since(*t) >= self.window) {
q.pop_front();
}
if q.len() >= self.limit {
return false;
}
q.push_back(now);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn limits_per_key() {
let l = RateLimiter::new(2, 60);
assert!(l.allow("a"));
assert!(l.allow("a"));
assert!(!l.allow("a"));
assert!(l.allow("b"));
}
}
+246
View File
@@ -0,0 +1,246 @@
//! Sync with a user's pl8list account.
//!
//! In: the personal feed (tracks of artists they follow), saved tracks (become likes
//! here) and saved artists (become follows). Out, only for devices that stay
//! connected: likes and follows made here are saved to their pl8list library.
//! Saving is add-only; nothing here ever removes anything from a pl8list account.
//!
//! Without "stay connected" a sign-in syncs once, then the session is logged out at
//! pl8list. With it, the session is stored encrypted (`link.rs`) and used again only
//! inside that device's own requests: a resync when the last one is older than
//! `RESYNC_AFTER`, the resync button, and pushing likes and follows.
use std::{
collections::HashSet,
sync::{Arc, Mutex},
time::Duration,
};
use secrecy::SecretString;
use serde_json::Value;
use crate::{
crawler::Crawler,
db::{self, Db},
link::{LinkKey, Sealer},
pl8::{Pl8, SessionExpired, UserSession, normalize},
};
/// A connected device resyncs on use once its last sync is this old.
pub const RESYNC_AFTER: f64 = 6.0 * 3600.0;
const MAX_PAGES: i64 = 20;
const DEADLINE: Duration = Duration::from_secs(120);
pub struct Syncer {
pl8: Arc<Pl8>,
db: Db,
crawler: Arc<Crawler>,
pub key: LinkKey,
/// Users with a sync running, so a burst of requests starts only one.
running: Mutex<HashSet<i64>>,
}
/// What to do with the pl8list session once a task is done with it.
enum After {
Logout,
Store(Sealer),
}
fn text(v: &Value, k: &str) -> Option<String> {
v.get(k).and_then(Value::as_str).filter(|s| !s.is_empty()).map(String::from)
}
/// Library items are `{track, ...}` like feed items; accept a bare track too.
fn library_track(item: &Value) -> Option<db::Track> {
normalize::item(item).or_else(|| normalize::item(&serde_json::json!({ "track": item })))
}
fn library_artist(item: &Value) -> Option<String> {
text(item, "handle")
.or_else(|| item.get("user").and_then(|u| text(u, "handle")))
.or_else(|| item.get("artist").and_then(|u| text(u, "handle")))
}
impl Syncer {
pub fn new(pl8: Arc<Pl8>, db: Db, crawler: Arc<Crawler>, key: LinkKey) -> Arc<Self> {
Arc::new(Syncer { pl8, db, crawler, key, running: Mutex::new(HashSet::new()) })
}
fn claim(&self, user_id: i64) -> bool {
self.running.lock().unwrap_or_else(|e| e.into_inner()).insert(user_id)
}
fn release(&self, user_id: i64) {
self.running.lock().unwrap_or_else(|e| e.into_inner()).remove(&user_id);
}
/// Sync once with a fresh sign-in session, then log it out at pl8list.
pub fn once(self: &Arc<Self>, user_id: i64, session: SecretString) {
self.spawn_sync(user_id, session, After::Logout);
}
/// Sync with a connected device's session. Returns false if one is already running.
pub fn linked(self: &Arc<Self>, sealer: Sealer, session: SecretString) -> bool {
self.spawn_sync(sealer.user_id, session, After::Store(sealer))
}
fn spawn_sync(self: &Arc<Self>, user_id: i64, session: SecretString, after: After) -> bool {
if !self.claim(user_id) {
if let After::Logout = after {
// Never keep a one-time session around just because a sync is running.
let pl8 = self.pl8.clone();
tokio::spawn(async move { pl8.logout(&session).await });
}
return false;
}
let me = self.clone();
tokio::spawn(async move {
if let After::Store(sealer) = &after {
let hash = sealer.token_hash.clone();
if let Err(e) = me.db.call(move |c| db::mark_synced(c, &hash)).await {
tracing::warn!("marking sync: {e:#}");
}
}
let mut s = UserSession::new(session);
let res = tokio::time::timeout(DEADLINE, me.read_account(user_id, &mut s)).await;
let expired = match &res {
Ok(Ok((feed, likes, follows))) => {
tracing::info!("synced pl8list: {feed} feed pages, {likes} new likes, {follows} new follows");
false
}
Ok(Err(e)) => {
tracing::warn!("pl8list sync failed: {e:#}");
e.is::<SessionExpired>()
}
Err(_) => {
tracing::warn!("pl8list sync timed out");
false
}
};
me.finish(s, after, expired).await;
me.release(user_id);
});
true
}
/// Save a liked track to the user's pl8list library.
pub fn push_like(self: &Arc<Self>, sealer: Sealer, session: SecretString, handle: String, slug: String) {
let me = self.clone();
tokio::spawn(async move {
let mut s = UserSession::new(session);
let res = me.pl8.save(&mut s, &[&handle, &slug]).await;
me.after_push(s, sealer, res).await;
});
}
/// Save a followed artist to the user's pl8list library.
pub fn push_follow(self: &Arc<Self>, sealer: Sealer, session: SecretString, handle: String) {
let me = self.clone();
tokio::spawn(async move {
let mut s = UserSession::new(session);
let res = me.pl8.save(&mut s, &[&handle]).await;
me.after_push(s, sealer, res).await;
});
}
async fn after_push(&self, s: UserSession, sealer: Sealer, res: anyhow::Result<()>) {
let expired = match &res {
Ok(()) => false,
Err(e) => {
tracing::warn!("pl8list save failed: {e:#}");
e.is::<SessionExpired>()
}
};
self.finish(s, After::Store(sealer), expired).await;
}
/// Log a one-time session out, or store a refreshed cookie, or forget an expired one.
async fn finish(&self, s: UserSession, after: After, expired: bool) {
match after {
After::Logout => {
if !expired {
self.pl8.logout(s.cookie()).await;
}
}
After::Store(sealer) => {
let hash = sealer.token_hash.clone();
let res = if expired {
tracing::info!("pl8list session expired, device disconnected");
self.db.call(move |c| db::clear_link(c, &hash)).await
} else if s.changed {
let blob = sealer.seal(s.cookie());
self.db.call(move |c| db::set_link(c, &hash, &blob, None)).await
} else {
Ok(())
};
if let Err(e) = res {
tracing::warn!("storing pl8list session: {e:#}");
}
}
}
}
/// Feed, saved tracks and saved artists. Returns (feed pages, new likes, new follows).
async fn read_account(&self, user_id: i64, s: &mut UserSession) -> anyhow::Result<(i64, usize, usize)> {
let mut feed_pages = 0;
for page in 1..=MAX_PAGES {
let d = self.pl8.feed(s, page).await?.unwrap_or(Value::Null);
let rows = normalize::items(d.get("tracks"));
self.crawler.discovered_artists(rows.iter().map(|r| r.handle.clone()));
self.db.call(move |c| db::upsert_tracks(c, &rows, None)).await?;
feed_pages = page;
if page >= d.get("totalPages").and_then(Value::as_i64).unwrap_or(0) {
break;
}
}
let mut likes = 0;
for page in 1..=MAX_PAGES {
let d = self.pl8.library_tracks(s, page).await?.unwrap_or(Value::Null);
let rows: Vec<db::Track> =
d.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(library_track).collect()).unwrap_or_default();
let ids: Vec<i64> = rows.iter().map(|r| r.id).collect();
self.crawler.discovered_artists(rows.iter().map(|r| r.handle.clone()));
likes += self
.db
.call(move |c| {
db::upsert_tracks(c, &rows, None)?;
db::import_likes(c, user_id, &ids)
})
.await?;
if page >= d.get("totalPages").and_then(Value::as_i64).unwrap_or(0) {
break;
}
}
let mut follows = 0;
for page in 1..=MAX_PAGES {
let d = self.pl8.library_artists(s, page).await?.unwrap_or(Value::Null);
let handles: Vec<String> =
d.get("items").and_then(Value::as_array).map(|a| a.iter().filter_map(library_artist).collect()).unwrap_or_default();
self.crawler.discovered_artists(handles.clone());
follows += self.db.call(move |c| db::add_follows(c, user_id, &handles, "pl8list")).await?;
if page >= d.get("totalPages").and_then(Value::as_i64).unwrap_or(0) {
break;
}
}
Ok((feed_pages, likes, follows))
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn library_items_in_either_shape() {
let wrapped = json!({"track": {"id": 1, "slug": "s", "user": {"handle": "h"}}});
let bare = json!({"id": 2, "slug": "s", "user": {"handle": "h"}});
assert_eq!(library_track(&wrapped).unwrap().id, 1);
assert_eq!(library_track(&bare).unwrap().id, 2);
assert_eq!(library_artist(&json!({"handle": "a"})).as_deref(), Some("a"));
assert_eq!(library_artist(&json!({"user": {"handle": "b"}})).as_deref(), Some("b"));
assert_eq!(library_artist(&json!({"name": "c"})), None);
}
}
+105
View File
@@ -0,0 +1,105 @@
//! Tags for a track, read from its title, description, artist name and BPM.
//!
//! pl8list has no genre field, so this is keyword work. Producers name their files
//! after what they are ("… VIP", "… [DUBPACK]", "… (X FLIP) v2.wav"), which says a
//! lot about versions, and a little about style. Style tags are sparse: most riddim
//! is not called riddim. `ranker::tag_affinity` spreads them from tagged tracks to
//! their artists and the artists pl8list relates them to.
//!
//! Bump `VERSION` when the rules change; the catalogue is retagged on the next start.
pub const VERSION: i64 = 2;
fn words(text: &str) -> Vec<String> {
text.split(|c: char| !c.is_alphanumeric())
.filter(|w| !w.is_empty())
.map(str::to_lowercase)
.collect()
}
/// Tags of one track, sorted and without duplicates.
pub fn of(title: &str, description: Option<&str>, artist: &str, handle: &str, bpm: Option<f64>) -> Vec<&'static str> {
let amp = title.contains(" & ");
// File names: "x_y-z.wav" splits into words like any other title.
let title = words(title);
let mut text = title.clone();
text.extend(words(description.unwrap_or("")));
let has = |w: &str| text.iter().any(|t| t == w);
let pair = |a: &[&str], b: &[&str]| text.windows(2).any(|p| a.contains(&p[0].as_str()) && b.contains(&p[1].as_str()));
let triple = |a: &str, b: &[&str], c: &str| text.windows(3).any(|p| p[0] == a && b.contains(&p[1].as_str()) && p[2] == c);
let dnb = has("dnb") || pair(&["drum"], &["bass"]) || triple("drum", &["and", "n"], "bass");
let mut tags = vec![];
let mut tag = |on: bool, t: &'static str| {
if on {
tags.push(t);
}
};
let name = format!("{artist} {handle}").to_lowercase();
tag(has("riddim") || has("riddims") || has("riddimz") || name.contains("riddim"), "riddim");
tag(has("tearout") || pair(&["tear"], &["out"]), "tearout");
tag(has("deathstep"), "deathstep");
tag(has("brostep"), "brostep");
tag(has("colourbass") || has("colorbass") || pair(&["colour", "color"], &["bass"]), "colourbass");
tag(has("melodic"), "melodic");
tag(has("wonk") || has("wonky"), "wonk");
tag(has("halftime") || pair(&["half"], &["time"]), "halftime");
tag(dnb, "dnb");
tag(has("jungle"), "jungle");
tag(has("garage") || has("ukg") || has("2step"), "garage");
tag(has("grime"), "grime");
tag(has("trap"), "trap");
tag(has("vip"), "vip");
tag(has("dubpack") || pair(&["dub"], &["pack"]), "dubpack");
tag(has("dub") && !pair(&["dub"], &["pack"]), "dub");
tag(has("remix") || has("rmx"), "remix");
tag(has("flip"), "flip");
tag(has("bootleg"), "bootleg");
tag(has("edit"), "edit");
tag(has("spesh") || has("special"), "special");
let between = |w: &str| title.len() > 2 && title[1..title.len() - 1].iter().any(|t| t == w);
tag(has("collab") || has("ft") || has("feat") || between("x") || between("vs") || amp && !dnb, "collab");
tag(has("wip") || has("unfinished") || text.iter().any(|w| w.len() > 1 && w.starts_with('v') && w[1..].bytes().all(|b| b.is_ascii_digit())), "wip");
if let Some(b) = bpm.filter(|b| *b > 0.0) {
tag((138.0..=152.0).contains(&b) || (69.0..=76.0).contains(&b), "140");
tag((165.0..=180.0).contains(&b) || (82.5..=90.0).contains(&b), "170");
}
tags.sort_unstable();
tags.dedup();
tags
}
#[cfg(test)]
mod tests {
use super::*;
fn t(title: &str) -> Vec<&'static str> {
of(title, None, "Someone", "someone", None)
}
#[test]
fn reads_versions_from_file_names() {
assert_eq!(t("THRAX x JADHA - MR.WAYNE V1.wav"), ["collab", "wip"]);
assert_eq!(t("4. VIRTU - GANG VIP [THE VAULT DUBPACK VOL. 1].wav"), ["dubpack", "vip"]);
assert_eq!(t("KOH - FRACTURE [Cmadden SPESH].wav"), ["special"]);
assert_eq!(t("MOTUS & LOWER - FEENZ"), ["collab"]);
assert_eq!(t("LV VS 5CHIZO - HARDIONICFLOW.wav"), ["collab"]);
assert_eq!(t("Drum & Bass Roller"), ["dnb"]);
assert_eq!(t("Riddimo - Stacked Vip v1.mp3"), ["vip", "wip"]);
assert_eq!(t("SOMETHING (DUB)"), ["dub"]);
// Words inside other words do not count.
assert_eq!(t("VIPER - EDITION X"), Vec::<&str>::new());
}
#[test]
fn style_from_text_and_artist() {
assert_eq!(of("Track", Some("#Khmer #DNB #174bpm"), "A", "a", Some(174.0)), ["170", "dnb"]);
assert_eq!(of("Heavy one", None, "Riddim Lord", "lord", Some(70.0)), ["140", "riddim"]);
assert_eq!(of("tear out weapon", Some("drum n bass"), "A", "a", None), ["dnb", "tearout"]);
assert_eq!(of("x", None, "A", "a", Some(100.0)), Vec::<&str>::new());
}
}
+188
View File
@@ -0,0 +1,188 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Datenschutzerklärung · pl8list ∞</title>
<link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/legal.css">
</head>
<body class="legal">
<main>
<p><a href="/">← zurück</a> · <a href="#english">English summary</a></p>
<h1>Datenschutzerklärung</h1>
<p>Stand: {{updated}}</p>
<h2>1. Verantwortlicher</h2>
<p>
{{name}}<br>
{{address}}<br>
E-Mail: {{email}}
</p>
<p>Eine Datenschutzbeauftragte oder ein Datenschutzbeauftragter ist nicht benannt, weil keine Pflicht dazu besteht.</p>
<h2>2. Kurz gesagt</h2>
<ul>
<li>Sie melden sich mit Ihrem pl8list-Konto an. Ihre E-Mail-Adresse und der Anmeldecode gehen dafür an pl8list.</li>
<li>Wir speichern Ihr pl8list-Profil (ID, Nutzername, Anzeigename, Profilbild-ID) und was Sie hier hören, liken und überspringen, um Ihnen passende Tracks vorzuschlagen.</li>
<li>Ihre pl8list-Anmeldung speichern wir nur, wenn Sie „stay connected to pl8list“ wählen, und dann verschlüsselt mit einem Schlüssel, den nur Ihr Browser hat. Sonst nutzen wir sie einmal kurz im Arbeitsspeicher und melden sie danach bei pl8list ab.</li>
<li>Keine Werbung, kein Tracking, keine Analyse-Tools, keine Inhalte von Drittanbietern im Browser.</li>
<li>Sie können Ihre Daten jederzeit selbst herunterladen und Ihr Konto löschen.</li>
</ul>
<h2>3. Aufruf der Website und Hosting</h2>
<p>
Beim Aufruf verarbeitet unser Server technisch zwingend Ihre IP-Adresse, die angefragte Adresse,
Datum und Uhrzeit sowie die Angaben Ihres Browsers (User-Agent), um die Seite auszuliefern.
Die Anwendung selbst schreibt keine Zugriffsprotokolle. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f
DSGVO; unser berechtigtes Interesse ist der sichere und funktionsfähige Betrieb.
</p>
<p>Der Server wird betrieben bei:<br>{{hosting}}</p>
<p>
Mit dem Hosting-Anbieter besteht ein Vertrag zur Auftragsverarbeitung nach Art. 28 DSGVO, soweit
er Zugriff auf personenbezogene Daten haben kann.
</p>
<h2>4. Anmeldung über pl8list</h2>
<p>
Ein eigenes Passwort gibt es hier nicht. Wenn Sie sich anmelden, passiert Folgendes:
</p>
<ol>
<li>Ihre E-Mail-Adresse wird an pl8list.com übermittelt. pl8list sendet Ihnen daraufhin einen sechsstelligen Code.</li>
<li>Den Code, den Sie hier eingeben, leiten wir an pl8list weiter. Ist er richtig, meldet pl8list unseren Server mit Ihrem Konto an.</li>
<li>Mit dieser Anmeldung lesen wir einmalig Ihre persönliche pl8list-Übersicht und Ihre pl8list-Bibliothek: gespeicherte Tracks übernehmen wir als Likes, gespeicherte Künstler als „folgen“, um die Vorschläge zu verbessern. Das dauert höchstens zwei Minuten. Danach melden wir die Sitzung bei pl8list ab und verwerfen sie. Sie wird weder auf Datenträger geschrieben noch protokolliert, außer Sie wählen Abschnitt 5.</li>
<li>Von pl8list übernehmen wir Ihre pl8list-Nutzer-ID, Ihren Nutzernamen, Ihren Anzeigenamen und die ID Ihres Profilbilds.</li>
</ol>
<p>
Ihre E-Mail-Adresse speichern wir nicht dauerhaft. Sie liegt nur während der Anmeldung (höchstens
15 Minuten) im Arbeitsspeicher, zusätzlich zum Schutz vor Missbrauch (siehe Abschnitt 9).
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO: Sie möchten die Anmeldung mit Ihrem pl8list-Konto
nutzen. Für die Verarbeitung bei pl8list selbst ist pl8list verantwortlich; es gilt deren
Datenschutzerklärung auf pl8list.com.
</p>
<h2>5. Mit pl8list verbunden bleiben (freiwillig)</h2>
<p>
Bei der Anmeldung können Sie „stay connected to pl8list on this device“ wählen. Diese Einstellung gilt nur für das
Gerät bzw. den Browser, auf dem Sie sie wählen. Dann gilt zusätzlich:
</p>
<ul>
<li>Wir speichern Ihre pl8list-Sitzung (das Anmelde-Cookie von pl8list) verschlüsselt (XChaCha20-Poly1305).
Der Schlüssel wird aus Ihrem Sitzungs-Cookie bei uns (Abschnitt 6) und einem Serverschlüssel abgeleitet.
Ihr Sitzungs-Cookie liegt nur in Ihrem Browser; bei uns liegt davon nur ein Hashwert. Ohne Ihren Browser
können also weder wir noch jemand mit einer Kopie unserer Datenbank die pl8list-Sitzung entschlüsseln.
Entschlüsselt wird sie nur, während Ihr Browser eine Anfrage an uns stellt, und nur im Arbeitsspeicher.</li>
<li>Damit gleichen wir Ihre pl8list-Übersicht und -Bibliothek wie in Abschnitt 4 ab, wenn Sie die App nutzen
und der letzte Abgleich länger als sechs Stunden her ist, oder wenn Sie „resync with pl8list“ wählen.</li>
<li>Tracks, die Sie hier liken, und Künstler, denen Sie hier folgen, speichern wir in Ihrer pl8list-Bibliothek.
Wir fügen dort nur hinzu; wir entfernen nie etwas aus Ihrem pl8list-Konto.</li>
<li>Weitere Aktionen in Ihrem pl8list-Konto (z. B. Käufe, Kommentare, Profiländerungen) führen wir nicht aus.</li>
</ul>
<p>
Rechtsgrundlage ist Ihre Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können sie jederzeit mit Wirkung für
die Zukunft widerrufen (Art. 7 Abs. 3 DSGVO): im Menü „account“ unter „stop staying connected on this device“, durch
Abmelden oder durch Löschen Ihres Kontos. Dann melden wir die pl8list-Sitzung dieses Geräts bei pl8list ab
und löschen sie sofort. Lehnt pl8list die Sitzung ab (z. B. weil sie abgelaufen ist), löschen wir sie
ebenfalls. Bitte beachten Sie: Ob pl8list eine abgemeldete Sitzung auf seiner Seite ungültig macht,
liegt nicht in unserer Hand.
</p>
<h2>6. Konto und Sitzung</h2>
<p>
Nach der Anmeldung legen wir ein Konto mit den Daten aus Abschnitt 4 an und merken uns den Zeitpunkt
der Erstellung und der letzten Nutzung. Ihr Browser erhält ein Sitzungs-Cookie mit einer Zufallszahl.
Bei uns liegt davon nur ein Hashwert, sodass auch wir das Cookie daraus nicht wiederherstellen können.
Das Cookie erhält alle sieben Tage einen neuen Wert. Eine Sitzung endet nach 30 Tagen ohne Nutzung oder
sofort, wenn Sie sich abmelden.
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.
</p>
<h2>7. Vorschläge aus Ihrem Hörverhalten</h2>
<p>
Wir speichern, welche Tracks Ihnen gezeigt wurden und ob Sie sie gehört (mit Hördauer), zu Ende gehört,
übersprungen, geliked oder als „nicht für mich“ markiert haben, und welchen Künstlern Sie folgen (hier oder,
per Abgleich, auf pl8list). Daraus berechnen wir automatisch, welche
Tracks wir Ihnen als Nächstes zeigen. Das ist eine Form von Profiling. Es hat keine rechtliche oder
ähnlich erhebliche Wirkung für Sie; es bestimmt nur die Reihenfolge der Musik. Rechtsgrundlage ist
Art. 6 Abs. 1 lit. b DSGVO, denn personalisierte Vorschläge sind der Zweck dieses Dienstes.
</p>
<p>
Der Eintrag „gezeigt“ wird nach 180 Tagen gelöscht. Alle anderen Einträge bleiben, bis Sie Ihr Konto
löschen, weil sie Ihre Vorschläge, Ihre Like-Liste und Ihre gefolgten Künstler bilden.
</p>
<h2>8. Audio und Bilder</h2>
<p>
Musik und Profilbilder laden Sie nicht direkt von pl8list, sondern über unseren Server. pl8list erfährt
dadurch Ihre IP-Adresse nicht. Abgerufene Audiodateien und Bilder legen wir auf unserem Server
zwischen, damit pl8list weniger belastet wird; dieser Zwischenspeicher enthält keine Angaben über Sie.
Die Programmbibliothek zum Abspielen (hls.js) liefern wir selbst aus.
</p>
<h2>9. Schutz vor Missbrauch</h2>
<p>
Jede Anmeldung lässt pl8list eine E-Mail verschicken. Damit niemand diesen Dienst nutzt, um fremde
Postfächer zu überfluten oder Codes zu erraten, begrenzen wir Anmeldeversuche je IP-Adresse und je
E-Mail-Adresse. Dafür halten wir IP-Adresse und E-Mail-Adresse höchstens 15 Minuten im Arbeitsspeicher;
gespeichert werden sie nicht. Erfolgreiche Anmeldungen protokollieren wir mit Ihrem pl8list-Nutzernamen,
um Fehler und Missbrauch erkennen zu können; diese Protokolle werden fortlaufend überschrieben
(höchstens etwa 3 MB). Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO.
</p>
<h2>10. Cookies und lokaler Speicher</h2>
<ul>
<li><code>__Host-pl8inf</code>: Ihre Sitzung, bis zu 30 Tage ohne Nutzung. Wenn Sie verbunden bleiben, ist es auch Teil des Schlüssels für Ihre pl8list-Sitzung (Abschnitt 5).</li>
<li><code>__Host-pl8inf_login</code>: verbindet die beiden Schritte der Anmeldung, 15 Minuten.</li>
<li>Im lokalen Speicher Ihres Browsers: Ihre Filter (z. B. BPM-Bereich), der gewählte Feed (z. B. „riddim“ oder ein Künstler-Radio) und ob Sie den Bedienhinweis schon gesehen haben.</li>
</ul>
<p>
Alle sind für den von Ihnen gewünschten Dienst unbedingt erforderlich (§ 25 Abs. 2 Nr. 2 TDDDG).
Deshalb fragen wir nicht nach einer Einwilligung. Andere Cookies, Tracking oder Analyse-Tools gibt es nicht.
</p>
<h2>11. Empfänger und Drittländer</h2>
<p>
Außer an pl8list (Abschnitt 4) und den Hosting-Anbieter (Abschnitt 3) geben wir keine Daten weiter.
Wir selbst übermitteln keine Daten in Länder außerhalb der EU bzw. des EWR.
</p>
<h2>12. Ihre Rechte</h2>
<p>Sie haben das Recht auf</p>
<ul>
<li>Auskunft (Art. 15 DSGVO) und Datenübertragbarkeit (Art. 20 DSGVO): im Menü „account“ unter „download my data“ erhalten Sie alle über Sie gespeicherten Daten als JSON-Datei;</li>
<li>Löschung (Art. 17 DSGVO): im Menü „account“ unter „delete account“ entfernen Sie Konto, Sitzungen, gespeicherte pl8list-Sitzungen, gefolgte Künstler und Hörverlauf sofort und vollständig;</li>
<li>Widerruf einer Einwilligung (Art. 7 Abs. 3 DSGVO), siehe Abschnitt 5;</li>
<li>Berichtigung (Art. 16 DSGVO) und Einschränkung der Verarbeitung (Art. 18 DSGVO);</li>
<li>Widerspruch (Art. 21 DSGVO) gegen Verarbeitungen, die auf Art. 6 Abs. 1 lit. f DSGVO beruhen, aus Gründen, die sich aus Ihrer besonderen Situation ergeben;</li>
<li>Beschwerde bei einer Datenschutz-Aufsichtsbehörde (Art. 77 DSGVO), insbesondere in dem Mitgliedstaat Ihres Aufenthaltsorts oder Arbeitsplatzes.</li>
</ul>
<p>Für alle Anliegen genügt eine E-Mail an {{email}}.</p>
<h2>13. Pflicht zur Bereitstellung</h2>
<p>
Sie sind nicht verpflichtet, uns Daten zu geben. Ohne Anmeldung können Sie den Dienst aber nicht nutzen,
weil die Vorschläge an Ihr Konto gebunden sind.
</p>
<h2 id="english">English summary</h2>
<p>
This site is not affiliated with pl8list. To sign in, your email address and login code are passed to
pl8list.com, which sends you the code. We keep your pl8list user id, handle, display name and avatar id,
plus what you are shown, play, finish, skip, like or mark "not for me" and which artists you follow, to
recommend tracks. By default your pl8list login is used once, in memory, for at most two minutes to read
your pl8list feed and library (saved tracks become likes, saved artists follows), then logged out at
pl8list. If you choose "stay connected" (consent, Art. 6(1)(a) GDPR, per device), we store it encrypted
with a key derived from your session cookie, which only your browser holds, and use it while you use the
app to resync every six hours and to add what you like and follow here to your pl8list library. We never
remove anything from or do anything else with your pl8list account. Withdraw at any time under "account"
(stop staying connected, sign out, or delete account); we then log the session out and delete it. Audio and images are fetched through our server, so pl8list does not
see your IP address. We use only the cookies listed above, which are strictly necessary; there is no
tracking or analytics. IP and email address are held in memory for up to 15 minutes to rate-limit
sign-ins. You can download all your data and delete your account in the "account" menu at any time.
Legal bases: Art. 6(1)(b) GDPR for the account and recommendations, Art. 6(1)(f) for delivery and abuse
protection. You may complain to a data protection authority.
</p>
</main>
</body>
</html>
+52
View File
@@ -0,0 +1,52 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex">
<title>Impressum · pl8list ∞</title>
<link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/legal.css">
</head>
<body class="legal">
<main>
<p><a href="/">← zurück</a></p>
<h1>Impressum</h1>
<h2>Angaben gemäß § 5 DDG</h2>
<p>
{{name}}<br>
{{address}}
</p>
<h2>Kontakt</h2>
<p>
E-Mail: {{email}}{{phone}}
</p>
<h2>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
<p>
{{name}}<br>
{{address}}
</p>
<h2>Hinweis zu pl8list</h2>
<p>
pl8list ∞ ist ein privates, nicht kommerzielles Projekt und steht in keiner Verbindung zu
pl8list.com oder deren Betreibern. Alle Titel, Namen, Bilder und Audioinhalte stammen von
pl8list.com und gehören den jeweiligen Künstlerinnen und Künstlern. Wenn Sie als Rechteinhaber
möchten, dass Inhalte hier nicht mehr erscheinen, schreiben Sie bitte an die oben genannte
E-Mail-Adresse.
</p>
<h2>Haftung für Links</h2>
<p>
Diese Seite verlinkt auf pl8list.com. Für deren Inhalte ist allein der jeweilige Anbieter
verantwortlich. Bei Bekanntwerden von Rechtsverletzungen entfernen wir entsprechende Links
umgehend.
</p>
<p><a href="/datenschutz">Datenschutzerklärung</a></p>
</main>
</body>
</html>
+2 -1
View File
@@ -3,7 +3,8 @@
services:
app:
environment:
TRUST_PROXY: "1"
TRUSTED_PROXY_HOPS: "1"
PUBLIC_ORIGIN: "https://pl8.narl.io"
ports: !reset []
networks:
- narl
+1 -1
View File
@@ -9,7 +9,7 @@ services:
- path: .env
required: false
environment:
TRUST_PROXY: "0"
TRUSTED_PROXY_HOPS: "0"
ports:
- "127.0.0.1:8790:8790"
volumes:
+16 -8
View File
@@ -85,7 +85,10 @@ class Crawler:
def on_login(self, user_id, session):
"""Crawl the user's personal feed (artists in their pl8list library) with their session."""
old = self.feed_sessions.get(user_id)
self.feed_sessions[user_id] = session
if old:
Client(old).logout()
self.push(P_FEEDBACK, "feed", (user_id, 1))
# Main loop -------------------------------------------------------------
@@ -171,14 +174,19 @@ class Crawler:
session = self.feed_sessions.get(user_id)
if not session:
return
d = Client(session).feed(page) or {}
self.db.upsert_tracks([normalize_item(i) for i in d.get("tracks") or []])
for i in d.get("tracks") or []:
self.push(P_ARTIST_FIRST, "artist", i["track"]["user"]["handle"])
if page < (d.get("totalPages") or 0):
self.push(P_FEEDBACK, "feed", (user_id, page + 1))
else:
self.feed_sessions.pop(user_id, None)
more = False
try:
d = Client(session).feed(page) or {}
self.db.upsert_tracks([normalize_item(i) for i in d.get("tracks") or []])
for i in d.get("tracks") or []:
self.push(P_ARTIST_FIRST, "artist", i["track"]["user"]["handle"])
more = page < (d.get("totalPages") or 0)
finally:
# Done or failed: forget the session here and end it at pl8list.
if more:
self.push(P_FEEDBACK, "feed", (user_id, page + 1))
elif self.feed_sessions.pop(user_id, None):
Client(session).logout()
def do_artist_refresh(self, handle):
"""Newest uploads are on page 1, so re-reading it catches new tracks."""
+9
View File
@@ -100,6 +100,13 @@ class Client:
return self.get_json("/search/users", {"q": q, "page": page, "pagination": "true"},
route="routes/search.users")
def logout(self):
"""End this session at pl8list. Errors are ignored: we drop the session either way."""
try:
self.request("/logout", {"_data": "routes/logout"}).close()
except OSError:
pass
class LoginError(Exception):
pass
@@ -181,6 +188,8 @@ class LoginFlow:
if not user:
raise LoginError("login failed, try again")
if not user.get("handle"):
Client(self.session).logout()
self.session = None
raise LoginError("finish setting up your account on pl8list.com first (choose a handle)")
return user
+34 -7
View File
@@ -48,6 +48,9 @@ CONTENT_TYPES = {
SESSION_COOKIE = "pl8inf"
LOGIN_COOKIE = "pl8inf_login"
LOGIN_TTL = 900
MAX_PENDING_LOGINS = 500
MAX_RESENDS = 2
MAX_CODE_ATTEMPTS = 5
MAX_BODY = 4096
CSP = "; ".join([
"default-src 'self'",
@@ -86,6 +89,9 @@ class RateLimiter:
def allow(self, key):
now = time.time()
with self.lock:
if len(self.hits) > 10000:
for k in [k for k, q in self.hits.items() if not q or q[-1] < now - self.window]:
del self.hits[k]
q = self.hits[key]
while q and q[0] < now - self.window:
q.popleft()
@@ -103,15 +109,24 @@ class PendingLogins:
self.lock = threading.Lock()
def add(self, flow):
"""Returns the new flow id, or None when too many logins are pending."""
flow_id = secrets.token_urlsafe(24)
with self.lock:
now = time.time()
for k in [k for k, f in self.flows.items() if f.created < now - LOGIN_TTL]:
del self.flows[k]
flow.created, flow.attempts = now, 0
if len(self.flows) >= MAX_PENDING_LOGINS:
return None
flow.created, flow.attempts, flow.resends = now, 0, 0
self.flows[flow_id] = flow
return flow_id
def count(self, flow, field, limit):
"""Increment flow.<field> under the lock. False once it goes over `limit`."""
with self.lock:
setattr(flow, field, getattr(flow, field) + 1)
return getattr(flow, field) <= limit
def get(self, flow_id):
with self.lock:
flow = self.flows.get(flow_id or "")
@@ -135,6 +150,8 @@ class Handler(BaseHTTPRequestHandler):
logins = PendingLogins()
login_ip_limit = RateLimiter(5, 900)
login_email_limit = RateLimiter(3, 900)
# Every start and resend makes pl8list send a mail; cap them for the whole server.
login_global_limit = RateLimiter(30, 600)
def log_message(self, fmt, *args):
log.debug(fmt, *args)
@@ -149,10 +166,16 @@ class Handler(BaseHTTPRequestHandler):
# Request helpers ------------------------------------------------------
def client_ip(self):
# The proxy appends the address it saw, so only the last entry is trustworthy.
# Anything before it was sent by the client.
if self.trust_proxy and self.headers.get("X-Forwarded-For"):
return self.headers["X-Forwarded-For"].split(",")[0].strip()
return self.headers["X-Forwarded-For"].split(",")[-1].strip()
return self.client_address[0]
def allow_login_mail(self, email):
return (self.login_ip_limit.allow(self.client_ip()) and self.login_email_limit.allow(email)
and self.login_global_limit.allow(""))
def is_https(self):
return self.trust_proxy and self.headers.get("X-Forwarded-Proto") == "https"
@@ -261,21 +284,26 @@ class Handler(BaseHTTPRequestHandler):
email = str(data.get("email") or "").strip().lower()
if len(email) > 254 or not EMAIL_RE.match(email):
return self.send_json({"error": "enter a valid email"}, 400)
if not self.login_ip_limit.allow(self.client_ip()) or not self.login_email_limit.allow(email):
if not self.allow_login_mail(email):
return self.send_json({"error": "too many attempts, wait a few minutes"}, 429)
flow = LoginFlow(email)
flow_id = self.logins.add(flow)
if not flow_id:
return self.send_json({"error": "too many sign-ins right now, try again in a few minutes"}, 429)
try:
flow.start()
except LoginError as e:
self.logins.drop(flow_id)
return self.send_json({"error": str(e)}, 400)
flow_id = self.logins.add(flow)
self.send_json({"ok": True, "email": email}, cookies=[(LOGIN_COOKIE, flow_id, LOGIN_TTL)])
def auth_resend(self, data):
flow = self.logins.get(self.cookie(LOGIN_COOKIE))
if not flow:
return self.send_json({"error": "login expired, start again"}, 400)
if not self.login_ip_limit.allow(self.client_ip()):
if not self.logins.count(flow, "resends", MAX_RESENDS):
return self.send_json({"error": "no more resends, start again"}, 429)
if not self.allow_login_mail(flow.email):
return self.send_json({"error": "too many attempts, wait a few minutes"}, 429)
try:
flow.resend()
@@ -291,8 +319,7 @@ class Handler(BaseHTTPRequestHandler):
code = str(data.get("code") or "").strip()
if not re.fullmatch(r"\d{6}", code):
return self.send_json({"error": "the code has 6 digits"}, 400)
flow.attempts += 1
if flow.attempts > 5:
if not self.logins.count(flow, "attempts", MAX_CODE_ATTEMPTS):
self.logins.drop(flow_id)
return self.send_json({"error": "too many wrong codes, start again"}, 429)
try:
-714
View File
@@ -1,714 +0,0 @@
"use strict";
const PL8 = "https://pl8list.com";
const PREFETCH_AHEAD = 4; // fetch more when this close to the end
const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
const COMPLETE_RATIO = 0.85;
const DOUBLE_TAP_MS = 300;
const FILTERS_KEY = "pl8inf.filters";
const HINT_KEY = "pl8inf.hintShown";
const feed = document.getElementById("feed");
const audio = document.getElementById("audio");
const tpl = document.getElementById("card-tpl");
const statsEl = document.getElementById("stats");
const filtersForm = document.getElementById("filters");
const filtersBtn = document.getElementById("filters-btn");
const likesPanel = document.getElementById("likes");
const likesBtn = document.getElementById("likes-btn");
const startEl = document.getElementById("start");
const helpEl = document.getElementById("help");
const toastEl = document.getElementById("toast");
const cards = []; // {track, el, canvas, liked}
let active = -1;
let loading = false;
let started = false;
let hls = null;
let listened = 0; // seconds actually heard on the active card
let lastTime = 0;
let reported = false; // final event already sent for the active card
let dragging = false; // waveform seek in progress
let filters = loadFilters();
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
// Server ----------------------------------------------------------------
function sendEvent(track, kind, seconds) {
const body = JSON.stringify({ track_id: track.id, kind, seconds });
if (!navigator.sendBeacon("/local/event", new Blob([body], { type: "application/json" }))) {
fetch("/local/event", { method: "POST", body, headers: { "Content-Type": "application/json" }, keepalive: true });
}
}
async function loadMore() {
if (loading) return;
loading = true;
const status = document.createElement("div");
status.className = "status";
status.textContent = cards.length ? "loading…" : "crawling pl8list, first tracks in a few seconds…";
feed.append(status);
try {
const params = new URLSearchParams({ n: "10", ...filters });
const res = await fetch("/local/feed?" + params);
if (res.status === 401) {
status.remove();
loading = false;
showLogin();
return;
}
const data = await res.json();
showStats(data.stats);
if (!data.tracks.length) {
status.textContent = Object.keys(filters).length
? "nothing new matches your filters right now. loosen them, or check back in a bit."
: "nothing new right now, crawler is still digging. scroll again in a bit.";
setTimeout(() => { status.remove(); loading = false; }, 8000);
return;
}
status.remove();
data.tracks.forEach((t) => appendCard(buildCard(t)));
} catch (err) {
status.textContent = "failed to load: " + err;
setTimeout(() => status.remove(), 5000);
}
loading = false;
}
let stats = null;
function showStats(s) {
if (s) stats = s;
if (stats) statsEl.textContent = `${stats.tracks} tracks · ${stats.artists_crawled}/${stats.artists} artists crawled · ${stats.liked} liked`;
}
// Uploads are often titled after their file name.
const cleanTitle = (t) => t.replace(/\.(wav|mp3|flac|aiff?|ogg|m4a)$/i, "");
const trackUrl = (t) => `${PL8}/${t.handle}/${t.slug}`;
const fmtTime = (s) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
let toastTimer = 0;
function toast(text, ms = 2200) {
toastEl.textContent = text;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), ms);
}
// Cards -----------------------------------------------------------------
function buildCard(track, { liked = false } = {}) {
const el = tpl.content.firstElementChild.cloneNode(true);
const img = track.artist_img ? `url("${PL8}/img/${track.artist_img}")` : "";
el.querySelector(".bg").style.backgroundImage = img;
if (img) el.querySelector(".art").style.backgroundImage = img;
else el.querySelector(".initial").textContent = (track.artist_name || "?")[0].toUpperCase();
el.querySelector(".title").textContent = cleanTitle(track.title);
const artist = el.querySelector(".artist");
artist.textContent = track.artist_name + (track.artist_verified ? " ✓" : "");
artist.href = `${PL8}/${track.handle}`;
el.querySelector(".open").href = trackUrl(track);
el.querySelector(".desc").textContent = track.description || "";
el.querySelector(".dur").textContent = track.duration ? fmtTime(track.duration) : "";
const chips = el.querySelector(".chips");
const chip = (text, cls) => {
const s = document.createElement("span");
s.textContent = text;
if (cls) s.className = cls;
chips.append(s);
};
if (track.bpm) chip(`${Math.round(track.bpm)} bpm`);
if (track.key) chip(track.key);
chip(track.price ? `$${track.price}` : "free", track.price ? "" : "free");
if (track.preview) chip("preview", "preview");
el.querySelector(".counts").textContent =
`${track.saves} saves · ${track.downloads} downloads · ${track.comments} comments` +
(track.purchases ? ` · ${track.purchases} sold` : "");
const card = { track, el, canvas: el.querySelector(".wave"), liked };
el.querySelector(".like").classList.toggle("on", liked);
el.querySelector(".like").addEventListener("click", () => setLike(card, !card.liked));
el.querySelector(".dislike").addEventListener("click", () => dislike(card));
el.querySelector(".share").addEventListener("click", () => share(card));
el.querySelector(".next").addEventListener("click", () => go(cards.indexOf(card) + 1));
bindWaveform(card);
return card;
}
function appendCard(card) {
cards.push(card);
feed.append(card.el);
observer.observe(card.el);
drawWave(card, 0);
}
function insertNext(card) {
const ref = cards[active];
cards.splice(active + 1, 0, card);
if (ref) ref.el.after(card.el);
else feed.prepend(card.el);
observer.observe(card.el);
drawWave(card, 0);
}
function drawWave(card, progress) {
const { canvas, track } = card;
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w) return;
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== w * dpr) { canvas.width = w * dpr; canvas.height = h * dpr; }
const ctx = canvas.getContext("2d");
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const data = track.waveform || [];
const bars = Math.max(1, Math.floor(w / 3));
const max = Math.max(1, ...data);
for (let i = 0; i < bars; i++) {
const v = data.length ? data[Math.floor((i / bars) * data.length)] / max : 0.15;
const bh = Math.max(2, v * h);
ctx.fillStyle = i / bars < progress ? "#ff4d6d" : "rgba(255,255,255,0.35)";
ctx.fillRect(i * 3, (h - bh) / 2, 2, bh);
}
}
// Drag (or click) the waveform to seek; hovering with a mouse shows the time.
function bindWaveform(card) {
const { canvas } = card;
const wrap = canvas.parentElement;
const tip = wrap.querySelector(".wave-tip");
const duration = () => (card === cards[active] && audio.duration) || card.track.duration || 0;
const fraction = (e) => {
const r = canvas.getBoundingClientRect();
return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
};
const showTip = (f) => {
tip.textContent = fmtTime(f * duration());
tip.style.left = `${f * 100}%`;
};
canvas.addEventListener("pointerdown", (e) => {
if (card !== cards[active] || !audio.duration) return;
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
dragging = true;
wrap.classList.add("hover");
showTip(fraction(e));
drawWave(card, fraction(e));
});
canvas.addEventListener("pointermove", (e) => {
if (dragging) drawWave(card, fraction(e));
if (dragging || e.pointerType === "mouse") {
wrap.classList.add("hover");
showTip(fraction(e));
}
});
const finish = (e) => {
if (!dragging) return;
dragging = false;
if (e.type === "pointerup") audio.currentTime = fraction(e) * audio.duration;
if (e.pointerType !== "mouse") wrap.classList.remove("hover");
};
canvas.addEventListener("pointerup", finish);
canvas.addEventListener("pointercancel", finish);
canvas.addEventListener("pointerleave", () => { if (!dragging) wrap.classList.remove("hover"); });
}
// Playback --------------------------------------------------------------
function streamUrl(t) {
const p = new URLSearchParams();
if (t.preview) p.set("preview", "true");
if (t.upload_id) p.set("v", t.upload_id);
return `/api/${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}/hls/playlist.m3u8?${p}`;
}
function playbackFailed() {
toast("couldn't play this one, skipping");
go(active + 1);
}
function play(track) {
const url = streamUrl(track);
if (window.Hls && Hls.isSupported()) {
if (hls) hls.destroy();
hls = new Hls();
hls.on(Hls.Events.ERROR, (_, d) => {
if (d.fatal) {
console.warn("hls fatal", d);
playbackFailed();
}
});
hls.loadSource(url);
hls.attachMedia(audio);
} else {
audio.src = url; // Safari plays HLS natively
}
audio.play().catch(() => {});
updateMediaSession(track);
}
function togglePlay() {
if (audio.paused) audio.play().catch(() => {});
else audio.pause();
}
function finishActive() {
if (active < 0 || reported) return;
reported = true;
// Flicking past a card before any audio played says nothing about the track.
if (listened < 1) return;
const { track } = cards[active];
const dur = audio.duration || track.duration || 0;
if (dur && listened / dur >= COMPLETE_RATIO) sendEvent(track, "complete", listened);
else if (listened < SKIP_SECONDS) sendEvent(track, "skip", listened);
else sendEvent(track, "play", listened);
}
function activate(idx) {
if (idx === active || !cards[idx]) return;
finishActive();
if (cards[active]) cards[active].el.classList.remove("paused", "buffering");
active = idx;
listened = 0;
lastTime = 0;
reported = false;
if (started) play(cards[idx].track);
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
}
// Activate directly as well as scrolling: with the screen locked the
// IntersectionObserver may not fire, but lock-screen "next" must still work.
function go(idx) {
if (idx < 0) return;
if (!cards[idx]) {
loadMore();
return;
}
activate(idx);
cards[idx].el.scrollIntoView({ behavior: document.hidden ? "instant" : "smooth" });
}
function setLike(card, liked) {
if (card.liked === liked) return;
card.liked = liked;
const btn = card.el.querySelector(".like");
btn.classList.toggle("on", liked);
if (liked) {
btn.classList.remove("pop");
void btn.offsetWidth; // restart the animation
btn.classList.add("pop");
}
if (stats) { stats.liked += liked ? 1 : -1; showStats(); }
sendEvent(card.track, liked ? "like" : "unlike");
}
function dislike(card) {
sendEvent(card.track, "dislike");
if (card === cards[active]) reported = true; // a dislike says more than a skip
if (card.liked) setLike(card, false);
toast("got it, less like this");
go(cards.indexOf(card) + 1);
}
async function share(card) {
const { track } = card;
const url = trackUrl(track);
if (navigator.share && isTouch) {
try {
await navigator.share({ title: cleanTitle(track.title), text: `${cleanTitle(track.title)} by ${track.artist_name}`, url });
} catch { /* dismissed */ }
return;
}
try {
await navigator.clipboard.writeText(url);
toast("link copied");
} catch {
toast(url, 4000);
}
}
audio.addEventListener("timeupdate", () => {
const t = audio.currentTime;
const d = t - lastTime;
if (d > 0 && d < 1.5) listened += d; // ignore seeks
lastTime = t;
const card = cards[active];
if (card && audio.duration) {
if (!dragging) drawWave(card, t / audio.duration);
card.el.querySelector(".cur").textContent = fmtTime(t);
}
updatePosition();
});
audio.addEventListener("ended", () => {
if (active >= 0 && !reported) {
sendEvent(cards[active].track, "complete", listened);
reported = true;
}
go(active + 1);
});
audio.addEventListener("play", () => {
cards[active]?.el.classList.remove("paused");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
});
audio.addEventListener("pause", () => {
if (!audio.ended) cards[active]?.el.classList.add("paused");
cards[active]?.el.classList.remove("buffering");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused";
});
audio.addEventListener("waiting", () => cards[active]?.el.classList.add("buffering"));
audio.addEventListener("playing", () => cards[active]?.el.classList.remove("buffering"));
audio.addEventListener("error", () => { if (!hls && started && audio.src) playbackFailed(); });
const observer = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting) activate(cards.findIndex((c) => c.el === e.target));
}
}, { root: feed, threshold: 0.6 });
// Lock screen, notification and headphone controls ------------------------
function updateMediaSession(track) {
if (!("mediaSession" in navigator)) return;
const artwork = [{ src: "/static/icons/icon-512.png", sizes: "512x512", type: "image/png" }];
if (track.artist_img) artwork.unshift({ src: `${PL8}/img/${track.artist_img}`, sizes: "512x512" });
navigator.mediaSession.metadata = new MediaMetadata({
title: cleanTitle(track.title),
artist: track.artist_name,
album: "pl8list ∞",
artwork,
});
}
let lastPositionUpdate = 0;
function updatePosition() {
if (!("mediaSession" in navigator) || !navigator.mediaSession.setPositionState) return;
const now = performance.now();
if (now - lastPositionUpdate < 1000 || !Number.isFinite(audio.duration)) return;
lastPositionUpdate = now;
try {
navigator.mediaSession.setPositionState({
duration: audio.duration,
position: Math.min(audio.currentTime, audio.duration),
playbackRate: audio.playbackRate || 1,
});
} catch { /* duration not known yet */ }
}
if ("mediaSession" in navigator) {
const handlers = {
play: () => audio.play().catch(() => {}),
pause: () => audio.pause(),
nexttrack: () => go(active + 1),
previoustrack: () => (audio.currentTime > 5 ? (audio.currentTime = 0) : go(active - 1)),
seekto: (d) => { audio.currentTime = d.seekTime; },
seekforward: (d) => { audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + (d.seekOffset || 10)); },
seekbackward: (d) => { audio.currentTime = Math.max(0, audio.currentTime - (d.seekOffset || 10)); },
};
for (const [action, fn] of Object.entries(handlers)) {
try { navigator.mediaSession.setActionHandler(action, fn); } catch { /* unsupported action */ }
}
}
// Tap to play/pause, double-tap to like ------------------------------------
let lastTap = { at: 0, card: null, toggled: false };
let swallowClick = false; // the tap that only closed a panel
function flash(card, playing) {
const f = document.createElement("div");
f.className = "flash";
f.innerHTML = `<svg><use href="#i-${playing ? "play" : "pause"}"/></svg>`;
card.el.append(f);
f.addEventListener("animationend", () => f.remove());
}
function burst(card, e) {
const r = card.el.getBoundingClientRect();
const b = document.createElementNS("http://www.w3.org/2000/svg", "svg");
b.classList.add("burst");
b.innerHTML = '<use href="#i-heart"/>';
b.style.left = `${e.clientX - r.left}px`;
b.style.top = `${e.clientY - r.top}px`;
b.style.setProperty("--tilt", `${Math.round(Math.random() * 30 - 15)}deg`);
card.el.append(b);
b.addEventListener("animationend", () => b.remove());
}
feed.addEventListener("click", (e) => {
if (swallowClick) { swallowClick = false; return; }
if (e.target.closest("button, a, input, .wave-wrap")) return;
const el = e.target.closest(".card");
const card = el && cards.find((c) => c.el === el);
if (!card || !started) return;
const now = performance.now();
if (lastTap.card === card && now - lastTap.at < DOUBLE_TAP_MS) {
// Second tap: undo the play/pause from the first one and like instead.
if (lastTap.toggled) {
togglePlay();
el.querySelectorAll(".flash").forEach((f) => f.remove());
}
burst(card, e);
setLike(card, true);
lastTap = { at: 0, card: null, toggled: false };
return;
}
const toggled = card === cards[active];
if (toggled) {
togglePlay();
flash(card, !audio.paused);
}
lastTap = { at: now, card, toggled };
});
// Panels and overlays -----------------------------------------------------
const panels = [[filtersForm, filtersBtn], [likesPanel, likesBtn]];
function closePanels() {
for (const [panel, btn] of panels) {
panel.hidden = true;
btn.setAttribute("aria-expanded", "false");
}
}
function togglePanel(panel) {
const open = panel.hidden;
closePanels();
if (open) {
panel.hidden = false;
panels.find(([p]) => p === panel)[1].setAttribute("aria-expanded", "true");
}
return open;
}
document.addEventListener("pointerdown", (e) => {
const open = panels.find(([p]) => !p.hidden);
if (!open || open[0].contains(e.target) || e.target.closest("#bar")) return;
closePanels();
swallowClick = true;
setTimeout(() => { swallowClick = false; }, 500);
}, true);
function showHelp() { helpEl.hidden = false; }
function hideHelp() { helpEl.hidden = true; }
document.getElementById("help-btn").addEventListener("click", showHelp);
document.getElementById("help-close").addEventListener("click", hideHelp);
helpEl.addEventListener("click", (e) => { if (e.target === helpEl) hideHelp(); });
function start() {
if (started) return;
started = true;
startEl.hidden = true;
if (cards[active]) play(cards[active].track);
if (!localStorage.getItem(HINT_KEY)) {
localStorage.setItem(HINT_KEY, "1");
toast(isTouch ? "tap to pause · double-tap to like · swipe up for next"
: "click to pause · double-click to like · ? for shortcuts", 5000);
}
}
startEl.addEventListener("click", start);
// Likes -----------------------------------------------------------------
likesBtn.addEventListener("click", async () => {
if (!togglePanel(likesPanel)) return;
const list = document.getElementById("likes-list");
const { tracks } = await (await fetch("/local/likes")).json();
list.replaceChildren(...tracks.map((t) => {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "play-now";
btn.title = "play now";
btn.innerHTML = '<svg><use href="#i-play"/></svg>';
btn.addEventListener("click", () => {
closePanels();
if (!started) start();
insertNext(buildCard(t, { liked: true }));
go(active + 1);
});
const meta = document.createElement("div");
meta.className = "meta";
const a = document.createElement("a");
a.href = trackUrl(t);
a.target = "_blank";
a.rel = "noopener";
a.textContent = cleanTitle(t.title);
const by = document.createElement("span");
by.textContent = t.artist_name;
meta.append(a, by);
li.append(btn, meta);
return li;
}));
if (!tracks.length) list.textContent = "nothing liked yet. double-tap a track you like.";
});
// Filters ---------------------------------------------------------------
function loadFilters() {
try {
return JSON.parse(localStorage.getItem(FILTERS_KEY)) || {};
} catch {
return {};
}
}
function showFilters() {
filtersForm.free.checked = filters.free === "1";
filtersForm.bpm_min.value = filters.bpm_min || "";
filtersForm.bpm_max.value = filters.bpm_max || "";
filtersBtn.classList.toggle("active", Object.keys(filters).length > 0);
}
function applyFilters() {
const f = new FormData(filtersForm);
filters = {};
if (f.get("free")) filters.free = "1";
if (f.get("bpm_min")) filters.bpm_min = f.get("bpm_min");
if (f.get("bpm_max")) filters.bpm_max = f.get("bpm_max");
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
showFilters();
closePanels();
// Drop cards below the current one so the new filter applies right away.
for (const c of cards.splice(active + 1)) { observer.unobserve(c.el); c.el.remove(); }
loadMore();
}
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
document.getElementById("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); });
showFilters();
// Keyboard --------------------------------------------------------------
document.addEventListener("keydown", (e) => {
if (e.target.tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (k === "Escape") {
closePanels();
hideHelp();
return;
}
if (!loginEl.hidden) return;
if (!started) {
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); }
return;
}
const card = cards[active];
if (k === "ArrowDown" || k === "j") { e.preventDefault(); go(active + 1); }
else if (k === "ArrowUp" || k === "k") { e.preventDefault(); go(active - 1); }
else if (k === " ") { e.preventDefault(); togglePlay(); if (card) flash(card, !audio.paused); }
else if (k === "ArrowRight") audio.currentTime = Math.min(audio.duration || 0, audio.currentTime + 10);
else if (k === "ArrowLeft") audio.currentTime = Math.max(0, audio.currentTime - 10);
else if (k === "l" && card) setLike(card, !card.liked);
else if (k === "x" && card) dislike(card);
else if (k === "s" && card) share(card);
else if (k === "m") { audio.muted = !audio.muted; toast(audio.muted ? "muted" : "sound on"); }
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
});
window.addEventListener("resize", () => cards.forEach((c, i) =>
drawWave(c, i === active && audio.duration ? audio.currentTime / audio.duration : 0)));
window.addEventListener("pagehide", finishActive);
// Sign-in -----------------------------------------------------------------
const loginEl = document.getElementById("login");
const emailForm = document.getElementById("email-form");
const codeForm = document.getElementById("code-form");
const loginError = document.getElementById("login-error");
async function postJSON(url, data) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data || {}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(body.error || `request failed (${res.status})`);
return body;
}
function showLogin() {
document.getElementById("bar").hidden = true;
startEl.hidden = true;
loginEl.hidden = false;
emailForm.hidden = false;
codeForm.hidden = true;
document.getElementById("email").focus();
}
async function withForm(form, fn) {
const buttons = form.querySelectorAll("button");
buttons.forEach((b) => { b.disabled = true; });
loginError.textContent = "";
try {
await fn();
} catch (err) {
loginError.textContent = err.message;
}
buttons.forEach((b) => { b.disabled = false; });
}
emailForm.addEventListener("submit", (e) => {
e.preventDefault();
withForm(emailForm, async () => {
const { email } = await postJSON("/auth/start", { email: emailForm.email.value });
document.getElementById("sent-to").textContent = email;
emailForm.hidden = true;
codeForm.hidden = false;
codeForm.code.value = "";
codeForm.code.focus();
});
});
codeForm.addEventListener("submit", (e) => {
e.preventDefault();
withForm(codeForm, async () => {
const { user } = await postJSON("/auth/verify", { code: codeForm.code.value });
signedIn(user);
});
});
codeForm.code.addEventListener("input", () => {
if (/^\d{6}$/.test(codeForm.code.value)) codeForm.requestSubmit();
});
document.getElementById("resend").addEventListener("click", () => {
withForm(codeForm, async () => {
await postJSON("/auth/resend");
loginError.textContent = "new code sent";
});
});
document.getElementById("restart").addEventListener("click", showLogin);
document.getElementById("logout-btn").addEventListener("click", async () => {
await postJSON("/auth/logout");
location.reload();
});
function signedIn(user) {
loginEl.hidden = true;
document.getElementById("bar").hidden = false;
document.getElementById("logout-btn").textContent = `sign out @${user.handle}`;
// Typing the login code counts as a user gesture, so playback may start right away.
if (navigator.userActivation?.hasBeenActive) start();
else if (!started) startEl.hidden = false;
if (!cards.length) loadMore();
}
(async () => {
const res = await fetch("/local/me");
if (res.ok) signedIn((await res.json()).user);
else showLogin();
})();
+64 -15
View File
@@ -10,13 +10,12 @@
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="pl8 ∞">
<link rel="manifest" href="/static/manifest.webmanifest">
<link rel="icon" href="/static/icons/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png">
<link rel="stylesheet" href="/static/style.css">
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.6.16/dist/hls.min.js"
integrity="sha384-5E8B0pTlZZJMabWpC0fyYf6OUpe15jJij34BqBAh4NXoHAlLNOjCPRrwtOXOQFAn"
crossorigin="anonymous"></script>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<!-- Everything, hls.js included, is bundled and served by us: no visitor address
goes to a CDN or to pl8list. -->
<script type="module" src="/src/main.ts"></script>
</head>
<body>
<!-- Shared icons, referenced with <use href="#i-…">. -->
@@ -32,11 +31,12 @@
<header id="bar" hidden>
<span class="logo">pl8list <b>∞</b></span>
<button id="mode-chip" type="button" title="back to your feed" hidden></button>
<span id="stats"></span>
<button id="filters-btn" type="button" aria-controls="filters" aria-expanded="false">filters</button>
<button id="likes-btn" type="button" aria-controls="likes" aria-expanded="false">likes</button>
<button id="help-btn" type="button" title="shortcuts (?)" aria-label="help">?</button>
<button id="logout-btn" type="button" title="sign out"></button>
<button id="account-btn" type="button" aria-controls="account" aria-expanded="false" title="account"></button>
</header>
<div id="login" hidden>
@@ -52,21 +52,42 @@
<label for="code">pl8list sent a 6-digit code to <span id="sent-to"></span></label>
<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" pattern="[0-9]{6}"
maxlength="6" required placeholder="123456">
<label class="check">
<input type="checkbox" id="connect" name="connect">
<span>stay connected to pl8list on this device
<small>keeps your feed and library in sync, and saves what you like and follow here to
your pl8list library. Your pl8list login is stored encrypted with a key only this browser
holds. Turn it off any time under your account.</small></span>
</label>
<button type="submit">sign in</button>
<div class="login-links">
<button type="button" id="resend" class="link">resend code</button>
<button type="button" id="restart" class="link">use another email</button>
</div>
</form>
<button type="button" id="login-cancel" class="link" hidden>cancel, keep using the app</button>
<p id="login-error" role="alert"></p>
<p class="fineprint">
No account? <a href="https://pl8list.com/signup" target="_blank" rel="noopener">sign up on pl8list</a>.
This site is not affiliated with pl8list. It never stores your pl8list login.
This site is not affiliated with pl8list. Your email and code go to pl8list, which signs this
server in for you. Unless you choose to stay connected, we use that pl8list login once to read
your feed and library, then log it out; it is never stored. We keep your pl8list handle, the
artists you follow and what you play and like, to pick tracks for you.
</p>
<p class="fineprint legal-links">
<a href="/datenschutz">Datenschutz / privacy</a> · <a href="/impressum">Impressum</a>
</p>
</div>
</div>
<form id="filters" class="panel" hidden>
<fieldset class="modes">
<legend>feed</legend>
<label><input type="radio" name="mode" value="classic"> for you</label>
<label><input type="radio" name="mode" value="riddim"> riddim</label>
<label><input type="radio" name="mode" value="fresh"> fresh</label>
<label><input type="radio" name="mode" value="deep"> deep cuts</label>
</fieldset>
<label><input type="checkbox" name="free"> free only</label>
<label>bpm <input type="number" name="bpm_min" min="40" max="250" placeholder="min"></label>
<label>– <input type="number" name="bpm_max" min="40" max="250" placeholder="max"></label>
@@ -79,7 +100,31 @@
<ol id="likes-list"></ol>
</aside>
<aside id="account" class="panel" hidden>
<h2 id="account-handle"></h2>
<section id="sync" aria-labelledby="sync-title">
<h3 id="sync-title">pl8list sync</h3>
<p id="sync-status"></p>
<button type="button" id="sync-btn">resync with pl8list</button>
<button type="button" id="disconnect-btn" class="link" hidden>stop staying connected on this device</button>
</section>
<button type="button" id="logout-btn">sign out</button>
<button type="button" id="logout-all-btn" class="link">sign out on all devices</button>
<a href="/account/export" download>download my data</a>
<button type="button" id="delete-open" class="link danger">delete account…</button>
<form id="delete-form" hidden>
<label for="delete-confirm">This deletes your likes and listening history here, right away.
Your pl8list account is not touched. Type <b id="delete-handle"></b> to confirm.</label>
<input id="delete-confirm" name="confirm" autocomplete="off" required>
<button type="submit" class="danger">delete my account</button>
</form>
<p class="fineprint legal-links">
<a href="/datenschutz">Datenschutz / privacy</a> · <a href="/impressum">Impressum</a>
</p>
</aside>
<main id="feed"></main>
<p id="feed-status" role="status" hidden></p>
<div id="start" hidden>
<button type="button">tap anywhere to start</button>
@@ -91,14 +136,16 @@
<dl class="touch-only">
<dt>tap</dt><dd>play / pause</dd>
<dt>double-tap</dt><dd>like</dd>
<dt>swipe up</dt><dd>next track</dd>
<dt>swipe up / down</dt><dd>next / previous</dd>
<dt>drag waveform</dt><dd>seek</dd>
</dl>
<dl class="pointer-only">
<dt>click · space</dt><dd>play / pause</dd>
<dt>double-click · l</dt><dd>like</dd>
<dt>↓ j · ↑ k</dt><dd>next / previous</dd>
<dt>↓ j · ↑ k · wheel</dt><dd>next / previous</dd>
<dt>← →</dt><dd>seek 10 s</dd>
<dt>f</dt><dd>follow artist</dd>
<dt>r</dt><dd>artist radio</dd>
<dt>x</dt><dd>not for me</dd>
<dt>s</dt><dd>share</dd>
<dt>m</dt><dd>mute</dd>
@@ -111,11 +158,10 @@
<div id="toast" role="status" aria-live="polite"></div>
<audio id="audio" preload="auto"></audio>
<template id="card-tpl">
<section class="card">
<div class="bg"></div>
<div class="glow"></div>
<div class="content">
<div class="art">
<span class="initial"></span>
@@ -123,7 +169,11 @@
<span class="spinner"></span>
</div>
<h1 class="title"></h1>
<a class="artist" target="_blank" rel="noopener"></a>
<div class="byline">
<a class="artist" target="_blank" rel="noopener"></a>
<button type="button" class="follow" title="follow artist (f)">follow</button>
<button type="button" class="radio" title="more like this artist (r)">radio</button>
</div>
<div class="chips"></div>
<p class="desc"></p>
<div class="wave-wrap">
@@ -143,6 +193,5 @@
</section>
</template>
<script src="/static/app.js"></script>
</body>
</html>
+1211
View File
File diff suppressed because it is too large. Load diff
+17
View File
@@ -0,0 +1,17 @@
{
"name": "pl8inf-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"check": "tsc --noEmit"
},
"dependencies": {
"hls.js": "1.7.4"
},
"devDependencies": {
"typescript": "7.0.2",
"vite": "8.3.5"
}
}
+28
View File
@@ -0,0 +1,28 @@
Copyright (c) 2017 Dailymotion (http://www.dailymotion.com)
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
src/remux/mp4-generator.js and src/demux/exp-golomb.ts implementation in this project
are derived from the HLS library for video.js (https://github.com/videojs/videojs-contrib-hls)
That work is also covered by the Apache 2 License, following copyright:
Copyright (c) 2013-2015 Brightcove
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+15
View File
@@ -0,0 +1,15 @@
/* Impressum and privacy pages. Plain CSS outside the app bundle, so the server's
templates can link it by a fixed name. */
:root { color-scheme: dark; --fg: #f2f2f2; --muted: #9a9a9a; --accent: #ff4d6d; }
* { box-sizing: border-box; }
body {
margin: 0;
background: #050507;
color: var(--fg);
font: 15px/1.6 system-ui, -apple-system, "Segoe UI", sans-serif;
}
main { max-width: 42rem; margin: 0 auto; padding: 2rem 1.2rem 4rem; }
h1 { font-size: 1.8rem; }
h2 { font-size: 1.1rem; margin-top: 2rem; }
a { color: var(--accent); }
code { font-size: 0.9em; }
File renamed without changes.
+117
View File
@@ -0,0 +1,117 @@
// Calls to our own server. The browser never talks to pl8list directly.
export interface Track {
id: number;
handle: string;
slug: string;
title: string;
description: string | null;
artist_name: string;
artist_img: string | null;
artist_verified: boolean;
bpm: number | null;
key: string | null;
duration: number | null;
price: number;
created_at: string | null;
saves: number;
downloads: number;
purchases: number;
comments: number;
preview: boolean;
upload_id: string | null;
waveform: number[] | null;
followed?: boolean;
/// From titles, descriptions and BPM, see backend/src/tags.rs.
tags?: string[];
}
export interface Stats {
tracks: number;
artists: number;
artists_crawled: number;
served: number;
liked: number;
}
export interface User {
handle: string;
name: string | null;
}
export interface Pl8Status {
connected: boolean;
synced_at: number | null;
}
export interface Me {
user: User;
pl8list: Pl8Status;
}
export type Filters = Partial<Record<"free" | "bpm_min" | "bpm_max", string>>;
/// Which part of the catalogue the feed draws from. `artist` is an artist radio:
/// that artist and the ones pl8list relates to them.
export type Mode =
| { mode: "classic" | "riddim" | "fresh" | "deep" }
| { mode: "artist"; artist: string; name: string };
export type EventKind = "play" | "like" | "unlike" | "skip" | "complete" | "dislike";
export class HttpError extends Error {
constructor(message: string, readonly status: number) {
super(message);
}
}
async function parse<T>(res: Response): Promise<T> {
const body = await res.json().catch(() => ({}));
if (!res.ok) throw new HttpError(body.error || `request failed (${res.status})`, res.status);
return body as T;
}
export async function getJSON<T>(url: string): Promise<T> {
return parse<T>(await fetch(url));
}
export async function postJSON<T = { ok: boolean }>(url: string, data?: unknown): Promise<T> {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data ?? {}),
});
return parse<T>(res);
}
/// Fire and forget; survives the page closing.
export function sendEvent(track: Track, kind: EventKind, seconds?: number): void {
const body = JSON.stringify({ track_id: track.id, kind, seconds });
if (!navigator.sendBeacon("/local/event", new Blob([body], { type: "application/json" }))) {
fetch("/local/event", { method: "POST", body, headers: { "Content-Type": "application/json" }, keepalive: true });
}
}
export function feed(filters: Filters, mode: Mode) {
const p = new URLSearchParams({ n: "10", ...filters });
if (mode.mode !== "classic") p.set("mode", mode.mode);
if (mode.mode === "artist") p.set("artist", mode.artist);
return getJSON<{ tracks: Track[]; stats: Stats }>("/local/feed?" + p);
}
export const likes = () => getJSON<{ tracks: Track[] }>("/local/likes");
export const me = () => getJSON<Me>("/local/me");
export const PL8 = "https://pl8list.com";
export const trackUrl = (t: Track) => `${PL8}/${t.handle}/${t.slug}`;
export const artistUrl = (t: Track) => `${PL8}/${t.handle}`;
// Images come through our server, so pl8list never sees the visitor's address.
export const artistImg = (t: Track) => (t.artist_img ? `/img/${encodeURIComponent(t.artist_img)}` : null);
export function streamUrl(t: Track): string {
const p = new URLSearchParams();
if (t.preview) p.set("preview", "true");
if (t.upload_id) p.set("v", t.upload_id);
return `/api/${encodeURIComponent(t.handle)}/${encodeURIComponent(t.slug)}/hls/playlist.m3u8?${p}`;
}
+253
View File
@@ -0,0 +1,253 @@
// One card per track. Elements are built when a card first comes near the screen
// and dropped again once it is far behind, so a long session stays light.
import { type Track, artistImg, artistUrl, trackUrl } from "./api";
import { accentFor } from "./color";
export interface CardActions {
like(card: Card): void;
dislike(card: Card): void;
share(card: Card): void;
next(card: Card): void;
follow(card: Card): void;
radio(card: Card): void;
/// Seeking only works on the playing card.
canSeek(card: Card): boolean;
duration(card: Card): number;
seek(card: Card, seconds: number): void;
}
// Uploads are often titled after their file name.
export const cleanTitle = (t: string) => t.replace(/\.(wav|mp3|flac|aiff?|ogg|m4a)$/i, "");
export const fmtTime = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const tpl = document.getElementById("card-tpl") as HTMLTemplateElement;
export class Card {
el: HTMLElement | null = null;
liked: boolean;
private canvas: HTMLCanvasElement | null = null;
private drawn = "";
private dragging = false;
private accent: string | null = null;
constructor(
readonly track: Track,
private actions: CardActions,
{ liked = false } = {},
) {
this.liked = liked;
}
element(): HTMLElement {
return this.el ?? this.build();
}
/// Forget the element; it is built again if the card comes back.
release(): void {
this.el?.remove();
this.el = this.canvas = null;
this.drawn = "";
}
private build(): HTMLElement {
const t = this.track;
const el = tpl.content.firstElementChild!.cloneNode(true) as HTMLElement;
const $ = <T extends HTMLElement>(sel: string) => el.querySelector(sel) as T;
const img = artistImg(t);
if (img) {
$(".bg").style.backgroundImage = `url("${img}")`;
$(".art").style.backgroundImage = `url("${img}")`;
if (this.accent) el.style.setProperty("--card-accent", this.accent);
else accentFor(img).then((c) => {
this.accent = c;
if (c && this.el) {
this.el.style.setProperty("--card-accent", c);
this.drawWave(this.lastProgress, true);
}
});
} else {
$(".initial").textContent = (t.artist_name || "?")[0].toUpperCase();
}
$(".title").textContent = cleanTitle(t.title);
const artist = $<HTMLAnchorElement>(".artist");
artist.textContent = t.artist_name + (t.artist_verified ? " ✓" : "");
artist.href = artistUrl(t);
$<HTMLAnchorElement>(".open").href = trackUrl(t);
$(".desc").textContent = t.description || "";
$(".dur").textContent = t.duration ? fmtTime(t.duration) : "";
const chips = $(".chips");
const chip = (text: string, cls = "") => {
const s = document.createElement("span");
s.textContent = text;
if (cls) s.className = cls;
chips.append(s);
};
if (t.bpm) chip(`${Math.round(t.bpm)} bpm`);
if (t.key) chip(t.key);
chip(t.price ? `$${t.price}` : "free", t.price ? "" : "free");
if (t.preview) chip("preview", "preview");
// Tempo tags repeat the BPM chip.
for (const tag of (t.tags ?? []).filter((g) => !/^\d+$/.test(g)).slice(0, 4)) chip(tag, "tag");
$(".counts").textContent =
`${t.saves} saves · ${t.downloads} downloads · ${t.comments} comments` +
(t.purchases ? ` · ${t.purchases} sold` : "");
$(".like").addEventListener("click", () => this.actions.like(this));
$(".dislike").addEventListener("click", () => this.actions.dislike(this));
$(".share").addEventListener("click", () => this.actions.share(this));
$(".next").addEventListener("click", () => this.actions.next(this));
$(".follow").addEventListener("click", () => this.actions.follow(this));
$(".radio").addEventListener("click", () => this.actions.radio(this));
this.el = el;
this.canvas = $<HTMLCanvasElement>(".wave");
this.bindWaveform();
this.showLike();
this.showFollow();
requestAnimationFrame(() => this.drawWave(this.lastProgress, true));
return el;
}
showLike(pop = false): void {
const btn = this.el?.querySelector(".like");
if (!btn) return;
btn.classList.toggle("on", this.liked);
if (pop && this.liked) {
btn.classList.remove("pop");
void (btn as HTMLElement).offsetWidth; // restart the animation
btn.classList.add("pop");
}
}
showFollow(): void {
const btn = this.el?.querySelector(".follow");
if (!btn) return;
btn.classList.toggle("on", !!this.track.followed);
btn.textContent = this.track.followed ? "following" : "follow";
}
setTime(seconds: number): void {
const cur = this.el?.querySelector(".cur");
if (cur) cur.textContent = fmtTime(seconds);
}
private lastProgress = 0;
/// Draw the waveform with `progress` (0..1) played. Cheap to call every frame:
/// it only redraws when a bar changes colour or the size changed.
drawWave(progress: number, force = false): void {
if (this.dragging && !force) return;
this.lastProgress = progress;
const canvas = this.canvas;
if (!canvas) return;
const w = canvas.clientWidth, h = canvas.clientHeight;
if (!w) return;
const bars = Math.max(1, Math.floor(w / 3));
const played = Math.round(progress * bars);
const key = `${w}x${h}:${played}`;
if (!force && key === this.drawn) return;
this.drawn = key;
const dpr = window.devicePixelRatio || 1;
if (canvas.width !== w * dpr) {
canvas.width = w * dpr;
canvas.height = h * dpr;
}
const ctx = canvas.getContext("2d")!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
const data = this.track.waveform || [];
const max = Math.max(1, ...data);
const accent = getComputedStyle(canvas).getPropertyValue("--card-accent").trim() || "#ff4d6d";
for (let i = 0; i < bars; i++) {
const v = data.length ? data[Math.floor((i / bars) * data.length)] / max : 0.15;
const bh = Math.max(2, v * h);
ctx.fillStyle = i < played ? accent : "rgba(255,255,255,0.35)";
ctx.fillRect(i * 3, (h - bh) / 2, 2, bh);
}
}
/// Loudness at `progress` from the waveform, 0..1. Stands in for the analyser
/// where there is no Web Audio graph.
loudnessAt(progress: number): number {
const data = this.track.waveform;
if (!data?.length) return 0;
this.peak ??= Math.max(1, ...data);
return data[Math.min(data.length - 1, Math.floor(progress * data.length))] / this.peak;
}
private peak: number | undefined;
// Drag (or click) the waveform to seek; hovering with a mouse shows the time.
private bindWaveform(): void {
const canvas = this.canvas!;
const wrap = canvas.parentElement!;
const tip = wrap.querySelector(".wave-tip") as HTMLElement;
const fraction = (e: PointerEvent) => {
const r = canvas.getBoundingClientRect();
return Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
};
const showTip = (f: number) => {
tip.textContent = fmtTime(f * this.actions.duration(this));
tip.style.left = `${f * 100}%`;
};
canvas.addEventListener("pointerdown", (e) => {
if (!this.actions.canSeek(this)) return;
e.preventDefault();
canvas.setPointerCapture(e.pointerId);
this.dragging = true;
wrap.classList.add("hover");
showTip(fraction(e));
this.drawWave(fraction(e), true);
});
canvas.addEventListener("pointermove", (e) => {
if (this.dragging) this.drawWave(fraction(e), true);
if (this.dragging || e.pointerType === "mouse") {
wrap.classList.add("hover");
showTip(fraction(e));
}
});
const finish = (e: PointerEvent) => {
if (!this.dragging) return;
this.dragging = false;
if (e.type === "pointerup") this.actions.seek(this, fraction(e) * this.actions.duration(this));
if (e.pointerType !== "mouse") wrap.classList.remove("hover");
};
canvas.addEventListener("pointerup", finish);
canvas.addEventListener("pointercancel", finish);
canvas.addEventListener("pointerleave", () => {
if (!this.dragging) wrap.classList.remove("hover");
});
}
/// Play/pause icon that pops up and fades.
flash(playing: boolean): void {
if (!this.el) return;
const f = document.createElement("div");
f.className = "flash";
f.innerHTML = `<svg><use href="#i-${playing ? "play" : "pause"}"/></svg>`;
this.el.append(f);
f.addEventListener("animationend", () => f.remove());
}
clearFlash(): void {
this.el?.querySelectorAll(".flash").forEach((f) => f.remove());
}
/// Heart burst where a double-tap landed.
burst(x: number, y: number): void {
if (!this.el) return;
const r = this.el.getBoundingClientRect();
const b = document.createElementNS("http://www.w3.org/2000/svg", "svg");
b.classList.add("burst");
b.innerHTML = '<use href="#i-heart"/>';
b.style.left = `${x - r.left}px`;
b.style.top = `${y - r.top}px`;
b.style.setProperty("--tilt", `${Math.round(Math.random() * 30 - 15)}deg`);
this.el.append(b);
b.addEventListener("animationend", () => b.remove());
}
}
+55
View File
@@ -0,0 +1,55 @@
// Accent colour from an artist image: the most vivid hue, at a lightness that reads
// well on the dark background. Images are same-origin (proxied), so the canvas can
// read them.
const cache = new Map<string, Promise<string | null>>();
export function accentFor(src: string): Promise<string | null> {
let p = cache.get(src);
if (!p) {
p = compute(src).catch(() => null);
cache.set(src, p);
}
return p;
}
async function compute(src: string): Promise<string | null> {
const img = new Image();
img.src = src;
await img.decode();
const size = 24;
const canvas = document.createElement("canvas");
canvas.width = canvas.height = size;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return null;
ctx.drawImage(img, 0, 0, size, size);
const px = ctx.getImageData(0, 0, size, size).data;
// Average hue as a vector, weighted by how vivid each pixel is.
let x = 0, y = 0, weight = 0, sat = 0;
for (let i = 0; i < px.length; i += 4) {
const [h, s, l] = hsl(px[i], px[i + 1], px[i + 2]);
const w = s * (1 - Math.abs(l - 0.5) * 2);
x += Math.cos(h * 2 * Math.PI) * w;
y += Math.sin(h * 2 * Math.PI) * w;
sat += s * w;
weight += w;
}
// Black-and-white artwork: keep the default accent.
if (weight < size * size * 0.04) return null;
const hue = ((Math.atan2(y, x) / (2 * Math.PI) + 1) % 1) * 360;
const s = Math.min(90, Math.max(60, (sat / weight) * 100));
return `hsl(${hue.toFixed(0)} ${s.toFixed(0)}% 62%)`;
}
function hsl(r: number, g: number, b: number): [number, number, number] {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b), min = Math.min(r, g, b);
const l = (max + min) / 2;
if (max === min) return [0, 0, l];
const d = max - min;
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
const h = max === r ? (g - b) / d + (g < b ? 6 : 0) : max === g ? (b - r) / d + 2 : (r - g) / d + 4;
return [h / 6, s, l];
}
+6
View File
@@ -0,0 +1,6 @@
// hls.js ships types for its full build only; the light build has the same API
// minus subtitles, alternate audio and DRM, none of which we use.
declare module "hls.js/light" {
export * from "hls.js";
export { default } from "hls.js";
}
+741
View File
@@ -0,0 +1,741 @@
import "./style.css";
import * as api from "./api";
import type { Filters, Mode, Pl8Status, Stats, Track, User } from "./api";
import { Card, type CardActions, cleanTitle } from "./cards";
import { Pager } from "./pager";
import { Player } from "./player";
import { Visualizer } from "./visual";
const PREFETCH_AHEAD = 4; // fetch more when this close to the end
const SKIP_SECONDS = 8; // leaving earlier than this counts as a skip
const COMPLETE_RATIO = 0.85;
const DOUBLE_TAP_MS = 300;
const PRELOAD_AFTER_MS = 2500; // start buffering the next track once this one has played a bit
const KEEP_BUILT = 3; // cards further than this from the current one drop their elements
const FILTERS_KEY = "pl8inf.filters";
const MODE_KEY = "pl8inf.mode";
const HINT_KEY = "pl8inf.hintShown";
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
const feedEl = $("feed");
const feedStatus = $("feed-status");
const statsEl = $("stats");
const filtersForm = $<HTMLFormElement>("filters");
const filtersBtn = $("filters-btn");
const likesPanel = $("likes");
const likesBtn = $("likes-btn");
const accountPanel = $("account");
const accountBtn = $("account-btn");
const startEl = $("start");
const helpEl = $("help");
const toastEl = $("toast");
const loginEl = $("login");
const isTouch = !matchMedia("(hover: hover) and (pointer: fine)").matches;
const player = new Player();
const visual = new Visualizer(player);
const cards: Card[] = [];
let active = -1;
let loading = false;
let feedGen = 0; // bumped when the feed changes, so answers for the old one are dropped
let started = false;
let listened = 0; // seconds actually heard on the active card
let lastTime = 0;
let reported = false; // final event already sent for the active card
let advanced = false; // already mixed into the next track
let preloadTimer = 0;
let filters: Filters = loadFilters();
let mode: Mode = loadMode();
let stats: Stats | null = null;
const current = () => cards[active] as Card | undefined;
// Feed ------------------------------------------------------------------
const pager = new Pager(feedEl, {
count: () => cards.length,
element: (i) => cards[i].element(),
onChange: (i) => activate(i),
onNeedMore: () => loadMore(),
});
function setStatus(text: string): void {
feedStatus.textContent = text;
feedStatus.hidden = !text;
}
async function loadMore(): Promise<void> {
if (loading) return;
loading = true;
if (!cards.length) setStatus("crawling pl8list, first tracks in a few seconds…");
const gen = feedGen;
try {
const data = await api.feed(filters, mode);
if (gen !== feedGen) return;
showStats(data.stats);
if (!data.tracks.length) {
setStatus(mode.mode === "artist"
? `nothing more around ${mode.name} yet. the crawler is looking up who they're related to, try again in a bit.`
: mode.mode !== "classic" || Object.keys(filters).length
? "nothing new matches this feed and your filters right now. loosen them, or check back in a bit."
: "nothing new right now, the crawler is still digging. try again in a bit.");
setTimeout(() => { if (gen === feedGen) loading = false; }, 8000);
return;
}
setStatus("");
cards.push(...data.tracks.map((t) => new Card(t, actions)));
pager.refresh();
if (active < 0) activate(0);
} catch (err) {
if (gen !== feedGen) return;
if (err instanceof api.HttpError && err.status === 401) {
loading = false;
showLogin();
return;
}
setStatus(`failed to load: ${(err as Error).message}`);
setTimeout(() => setStatus(""), 5000);
}
loading = false;
}
function showStats(s?: Stats): void {
if (s) stats = s;
if (stats) statsEl.textContent = `${stats.tracks} tracks · ${stats.artists_crawled}/${stats.artists} artists crawled · ${stats.liked} liked`;
}
let toastTimer = 0;
function toast(text: string, ms = 2200): void {
toastEl.textContent = text;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = window.setTimeout(() => toastEl.classList.remove("show"), ms);
}
function finishActive(): void {
const card = current();
if (!card || reported) return;
reported = true;
// Flicking past a card before any audio played says nothing about the track.
if (listened < 1) return;
const dur = player.audio.duration || card.track.duration || 0;
if (dur && listened / dur >= COMPLETE_RATIO) api.sendEvent(card.track, "complete", listened);
else if (listened < SKIP_SECONDS) api.sendEvent(card.track, "skip", listened);
else api.sendEvent(card.track, "play", listened);
}
function activate(idx: number): void {
if (idx === active || !cards[idx]) return;
finishActive();
current()?.el?.classList.remove("paused", "buffering", "current");
active = idx;
listened = 0;
lastTime = 0;
reported = false;
advanced = false;
const card = cards[idx];
if (pager.index !== idx) pager.go(idx, { instant: true });
card.element().classList.add("current");
visual.attach(card);
if (started) play(card.track);
clearTimeout(preloadTimer);
preloadTimer = window.setTimeout(() => {
const next = cards[active + 1];
if (next && started) player.preload(next.track);
}, PRELOAD_AFTER_MS);
if (idx >= cards.length - PREFETCH_AHEAD) loadMore();
cards.forEach((c, i) => { if (c.el && Math.abs(i - idx) > KEEP_BUILT) c.release(); });
}
function go(idx: number): void {
if (!pager.go(idx, { instant: document.hidden }) && idx >= cards.length) loadMore();
}
function play(track: Track): void {
player.play(track);
updateMediaSession(track);
}
function playbackFailed(): void {
toast("couldn't play this one, skipping");
go(active + 1);
}
// Card actions ------------------------------------------------------------
const actions: CardActions = {
like: (c) => setLike(c, !c.liked),
dislike: (c) => dislike(c),
share: (c) => share(c),
next: (c) => go(cards.indexOf(c) + 1),
follow: (c) => setFollow(c, !c.track.followed),
radio: (c) => setMode({ mode: "artist", artist: c.track.handle, name: c.track.artist_name }),
canSeek: (c) => c === current() && !!player.audio.duration,
duration: (c) => (c === current() && player.audio.duration) || c.track.duration || 0,
seek: (_, s) => player.seek(s),
};
function setLike(card: Card, liked: boolean): void {
if (card.liked === liked) return;
card.liked = liked;
card.showLike(true);
if (stats) { stats.liked += liked ? 1 : -1; showStats(); }
api.sendEvent(card.track, liked ? "like" : "unlike");
}
function dislike(card: Card): void {
api.sendEvent(card.track, "dislike");
if (card === current()) reported = true; // a dislike says more than a skip
if (card.liked) setLike(card, false);
toast("got it, less like this");
go(cards.indexOf(card) + 1);
}
async function setFollow(card: Card, follow: boolean): Promise<void> {
const { handle, artist_name } = card.track;
// Every card by this artist changes together.
const update = (on: boolean) => cards.filter((c) => c.track.handle === handle).forEach((c) => {
c.track.followed = on;
c.showFollow();
});
update(follow);
try {
await api.postJSON("/local/follow", { handle, follow });
toast(follow ? `following ${artist_name}` : `unfollowed ${artist_name}`);
} catch (err) {
update(!follow);
toast((err as Error).message, 4000);
}
}
async function share(card: Card): Promise<void> {
const { track } = card;
const url = api.trackUrl(track);
if (navigator.share && isTouch) {
try {
await navigator.share({ title: cleanTitle(track.title), text: `${cleanTitle(track.title)} by ${track.artist_name}`, url });
} catch { /* dismissed */ }
return;
}
try {
await navigator.clipboard.writeText(url);
toast("link copied");
} catch {
toast(url, 4000);
}
}
// Player events -----------------------------------------------------------
player.on("timeupdate", () => {
const a = player.audio;
const t = a.currentTime;
const d = t - lastTime;
if (d > 0 && d < 1.5) listened += d; // ignore seeks
lastTime = t;
current()?.setTime(t);
updatePosition();
// Where tracks can be mixed, start the next one just before this one ends.
if (player.mixing && !advanced && !document.hidden && a.duration && a.duration - t < 1 && cards[active + 1]) {
advanced = true;
if (!reported) { api.sendEvent(cards[active].track, "complete", listened); reported = true; }
go(active + 1);
}
});
player.on("ended", () => {
const card = current();
if (card && !reported) {
api.sendEvent(card.track, "complete", listened);
reported = true;
}
go(active + 1);
});
player.on("play", () => {
current()?.el?.classList.remove("paused");
visual.start();
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "playing";
});
player.on("pause", () => {
if (!player.audio.ended) current()?.el?.classList.add("paused");
current()?.el?.classList.remove("buffering");
if ("mediaSession" in navigator) navigator.mediaSession.playbackState = "paused";
});
player.on("waiting", () => current()?.el?.classList.add("buffering"));
player.on("playing", () => current()?.el?.classList.remove("buffering"));
player.on("failed", () => { if (started) playbackFailed(); });
// Lock screen, notification and headphone controls ------------------------
function updateMediaSession(track: Track): void {
if (!("mediaSession" in navigator)) return;
const artwork: MediaImage[] = [{ src: "/static/icons/icon-512.png", sizes: "512x512", type: "image/png" }];
const img = api.artistImg(track);
if (img) artwork.unshift({ src: img, sizes: "512x512" });
navigator.mediaSession.metadata = new MediaMetadata({
title: cleanTitle(track.title),
artist: track.artist_name,
album: "pl8list ∞",
artwork,
});
}
let lastPositionUpdate = 0;
function updatePosition(): void {
if (!("mediaSession" in navigator) || !navigator.mediaSession.setPositionState) return;
const a = player.audio;
const now = performance.now();
if (now - lastPositionUpdate < 1000 || !Number.isFinite(a.duration)) return;
lastPositionUpdate = now;
try {
navigator.mediaSession.setPositionState({
duration: a.duration,
position: Math.min(a.currentTime, a.duration),
playbackRate: a.playbackRate || 1,
});
} catch { /* duration not known yet */ }
}
if ("mediaSession" in navigator) {
const a = () => player.audio;
const handlers: [MediaSessionAction, MediaSessionActionHandler][] = [
["play", () => player.resume()],
["pause", () => player.pause()],
["nexttrack", () => go(active + 1)],
["previoustrack", () => (a().currentTime > 5 ? player.seek(0) : go(active - 1))],
["seekto", (d) => player.seek(d.seekTime ?? 0)],
["seekforward", (d) => player.seek(a().currentTime + (d.seekOffset || 10))],
["seekbackward", (d) => player.seek(a().currentTime - (d.seekOffset || 10))],
];
for (const [action, fn] of handlers) {
try { navigator.mediaSession.setActionHandler(action, fn); } catch { /* unsupported action */ }
}
}
// Tap to play/pause, double-tap to like ------------------------------------
let lastTap: { at: number; card: Card | null; toggled: boolean } = { at: 0, card: null, toggled: false };
let swallowClick = false; // the tap that only closed a panel
feedEl.addEventListener("click", (e) => {
if (swallowClick || pager.suppressClick) { swallowClick = false; return; }
const target = e.target as Element;
if (target.closest("button, a, input, .wave-wrap")) return;
const el = target.closest(".card");
const card = el && cards.find((c) => c.el === el);
if (!card || !started) return;
const now = performance.now();
if (lastTap.card === card && now - lastTap.at < DOUBLE_TAP_MS) {
// Second tap: undo the play/pause from the first one and like instead.
if (lastTap.toggled) {
player.toggle();
card.clearFlash();
}
card.burst(e.clientX, e.clientY);
setLike(card, true);
lastTap = { at: 0, card: null, toggled: false };
return;
}
const toggled = card === current();
if (toggled) {
player.toggle();
card.flash(!player.audio.paused);
}
lastTap = { at: now, card, toggled };
});
// Any gesture may set up the audio graph; browsers only allow it from one.
for (const ev of ["pointerdown", "keydown"]) document.addEventListener(ev, () => { if (started) player.unlock(); }, true);
// Panels and overlays -----------------------------------------------------
const panels: [HTMLElement, HTMLElement][] = [[filtersForm, filtersBtn], [likesPanel, likesBtn], [accountPanel, accountBtn]];
function closePanels(): void {
for (const [panel, btn] of panels) {
panel.hidden = true;
btn.setAttribute("aria-expanded", "false");
}
}
function togglePanel(panel: HTMLElement): boolean {
const open = panel.hidden !== false;
closePanels();
if (open) {
panel.hidden = false;
panels.find(([p]) => p === panel)![1].setAttribute("aria-expanded", "true");
}
return open;
}
document.addEventListener("pointerdown", (e) => {
const open = panels.find(([p]) => !p.hidden);
const target = e.target as Element;
if (!open || open[0].contains(target) || target.closest("#bar")) return;
closePanels();
swallowClick = true;
setTimeout(() => { swallowClick = false; }, 500);
}, true);
const showHelp = () => { helpEl.hidden = false; };
const hideHelp = () => { helpEl.hidden = true; };
$("help-btn").addEventListener("click", showHelp);
$("help-close").addEventListener("click", hideHelp);
helpEl.addEventListener("click", (e) => { if (e.target === helpEl) hideHelp(); });
function start(): void {
if (started) return;
started = true;
startEl.hidden = true;
player.unlock();
const card = current();
if (card) activateAgain(card);
if (!localStorage.getItem(HINT_KEY)) {
localStorage.setItem(HINT_KEY, "1");
toast(isTouch ? "tap to pause · double-tap to like · swipe up for next"
: "click to pause · double-click to like · ? for shortcuts", 5000);
}
}
startEl.addEventListener("click", start);
/// Start playing the current card after `start()`; activate() did not, as nothing played yet.
function activateAgain(card: Card): void {
play(card.track);
clearTimeout(preloadTimer);
preloadTimer = window.setTimeout(() => {
const next = cards[active + 1];
if (next) player.preload(next.track);
}, PRELOAD_AFTER_MS);
}
// Likes -----------------------------------------------------------------
likesBtn.addEventListener("click", async () => {
if (!togglePanel(likesPanel)) return;
const list = $("likes-list");
const { tracks } = await api.likes();
list.replaceChildren(...tracks.map((t) => {
const li = document.createElement("li");
const btn = document.createElement("button");
btn.type = "button";
btn.className = "play-now";
btn.title = "play now";
btn.innerHTML = '<svg><use href="#i-play"/></svg>';
btn.addEventListener("click", () => {
closePanels();
if (!started) start();
cards.splice(active + 1, 0, new Card(t, actions, { liked: true }));
pager.refresh();
go(active + 1);
});
const meta = document.createElement("div");
meta.className = "meta";
const a = document.createElement("a");
a.href = api.trackUrl(t);
a.target = "_blank";
a.rel = "noopener";
a.textContent = cleanTitle(t.title);
const by = document.createElement("span");
by.textContent = t.artist_name + (t.followed ? " · following" : "");
meta.append(a, by);
li.append(btn, meta);
return li;
}));
if (!tracks.length) list.textContent = "nothing liked yet. double-tap a track you like.";
});
// Filters ---------------------------------------------------------------
function loadFilters(): Filters {
try {
return JSON.parse(localStorage.getItem(FILTERS_KEY) || "{}") || {};
} catch {
return {};
}
}
function showFilters(): void {
const f = filtersForm.elements as unknown as Record<string, HTMLInputElement>;
f.free.checked = filters.free === "1";
f.bpm_min.value = filters.bpm_min || "";
f.bpm_max.value = filters.bpm_max || "";
filtersBtn.classList.toggle("active", Object.keys(filters).length > 0);
}
function applyFilters(): void {
const f = new FormData(filtersForm);
filters = {};
if (f.get("free")) filters.free = "1";
if (f.get("bpm_min")) filters.bpm_min = String(f.get("bpm_min"));
if (f.get("bpm_max")) filters.bpm_max = String(f.get("bpm_max"));
localStorage.setItem(FILTERS_KEY, JSON.stringify(filters));
showFilters();
closePanels();
refill();
}
/// Drop cards after the current one so a new filter or mode applies right away.
function refill(): void {
for (const c of cards.splice(active + 1)) c.release();
pager.refresh();
feedGen++;
loading = false;
loadMore();
}
// Feed modes --------------------------------------------------------------
const modeChip = $("mode-chip");
const MODE_NAMES = { classic: "for you", riddim: "riddim", fresh: "fresh", deep: "deep cuts" };
function loadMode(): Mode {
try {
const m = JSON.parse(localStorage.getItem(MODE_KEY) || "null");
if (m?.mode === "artist" ? typeof m.artist === "string" && typeof m.name === "string" : m?.mode in MODE_NAMES) return m;
} catch { /* fall through */ }
return { mode: "classic" };
}
const modeName = (m: Mode) => (m.mode === "artist" ? `${m.name} radio` : MODE_NAMES[m.mode]);
function showMode(): void {
modeChip.hidden = mode.mode === "classic";
modeChip.textContent = `${modeName(mode)} ×`;
for (const input of filtersForm.querySelectorAll<HTMLInputElement>("input[name=mode]")) input.checked = input.value === mode.mode;
}
function setMode(m: Mode): void {
if (JSON.stringify(m) === JSON.stringify(mode)) return;
mode = m;
localStorage.setItem(MODE_KEY, JSON.stringify(mode));
showMode();
closePanels();
toast(m.mode === "classic" ? "back to your feed" : `now playing: ${modeName(m)}`);
refill();
}
modeChip.addEventListener("click", () => setMode({ mode: "classic" }));
filtersForm.addEventListener("change", (e) => {
const input = e.target as HTMLInputElement;
if (input.name === "mode") setMode({ mode: input.value as "classic" | "riddim" | "fresh" | "deep" });
});
showMode();
filtersBtn.addEventListener("click", () => togglePanel(filtersForm));
filtersForm.addEventListener("submit", (e) => { e.preventDefault(); applyFilters(); });
$("filters-clear").addEventListener("click", () => { filtersForm.reset(); applyFilters(); });
showFilters();
// Keyboard --------------------------------------------------------------
document.addEventListener("keydown", (e) => {
if ((e.target as Element).tagName === "INPUT" || e.ctrlKey || e.metaKey || e.altKey) return;
const k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
if (k === "Escape") {
closePanels();
hideHelp();
return;
}
if (!loginEl.hidden) return;
if (!started) {
if (k !== "Tab" && k !== "Shift") { e.preventDefault(); start(); }
return;
}
const card = current();
const a = player.audio;
if (k === "ArrowDown" || k === "j") { e.preventDefault(); go(active + 1); }
else if (k === "ArrowUp" || k === "k") { e.preventDefault(); go(active - 1); }
else if (k === " ") { e.preventDefault(); player.toggle(); card?.flash(!a.paused); }
else if (k === "ArrowRight") player.seek(a.currentTime + 10);
else if (k === "ArrowLeft") player.seek(a.currentTime - 10);
else if (k === "l" && card) setLike(card, !card.liked);
else if (k === "f" && card) setFollow(card, !card.track.followed);
else if (k === "r" && card) actions.radio(card);
else if (k === "x" && card) dislike(card);
else if (k === "s" && card) share(card);
else if (k === "m") { player.muted = !player.muted; toast(player.muted ? "muted" : "sound on"); }
else if (k === "?") { helpEl.hidden ? showHelp() : hideHelp(); }
});
addEventListener("resize", () => current()?.drawWave(player.audio.duration ? player.audio.currentTime / player.audio.duration : 0, true));
addEventListener("pagehide", finishActive);
// Sign-in -----------------------------------------------------------------
const emailForm = $<HTMLFormElement>("email-form");
const codeForm = $<HTMLFormElement>("code-form");
const codeInput = $<HTMLInputElement>("code");
const connectInput = $<HTMLInputElement>("connect");
const loginError = $("login-error");
const loginCancel = $("login-cancel");
let signedInUser: User | null = null;
// `resync`: already signed in, signing in again only to sync with pl8list.
function showLogin({ resync = false } = {}): void {
closePanels();
$("bar").hidden = true;
startEl.hidden = true;
loginEl.hidden = false;
emailForm.hidden = false;
codeForm.hidden = true;
loginCancel.hidden = !resync;
loginError.textContent = resync ? "sign in with pl8list again to sync your library" : "";
$("email").focus();
}
loginCancel.addEventListener("click", () => { if (signedInUser) signedIn(signedInUser); });
async function withForm(form: HTMLFormElement, fn: () => Promise<void>): Promise<void> {
const buttons = form.querySelectorAll("button");
buttons.forEach((b) => { b.disabled = true; });
loginError.textContent = "";
try {
await fn();
} catch (err) {
loginError.textContent = (err as Error).message;
}
buttons.forEach((b) => { b.disabled = false; });
}
emailForm.addEventListener("submit", (e) => {
e.preventDefault();
withForm(emailForm, async () => {
const { email } = await api.postJSON<{ email: string }>("/auth/start", { email: $<HTMLInputElement>("email").value });
$("sent-to").textContent = email;
emailForm.hidden = true;
codeForm.hidden = false;
codeInput.value = "";
codeInput.focus();
});
});
codeForm.addEventListener("submit", (e) => {
e.preventDefault();
withForm(codeForm, async () => {
const { user, connected } = await api.postJSON<{ user: User; connected: boolean }>("/auth/verify", {
code: codeInput.value,
connect: connectInput.checked,
});
signedIn(user, { connected, synced_at: Date.now() / 1000 });
toast(connected ? "connected, syncing your pl8list library…" : "syncing your pl8list library…");
});
});
codeInput.addEventListener("input", () => {
if (/^\d{6}$/.test(codeInput.value)) codeForm.requestSubmit();
});
$("resend").addEventListener("click", () => {
withForm(codeForm, async () => {
await api.postJSON("/auth/resend");
loginError.textContent = "new code sent";
});
});
$("restart").addEventListener("click", () => showLogin());
// Account -----------------------------------------------------------------
const deleteForm = $<HTMLFormElement>("delete-form");
const deleteConfirm = $<HTMLInputElement>("delete-confirm");
const syncStatus = $("sync-status");
const disconnectBtn = $("disconnect-btn");
function ago(ts: number): string {
const m = Math.round((Date.now() / 1000 - ts) / 60);
if (m < 1) return "just now";
if (m < 60) return `${m} min ago`;
if (m < 48 * 60) return `${Math.round(m / 60)} h ago`;
return `${Math.round(m / 1440)} days ago`;
}
function showSync(s: Pl8Status | undefined): void {
const connected = !!s?.connected;
syncStatus.classList.toggle("on", connected);
syncStatus.textContent = connected
? `connected on this device${s?.synced_at ? `, last sync ${ago(s.synced_at)}` : ""}. ` +
"Likes and follows are saved to your pl8list library."
: "not connected. Resync signs you in with pl8list once to read your feed and library.";
disconnectBtn.hidden = !connected;
}
accountBtn.addEventListener("click", async () => {
if (!togglePanel(accountPanel)) return;
deleteForm.hidden = true;
try { showSync((await api.me()).pl8list); } catch { /* keep what is shown */ }
});
$("sync-btn").addEventListener("click", async () => {
try {
await api.postJSON("/account/pl8list/sync");
toast("syncing with pl8list…");
showSync({ connected: true, synced_at: Date.now() / 1000 });
} catch (err) {
if (err instanceof api.HttpError && err.status === 409) showLogin({ resync: true });
else toast((err as Error).message, 4000);
}
});
disconnectBtn.addEventListener("click", async () => {
try {
await api.postJSON("/account/pl8list/disconnect");
showSync({ connected: false, synced_at: null });
toast("disconnected from pl8list");
} catch (err) {
toast((err as Error).message, 4000);
}
});
$("logout-btn").addEventListener("click", async () => {
await api.postJSON("/auth/logout");
location.reload();
});
$("logout-all-btn").addEventListener("click", async () => {
await api.postJSON("/account/logout-all");
location.reload();
});
$("delete-open").addEventListener("click", () => {
deleteForm.hidden = false;
deleteConfirm.value = "";
deleteConfirm.focus();
});
deleteForm.addEventListener("submit", async (e) => {
e.preventDefault();
try {
await api.postJSON("/account/delete", { confirm: deleteConfirm.value.trim() });
localStorage.removeItem(FILTERS_KEY);
localStorage.removeItem(MODE_KEY);
localStorage.removeItem(HINT_KEY);
location.reload();
} catch (err) {
toast((err as Error).message, 4000);
}
});
function signedIn(user: User, pl8list?: Pl8Status): void {
signedInUser = user;
if (pl8list) showSync(pl8list);
loginEl.hidden = true;
$("bar").hidden = false;
accountBtn.textContent = `@${user.handle}`;
$("account-handle").textContent = `@${user.handle}`;
$("delete-handle").textContent = user.handle;
// Typing the login code counts as a user gesture, so playback may start right away.
if (navigator.userActivation?.hasBeenActive) start();
else if (!started) startEl.hidden = false;
if (!cards.length) loadMore();
}
api.me().then(
(me) => signedIn(me.user, me.pl8list),
() => showLogin(),
);
+232
View File
@@ -0,0 +1,232 @@
// Full-screen vertical pager. One card fills the screen; drag, fling, wheel and keys
// move between cards. Only the current card and its two neighbours are in the DOM,
// placed with transforms and settled by a spring, so long sessions stay light and
// the motion follows the finger exactly.
export interface PagerSource {
/// How many cards exist right now.
count(): number;
/// The element for card `i`; the owner builds and caches it.
element(i: number): HTMLElement;
/// Card `i` became the current one. Called as soon as a move is decided, before
/// the animation ends, so audio can start right away.
onChange(i: number): void;
/// The user is at (or pulling past) the last card.
onNeedMore(): void;
}
const STIFFNESS = 380; // 1/s², spring towards the resting position
const DAMPING = 34; // 1/s, a little under critical (39): settles with a hint of bounce
const FLING = 0.45; // px/ms; a flick faster than this turns the page
const DISTANCE = 0.22; // or dragging further than this share of the height
const SLOP = 8; // px before a touch counts as a drag
const WHEEL_STEP = 40; // px of wheel delta per page
const WHEEL_IDLE = 180; // ms of quiet that ends one wheel gesture (trackpad inertia)
interface Drag {
id: number;
x0: number;
y0: number;
base: number;
active: boolean;
samples: [number, number][];
}
export class Pager {
index = 0;
/// True right after a drag, so the click that follows it is not a tap.
suppressClick = false;
private offset = 0; // px; positive while pulled down (towards the previous card)
private velocity = 0; // px/s
private height = 1;
private mounted = new Map<number, HTMLElement>();
private raf = 0;
private last = 0;
private drag: Drag | null = null;
private wheelAcc = 0;
private wheelLocked = false;
private wheelTimer = 0;
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
constructor(
private root: HTMLElement,
private src: PagerSource,
) {
this.height = root.clientHeight || innerHeight;
root.addEventListener("pointerdown", (e) => this.down(e));
root.addEventListener("pointermove", (e) => this.move(e));
root.addEventListener("pointerup", (e) => this.up(e));
root.addEventListener("pointercancel", (e) => this.up(e, true));
root.addEventListener("wheel", (e) => this.wheel(e), { passive: false });
new ResizeObserver(() => {
this.height = root.clientHeight || innerHeight;
this.render();
}).observe(root);
}
/// Move to card `i`. Returns false when there is no such card (yet).
go(i: number, { instant = false } = {}): boolean {
if (i < 0) return false;
if (i >= this.src.count()) {
this.src.onNeedMore();
return false;
}
if (i === this.index) return true;
this.offset += (i - this.index) * this.height;
this.index = i;
this.refresh();
this.src.onChange(i);
if (instant || this.reduced.matches || document.hidden) this.settleNow();
else this.animate();
if (i >= this.src.count() - 1) this.src.onNeedMore();
return true;
}
/// Re-read the cards around the current one (after inserting or removing cards).
refresh(): void {
const want = new Map<number, HTMLElement>();
for (let i = this.index - 1; i <= this.index + 1; i++) {
if (i >= 0 && i < this.src.count()) want.set(i, this.src.element(i));
}
for (const [i, el] of this.mounted) {
if (want.get(i) !== el && ![...want.values()].includes(el)) el.remove();
}
for (const el of want.values()) {
if (el.parentElement !== this.root) this.root.append(el);
}
this.mounted = want;
this.render();
}
private render(): void {
const h = this.height;
for (const [i, el] of this.mounted) {
const p = i - this.index + this.offset / h;
el.style.transform = `translate3d(0, ${(p * h).toFixed(2)}px, 0)`;
// 0 when resting in place, 1 one full page away; cards use it for depth effects.
el.style.setProperty("--away", Math.min(1, Math.abs(p)).toFixed(3));
}
}
private settleNow(): void {
cancelAnimationFrame(this.raf);
this.raf = 0;
this.offset = 0;
this.velocity = 0;
this.render();
}
private animate(): void {
if (this.raf) return;
this.last = performance.now();
const step = (now: number) => {
// Small fixed steps keep the spring stable on slow frames.
let dt = Math.min(0.05, (now - this.last) / 1000);
this.last = now;
while (dt > 0) {
const h = Math.min(dt, 1 / 240);
this.velocity += (-STIFFNESS * this.offset - DAMPING * this.velocity) * h;
this.offset += this.velocity * h;
dt -= h;
}
if (Math.abs(this.offset) < 0.5 && Math.abs(this.velocity) < 20) {
this.raf = 0;
this.settleNow();
return;
}
this.render();
this.raf = requestAnimationFrame(step);
};
this.raf = requestAnimationFrame(step);
}
// Pointer ---------------------------------------------------------------
private down(e: PointerEvent): void {
if (this.drag || (e.pointerType === "mouse" && e.button !== 0)) return;
if ((e.target as Element).closest("button, a, input, textarea, select, .wave-wrap")) return;
this.drag = { id: e.pointerId, x0: e.clientX, y0: e.clientY, base: this.offset, active: false, samples: [] };
}
private move(e: PointerEvent): void {
const d = this.drag;
if (!d || e.pointerId !== d.id) return;
const dx = e.clientX - d.x0;
const dy = e.clientY - d.y0;
if (!d.active) {
if (Math.abs(dy) < SLOP || Math.abs(dy) < Math.abs(dx)) return;
d.active = true;
this.root.setPointerCapture(e.pointerId);
// Catch a card that is still settling where it is.
cancelAnimationFrame(this.raf);
this.raf = 0;
d.base = this.offset;
d.y0 = e.clientY;
}
let off = d.base + (e.clientY - d.y0);
const atStart = this.index === 0 && off > 0;
const atEnd = this.index >= this.src.count() - 1 && off < 0;
if (atStart || atEnd) off = Math.sign(off) * this.rubber(Math.abs(off));
if (atEnd) this.src.onNeedMore();
this.offset = off;
const now = performance.now();
d.samples.push([now, e.clientY]);
while (d.samples.length > 2 && now - d.samples[0][0] > 100) d.samples.shift();
this.render();
}
/// Resistance past the first and last card.
private rubber(x: number): number {
const h = this.height;
return (1 - 1 / ((x * 0.55) / h + 1)) * h;
}
private up(e: PointerEvent, cancelled = false): void {
const d = this.drag;
if (!d || e.pointerId !== d.id) return;
this.drag = null;
if (!d.active) return;
this.suppressClick = true;
setTimeout(() => (this.suppressClick = false), 350);
let v = 0; // px/ms
if (!cancelled && d.samples.length > 1) {
const [t0, y0] = d.samples[0];
const [t1, y1] = d.samples[d.samples.length - 1];
if (t1 > t0) v = (y1 - y0) / (t1 - t0);
}
const h = this.height;
let target = this.index;
if ((this.offset < -h * DISTANCE || v < -FLING) && this.index + 1 < this.src.count()) target++;
else if ((this.offset > h * DISTANCE || v > FLING) && this.index > 0) target--;
this.velocity = v * 1000;
if (target !== this.index) {
this.offset += (target - this.index) * h;
this.index = target;
this.refresh();
this.src.onChange(target);
if (target >= this.src.count() - 1) this.src.onNeedMore();
}
if (this.reduced.matches) this.settleNow();
else this.animate();
}
// Wheel and trackpad -------------------------------------------------------
private wheel(e: WheelEvent): void {
if (e.ctrlKey) return; // pinch zoom
e.preventDefault();
clearTimeout(this.wheelTimer);
this.wheelTimer = window.setTimeout(() => {
this.wheelLocked = false;
this.wheelAcc = 0;
}, WHEEL_IDLE);
if (this.wheelLocked) return;
this.wheelAcc += e.deltaY * (e.deltaMode === 1 ? 30 : e.deltaMode === 2 ? this.height : 1);
if (Math.abs(this.wheelAcc) >= WHEEL_STEP) {
// One page per gesture: the rest of a trackpad swipe's inertia is ignored.
this.wheelLocked = true;
this.go(this.index + Math.sign(this.wheelAcc));
this.wheelAcc = 0;
}
}
}
+285
View File
@@ -0,0 +1,285 @@
// Audio playback with two decks, so the next track can be preloaded and tracks
// crossfade into each other.
//
// Where the browser allows it, both <audio> elements run through a Web Audio graph:
// a gain per deck (for the crossfade) into one analyser (for the visuals). iOS is
// left out: its element volume is read-only and Web Audio stops when the screen
// locks, which would break lock-screen playback. There tracks switch with a cut.
import type HlsType from "hls.js/light";
import { type Track, streamUrl } from "./api";
const CROSSFADE = 0.9; // seconds
const PRELOAD_BUFFER = 6; // seconds buffered of the track after the current one
const PLAY_BUFFER = 30;
type Hls = HlsType;
type HlsClass = typeof HlsType;
let hlsClass: Promise<HlsClass | null> | null = null;
/// Set once the module has loaded, so later loads can attach synchronously and
/// play() still runs inside the user's tap (iOS insists on that).
let hlsLoaded: HlsClass | null | undefined;
/// hls.js where it works (MSE), otherwise null and the browser plays HLS itself (Safari).
function loadHls(): Promise<HlsClass | null> {
hlsClass ??= import("hls.js/light")
.then((m) => (m.default.isSupported() ? m.default : null))
.catch(() => null)
.then((h) => (hlsLoaded = h));
return hlsClass;
}
interface Deck {
el: HTMLAudioElement;
hls: Hls | null;
gain: GainNode | null;
track: Track | null;
/// Should be playing as soon as its source is attached.
wantPlay: boolean;
/// Bumped on every load/unload, so late callbacks of an old load do nothing.
gen: number;
}
export type PlayerEvent = "timeupdate" | "ended" | "play" | "pause" | "waiting" | "playing" | "failed";
const FORWARDED: PlayerEvent[] = ["timeupdate", "ended", "play", "pause", "waiting", "playing"];
/// Equal-power fade of `param` from its current value to `to`. A fade can start
/// while the previous one still runs (fast flicking), so it starts where that one is.
function fade(param: AudioParam, to: number, at: number): void {
const from = param.value;
param.cancelScheduledValues(0);
const up = to > from;
const curve = Float32Array.from({ length: 32 }, (_, i) => {
const x = (i / 31) * (Math.PI / 2);
return up ? from + (to - from) * Math.sin(x) : to + (from - to) * Math.cos(x);
});
try {
param.setValueCurveAtTime(curve, at, CROSSFADE);
} catch {
param.value = to;
}
}
export class Player {
private decks: [Deck, Deck];
private cur = 0;
private ctx: AudioContext | null = null;
private unlocking: Promise<void> | null = null;
private fadeEnd = 0;
private listeners = new Map<PlayerEvent, Set<() => void>>();
analyser: AnalyserNode | null = null;
/// False on iOS, see the top of this file.
readonly canMix: boolean;
constructor() {
const probe = document.createElement("audio");
probe.volume = 0.5;
this.canMix = probe.volume === 0.5 && "AudioContext" in window;
const deck = (): Deck => {
const el = document.createElement("audio");
el.preload = "auto";
document.body.append(el);
return { el, hls: null, gain: null, track: null, wantPlay: false, gen: 0 };
};
this.decks = [deck(), deck()];
for (const d of this.decks) {
for (const ev of FORWARDED) {
d.el.addEventListener(ev, () => {
if (d === this.decks[this.cur]) this.emit(ev);
});
}
// hls.js reports its own errors; this is for native HLS.
d.el.addEventListener("error", () => {
if (d === this.decks[this.cur] && !d.hls && d.track) this.emit("failed");
});
}
loadHls(); // start fetching the module early
}
on(ev: PlayerEvent, fn: () => void): void {
if (!this.listeners.has(ev)) this.listeners.set(ev, new Set());
this.listeners.get(ev)!.add(fn);
}
private emit(ev: PlayerEvent): void {
this.listeners.get(ev)?.forEach((fn) => fn());
}
/// The element of the current deck, for time, duration and pause state.
get audio(): HTMLAudioElement {
return this.decks[this.cur].el;
}
get track(): Track | null {
return this.decks[this.cur].track;
}
/// True while two tracks are mixed and crossfades work.
get mixing(): boolean {
return this.ctx?.state === "running";
}
/// Call from a user gesture. Sets up the Web Audio graph where the browser can mix.
unlock(): Promise<void> {
if (this.ctx) {
if (this.ctx.state === "suspended") this.ctx.resume().catch(() => {});
return Promise.resolve();
}
if (!this.canMix) return Promise.resolve();
this.unlocking ??= this.setUpGraph();
return this.unlocking;
}
private async setUpGraph(): Promise<void> {
let ctx: AudioContext;
try {
ctx = new AudioContext();
await ctx.resume();
} catch {
return;
}
// Routing an element through the graph can't be undone, and a suspended graph
// is silent, so only route once it actually runs.
if (ctx.state !== "running") {
ctx.close();
this.unlocking = null;
return;
}
const analyser = ctx.createAnalyser();
analyser.fftSize = 1024;
analyser.smoothingTimeConstant = 0.7;
analyser.connect(ctx.destination);
for (const d of this.decks) {
const gain = ctx.createGain();
gain.gain.value = d === this.decks[this.cur] ? 1 : 0;
ctx.createMediaElementSource(d.el).connect(gain).connect(analyser);
d.gain = gain;
}
this.ctx = ctx;
this.analyser = analyser;
}
/// Play a track, crossfading from the current one when possible.
async play(track: Track): Promise<void> {
const from = this.decks[this.cur];
if (from.track?.id === track.id) {
from.wantPlay = true;
from.el.play().catch(() => {});
return;
}
const toIdx = 1 - this.cur;
const to = this.decks[toIdx];
if (to.track?.id === track.id && performance.now() >= this.fadeEnd) {
if (to.hls) to.hls.config.maxBufferLength = PLAY_BUFFER;
} else {
this.load(to, track, PLAY_BUFFER);
}
const crossfade = this.mixing && !document.hidden && !from.el.paused && from.track !== null;
this.cur = toIdx;
to.wantPlay = true;
if (this.ctx?.state === "suspended") this.ctx.resume().catch(() => {});
if (crossfade && to.gain && from.gain) {
const t = this.ctx!.currentTime;
fade(to.gain.gain, 1, t);
fade(from.gain.gain, 0, t);
this.fadeEnd = performance.now() + CROSSFADE * 1000;
const gen = from.gen;
setTimeout(() => {
if (from.gen === gen && from !== this.decks[this.cur]) this.unload(from);
}, CROSSFADE * 1000 + 50);
} else {
if (to.gain) {
to.gain.gain.cancelScheduledValues(0);
to.gain.gain.value = 1;
}
this.unload(from);
}
if (to.hls || to.el.hasAttribute("src")) to.el.play().catch(() => {});
}
/// Buffer the start of the track that is likely to come next.
preload(track: Track): void {
const wait = this.fadeEnd - performance.now();
if (wait > 0) {
setTimeout(() => this.preload(track), wait + 100);
return;
}
const idle = this.decks[1 - this.cur];
if (idle.track?.id !== track.id) this.load(idle, track, PRELOAD_BUFFER);
}
private load(d: Deck, track: Track, buffer: number): void {
this.unload(d);
const gen = d.gen;
d.track = track;
const url = streamUrl(track);
const attach = (Hls: HlsClass | null) => {
if (d.gen !== gen) return;
if (Hls) {
const hls = new Hls({ maxBufferLength: buffer, maxMaxBufferLength: 60 });
hls.on(Hls.Events.ERROR, (_, data) => {
if (!data.fatal || d.gen !== gen) return;
console.warn("hls fatal", data.details);
if (d === this.decks[this.cur]) this.emit("failed");
else this.unload(d);
});
hls.loadSource(url);
hls.attachMedia(d.el);
d.hls = hls;
} else {
d.el.src = url;
}
if (d.wantPlay) d.el.play().catch(() => {});
};
if (hlsLoaded !== undefined) attach(hlsLoaded);
else loadHls().then(attach);
}
private unload(d: Deck): void {
d.gen++;
d.wantPlay = false;
d.hls?.destroy();
d.hls = null;
d.track = null;
d.el.pause();
if (d.el.hasAttribute("src")) {
d.el.removeAttribute("src");
d.el.load();
}
if (d.gain) {
d.gain.gain.cancelScheduledValues(0);
d.gain.gain.value = 0;
}
}
toggle(): void {
if (this.audio.paused) this.resume();
else this.pause();
}
resume(): void {
if (this.ctx?.state === "suspended") this.ctx.resume().catch(() => {});
this.decks[this.cur].wantPlay = true;
this.audio.play().catch(() => {});
}
pause(): void {
this.decks[this.cur].wantPlay = false;
this.audio.pause();
}
seek(seconds: number): void {
const a = this.audio;
a.currentTime = Math.max(0, Math.min(a.duration || 0, seconds));
}
get muted(): boolean {
return this.decks[0].el.muted;
}
set muted(m: boolean) {
for (const d of this.decks) d.el.muted = m;
}
}
+122 -27
View File
@@ -1,9 +1,24 @@
/* Design tokens ---------------------------------------------------------- */
:root {
color-scheme: dark;
--bg: #000;
--fg: #f2f2f2;
--muted: #9a9a9a;
--faint: #666;
--accent: #ff4d6d;
--panel: rgba(18, 18, 20, 0.94);
color-scheme: dark;
--line: #ffffff1f;
--glass: rgba(255, 255, 255, 0.1);
--glass-strong: rgba(255, 255, 255, 0.2);
--radius-s: 8px;
--radius-m: 14px;
--radius-l: 18px;
--shadow: 0 16px 40px #000a;
--font: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-display: var(--font);
--ease-out: cubic-bezier(0.2, 0.8, 0.2, 1);
--fast: 0.15s;
}
* { box-sizing: border-box; }
@@ -11,9 +26,9 @@
html, body {
margin: 0;
height: 100%;
background: #000;
background: var(--bg);
color: var(--fg);
font: 15px/1.4 system-ui, -apple-system, "Segoe UI", sans-serif;
font: 15px/1.4 var(--font);
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
@@ -55,7 +70,7 @@ button {
.logo b { color: var(--accent); }
#stats { color: var(--muted); font-size: 0.8rem; }
#help-btn { width: 2.2em; padding: 0.35em 0; }
#logout-btn { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#account-btn { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#filters-btn.active::after {
content: "";
display: inline-block;
@@ -67,6 +82,13 @@ button {
vertical-align: middle;
}
#bar button[aria-expanded="true"] { background: rgba(255, 255, 255, 0.22); }
#mode-chip {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
background: color-mix(in oklab, var(--accent) 40%, transparent);
}
.panel {
position: fixed;
@@ -80,7 +102,19 @@ button {
box-shadow: 0 16px 40px #000a;
}
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
#filters { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; max-width: 24rem; }
.modes { display: flex; gap: 0.4rem; flex-wrap: wrap; flex-basis: 100%; margin: 0; padding: 0; border: 0; }
.modes legend { color: var(--muted); font-size: 0.8rem; padding: 0; margin-bottom: 0.3rem; }
.modes label { cursor: pointer; }
.modes input { position: absolute; opacity: 0; }
.modes label:has(input) {
padding: 0.25em 0.8em;
border-radius: 999px;
border: 1px solid #ffffff40;
font-size: 0.85rem;
}
.modes label:has(input:checked) { background: var(--accent); border-color: transparent; color: #000; }
.modes label:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
#filters input[type=number] {
width: 4.5em;
background: #222;
@@ -114,21 +148,42 @@ button {
}
#likes li .play-now svg { width: 0.9rem; height: 0.9rem; }
#account { width: min(20rem, calc(100vw - 2rem)); display: flex; flex-direction: column; align-items: flex-start; gap: 0.7rem; }
#account h2 { margin: 0; font-size: 1rem; }
#account a { font-size: 0.85rem; color: var(--muted); }
#sync { width: 100%; display: flex; flex-direction: column; align-items: flex-start; gap: 0.5rem;
padding-bottom: 0.7rem; border-bottom: 1px solid #ffffff14; }
#sync h3 { margin: 0; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
#sync-status { margin: 0; font-size: 0.85rem; color: var(--muted); }
#sync-status.on::before { content: "● "; color: #3ddc84; }
#delete-form { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
#delete-form label { color: var(--muted); font-size: 0.85rem; }
#delete-form input { padding: 0.4em 0.6em; border-radius: 8px; border: 1px solid #444; background: #111; }
.link.danger { color: var(--accent); }
button.danger:not(.link) { background: var(--accent); border: none; color: #fff; font-weight: 600; }
.legal-links a { color: inherit; }
/* Feed ------------------------------------------------------------------- */
/* The pager (pager.ts) places cards with transforms and sets --away (0 in place,
1 a full page away); the visualizer sets --energy (0..1, bass level) on the
playing card; --card-accent comes from the artist image. */
#feed {
height: 100dvh;
overflow-y: scroll;
scroll-snap-type: y mandatory;
scrollbar-width: none;
position: fixed;
inset: 0;
overflow: hidden;
touch-action: none;
overscroll-behavior: none;
}
#feed::-webkit-scrollbar { display: none; }
.card {
position: relative;
height: 100dvh;
scroll-snap-align: start;
scroll-snap-stop: always;
--card-accent: var(--accent);
--energy: 0;
--away: 0;
position: absolute;
inset: 0;
will-change: transform;
backface-visibility: hidden;
display: flex;
align-items: center;
justify-content: center;
@@ -146,8 +201,19 @@ button {
filter: blur(40px) brightness(0.45) saturate(1.3);
}
.glow {
position: absolute;
inset: 0;
pointer-events: none;
background: radial-gradient(55% 40% at 50% 40%, color-mix(in oklab, var(--card-accent) 55%, transparent), transparent 70%);
opacity: calc(0.18 + var(--energy) * 0.55);
}
.content {
position: relative;
/* Cards shrink and dim a little as they leave, for depth while swiping. */
transform: scale(calc(1 - var(--away) * 0.08));
opacity: calc(1 - var(--away) * 0.55);
width: min(34rem, calc(100vw - 5.5rem));
display: flex;
flex-direction: column;
@@ -162,11 +228,15 @@ button {
aspect-ratio: 1;
border-radius: 14px;
background: #222 center / cover no-repeat;
box-shadow: 0 20px 60px #000a;
box-shadow:
0 20px 60px #000a,
0 0 calc(10px + var(--energy) * 70px) color-mix(in oklab, var(--card-accent) calc(15% + var(--energy) * 45%), transparent);
display: grid;
place-items: center;
margin-bottom: 0.6rem;
transition: transform 0.25s ease, filter 0.25s ease;
/* Pulse on the beat through `scale`; `transform` is left to the pause animation. */
scale: calc(1 + var(--energy) * 0.045);
transition: transform 0.25s var(--ease-out), filter 0.25s ease;
}
.initial { font-size: 4rem; font-weight: 700; color: #fff6; }
@@ -205,9 +275,24 @@ button {
.card.buffering .paused-badge { opacity: 0; }
@keyframes spin { to { transform: rotate(360deg); } }
.title { font-size: 1.25rem; margin: 0; overflow-wrap: anywhere; }
.title {
font: 800 clamp(1.3rem, 4.2vw, 1.9rem)/1.1 var(--font-display);
letter-spacing: -0.01em;
margin: 0;
overflow-wrap: anywhere;
text-wrap: balance;
}
.byline { display: flex; align-items: center; gap: 0.6rem; }
.artist { color: var(--muted); text-decoration: none; }
.artist:hover { text-decoration: underline; }
.follow, .radio {
font-size: 0.75rem;
padding: 0.15em 0.75em;
border-radius: 999px;
border: 1px solid #ffffff40;
background: transparent;
}
.follow.on { background: #ffffff1f; border-color: transparent; color: var(--muted); }
.chips { display: flex; gap: 0.4rem; flex-wrap: wrap; justify-content: center; }
.chips span {
@@ -217,7 +302,9 @@ button {
background: rgba(255, 255, 255, 0.12);
}
.chips .free { background: #1f7a4d; }
.chips span:first-child { background: color-mix(in oklab, var(--card-accent) 35%, transparent); }
.chips .preview { background: #6a4b16; }
.chips .tag { background: transparent; box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--card-accent) 60%, transparent); }
.desc {
margin: 0;
@@ -272,7 +359,7 @@ button {
.actions > *:hover { background: rgba(255, 255, 255, 0.2); }
.actions > *:active { transform: scale(0.9); }
.actions svg { width: 1.3rem; height: 1.3rem; }
.actions .like.on { color: var(--accent); background: rgba(255, 77, 109, 0.2); }
.actions .like.on { color: var(--card-accent); background: color-mix(in oklab, var(--card-accent) 22%, transparent); }
.actions .like.pop svg { animation: pop 0.35s ease; }
@keyframes pop { 50% { transform: scale(1.4); } }
@@ -305,9 +392,9 @@ button {
width: 6rem;
height: 6rem;
margin: -3rem 0 0 -3rem;
color: var(--accent);
color: var(--card-accent);
pointer-events: none;
filter: drop-shadow(0 6px 20px #ff4d6d88);
filter: drop-shadow(0 6px 20px color-mix(in oklab, var(--card-accent) 55%, transparent));
animation: burst 0.8s cubic-bezier(.2, .9, .3, 1.3) forwards;
}
@keyframes burst {
@@ -335,15 +422,19 @@ button {
}
#toast.show { opacity: 1; transform: translate(-50%, 0); }
.status {
height: 100dvh;
display: grid;
place-items: center;
#feed-status {
position: fixed;
z-index: 5;
left: 50%;
bottom: max(6rem, calc(env(safe-area-inset-bottom) + 5rem));
transform: translateX(-50%);
width: min(26rem, calc(100vw - 2rem));
margin: 0;
color: var(--muted);
scroll-snap-align: start;
padding: 1rem;
text-align: center;
pointer-events: none;
}
#feed:empty + #feed-status { bottom: 50%; }
#start {
position: fixed;
@@ -422,6 +513,10 @@ button {
.login-box button[type=submit] { background: var(--accent); border: none; color: #fff; padding: 0.7em; font-weight: 600; }
.login-box button[disabled] { opacity: 0.6; cursor: wait; }
.login-links { display: flex; justify-content: space-between; }
.login-box label.check { display: flex; gap: 0.6rem; align-items: flex-start; color: var(--fg); cursor: pointer; }
.check input { margin-top: 0.2rem; accent-color: var(--accent); }
.check small { display: block; margin-top: 0.2rem; color: #777; font-size: 0.75rem; line-height: 1.4; }
#login-cancel { align-self: center; }
.link { background: none; border: none; padding: 0; color: var(--muted); text-decoration: underline; font-size: 0.85rem; }
#login-error { margin: 0; min-height: 1.2em; color: var(--accent); font-size: 0.9rem; }
.fineprint { margin: 0; color: #666; font-size: 0.75rem; }
@@ -433,7 +528,7 @@ button {
#bar { gap: 0.4rem; padding-left: 0.7rem; padding-right: 0.7rem; }
#bar button { padding: 0.3em 0.7em; font-size: 0.85rem; }
#help-btn { display: none; }
#logout-btn { max-width: 6.5rem; }
#account-btn { max-width: 6.5rem; }
.panel { left: 0.7rem; right: 0.7rem; }
#likes { width: auto; }
.content { width: calc(100vw - 2rem); margin-bottom: 4.5rem; }
+69
View File
@@ -0,0 +1,69 @@
// Per-frame visuals of the playing card: waveform progress, and a bass level that
// the stylesheet turns into a pulse of the artwork and a glow (`--energy`, 0..1).
// The level comes from the analyser where audio runs through Web Audio, otherwise
// from the track's waveform at the current position.
import type { Card } from "./cards";
import type { Player } from "./player";
export class Visualizer {
private raf = 0;
private bins: Uint8Array<ArrayBuffer> | null = null;
private level = 0;
private peak = 0.3;
private card: Card | null = null;
private reduced = matchMedia("(prefers-reduced-motion: reduce)");
constructor(private player: Player) {
document.addEventListener("visibilitychange", () => (document.hidden ? this.stop() : this.start()));
}
/// Follow `card` from now on.
attach(card: Card | null): void {
if (this.card && this.card !== card) this.card.el?.style.setProperty("--energy", "0");
this.card = card;
this.start();
}
start(): void {
if (!this.raf && !document.hidden) this.raf = requestAnimationFrame(this.frame);
}
stop(): void {
cancelAnimationFrame(this.raf);
this.raf = 0;
}
private frame = () => {
this.raf = 0;
const card = this.card;
const a = this.player.audio;
if (!card) return;
const progress = a.duration ? a.currentTime / a.duration : 0;
card.drawWave(progress);
if (!this.reduced.matches) {
const raw = a.paused ? 0 : this.bass(card, progress);
// Fast attack, slow release, against a slowly falling peak: kicks stand out
// the same whether a track is mastered loud or quiet.
this.level += (raw - this.level) * (raw > this.level ? 0.55 : 0.12);
this.peak = Math.max(this.peak * 0.997, this.level, 0.2);
const energy = Math.max(0, (this.level / this.peak - 0.35) / 0.65);
card.el?.style.setProperty("--energy", energy.toFixed(3));
}
// Keep running while playing; one last frame after a pause lets the glow fade.
if (!a.paused || this.level > 0.01) this.raf = requestAnimationFrame(this.frame);
};
private bass(card: Card, progress: number): number {
const an = this.player.analyser;
if (!an || !this.player.mixing) return card.loudnessAt(progress) * 0.8;
if (this.bins?.length !== an.frequencyBinCount) this.bins = new Uint8Array(an.frequencyBinCount);
an.getByteFrequencyData(this.bins);
// Bins up to ~150 Hz: kick and sub.
const hz = an.context.sampleRate / an.fftSize;
const top = Math.max(2, Math.round(150 / hz));
let sum = 0;
for (let i = 1; i <= top; i++) sum += this.bins[i];
return sum / top / 255;
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"]
}
+24
View File
@@ -0,0 +1,24 @@
import { defineConfig } from "vite";
declare const process: { env: Record<string, string | undefined> };
// The Rust server embeds dist/: index.html is served at "/", everything else
// under "/static/". In development Vite proxies the API to a running server.
const backend = process.env.BACKEND ?? "http://127.0.0.1:8790";
export default defineConfig({
base: "/static/",
build: {
outDir: "dist",
emptyOutDir: true,
target: "es2022",
// Keep everything as files: the CSP allows no inline scripts or styles.
assetsInlineLimit: 0,
sourcemap: false,
},
server: {
proxy: Object.fromEntries(
["/local", "/auth", "/account", "/api", "/img", "/impressum", "/datenschutz"].map((p) => [p, backend]),
),
},
});