feat: add recent plays history modal
Publish Metadata Agent Image / build-and-push-image (push) Successful in 1m23s
Publish Node Player Image / build-and-push-image (push) Successful in 37s
Publish Web Player Image / build-and-push-image (push) Successful in 1m49s

- GET /api/me/recent endpoint returning last 50 play events with track
  and artist info
- RecentPlays modal component with time-ago display
- "Recent plays" button in user dropdown menu
- Clicking a track in history starts playback

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ultradesu
2026-04-08 17:08:54 +01:00
parent 4fdd56dae4
commit 6b1aa6b5d5
10 changed files with 286 additions and 24 deletions
+1
View File
@@ -9,6 +9,7 @@ axum = { version = "0.7", features = ["tokio", "macros"] }
clap = { version = "4.5", features = ["derive", "env"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate"] }
tokio = { version = "1.50", features = ["full"] }
tower = { version = "0.4", features = ["util"] }
+32
View File
@@ -126,6 +126,38 @@ pub async fn record_play_event(
Ok(result.rows_affected() > 0)
}
#[derive(Debug, Serialize, sqlx::FromRow)]
pub struct RecentPlay {
pub track_slug: String,
pub track_title: String,
pub artist_name: String,
pub album_slug: Option<String>,
pub played_at: chrono::DateTime<chrono::Utc>,
}
pub async fn recent_plays(
pool: &PgPool,
user_id: &str,
limit: i32,
) -> Result<Vec<RecentPlay>, sqlx::Error> {
sqlx::query_as::<_, RecentPlay>(
r#"SELECT t.slug AS track_slug, t.title AS track_title,
ar.name AS artist_name, al.slug AS album_slug,
pe.played_at
FROM play_events pe
JOIN tracks t ON pe.track_id = t.id
JOIN artists ar ON t.artist_id = ar.id
LEFT JOIN albums al ON t.album_id = al.id
WHERE pe.user_id = $1
ORDER BY pe.played_at DESC
LIMIT $2"#
)
.bind(user_id)
.bind(limit)
.fetch_all(pool)
.await
}
// --- Queries ---
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
+10
View File
@@ -295,6 +295,16 @@ pub async fn search(State(state): State<S>, Query(q): Query<SearchQuery>) -> imp
// --- Play tracking ---
pub async fn recent_plays(
State(state): State<S>,
Extension(user): Extension<AuthUser>,
) -> impl IntoResponse {
match db::recent_plays(&state.pool, &user.id, 50).await {
Ok(plays) => (StatusCode::OK, Json(serde_json::to_value(plays).unwrap())).into_response(),
Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()),
}
}
pub async fn record_play(
State(state): State<S>,
Path(slug): Path<String>,
+2 -1
View File
@@ -30,7 +30,8 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/tracks/:slug/cover", get(api::track_cover))
.route("/stream/:slug", get(api::stream_track))
.route("/search", get(api::search))
.route("/tracks/:slug/play", post(api::record_play));
.route("/tracks/:slug/play", post(api::record_play))
.route("/me/recent", get(api::recent_plays));
let api = Router::new()
.nest("/api", library);