From 4fdd56dae414cb60106b91cb39acd953ae7291ec Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 16:51:53 +0100 Subject: [PATCH 1/2] feat: add user support with play event tracking Backend (Rust API): - Add users and play_events tables (migration 0005) - Extract full user identity from JWT (sub, username, email, name) and pass AuthUser via request extensions to all handlers - Auto-upsert user in background on every authenticated request - POST /api/tracks/:slug/play endpoint to record play events - Allow POST method in CORS Frontend (Node player): - Call recordPlay() when a track starts playing - Add user profile avatar with dropdown menu (name, email, sign out) - Pass user info from App through FurumiPlayer to Header Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migrations/0005_users_and_play_events.sql | 20 +++++ furumi-node-player/client/src/App.tsx | 2 +- .../client/src/FurumiPlayer.tsx | 12 ++- .../client/src/components/header/Header.tsx | 47 +++++++++++ .../src/components/header/header.module.css | 76 ++++++++++++++++++ furumi-node-player/client/src/furumiApi.ts | 4 + furumi-web-player/src/db.rs | 44 ++++++++++ furumi-web-player/src/web/api.rs | 16 ++++ furumi-web-player/src/web/auth.rs | 80 ++++++++++++++----- furumi-web-player/src/web/mod.rs | 7 +- 10 files changed, 283 insertions(+), 25 deletions(-) create mode 100644 furumi-agent/migrations/0005_users_and_play_events.sql diff --git a/furumi-agent/migrations/0005_users_and_play_events.sql b/furumi-agent/migrations/0005_users_and_play_events.sql new file mode 100644 index 0000000..cae6b92 --- /dev/null +++ b/furumi-agent/migrations/0005_users_and_play_events.sql @@ -0,0 +1,20 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL, + display_name TEXT, + email TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE play_events ( + id BIGSERIAL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_id BIGINT NOT NULL REFERENCES tracks(id) ON DELETE CASCADE, + played_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_play_events_user_id ON play_events(user_id); +CREATE INDEX idx_play_events_track_id ON play_events(track_id); +CREATE INDEX idx_play_events_user_track ON play_events(user_id, track_id); +CREATE INDEX idx_play_events_played_at ON play_events(played_at DESC); diff --git a/furumi-node-player/client/src/App.tsx b/furumi-node-player/client/src/App.tsx index 82ac92a..34d51a3 100644 --- a/furumi-node-player/client/src/App.tsx +++ b/furumi-node-player/client/src/App.tsx @@ -57,7 +57,7 @@ function App() { // Authenticated — render player immediately if (!loading && user) { - return + return } // Loading — show spinner (no login form flash) diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index 64d3410..c2572e5 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react' import './furumi-player.css' -import { searchTracks, preloadStream, fetchCoverBlob } from './furumiApi' +import { searchTracks, preloadStream, fetchCoverBlob, recordPlay } from './furumiApi' import { store, useAppDispatch, useAppSelector } from './store' import { fetchArtists } from './store/slices/artistsSlice' import { fetchArtistAlbums } from './store/slices/albumsSlice' @@ -29,7 +29,13 @@ import { MainPanel, type Crumb } from './components/MainPanel' import { PlayerBar } from './components/PlayerBar' import type { Track } from './types' -export function FurumiPlayer() { +export type UserProfile = { + sub: string + name?: string + email?: string +} + +export function FurumiPlayer({ user }: { user: UserProfile }) { const dispatch = useAppDispatch() const artistsLoading = useAppSelector((s) => s.artists.loading) const artistsError = useAppSelector((s) => s.artists.error) @@ -293,6 +299,7 @@ export function FurumiPlayer() { dispatch(playAtIndex(i)) const track = store.getState().queue.items[i] void playback.loadStreamForTrack(track.slug) + void recordPlay(track.slug) if (window.history && window.history.replaceState) { const url = new URL(window.location.href) url.searchParams.set('t', track.slug) @@ -513,6 +520,7 @@ export function FurumiPlayer() { searchOpen={searchOpen} searchResults={searchResults} onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)} + user={user} /> void + user: UserInfo +} + +function UserMenu({ user }: { user: UserInfo }) { + const [open, setOpen] = useState(false) + const ref = useRef(null) + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false) + } + document.addEventListener('mousedown', handleClick) + return () => document.removeEventListener('mousedown', handleClick) + }, []) + + const initials = (user.name ?? user.sub) + .split(' ') + .map((w) => w[0]) + .slice(0, 2) + .join('') + .toUpperCase() + + return ( +
+ + {open && ( +
+
+
{user.name ?? user.sub}
+ {user.email &&
{user.email}
} +
+ Sign out +
+ )} +
+ ) } export function Header({ searchOpen, searchResults, onSearchSelect, + user, }: HeaderProps) { return (
@@ -40,6 +86,7 @@ export function Header({ onSelect={onSearchSelect} /> +
) diff --git a/furumi-node-player/client/src/components/header/header.module.css b/furumi-node-player/client/src/components/header/header.module.css index 3777ab4..02af660 100644 --- a/furumi-node-player/client/src/components/header/header.module.css +++ b/furumi-node-player/client/src/components/header/header.module.css @@ -32,4 +32,80 @@ margin-left: 0.25rem; font-weight: 500; text-decoration: none; +} + +/* User menu */ + +.userMenu { + position: relative; +} + +.userAvatar { + width: 32px; + height: 32px; + border-radius: 50%; + background: var(--accent); + color: #fff; + border: none; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.2s; +} + +.userAvatar:hover { + background: var(--accent-dim); +} + +.userDropdown { + position: absolute; + top: calc(100% + 8px); + right: 0; + min-width: 200px; + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: 10px; + box-shadow: 0 12px 32px rgba(0, 0, 0, 0.4); + z-index: 100; + overflow: hidden; + animation: fadeIn 0.15s ease; +} + +@keyframes fadeIn { + from { opacity: 0; transform: translateY(-4px); } + to { opacity: 1; transform: translateY(0); } +} + +.userInfo { + padding: 12px 16px; + border-bottom: 1px solid var(--border); +} + +.userName { + font-weight: 600; + font-size: 0.9rem; + color: var(--text); +} + +.userEmail { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.userLogout { + display: block; + padding: 10px 16px; + color: var(--danger); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + transition: background 0.15s; +} + +.userLogout:hover { + background: var(--bg-hover); } \ No newline at end of file diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index fa447c7..eb9aa4e 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -80,6 +80,10 @@ export async function getTrackInfo(trackSlug: string): Promise { + await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null) +} + export async function preloadStream(trackSlug: string) { return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null) } diff --git a/furumi-web-player/src/db.rs b/furumi-web-player/src/db.rs index f33fac6..0fad353 100644 --- a/furumi-web-player/src/db.rs +++ b/furumi-web-player/src/db.rs @@ -82,6 +82,50 @@ pub struct SearchResult { pub detail: Option, // artist name for albums/tracks } +// --- User management --- + +pub async fn upsert_user( + pool: &PgPool, + id: &str, + username: &str, + display_name: Option<&str>, + email: Option<&str>, +) -> Result<(), sqlx::Error> { + sqlx::query( + r#"INSERT INTO users (id, username, display_name, email, last_seen_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (id) DO UPDATE SET + username = EXCLUDED.username, + display_name = EXCLUDED.display_name, + email = EXCLUDED.email, + last_seen_at = NOW()"# + ) + .bind(id) + .bind(username) + .bind(display_name) + .bind(email) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn record_play_event( + pool: &PgPool, + user_id: &str, + track_slug: &str, +) -> Result { + let result = sqlx::query( + r#"INSERT INTO play_events (user_id, track_id, played_at) + SELECT $1, t.id, NOW() + FROM tracks t WHERE t.slug = $2"# + ) + .bind(user_id) + .bind(track_slug) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + // --- Queries --- pub async fn list_artists(pool: &PgPool) -> Result, sqlx::Error> { diff --git a/furumi-web-player/src/web/api.rs b/furumi-web-player/src/web/api.rs index e4fe88a..7357702 100644 --- a/furumi-web-player/src/web/api.rs +++ b/furumi-web-player/src/web/api.rs @@ -9,8 +9,10 @@ use axum::{ use serde::Deserialize; use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use axum::Extension; use crate::db; use super::AppState; +use super::auth::AuthUser; type S = Arc; @@ -291,6 +293,20 @@ pub async fn search(State(state): State, Query(q): Query) -> imp } } +// --- Play tracking --- + +pub async fn record_play( + State(state): State, + Path(slug): Path, + Extension(user): Extension, +) -> impl IntoResponse { + match db::record_play_event(&state.pool, &user.id, &slug).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => error_json(StatusCode::NOT_FOUND, "track not found"), + Err(e) => error_json(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + // --- Helpers --- fn error_json(status: StatusCode, message: &str) -> Response { diff --git a/furumi-web-player/src/web/auth.rs b/furumi-web-player/src/web/auth.rs index 98574f7..8f61b86 100644 --- a/furumi-web-player/src/web/auth.rs +++ b/furumi-web-player/src/web/auth.rs @@ -109,12 +109,23 @@ impl OidcState { } } +#[derive(Debug, Clone)] +pub struct AuthUser { + pub id: String, + pub username: String, + pub display_name: Option, + pub email: Option, +} + #[derive(Debug, serde::Deserialize)] struct BearerClaims { sub: String, + preferred_username: Option, + name: Option, + email: Option, } -async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option { +async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option { let header = decode_header(token).ok()?; let kid = header.kid.as_ref()?; @@ -134,7 +145,13 @@ async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option validation.validate_aud = false; let data = decode::(token, &key, &validation).ok()?; - Some(data.claims.sub) + let c = data.claims; + Some(AuthUser { + id: c.sub.clone(), + username: c.preferred_username.unwrap_or(c.sub), + display_name: c.name, + email: c.email, + }) } fn generate_sso_cookie(secret: &[u8], user_id: &str) -> String { @@ -164,11 +181,14 @@ fn verify_sso_cookie(secret: &[u8], cookie_val: &str) -> Option { } /// Auth middleware: requires valid Bearer JWT or SSO session cookie. +/// Inserts AuthUser into request extensions and upserts user in DB. pub async fn require_auth( State(state): State>, - req: Request, + mut req: Request, next: Next, ) -> Response { + let mut auth_user: Option = None; + // 1. Check Bearer token — JWT from OIDC provider if let Some(ref oidc) = state.oidc { if let Some(token) = req @@ -177,32 +197,54 @@ pub async fn require_auth( .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) { - if let Some(user_id) = validate_bearer_token(oidc, token).await { - tracing::debug!("Bearer auth OK for user: {}", user_id); - return next.run(req).await; - } + auth_user = validate_bearer_token(oidc, token).await; } } // 2. Check SSO session cookie (if OIDC configured) - if let Some(ref oidc) = state.oidc { - let cookies = req - .headers() - .get(header::COOKIE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); + if auth_user.is_none() { + if let Some(ref oidc) = state.oidc { + let cookies = req + .headers() + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); - for c in cookies.split(';') { - let c = c.trim(); - if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { - if verify_sso_cookie(&oidc.session_secret, val).is_some() { - return next.run(req).await; + for c in cookies.split(';') { + let c = c.trim(); + if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) { + if let Some(user_id) = verify_sso_cookie(&oidc.session_secret, val) { + auth_user = Some(AuthUser { + id: user_id.clone(), + username: user_id, + display_name: None, + email: None, + }); + break; + } } } } } - (StatusCode::UNAUTHORIZED, "Unauthorized").into_response() + match auth_user { + Some(user) => { + tracing::debug!("Auth OK for user: {}", user.username); + // Upsert user in background + let pool = state.pool.clone(); + let u = user.clone(); + tokio::spawn(async move { + if let Err(e) = crate::db::upsert_user( + &pool, &u.id, &u.username, u.display_name.as_deref(), u.email.as_deref(), + ).await { + tracing::warn!("Failed to upsert user: {}", e); + } + }); + req.extensions_mut().insert(user); + next.run(req).await + } + None => (StatusCode::UNAUTHORIZED, "Unauthorized").into_response(), + } } #[derive(Deserialize)] diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index c8d95b9..d35a8a1 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use std::path::PathBuf; use std::time::Duration; -use axum::{Router, routing::get, middleware}; +use axum::{Router, routing::{get, post}, middleware}; use axum::http::{header, Method}; use sqlx::PgPool; use tower_http::cors::{Any, CorsLayer}; @@ -29,7 +29,8 @@ pub fn build_router(state: Arc) -> Router { .route("/tracks/:slug", get(api::get_track_detail)) .route("/tracks/:slug/cover", get(api::track_cover)) .route("/stream/:slug", get(api::stream_track)) - .route("/search", get(api::search)); + .route("/search", get(api::search)) + .route("/tracks/:slug/play", post(api::record_play)); let api = Router::new() .nest("/api", library); @@ -44,7 +45,7 @@ pub fn build_router(state: Arc) -> Router { let cors = CorsLayer::new() .allow_origin(Any) - .allow_methods([Method::GET, Method::OPTIONS, Method::HEAD]) + .allow_methods([Method::GET, Method::POST, Method::OPTIONS, Method::HEAD]) .allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION]) .max_age(Duration::from_secs(600)); From 6b1aa6b5d50ffa8580f750b51fc20496a8b0d205 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 8 Apr 2026 17:08:54 +0100 Subject: [PATCH 2/2] feat: add recent plays history modal - 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) --- Cargo.lock | 1 + .../client/src/FurumiPlayer.tsx | 1 + .../client/src/components/header/Header.tsx | 62 +++++---- .../src/components/header/RecentPlays.tsx | 64 +++++++++ .../src/components/header/header.module.css | 123 ++++++++++++++++++ furumi-node-player/client/src/furumiApi.ts | 13 ++ furumi-web-player/Cargo.toml | 1 + furumi-web-player/src/db.rs | 32 +++++ furumi-web-player/src/web/api.rs | 10 ++ furumi-web-player/src/web/mod.rs | 3 +- 10 files changed, 286 insertions(+), 24 deletions(-) create mode 100644 furumi-node-player/client/src/components/header/RecentPlays.tsx diff --git a/Cargo.lock b/Cargo.lock index 0e209f0..98a7483 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1176,6 +1176,7 @@ dependencies = [ "anyhow", "axum", "base64 0.22.1", + "chrono", "clap", "hmac", "jsonwebtoken 9.3.1", diff --git a/furumi-node-player/client/src/FurumiPlayer.tsx b/furumi-node-player/client/src/FurumiPlayer.tsx index c2572e5..4217803 100644 --- a/furumi-node-player/client/src/FurumiPlayer.tsx +++ b/furumi-node-player/client/src/FurumiPlayer.tsx @@ -520,6 +520,7 @@ export function FurumiPlayer({ user }: { user: UserProfile }) { searchOpen={searchOpen} searchResults={searchResults} onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)} + onPlayTrack={(slug) => searchSelectRef.current('track', slug)} user={user} /> diff --git a/furumi-node-player/client/src/components/header/Header.tsx b/furumi-node-player/client/src/components/header/Header.tsx index c227db3..9a1aada 100644 --- a/furumi-node-player/client/src/components/header/Header.tsx +++ b/furumi-node-player/client/src/components/header/Header.tsx @@ -1,5 +1,6 @@ import { useState, useRef, useEffect } from 'react' import { SearchDropdown } from '../SearchDropdown' +import { RecentPlays } from './RecentPlays' import styles from './header.module.css' type SearchResultItem = { @@ -19,10 +20,11 @@ type HeaderProps = { searchOpen: boolean searchResults: SearchResultItem[] onSearchSelect: (type: string, slug: string) => void + onPlayTrack: (slug: string) => void user: UserInfo } -function UserMenu({ user }: { user: UserInfo }) { +function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) { const [open, setOpen] = useState(false) const ref = useRef(null) @@ -52,6 +54,9 @@ function UserMenu({ user }: { user: UserInfo }) {
{user.name ?? user.sub}
{user.email &&
{user.email}
} + Sign out )} @@ -63,31 +68,42 @@ export function Header({ searchOpen, searchResults, onSearchSelect, + onPlayTrack, user, }: HeaderProps) { + const [showRecent, setShowRecent] = useState(false) + return ( -
-
- - - - - - - Furumi - v -
-
-
- - + <> +
+
+ + + + + + + Furumi + v
- -
-
+
+
+ + +
+ setShowRecent(true)} /> +
+ + {showRecent && ( + setShowRecent(false)} + onPlay={onPlayTrack} + /> + )} + ) } diff --git a/furumi-node-player/client/src/components/header/RecentPlays.tsx b/furumi-node-player/client/src/components/header/RecentPlays.tsx new file mode 100644 index 0000000..fcc645f --- /dev/null +++ b/furumi-node-player/client/src/components/header/RecentPlays.tsx @@ -0,0 +1,64 @@ +import { useEffect, useState } from 'react' +import { getRecentPlays, type RecentPlay } from '../../furumiApi' +import styles from './header.module.css' + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const mins = Math.floor(diff / 60000) + if (mins < 1) return 'just now' + if (mins < 60) return `${mins}m ago` + const hrs = Math.floor(mins / 60) + if (hrs < 24) return `${hrs}h ago` + const days = Math.floor(hrs / 24) + return `${days}d ago` +} + +export function RecentPlays({ onClose, onPlay }: { onClose: () => void; onPlay: (slug: string) => void }) { + const [plays, setPlays] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + getRecentPlays().then((data) => { + setPlays(data) + setLoading(false) + }) + }, []) + + useEffect(() => { + function onKey(e: KeyboardEvent) { + if (e.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [onClose]) + + return ( +
+
e.stopPropagation()}> +
+

Recent plays

+ +
+
+ {loading &&

Loading...

} + {!loading && (!plays || plays.length === 0) && ( +

No play history yet

+ )} + {plays?.map((p, i) => ( +
{ onPlay(p.track_slug); onClose() }} + > +
+
{p.track_title}
+
{p.artist_name}
+
+
{timeAgo(p.played_at)}
+
+ ))} +
+
+
+ ) +} diff --git a/furumi-node-player/client/src/components/header/header.module.css b/furumi-node-player/client/src/components/header/header.module.css index 02af660..0375b19 100644 --- a/furumi-node-player/client/src/components/header/header.module.css +++ b/furumi-node-player/client/src/components/header/header.module.css @@ -108,4 +108,127 @@ .userLogout:hover { background: var(--bg-hover); +} + +.userAction { + display: block; + padding: 10px 16px; + color: var(--text); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + cursor: pointer; + background: none; + border: none; + width: 100%; + text-align: left; + transition: background 0.15s; +} + +.userAction:hover { + background: var(--bg-hover); +} + +/* Recent plays overlay */ + +.recentOverlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 200; + display: grid; + place-items: center; + animation: fadeIn 0.15s ease; +} + +.recentPanel { + width: min(480px, 90vw); + max-height: 70vh; + background: var(--bg-panel); + border: 1px solid var(--border); + border-radius: 14px; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} + +.recentHeader { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid var(--border); +} + +.recentHeader h2 { + font-size: 1rem; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.recentClose { + background: none; + border: none; + color: var(--text-muted); + font-size: 1rem; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.recentClose:hover { + color: var(--text); + background: var(--bg-hover); +} + +.recentList { + overflow-y: auto; + flex: 1; +} + +.recentItem { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 20px; + cursor: pointer; + transition: background 0.15s; +} + +.recentItem:hover { + background: var(--bg-hover); +} + +.recentTrack { + min-width: 0; + flex: 1; +} + +.recentTitle { + font-size: 0.85rem; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.recentArtist { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 1px; +} + +.recentTime { + font-size: 0.7rem; + color: var(--text-dim); + flex-shrink: 0; + margin-left: 12px; +} + +.recentEmpty { + padding: 32px 20px; + text-align: center; + color: var(--text-muted); + font-size: 0.85rem; } \ No newline at end of file diff --git a/furumi-node-player/client/src/furumiApi.ts b/furumi-node-player/client/src/furumiApi.ts index eb9aa4e..4ed3312 100644 --- a/furumi-node-player/client/src/furumiApi.ts +++ b/furumi-node-player/client/src/furumiApi.ts @@ -80,6 +80,19 @@ export async function getTrackInfo(trackSlug: string): Promise { + const res = await furumiApi.get('/me/recent').catch(() => null) + return res?.data ?? null +} + export async function recordPlay(trackSlug: string): Promise { await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null) } diff --git a/furumi-web-player/Cargo.toml b/furumi-web-player/Cargo.toml index 6638f38..17270c2 100644 --- a/furumi-web-player/Cargo.toml +++ b/furumi-web-player/Cargo.toml @@ -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"] } diff --git a/furumi-web-player/src/db.rs b/furumi-web-player/src/db.rs index 0fad353..be139ac 100644 --- a/furumi-web-player/src/db.rs +++ b/furumi-web-player/src/db.rs @@ -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, + pub played_at: chrono::DateTime, +} + +pub async fn recent_plays( + pool: &PgPool, + user_id: &str, + limit: i32, +) -> Result, 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, sqlx::Error> { diff --git a/furumi-web-player/src/web/api.rs b/furumi-web-player/src/web/api.rs index 7357702..b19249a 100644 --- a/furumi-web-player/src/web/api.rs +++ b/furumi-web-player/src/web/api.rs @@ -295,6 +295,16 @@ pub async fn search(State(state): State, Query(q): Query) -> imp // --- Play tracking --- +pub async fn recent_plays( + State(state): State, + Extension(user): Extension, +) -> 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, Path(slug): Path, diff --git a/furumi-web-player/src/web/mod.rs b/furumi-web-player/src/web/mod.rs index d35a8a1..c25cb71 100644 --- a/furumi-web-player/src/web/mod.rs +++ b/furumi-web-player/src/web/mod.rs @@ -30,7 +30,8 @@ pub fn build_router(state: Arc) -> 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);