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));