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) <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||||
@@ -57,7 +57,7 @@ function App() {
|
|||||||
|
|
||||||
// Authenticated — render player immediately
|
// Authenticated — render player immediately
|
||||||
if (!loading && user) {
|
if (!loading && user) {
|
||||||
return <FurumiPlayer />
|
return <FurumiPlayer user={user} />
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading — show spinner (no login form flash)
|
// Loading — show spinner (no login form flash)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
import { useEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||||
import './furumi-player.css'
|
import './furumi-player.css'
|
||||||
import { searchTracks, preloadStream, fetchCoverBlob } from './furumiApi'
|
import { searchTracks, preloadStream, fetchCoverBlob, recordPlay } from './furumiApi'
|
||||||
import { store, useAppDispatch, useAppSelector } from './store'
|
import { store, useAppDispatch, useAppSelector } from './store'
|
||||||
import { fetchArtists } from './store/slices/artistsSlice'
|
import { fetchArtists } from './store/slices/artistsSlice'
|
||||||
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
import { fetchArtistAlbums } from './store/slices/albumsSlice'
|
||||||
@@ -29,7 +29,13 @@ import { MainPanel, type Crumb } from './components/MainPanel'
|
|||||||
import { PlayerBar } from './components/PlayerBar'
|
import { PlayerBar } from './components/PlayerBar'
|
||||||
import type { Track } from './types'
|
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 dispatch = useAppDispatch()
|
||||||
const artistsLoading = useAppSelector((s) => s.artists.loading)
|
const artistsLoading = useAppSelector((s) => s.artists.loading)
|
||||||
const artistsError = useAppSelector((s) => s.artists.error)
|
const artistsError = useAppSelector((s) => s.artists.error)
|
||||||
@@ -293,6 +299,7 @@ export function FurumiPlayer() {
|
|||||||
dispatch(playAtIndex(i))
|
dispatch(playAtIndex(i))
|
||||||
const track = store.getState().queue.items[i]
|
const track = store.getState().queue.items[i]
|
||||||
void playback.loadStreamForTrack(track.slug)
|
void playback.loadStreamForTrack(track.slug)
|
||||||
|
void recordPlay(track.slug)
|
||||||
if (window.history && window.history.replaceState) {
|
if (window.history && window.history.replaceState) {
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href)
|
||||||
url.searchParams.set('t', track.slug)
|
url.searchParams.set('t', track.slug)
|
||||||
@@ -513,6 +520,7 @@ export function FurumiPlayer() {
|
|||||||
searchOpen={searchOpen}
|
searchOpen={searchOpen}
|
||||||
searchResults={searchResults}
|
searchResults={searchResults}
|
||||||
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
onSearchSelect={(type, slug) => searchSelectRef.current(type, slug)}
|
||||||
|
user={user}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<MainPanel
|
<MainPanel
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { SearchDropdown } from '../SearchDropdown'
|
import { SearchDropdown } from '../SearchDropdown'
|
||||||
import styles from './header.module.css'
|
import styles from './header.module.css'
|
||||||
|
|
||||||
@@ -8,16 +9,61 @@ type SearchResultItem = {
|
|||||||
detail?: string
|
detail?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserInfo = {
|
||||||
|
sub: string
|
||||||
|
name?: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
type HeaderProps = {
|
type HeaderProps = {
|
||||||
searchOpen: boolean
|
searchOpen: boolean
|
||||||
searchResults: SearchResultItem[]
|
searchResults: SearchResultItem[]
|
||||||
onSearchSelect: (type: string, slug: string) => void
|
onSearchSelect: (type: string, slug: string) => void
|
||||||
|
user: UserInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserMenu({ user }: { user: UserInfo }) {
|
||||||
|
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>
|
||||||
|
<a href="/auth/logout" className={styles.userLogout}>Sign out</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Header({
|
export function Header({
|
||||||
searchOpen,
|
searchOpen,
|
||||||
searchResults,
|
searchResults,
|
||||||
onSearchSelect,
|
onSearchSelect,
|
||||||
|
user,
|
||||||
}: HeaderProps) {
|
}: HeaderProps) {
|
||||||
return (
|
return (
|
||||||
<header className={styles.header}>
|
<header className={styles.header}>
|
||||||
@@ -40,6 +86,7 @@ export function Header({
|
|||||||
onSelect={onSearchSelect}
|
onSelect={onSearchSelect}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<UserMenu user={user} />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,4 +32,80 @@
|
|||||||
margin-left: 0.25rem;
|
margin-left: 0.25rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
text-decoration: none;
|
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);
|
||||||
}
|
}
|
||||||
@@ -80,6 +80,10 @@ export async function getTrackInfo(trackSlug: string): Promise<TrackDetail | nul
|
|||||||
return res?.data ?? 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) {
|
export async function preloadStream(trackSlug: string) {
|
||||||
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
|
return await furumiApi.get(`/stream/${trackSlug}`, { responseType: 'blob' }).catch(() => null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,50 @@ pub struct SearchResult {
|
|||||||
pub detail: Option<String>, // artist name for albums/tracks
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Queries ---
|
// --- Queries ---
|
||||||
|
|
||||||
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
|
pub async fn list_artists(pool: &PgPool) -> Result<Vec<ArtistListItem>, sqlx::Error> {
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
use tokio::io::{AsyncReadExt, AsyncSeekExt};
|
||||||
|
|
||||||
|
use axum::Extension;
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use super::AppState;
|
use super::AppState;
|
||||||
|
use super::auth::AuthUser;
|
||||||
|
|
||||||
type S = Arc<AppState>;
|
type S = Arc<AppState>;
|
||||||
|
|
||||||
@@ -291,6 +293,20 @@ pub async fn search(State(state): State<S>, Query(q): Query<SearchQuery>) -> imp
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Play tracking ---
|
||||||
|
|
||||||
|
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 ---
|
// --- Helpers ---
|
||||||
|
|
||||||
fn error_json(status: StatusCode, message: &str) -> Response {
|
fn error_json(status: StatusCode, message: &str) -> Response {
|
||||||
|
|||||||
@@ -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)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct BearerClaims {
|
struct BearerClaims {
|
||||||
sub: String,
|
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 header = decode_header(token).ok()?;
|
||||||
let kid = header.kid.as_ref()?;
|
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;
|
validation.validate_aud = false;
|
||||||
|
|
||||||
let data = decode::<BearerClaims>(token, &key, &validation).ok()?;
|
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 {
|
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.
|
/// 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(
|
pub async fn require_auth(
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
req: Request,
|
mut req: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
|
let mut auth_user: Option<AuthUser> = None;
|
||||||
|
|
||||||
// 1. Check Bearer token — JWT from OIDC provider
|
// 1. Check Bearer token — JWT from OIDC provider
|
||||||
if let Some(ref oidc) = state.oidc {
|
if let Some(ref oidc) = state.oidc {
|
||||||
if let Some(token) = req
|
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.to_str().ok())
|
||||||
.and_then(|v| v.strip_prefix("Bearer "))
|
.and_then(|v| v.strip_prefix("Bearer "))
|
||||||
{
|
{
|
||||||
if let Some(user_id) = validate_bearer_token(oidc, token).await {
|
auth_user = validate_bearer_token(oidc, token).await;
|
||||||
tracing::debug!("Bearer auth OK for user: {}", user_id);
|
|
||||||
return next.run(req).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check SSO session cookie (if OIDC configured)
|
// 2. Check SSO session cookie (if OIDC configured)
|
||||||
if let Some(ref oidc) = state.oidc {
|
if auth_user.is_none() {
|
||||||
let cookies = req
|
if let Some(ref oidc) = state.oidc {
|
||||||
.headers()
|
let cookies = req
|
||||||
.get(header::COOKIE)
|
.headers()
|
||||||
.and_then(|v| v.to_str().ok())
|
.get(header::COOKIE)
|
||||||
.unwrap_or("");
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.unwrap_or("");
|
||||||
|
|
||||||
for c in cookies.split(';') {
|
for c in cookies.split(';') {
|
||||||
let c = c.trim();
|
let c = c.trim();
|
||||||
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
if let Some(val) = c.strip_prefix(&format!("{}=", SESSION_COOKIE)) {
|
||||||
if verify_sso_cookie(&oidc.session_secret, val).is_some() {
|
if let Some(user_id) = verify_sso_cookie(&oidc.session_secret, val) {
|
||||||
return next.run(req).await;
|
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)]
|
#[derive(Deserialize)]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use std::sync::Arc;
|
|||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::{Router, routing::get, middleware};
|
use axum::{Router, routing::{get, post}, middleware};
|
||||||
use axum::http::{header, Method};
|
use axum::http::{header, Method};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower_http::cors::{Any, CorsLayer};
|
use tower_http::cors::{Any, CorsLayer};
|
||||||
@@ -29,7 +29,8 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
.route("/tracks/:slug", get(api::get_track_detail))
|
.route("/tracks/:slug", get(api::get_track_detail))
|
||||||
.route("/tracks/:slug/cover", get(api::track_cover))
|
.route("/tracks/:slug/cover", get(api::track_cover))
|
||||||
.route("/stream/:slug", get(api::stream_track))
|
.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()
|
let api = Router::new()
|
||||||
.nest("/api", library);
|
.nest("/api", library);
|
||||||
@@ -44,7 +45,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
|
|||||||
|
|
||||||
let cors = CorsLayer::new()
|
let cors = CorsLayer::new()
|
||||||
.allow_origin(Any)
|
.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])
|
.allow_headers([header::ACCEPT, header::CONTENT_TYPE, header::AUTHORIZATION])
|
||||||
.max_age(Duration::from_secs(600));
|
.max_age(Duration::from_secs(600));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user