use std::fmt::Write as _;
use axum::Extension;
use axum::body::Bytes;
use axum::extract::{Query, State};
use axum::http::HeaderMap;
use axum::response::Response;
use chrono::{DateTime, Datelike, FixedOffset, TimeZone, Utc};
use koan_core::db::queries::{self, PlayHistoryRow};
use super::favourite::Hearts;
use super::pages::{EMPTY, ERROR, MORE, cover_url, track_versions, unavailable};
use super::{UiState, events, open, patch};
use crate::auth::AuthUser;
use crate::share::{blocking, duration, escape};
const PAGE: u32 = 300;
#[derive(serde::Deserialize, Default)]
#[serde(default)]
pub(super) struct Paging {
page: u32,
}
fn zone(headers: &HeaderMap) -> FixedOffset {
super::cookie(headers, "koan_tz")
.and_then(|m| m.parse::<i32>().ok())
.filter(|m| m.abs() <= 18 * 60)
.and_then(|m| FixedOffset::east_opt(m * 60))
.unwrap_or(FixedOffset::east_opt(0).expect("zero is an offset"))
}
fn day_label(day: DateTime<FixedOffset>, now: DateTime<FixedOffset>) -> String {
let ago = now
.date_naive()
.signed_duration_since(day.date_naive())
.num_days();
match ago {
0 => "Today".into(),
1 => "Yesterday".into(),
2..=6 => day.format("%A").to_string(),
_ if day.year() == now.year() => day.format("%A %-d %B").to_string(),
_ => day.format("%-d %B %Y").to_string(),
}
}
fn plays(
rows: &[PlayHistoryRow],
versions: &super::pages::Versions,
zone: FixedOffset,
hearts: Option<&Hearts>,
) -> String {
let now = Utc::now().with_timezone(&zone);
let mut out = String::new();
let mut day = None;
for p in rows {
let at = zone.timestamp_opt(p.played_at, 0).single().unwrap_or(now);
if day != Some(at.date_naive()) {
day = Some(at.date_naive());
let _ = write!(out, "<li class=\"disc\">{}</li>", day_label(at, now));
}
let t = &p.track;
let _ = write!(
out,
"<li tabindex=0 data-id={id} data-dur={secs} data-title=\"{title}\" data-artist=\"{artist}\" \
data-album=\"{album}\" data-album-id={album_id} data-artist-id={artist_id} data-cover=\"{cover}\">\
<input type=checkbox class=\"flex-none max-wide:hidden\" value={play} aria-label=\"Select this play\">\
<span class=\"n w-auto max-wide:text-meta\">{time}</span><span class=\"t\">{title}<small>{artist} · {album}</small></span>\
<span class=\"d\">{dur}</span><span class=\"contents max-wide:hidden\">{heart}\
<button class=\"quiet\" data-act=add aria-label=\"Add to queue\" title=\"Add to queue\">+</button></span>{mark}{MORE}</li>",
id = t.id,
play = p.id,
secs = t.duration_ms.unwrap_or(0) / 1000,
title = escape(&t.title),
artist = escape(&t.artist_name),
album = escape(&t.album_title),
album_id = t.album_id.unwrap_or(0),
artist_id = t.artist_id.unwrap_or(0),
cover = t
.album_id
.map(|a| cover_url(a, crate::covers::LARGE, versions))
.unwrap_or_default(),
time = at.format("%H:%M"),
dur = duration(t.duration_ms),
heart = hearts.map(|h| h.track(t.id)).unwrap_or_default(),
mark = hearts.map(Hearts::mark).unwrap_or_default(),
);
}
out
}
struct Plays {
rows: Vec<PlayHistoryRow>,
more: bool,
versions: super::pages::Versions,
hearts: Option<Hearts>,
}
fn read(s: &UiState, user: &AuthUser, page: u32) -> Option<Plays> {
let db = open(&s.pool)?;
let mut rows = queries::play_history_with_tracks(
&db.conn,
user.user_id,
None,
Some(PAGE + 1),
page * PAGE,
)
.ok()?;
let more = rows.len() > PAGE as usize;
rows.truncate(PAGE as usize);
let tracks: Vec<_> = rows.iter().map(|r| r.track.clone()).collect();
let versions = track_versions(&db.conn, &tracks);
let hearts = Hearts::load(&db.conn, user);
Some(Plays {
rows,
more,
versions,
hearts,
})
}
fn list(p: &Plays, page: u32, zone: FixedOffset) -> String {
let (rows, more, versions) = (&p.rows, p.more, &p.versions);
if rows.is_empty() && page == 0 {
return format!(
"<div id=history><p class=\"{EMPTY}\">Nothing played yet.</p>\
<p class=\"{EMPTY}\">What you play here, in the kōan apps or in a Subsonic app signed in as you is listed \
by day.</p></div>"
);
}
let mut nav = Vec::new();
if page > 0 {
nav.push(format!(
"<a href=\"/history{}\">Newer</a>",
if page == 1 {
String::new()
} else {
format!("?page={}", page - 1)
}
));
}
if more {
nav.push(format!(
"<a href=\"/history?page={}\">Earlier</a>",
page + 1
));
}
let nav = if nav.is_empty() {
String::new()
} else {
format!("<p class=\"mt-5 flex gap-4\">{}</p>", nav.join(""))
};
format!(
"<div id=history><ol class=\"tracks\" data-context=one>{}</ol>{nav}</div>",
plays(rows, versions, zone, p.hearts.as_ref())
)
}
pub(super) async fn page(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
Query(paging): Query<Paging>,
headers: HeaderMap,
) -> Response {
let (st, page, who) = (s.clone(), paging.page, user.clone());
let Some(plays) = blocking(move || read(&st, &who, page)).await else {
return unavailable();
};
let inner = format!(
"<div data-signals:forget=\"[]\" \
data-on:change=\"$forget = [...el.querySelectorAll('#history input:checked')].map(i => i.value)\"><div class=\"flex flex-wrap items-baseline justify-between gap-2\">\
<h1>History</h1><div class=\"flex items-center gap-2\" data-show=\"$forget.length > 0\">\
<span class=\"text-meta text-muted\" data-text=\"$forget.length + ' selected'\"></span>\
<button data-on:click=\"el.closest('.page').querySelectorAll('#history input:checked').forEach(i => i.checked = false); $forget = []\">Deselect</button>\
<button class=\"primary\" data-act-forget data-indicator:_forgetting data-attr:disabled=\"$_forgetting\" \
data-on:click=\"@post('/history/forget?page={page}')\">Forget</button></div></div>\
<div id=history-result></div>{}</div>",
list(&plays, page, zone(&headers))
);
super::pages::respond(&s, &headers, &user, "History", &inner)
}
pub(super) async fn forget(
State(s): State<UiState>,
Extension(user): Extension<AuthUser>,
Query(paging): Query<Paging>,
headers: HeaderMap,
body: Bytes,
) -> Response {
let ids: Vec<i64> = serde_json::from_slice::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("forget").cloned())
.and_then(|v| serde_json::from_value::<Vec<serde_json::Value>>(v).ok())
.unwrap_or_default()
.iter()
.filter_map(|v| v.as_i64().or_else(|| v.as_str()?.parse().ok()))
.collect();
let page = paging.page;
let found = blocking(move || {
let db = open(&s.pool)?;
queries::forget_shared_entries(&db.conn, user.user_id, &ids).ok()?;
use koan_core::smart::Field;
crate::clients::smart_activity(&db, user.user_id, &[Field::PlayCount, Field::LastPlayed]);
drop(db);
crate::clients::registry().broadcast(
Some(&user.username),
koan_core::remote::link::LinkCommand::HistoryChanged,
);
read(&s, &user, page)
})
.await;
match found {
Some(plays) => events(vec![
patch(&list(&plays, page, zone(&headers)), None),
patch("<div id=history-result></div>", None),
axum::response::sse::Event::default()
.event("datastar-patch-signals")
.data("signals {\"forget\":[]}"),
]),
None => events(vec![patch(
&format!(
"<div id=history-result class=\"mt-3 {ERROR}\" role=alert>Those plays could not be forgotten.</div>"
),
None,
)]),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn days_are_named_as_near_as_they_are() {
let zone = FixedOffset::east_opt(3600).unwrap();
let now = zone.with_ymd_and_hms(2026, 10, 5, 0, 30, 0).unwrap();
let at = |d, h| zone.with_ymd_and_hms(2026, 10, d, h, 0, 0).unwrap();
assert_eq!(day_label(at(5, 0), now), "Today");
assert_eq!(day_label(at(4, 23), now), "Yesterday");
assert_eq!(day_label(at(1, 12), now), "Thursday");
let earlier = zone.with_ymd_and_hms(2026, 9, 28, 12, 0, 0).unwrap();
assert_eq!(day_label(earlier, now), "Monday 28 September");
let last_year = zone.with_ymd_and_hms(2025, 12, 31, 9, 0, 0).unwrap();
assert_eq!(day_label(last_year, now), "31 December 2025");
}
#[test]
fn the_zone_is_the_browsers_and_utc_without_one() {
let mut headers = HeaderMap::new();
assert_eq!(zone(&headers).local_minus_utc(), 0);
headers.insert(
axum::http::header::COOKIE,
"a=b; koan_tz=-300".parse().unwrap(),
);
assert_eq!(zone(&headers).local_minus_utc(), -300 * 60);
headers.insert(axum::http::header::COOKIE, "koan_tz=99999".parse().unwrap());
assert_eq!(zone(&headers).local_minus_utc(), 0, "nonsense is ignored");
}
}