Merge pull request 'feature/USERS' (#11) from feature/USERS into DEV
Publish Metadata Agent Image (dev) / build-and-push-image (push) Successful in 1m28s
Publish Node Player Image (dev) / build-and-push-image (push) Successful in 37s
Publish Web Player Image (dev) / build-and-push-image (push) Successful in 1m53s

Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
2026-04-08 16:23:15 +00:00
13 changed files with 566 additions and 46 deletions
Generated
+1
View File
@@ -1176,6 +1176,7 @@ dependencies = [
"anyhow",
"axum",
"base64 0.22.1",
"chrono",
"clap",
"hmac",
"jsonwebtoken 9.3.1",
@@ -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);
+1 -1
View File
@@ -57,7 +57,7 @@ function App() {
// Authenticated — render player immediately
if (!loading && user) {
return <FurumiPlayer />
return <FurumiPlayer user={user} />
}
// Loading — show spinner (no login form flash)
+11 -2
View File
@@ -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,8 @@ export function FurumiPlayer() {
searchOpen={searchOpen}
searchResults={searchResults}
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
onPlayTrack={(slug) => searchSelectRef.current('track', slug)}
user={user}
/>
<MainPanel
@@ -1,4 +1,6 @@
import { useState, useRef, useEffect } from 'react'
import { SearchDropdown } from '../SearchDropdown'
import { RecentPlays } from './RecentPlays'
import styles from './header.module.css'
type SearchResultItem = {
@@ -8,39 +10,100 @@ type SearchResultItem = {
detail?: string
}
type UserInfo = {
sub: string
name?: string
email?: string
}
type HeaderProps = {
searchOpen: boolean
searchResults: SearchResultItem[]
onSearchSelect: (type: string, slug: string) => void
onPlayTrack: (slug: string) => void
user: UserInfo
}
function UserMenu({ user, onShowRecent }: { user: UserInfo; onShowRecent: () => void }) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(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 (
<div className={styles.userMenu} ref={ref}>
<button className={styles.userAvatar} onClick={() => setOpen(!open)} title={user.name ?? user.sub}>
{initials}
</button>
{open && (
<div className={styles.userDropdown}>
<div className={styles.userInfo}>
<div className={styles.userName}>{user.name ?? user.sub}</div>
{user.email && <div className={styles.userEmail}>{user.email}</div>}
</div>
<button className={styles.userAction} onClick={() => { setOpen(false); onShowRecent() }}>
Recent plays
</button>
<a href="/auth/logout" className={styles.userLogout}>Sign out</a>
</div>
)}
</div>
)
}
export function Header({
searchOpen,
searchResults,
onSearchSelect,
onPlayTrack,
user,
}: HeaderProps) {
const [showRecent, setShowRecent] = useState(false)
return (
<header className={styles.header}>
<div className={styles.headerLogo}>
<button className="btn-menu">&#9776;</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="18" r="3" />
<circle cx="18" cy="15" r="3" />
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className={styles.headerVersion}>v</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
<input id="searchInput" placeholder="Search..." />
<SearchDropdown
isOpen={searchOpen}
results={searchResults}
onSelect={onSearchSelect}
/>
<>
<header className={styles.header}>
<div className={styles.headerLogo}>
<button className="btn-menu">&#9776;</button>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<circle cx="9" cy="18" r="3" />
<circle cx="18" cy="15" r="3" />
<path d="M12 18V6l9-3v3" />
</svg>
Furumi
<span className={styles.headerVersion}>v</span>
</div>
</div>
</header>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<div className="search-wrap">
<input id="searchInput" placeholder="Search..." />
<SearchDropdown
isOpen={searchOpen}
results={searchResults}
onSelect={onSearchSelect}
/>
</div>
<UserMenu user={user} onShowRecent={() => setShowRecent(true)} />
</div>
</header>
{showRecent && (
<RecentPlays
onClose={() => setShowRecent(false)}
onPlay={onPlayTrack}
/>
)}
</>
)
}
@@ -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<RecentPlay[] | null>(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 (
<div className={styles.recentOverlay} onClick={onClose}>
<div className={styles.recentPanel} onClick={(e) => e.stopPropagation()}>
<div className={styles.recentHeader}>
<h2>Recent plays</h2>
<button className={styles.recentClose} onClick={onClose}>&#10005;</button>
</div>
<div className={styles.recentList}>
{loading && <p className={styles.recentEmpty}>Loading...</p>}
{!loading && (!plays || plays.length === 0) && (
<p className={styles.recentEmpty}>No play history yet</p>
)}
{plays?.map((p, i) => (
<div
key={`${p.track_slug}-${i}`}
className={styles.recentItem}
onClick={() => { onPlay(p.track_slug); onClose() }}
>
<div className={styles.recentTrack}>
<div className={styles.recentTitle}>{p.track_title}</div>
<div className={styles.recentArtist}>{p.artist_name}</div>
</div>
<div className={styles.recentTime}>{timeAgo(p.played_at)}</div>
</div>
))}
</div>
</div>
</div>
)
}
@@ -32,4 +32,203 @@
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);
}
.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;
}
@@ -80,6 +80,23 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
return res?.data ?? null
}
export type RecentPlay = {
track_slug: string
track_title: string
artist_name: string
album_slug: string | null
played_at: string
}
export async function getRecentPlays(): Promise<RecentPlay[] | null> {
const res = await furumiApi.get<RecentPlay[]>('/me/recent').catch(() => null)
return res?.data ?? null
}
export async function recordPlay(trackSlug: string): Promise<void> {
await furumiApi.post(`/tracks/${trackSlug}/play`).catch(() => null)
}
export async function preloadStream(trackSlug: string) {
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
}
+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"] }
+76
View File
@@ -82,6 +82,82 @@ pub struct SearchResult {
pub detail: Option<String>, // 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<bool, sqlx::Error> {
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)
}
#[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> {
+26
View File
@@ -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<AppState>;
@@ -291,6 +293,30 @@ 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>,
Extension(user): Extension<AuthUser>,
) -> 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 {
+61 -19
View File
@@ -109,12 +109,23 @@ impl OidcState {
}
}
#[derive(Debug, Clone)]
pub struct AuthUser {
pub id: String,
pub username: String,
pub display_name: Option<String>,
pub email: Option<String>,
}
#[derive(Debug, serde::Deserialize)]
struct BearerClaims {
sub: String,
preferred_username: Option<String>,
name: Option<String>,
email: Option<String>,
}
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<String> {
async fn validate_bearer_token(oidc: &OidcState, token: &str) -> Option<AuthUser> {
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<String>
validation.validate_aud = false;
let data = decode::<BearerClaims>(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<String> {
}
/// 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<Arc<AppState>>,
req: Request,
mut req: Request,
next: Next,
) -> Response {
let mut auth_user: Option<AuthUser> = 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)]
+5 -3
View File
@@ -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,9 @@ pub fn build_router(state: Arc<AppState>) -> 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))
.route("/me/recent", get(api::recent_plays));
let api = Router::new()
.nest("/api", library);
@@ -44,7 +46,7 @@ pub fn build_router(state: Arc<AppState>) -> 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));