diff --git a/.env.example b/.env.example
index d6b7586..f9d9c04 100644
--- a/.env.example
+++ b/.env.example
@@ -4,6 +4,11 @@
# 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:
# TRUSTED_PROXY_HOPS=1 # proxies that append to X-Forwarded-For (TRUST_PROXY=1 also means 1)
# PUBLIC_ORIGIN=https://pl8.narl.io
diff --git a/README.md b/README.md
index f7de701..1463ee1 100644
--- a/README.md
+++ b/README.md
@@ -53,6 +53,8 @@ Data (catalogue, users, listening history) lives in the `data` volume as one SQL
| `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 |
@@ -68,10 +70,27 @@ There are no passwords here. Users sign in with the email of their pl8list accou
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, and when the feed has been
-read (or reading it fails) the server logs that session out at pl8list. Only a SHA-256 hash
-of the local session token is stored.
+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
@@ -79,8 +98,9 @@ and 5 code attempts per login, and at most 500 logins pending at once. Behind a
(`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 lets users download everything stored about them (JSON), sign out on all
-devices, or delete their account (likes, history and sessions, right away). Expired sessions
+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 self-hosted in `static/vendor/` and audio and images are proxied, so no
visitor address reaches pl8list or a CDN.
@@ -93,6 +113,7 @@ visitor address reaches pl8list or a CDN.
| double-tap | double-click · l | like |
| swipe up / down | ↓ j · ↑ k | next / previous |
| drag waveform | drag waveform · ← → | seek |
+| follow button | f | follow the artist |
| ✕ button | x | not for me (skip, show less) |
| share button | s | share / copy link |
| | m | mute |
@@ -119,6 +140,9 @@ pl8list has no public API, but it is a Remix app: every page loader returns JSON
| `///related?_data=routes/$handle_.$slug.related` | 3 related tracks |
| `/api///hls/playlist.m3u8?v=[&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/[/]/add-to-library?addOnly=true` | save artist or track (needs session) |
**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;
@@ -129,7 +153,7 @@ one request every 20 seconds. When a user likes or finishes a track, its related
its artist's catalogue are crawled first.
**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 and "not for me"), closeness to the BPM and
+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. Weights live in a `Ranker`; `?mode=` on `/local/feed` picks one, so discovery modes
diff --git a/backend/Cargo.lock b/backend/Cargo.lock
index 2f76079..5194d4d 100644
--- a/backend/Cargo.lock
+++ b/backend/Cargo.lock
@@ -2,6 +2,16 @@
# It is not intended for manual editing.
version = 4
+[[package]]
+name = "aead"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
+dependencies = [
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "aho-corasick"
version = "1.1.5"
@@ -178,10 +188,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [
"cfg-if",
+ "cipher",
"cpufeatures",
"rand_core",
]
+[[package]]
+name = "chacha20poly1305"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+]
+
+[[package]]
+name = "cipher"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "inout",
+]
+
[[package]]
name = "cmake"
version = "0.1.58"
@@ -191,6 +225,12 @@ dependencies = [
"cc",
]
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
[[package]]
name = "combine"
version = "4.6.9"
@@ -238,7 +278,18 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
+ "getrandom 0.4.3",
"hybrid-array",
+ "rand_core",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a"
+dependencies = [
+ "cmov",
]
[[package]]
@@ -274,6 +325,7 @@ dependencies = [
"block-buffer",
"const-oid",
"crypto-common",
+ "ctutils",
]
[[package]]
@@ -518,6 +570,24 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+[[package]]
+name = "hkdf"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
+dependencies = [
+ "hmac",
+]
+
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest",
+]
+
[[package]]
name = "http"
version = "1.5.0"
@@ -747,6 +817,15 @@ dependencies = [
"hashbrown",
]
+[[package]]
+name = "inout"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
+dependencies = [
+ "hybrid-array",
+]
+
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -1013,8 +1092,10 @@ dependencies = [
"axum",
"base64 0.23.1",
"bytes",
+ "chacha20poly1305",
"futures-util",
"hex",
+ "hkdf",
"percent-encoding",
"rand",
"reqwest",
@@ -1032,6 +1113,17 @@ dependencies = [
"tracing-subscriber",
"url",
"wiremock",
+ "zeroize",
+]
+
+[[package]]
+name = "poly1305"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
+dependencies = [
+ "cpufeatures",
+ "universal-hash",
]
[[package]]
@@ -1956,6 +2048,16 @@ version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
+[[package]]
+name = "universal-hash"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
+dependencies = [
+ "crypto-common",
+ "ctutils",
+]
+
[[package]]
name = "untrusted"
version = "0.9.0"
diff --git a/backend/Cargo.toml b/backend/Cargo.toml
index 4ae4323..ceab361 100644
--- a/backend/Cargo.toml
+++ b/backend/Cargo.toml
@@ -8,8 +8,10 @@ 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"] }
@@ -26,6 +28,7 @@ tower-http = { version = "0.7.1", features = ["timeout", "limit", "trace", "set-
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"
diff --git a/backend/src/config.rs b/backend/src/config.rs
index ca49125..cd5895f 100644
--- a/backend/src/config.rs
+++ b/backend/src/config.rs
@@ -16,6 +16,10 @@ pub struct Config {
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,
+ pub link_key_file: PathBuf,
/// pl8list itself; only changed by tests.
pub pl8_base: String,
pub legal: Legal,
@@ -65,6 +69,9 @@ pub fn load_dotenv(path: &str) {
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"))
});
@@ -81,6 +88,8 @@ impl Config {
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(),
diff --git a/backend/src/crawler.rs b/backend/src/crawler.rs
index c1ecf08..3b624cd 100644
--- a/backend/src/crawler.rs
+++ b/backend/src/crawler.rs
@@ -9,8 +9,8 @@
//! 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: `crawl_feed` owns it in its own
-//! task, reads the personal feed, logs the session out at pl8list and drops it.
+//! 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,
@@ -19,7 +19,6 @@ use std::{
time::Duration,
};
-use secrecy::SecretString;
use serde_json::Value;
use tokio::{sync::Notify, time::Instant};
@@ -48,8 +47,6 @@ const IDLE_DELAY: Duration = Duration::from_secs(20); // extra wait between requ
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);
-const FEED_MAX_PAGES: i64 = 20;
-const FEED_DEADLINE: Duration = Duration::from_secs(120);
#[derive(Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Debug)]
enum Job {
@@ -140,37 +137,10 @@ impl Crawler {
}
}
- /// Read the user's personal feed (artists in their pl8list library) with their
- /// freshly logged-in session, then end that session at pl8list. Takes ownership
- /// of the session; it is dropped (and zeroed) when this task ends.
- pub fn crawl_feed(self: &Arc, session: SecretString) {
- let me = self.clone();
- tokio::spawn(async move {
- let res = tokio::time::timeout(FEED_DEADLINE, me.read_feed(&session)).await;
- match res {
- Ok(Ok(pages)) => tracing::info!("personal feed: {pages} pages"),
- Ok(Err(e)) => tracing::warn!("personal feed failed: {e:#}"),
- Err(_) => tracing::warn!("personal feed timed out"),
- }
- me.pl8.logout(&session).await;
- });
- }
-
- async fn read_feed(&self, session: &SecretString) -> anyhow::Result {
- let mut page = 1;
- loop {
- let d = self.pl8.feed(session, page).await?.unwrap_or(Value::Null);
- let list = d.get("tracks");
- let rows = normalize::items(list);
- for r in &rows {
- self.push(P_ARTIST_FIRST, Job::Artist(r.handle.clone()));
- }
- self.db.call(move |c| db::upsert_tracks(c, &rows, None)).await?;
- let total = d.get("totalPages").and_then(Value::as_i64).unwrap_or(0);
- if page >= total.min(FEED_MAX_PAGES) {
- return Ok(page);
- }
- page += 1;
+ /// Artists found in a user's pl8list feed or library: crawl them soon.
+ pub fn discovered_artists(&self, handles: impl IntoIterator) {
+ for h in handles {
+ self.push(P_ARTIST_FIRST, Job::Artist(h));
}
}
diff --git a/backend/src/db.rs b/backend/src/db.rs
index ef2354a..e08a65d 100644
--- a/backend/src/db.rs
+++ b/backend/src/db.rs
@@ -93,6 +93,20 @@ 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)
+);
+"#;
+
pub fn now() -> f64 {
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs_f64()
}
@@ -145,6 +159,9 @@ fn migrate(conn: &Connection) -> rusqlite::Result<()> {
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;"))?;
+ }
Ok(())
}
@@ -314,6 +331,10 @@ pub fn add_event(conn: &Connection, user_id: i64, track_id: i64, kind: &str, sec
Ok(())
}
+pub fn track_path(conn: &Connection, track_id: i64) -> rusqlite::Result
-
10. Empfänger und Drittländer
+
11. Empfänger und Drittländer
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.
-
11. Ihre Rechte
+
12. Ihre Rechte
Sie haben das Recht auf
-
Auskunft (Art. 15 DSGVO) und Datenübertragbarkeit (Art. 20 DSGVO): im Menü „Konto“ unter „Daten herunterladen“ erhalten Sie alle über Sie gespeicherten Daten als JSON-Datei;
-
Löschung (Art. 17 DSGVO): im Menü „Konto“ unter „Konto löschen“ entfernen Sie Konto, Sitzungen und Hörverlauf sofort und vollständig;
+
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;
+
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;
Berichtigung (Art. 16 DSGVO) und Einschränkung der Verarbeitung (Art. 18 DSGVO);
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;
Beschwerde bei einer Datenschutz-Aufsichtsbehörde (Art. 77 DSGVO), insbesondere in dem Mitgliedstaat Ihres Aufenthaltsorts oder Arbeitsplatzes.
Für alle Anliegen genügt eine E-Mail an {{email}}.
-
12. Pflicht zur Bereitstellung
+
13. Pflicht zur Bereitstellung
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.
@@ -140,9 +169,14 @@
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", to recommend tracks. Your
- pl8list login is never stored: we use it once, in memory, for at most two minutes to read your pl8list
- library, then log it out at pl8list. Audio and images are fetched through our server, so pl8list does not
+ 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.
diff --git a/static/app.js b/static/app.js
index afeadc1..fd8a36c 100644
--- a/static/app.js
+++ b/static/app.js
@@ -135,8 +135,10 @@ function buildCard(track, { liked = false } = {}) {
const card = { track, el, canvas: el.querySelector(".wave"), liked };
el.querySelector(".like").classList.toggle("on", liked);
+ showFollow(card);
el.querySelector(".like").addEventListener("click", () => setLike(card, !card.liked));
+ el.querySelector(".follow").addEventListener("click", () => setFollow(card, !card.track.followed));
el.querySelector(".dislike").addEventListener("click", () => dislike(card));
el.querySelector(".share").addEventListener("click", () => share(card));
el.querySelector(".next").addEventListener("click", () => go(cards.indexOf(card) + 1));
@@ -311,6 +313,29 @@ function setLike(card, liked) {
sendEvent(card.track, liked ? "like" : "unlike");
}
+function showFollow(card) {
+ const btn = card.el.querySelector(".follow");
+ btn.classList.toggle("on", !!card.track.followed);
+ btn.textContent = card.track.followed ? "following" : "follow";
+}
+
+async function setFollow(card, follow) {
+ const { handle } = card.track;
+ // Every card by this artist changes together.
+ const update = (on) => cards.filter((c) => c.track.handle === handle).forEach((c) => {
+ c.track.followed = on;
+ showFollow(c);
+ });
+ update(follow);
+ try {
+ await postJSON("/local/follow", { handle, follow });
+ toast(follow ? `following ${card.track.artist_name}` : `unfollowed ${card.track.artist_name}`);
+ } catch (err) {
+ update(!follow);
+ toast(err.message, 4000);
+ }
+}
+
function dislike(card) {
sendEvent(card.track, "dislike");
if (card === cards[active]) reported = true; // a dislike says more than a skip
@@ -614,6 +639,7 @@ document.addEventListener("keydown", (e) => {
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 === "f" && card) setFollow(card, !card.track.followed);
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"); }
@@ -630,6 +656,8 @@ const loginEl = document.getElementById("login");
const emailForm = document.getElementById("email-form");
const codeForm = document.getElementById("code-form");
const loginError = document.getElementById("login-error");
+const loginCancel = document.getElementById("login-cancel");
+let signedInUser = null;
async function postJSON(url, data) {
const res = await fetch(url, {
@@ -638,19 +666,25 @@ async function postJSON(url, data) {
body: JSON.stringify(data || {}),
});
const body = await res.json().catch(() => ({}));
- if (!res.ok) throw new Error(body.error || `request failed (${res.status})`);
+ if (!res.ok) throw Object.assign(new Error(body.error || `request failed (${res.status})`), { status: res.status });
return body;
}
-function showLogin() {
+// `resync`: already signed in, signing in again only to sync with pl8list.
+function showLogin({ resync = false } = {}) {
+ closePanels();
document.getElementById("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" : "";
document.getElementById("email").focus();
}
+loginCancel.addEventListener("click", () => signedIn(signedInUser));
+
async function withForm(form, fn) {
const buttons = form.querySelectorAll("button");
buttons.forEach((b) => { b.disabled = true; });
@@ -678,8 +712,12 @@ emailForm.addEventListener("submit", (e) => {
codeForm.addEventListener("submit", (e) => {
e.preventDefault();
withForm(codeForm, async () => {
- const { user } = await postJSON("/auth/verify", { code: codeForm.code.value });
- signedIn(user);
+ const { user, connected } = await postJSON("/auth/verify", {
+ code: codeForm.code.value,
+ connect: codeForm.connect.checked,
+ });
+ signedIn(user, { connected, synced_at: Date.now() / 1000 });
+ toast(connected ? "connected, syncing your pl8list library…" : "syncing your pl8list library…");
});
});
@@ -699,9 +737,54 @@ document.getElementById("restart").addEventListener("click", showLogin);
// Account -----------------------------------------------------------------
const deleteForm = document.getElementById("delete-form");
+const syncStatus = document.getElementById("sync-status");
+const syncBtn = document.getElementById("sync-btn");
+const disconnectBtn = document.getElementById("disconnect-btn");
-accountBtn.addEventListener("click", () => {
- if (togglePanel(accountPanel)) deleteForm.hidden = true;
+function ago(ts) {
+ 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(pl8list) {
+ const connected = !!pl8list?.connected;
+ syncStatus.classList.toggle("on", connected);
+ syncStatus.textContent = connected
+ ? `connected on this device${pl8list.synced_at ? `, last sync ${ago(pl8list.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;
+}
+
+syncBtn.addEventListener("click", async () => {
+ try {
+ await postJSON("/account/pl8list/sync");
+ toast("syncing with pl8list…");
+ showSync({ connected: true, synced_at: Date.now() / 1000 });
+ } catch (err) {
+ if (err.status === 409) showLogin({ resync: true });
+ else toast(err.message, 4000);
+ }
+});
+
+disconnectBtn.addEventListener("click", async () => {
+ try {
+ await postJSON("/account/pl8list/disconnect");
+ showSync({ connected: false });
+ toast("disconnected from pl8list");
+ } catch (err) {
+ toast(err.message, 4000);
+ }
+});
+
+accountBtn.addEventListener("click", async () => {
+ if (!togglePanel(accountPanel)) return;
+ deleteForm.hidden = true;
+ const res = await fetch("/local/me");
+ if (res.ok) showSync((await res.json()).pl8list);
});
document.getElementById("logout-btn").addEventListener("click", async () => {
@@ -732,7 +815,9 @@ deleteForm.addEventListener("submit", async (e) => {
}
});
-function signedIn(user) {
+function signedIn(user, pl8list) {
+ signedInUser = user;
+ if (pl8list) showSync(pl8list);
loginEl.hidden = true;
document.getElementById("bar").hidden = false;
accountBtn.textContent = `@${user.handle}`;
@@ -746,6 +831,9 @@ function signedIn(user) {
(async () => {
const res = await fetch("/local/me");
- if (res.ok) signedIn((await res.json()).user);
+ if (res.ok) {
+ const me = await res.json();
+ signedIn(me.user, me.pl8list);
+ }
else showLogin();
})();
diff --git a/static/index.html b/static/index.html
index e6b117a..2827573 100644
--- a/static/index.html
+++ b/static/index.html
@@ -52,18 +52,27 @@
+
+
No account? sign up on pl8list.
This site is not affiliated with pl8list. Your email and code go to pl8list, which signs this
- server in for you. We use that pl8list login once to read your library, then log it out; it is
- never stored. We keep your pl8list handle and what you play and like, to pick tracks for you.
+ 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.