Add music sharing and mobile player polish

Add track, release, and queue sharing with post-login redirects; support shared playlist links and highlighted shared tracks.

Add local synchronized playback for jams, constrain HTTP metrics to known routes, and refine mobile player controls/layout.
This commit is contained in:
Ultradesu
2026-06-03 17:35:55 +03:00
parent d31dce3ece
commit 3a9240b82c
13 changed files with 1550 additions and 163 deletions
+38
View File
@@ -37,6 +37,7 @@ impl Role {
// ---------------------------------------------------------------------------
const SESSION_USER_ID: &str = "user_id";
const SESSION_POST_LOGIN_REDIRECT: &str = "post_login_redirect";
#[derive(Debug, Clone)]
pub struct AuthenticatedUser {
@@ -103,6 +104,43 @@ pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
Ok(())
}
pub async fn remember_post_login_redirect(session: &Session, location: &str) -> cot::Result<()> {
if let Some(location) = safe_internal_redirect(location) {
session
.insert(SESSION_POST_LOGIN_REDIRECT, location)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
}
Ok(())
}
pub async fn get_post_login_redirect(session: &Session) -> cot::Result<Option<String>> {
let location: Option<String> = session
.get(SESSION_POST_LOGIN_REDIRECT)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(location.and_then(|value| safe_internal_redirect(&value)))
}
pub async fn clear_post_login_redirect(session: &Session) -> cot::Result<()> {
let _: Option<String> = session
.remove(SESSION_POST_LOGIN_REDIRECT)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(())
}
fn safe_internal_redirect(location: &str) -> Option<String> {
let location = location.trim();
if !location.starts_with('/') || location.starts_with("//") {
return None;
}
if location.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
return None;
}
Some(location.chars().take(2048).collect())
}
/// Flush (destroy) the session.
pub async fn logout(session: &Session) -> cot::Result<()> {
session
+5
View File
@@ -358,6 +358,11 @@ translations! {
player_add_to_queue: "Add to queue" , "Добавить в очередь";
player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди";
player_play_next: "Play next" , "Играть следующим";
player_share: "Share" , "Поделиться";
player_share_track: "Share track" , "Поделиться треком";
player_share_queue: "Share queue" , "Поделиться очередью";
player_shared_playlist: "Shared playlist" , "Общий плейлист";
player_jam_play_on_this_device: "Play on this device" , "Играть на этом устройстве";
player_queue: "Queue" , "Очередь";
player_next: "Next" , "Далее";
player_previous: "Previous" , "Назад";
+137 -13
View File
@@ -29,7 +29,7 @@ use cot::form::{Form, FormResult};
use cot::html::Html;
use cot::middleware::SessionMiddleware;
use cot::project::RegisterAppsContext;
use cot::request::extractors::{RequestForm, UrlQuery};
use cot::request::extractors::{Path, RequestForm, UrlQuery};
use cot::response::IntoResponse;
use cot::router::method::get;
use cot::router::{Route, Router};
@@ -63,15 +63,55 @@ fn build_registry() -> Arc<JobRegistry> {
// Handlers
// ---------------------------------------------------------------------------
async fn index(session: Session, db: Database, i18n: I18n) -> cot::Result<cot::response::Response> {
#[derive(Deserialize)]
struct IndexQuery {
track: Option<i64>,
release: Option<i64>,
playlist_share: Option<String>,
}
async fn index(
session: Session,
db: Database,
i18n: I18n,
UrlQuery(query): UrlQuery<IndexQuery>,
) -> cot::Result<cot::response::Response> {
let _user = match auth::get_session_user(&session, &db).await {
Some(u) => u,
None => return Ok(auth::redirect("/login")),
None => {
if let Some(location) = share_query_redirect(&query) {
auth::remember_post_login_redirect(&session, &location).await?;
}
return Ok(auth::redirect("/login"));
}
};
let template = player::PlayerPageTemplate { t: i18n.t };
Html::new(template.render()?).into_response()
}
fn share_query_redirect(query: &IndexQuery) -> Option<String> {
if let Some(track_id) = query.track.filter(|id| *id > 0) {
return Some(format!("/?track={track_id}"));
}
if let Some(release_id) = query.release.filter(|id| *id > 0) {
return Some(format!("/?release={release_id}"));
}
let token = query.playlist_share.as_deref()?.trim();
if is_share_token(token) {
Some(format!("/?playlist_share={token}"))
} else {
None
}
}
fn is_share_token(token: &str) -> bool {
!token.is_empty()
&& token.len() <= 64
&& token
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'))
}
#[derive(Deserialize)]
struct SetLangQuery {
lang: String,
@@ -131,6 +171,21 @@ struct LoginForm {
password: String,
}
#[derive(Deserialize)]
struct LoginQuery {
error: Option<String>,
}
#[derive(Deserialize)]
struct SharePathId {
id: i64,
}
#[derive(Deserialize)]
struct SharePathToken {
token: String,
}
// ---------------------------------------------------------------------------
// Logout
// ---------------------------------------------------------------------------
@@ -168,6 +223,58 @@ async fn metrics_handler(
.expect("valid response"))
}
async fn share_track_handler(
session: Session,
db: Database,
Path(path): Path<SharePathId>,
) -> cot::Result<cot::response::Response> {
let location = if path.id > 0 {
format!("/?track={}", path.id)
} else {
"/".to_string()
};
if auth::get_session_user(&session, &db).await.is_none() {
auth::remember_post_login_redirect(&session, &location).await?;
return Ok(auth::redirect("/login"));
}
Ok(auth::redirect(&location))
}
async fn share_release_handler(
session: Session,
db: Database,
Path(path): Path<SharePathId>,
) -> cot::Result<cot::response::Response> {
let location = if path.id > 0 {
format!("/?release={}", path.id)
} else {
"/".to_string()
};
if auth::get_session_user(&session, &db).await.is_none() {
auth::remember_post_login_redirect(&session, &location).await?;
return Ok(auth::redirect("/login"));
}
Ok(auth::redirect(&location))
}
async fn share_playlist_handler(
session: Session,
db: Database,
Path(path): Path<SharePathToken>,
) -> cot::Result<cot::response::Response> {
let token = path.token.trim();
let location = if is_share_token(token) {
format!("/?playlist_share={token}")
} else {
"/".to_string()
};
if auth::get_session_user(&session, &db).await.is_none() {
auth::remember_post_login_redirect(&session, &location).await?;
return Ok(auth::redirect("/login"));
}
Ok(auth::redirect(&location))
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
@@ -196,11 +303,26 @@ impl App for FuruApp {
),
Route::with_handler_and_name(
"/",
|session: Session, db: Database, i18n: I18n| async move {
index(session, db, i18n).await
|session: Session, db: Database, i18n: I18n, query: UrlQuery<IndexQuery>| async move {
index(session, db, i18n, query).await
},
"index",
),
Route::with_handler_and_name(
"/share/track/{id}",
get(share_track_handler),
"share_track",
),
Route::with_handler_and_name(
"/share/release/{id}",
get(share_release_handler),
"share_release",
),
Route::with_handler_and_name(
"/share/playlist/{token}",
get(share_playlist_handler),
"share_playlist",
),
Route::with_handler_and_name(
"/metrics",
get({
@@ -218,14 +340,15 @@ impl App for FuruApp {
"/login",
get({
let config = Arc::clone(&self.config);
move |i18n: I18n, db: Database| {
move |i18n: I18n, db: Database, query: UrlQuery<LoginQuery>| {
let config = Arc::clone(&config);
async move {
// No users at all → redirect to first-run setup
if User::count_all(&db).await.unwrap_or(0) == 0 {
return Ok(auth::redirect("/admin/setup"));
}
login_page_handler(i18n, &config, db, String::new())
let message = query.0.error.unwrap_or_default();
login_page_handler(i18n, &config, db, message)
.await?
.into_response()
}
@@ -263,23 +386,24 @@ impl App for FuruApp {
match hash.verify(&password) {
PasswordVerificationResult::Ok
| PasswordVerificationResult::OkObsolete(_) => {
let redirect_to =
auth::get_post_login_redirect(&session)
.await?
.unwrap_or_else(|| "/".to_string());
auth::login(&session, user.id_val()).await?;
auth::clear_post_login_redirect(&session).await?;
metrics::record_auth_attempt(
"password", "success", "ok",
);
metrics::record_session_created("password");
return Ok(auth::redirect("/"));
return Ok(auth::redirect(&redirect_to));
}
PasswordVerificationResult::Invalid => {}
}
}
}
metrics::record_auth_attempt(
"password",
"failure",
"bad_credentials",
);
metrics::record_auth_attempt("password", "failure", "bad_credentials");
let msg = i18n.t.login_invalid.to_owned();
login_page_handler(i18n, &config, db, msg)
.await?
+319 -60
View File
@@ -6,11 +6,11 @@ use std::sync::{LazyLock, Mutex};
use std::task::{Context, Poll};
use std::time::{Duration, Instant};
use cot::http::header::CONTENT_LENGTH;
use cot::Error;
use cot::http::Method;
use cot::http::header::CONTENT_LENGTH;
use cot::request::Request;
use cot::response::Response;
use cot::Error;
use sqlx::PgPool;
use tower::{Layer, Service};
@@ -80,28 +80,33 @@ where
fn call(&mut self, request: Request) -> Self::Future {
let method = request.method().clone();
let route = normalize_route(request.uri().path());
let route = known_http_route(request.uri().path()).map(str::to_owned);
let request_bytes = request
.headers()
.get(CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<f64>().ok())
.unwrap_or(0.0);
let labels = http_labels(&method, &route, "in_flight");
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
REGISTRY.inc_counter(
"furumusic_http_request_body_bytes_total",
vec![
("method", method.as_str().to_owned()),
("route", route.clone()),
],
request_bytes,
);
if let Some(route) = &route {
let labels = http_labels(&method, route, "in_flight");
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
REGISTRY.inc_counter(
"furumusic_http_request_body_bytes_total",
vec![
("method", method.as_str().to_owned()),
("route", route.clone()),
],
request_bytes,
);
}
let start = Instant::now();
let fut = self.inner.call(request);
Box::pin(async move {
let result = fut.await;
let Some(route) = route else {
return result;
};
let elapsed = start.elapsed().as_secs_f64();
REGISTRY.inc_gauge(
"furumusic_http_in_flight_requests",
@@ -236,8 +241,17 @@ pub fn record_agent_discover_run(outcome: &'static str, duration: Duration) {
);
}
pub fn record_agent_discover_files(seen: u64, queued: u64, skipped_hash: u64, skipped_existing: u64) {
REGISTRY.inc_counter("furumusic_agent_discover_files_seen_total", Vec::new(), seen as f64);
pub fn record_agent_discover_files(
seen: u64,
queued: u64,
skipped_hash: u64,
skipped_existing: u64,
) {
REGISTRY.inc_counter(
"furumusic_agent_discover_files_seen_total",
Vec::new(),
seen as f64,
);
REGISTRY.inc_counter(
"furumusic_agent_discover_files_queued_total",
Vec::new(),
@@ -340,18 +354,12 @@ pub fn record_agent_llm(
let model = normalize_model_label(model);
REGISTRY.inc_counter(
"furumusic_agent_llm_requests_total",
vec![
("model", model.clone()),
("outcome", outcome.to_owned()),
],
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
1.0,
);
REGISTRY.observe_histogram(
"furumusic_agent_llm_duration_seconds",
vec![
("model", model.clone()),
("outcome", outcome.to_owned()),
],
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
duration.as_secs_f64(),
JOB_BUCKETS,
);
@@ -362,10 +370,7 @@ pub fn record_agent_llm(
);
REGISTRY.inc_counter(
"furumusic_agent_llm_tokens_total",
vec![
("model", model.clone()),
("type", "completion".to_owned()),
],
vec![("model", model.clone()), ("type", "completion".to_owned())],
completion_tokens as f64,
);
REGISTRY.observe_histogram(
@@ -400,7 +405,12 @@ pub fn record_agent_llm_parse_failure(model: &str) {
);
}
pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Duration, results: usize) {
pub fn record_agent_rag(
kind: &'static str,
outcome: &'static str,
duration: Duration,
results: usize,
) {
REGISTRY.inc_counter(
"furumusic_agent_rag_queries_total",
vec![("kind", kind.to_owned()), ("outcome", outcome.to_owned())],
@@ -423,7 +433,10 @@ pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Dur
pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, bytes: usize) {
REGISTRY.inc_counter(
"furumusic_agent_cover_lookup_total",
vec![("source", source.to_owned()), ("outcome", outcome.to_owned())],
vec![
("source", source.to_owned()),
("outcome", outcome.to_owned()),
],
1.0,
);
REGISTRY.inc_counter(
@@ -433,7 +446,11 @@ pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, by
);
}
pub fn record_agent_cover_variant(variant: &'static str, outcome: &'static str, duration: Duration) {
pub fn record_agent_cover_variant(
variant: &'static str,
outcome: &'static str,
duration: Duration,
) {
REGISTRY.inc_counter(
"furumusic_agent_cover_variant_generation_total",
vec![
@@ -489,7 +506,12 @@ pub fn record_torrent_download(outcome: &'static str, selected_bytes: u64, durat
pub async fn render(pool: &PgPool, config: &AppConfig) -> String {
let mut out = String::new();
emit_static_gauge(&mut out, "furumusic_build_info", &[("version", env!("CARGO_PKG_VERSION"))], 1.0);
emit_static_gauge(
&mut out,
"furumusic_build_info",
&[("version", env!("CARGO_PKG_VERSION"))],
1.0,
);
render_active_users(&mut out);
render_storage(&mut out, config);
render_db_metrics(&mut out, pool).await;
@@ -538,11 +560,42 @@ fn render_storage(out: &mut String, config: &AppConfig) {
}
async fn render_db_metrics(out: &mut String, pool: &PgPool) {
render_group_counts(out, pool, "furumusic_users_total", "SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role", "role").await;
render_single_count(out, pool, "furumusic_library_tracks_total", "SELECT COUNT(*) FROM furumusic__track").await;
render_single_count(out, pool, "furumusic_library_releases_total", "SELECT COUNT(*) FROM furumusic__release").await;
render_single_count(out, pool, "furumusic_library_artists_total", "SELECT COUNT(*) FROM furumusic__artist").await;
render_single_count(out, pool, "furumusic_library_playlists_total", "SELECT COUNT(*) FROM furumusic__playlist").await;
render_group_counts(
out,
pool,
"furumusic_users_total",
"SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role",
"role",
)
.await;
render_single_count(
out,
pool,
"furumusic_library_tracks_total",
"SELECT COUNT(*) FROM furumusic__track",
)
.await;
render_single_count(
out,
pool,
"furumusic_library_releases_total",
"SELECT COUNT(*) FROM furumusic__release",
)
.await;
render_single_count(
out,
pool,
"furumusic_library_artists_total",
"SELECT COUNT(*) FROM furumusic__artist",
)
.await;
render_single_count(
out,
pool,
"furumusic_library_playlists_total",
"SELECT COUNT(*) FROM furumusic__playlist",
)
.await;
render_group_counts(out, pool, "furumusic_media_files_total", "SELECT file_type::text AS label, COUNT(*) AS count FROM furumusic__media_file GROUP BY file_type", "type").await;
render_group_sums(out, pool, "furumusic_media_file_bytes_total", "SELECT file_type::text AS label, COALESCE(SUM(file_size_bytes), 0)::bigint AS value FROM furumusic__media_file GROUP BY file_type", "type").await;
render_group_counts(out, pool, "furumusic_agent_reviews_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status", "status").await;
@@ -550,7 +603,13 @@ async fn render_db_metrics(out: &mut String, pool: &PgPool) {
render_group_counts(out, pool, "furumusic_scheduler_job_running", "SELECT job_name::text AS label, COUNT(*) AS count FROM furumusic__job_run WHERE status = 'running' GROUP BY job_name", "job").await;
render_group_sums(out, pool, "furumusic_scheduler_job_enabled", "SELECT name::text AS label, (CASE WHEN enabled THEN 1 ELSE 0 END)::bigint AS value FROM furumusic__scheduled_job", "job").await;
render_group_counts(out, pool, "furumusic_torrent_sessions_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__torrent_session GROUP BY status", "status").await;
render_single_count(out, pool, "furumusic_play_history_total", "SELECT COUNT(*) FROM furumusic__play_history").await;
render_single_count(
out,
pool,
"furumusic_play_history_total",
"SELECT COUNT(*) FROM furumusic__play_history",
)
.await;
}
async fn render_single_count(out: &mut String, pool: &PgPool, metric: &'static str, sql: &str) {
@@ -566,7 +625,10 @@ async fn render_group_counts(
sql: &str,
label_name: &'static str,
) {
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
.fetch_all(pool)
.await
{
for (label, count) in rows {
emit_static_gauge(out, metric, &[(label_name, label.as_str())], count as f64);
}
@@ -580,7 +642,10 @@ async fn render_group_sums(
sql: &str,
label_name: &'static str,
) {
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
.fetch_all(pool)
.await
{
for (label, value) in rows {
emit_static_gauge(out, metric, &[(label_name, label.as_str())], value as f64);
}
@@ -641,7 +706,12 @@ impl Registry {
for (bucket, count) in state.buckets.iter().zip(state.counts.iter()) {
let mut labels = key.labels.clone();
labels.push(("le", bucket.to_string()));
emit_metric(&mut out, &format!("{}_bucket", key.name), &labels, *count as f64);
emit_metric(
&mut out,
&format!("{}_bucket", key.name),
&labels,
*count as f64,
);
}
let mut inf_labels = key.labels.clone();
inf_labels.push(("le", "+Inf".to_owned()));
@@ -683,31 +753,220 @@ fn http_labels(method: &Method, route: &str, status: &str) -> Vec<(&'static str,
]
}
fn normalize_route(path: &str) -> String {
let mut route = String::with_capacity(path.len());
for segment in path.split('/') {
if segment.is_empty() {
continue;
}
route.push('/');
if segment.parse::<i64>().is_ok() || looks_like_uuid(segment) {
route.push_str("{id}");
} else {
route.push_str(segment);
}
}
if route.is_empty() {
fn known_http_route(path: &str) -> Option<&'static str> {
let path = canonicalize_http_path(path);
KNOWN_HTTP_ROUTES
.iter()
.copied()
.find(|pattern| route_pattern_matches(pattern, &path))
}
fn canonicalize_http_path(path: &str) -> String {
let without_trailing = path.trim_end_matches('/');
if without_trailing.is_empty() {
"/".to_owned()
} else {
route
without_trailing.to_owned()
}
}
fn looks_like_uuid(value: &str) -> bool {
value.len() == 36
&& value
.chars()
.all(|ch| ch.is_ascii_hexdigit() || ch == '-')
fn route_pattern_matches(pattern: &str, path: &str) -> bool {
if pattern == "/" {
return path == "/";
}
let mut pattern_segments = pattern.trim_start_matches('/').split('/');
let mut path_segments = path.trim_start_matches('/').split('/');
loop {
match (pattern_segments.next(), path_segments.next()) {
(None, None) => return true,
(Some(pattern_segment), Some(path_segment)) => {
if path_segment.is_empty() {
return false;
}
if is_route_param(pattern_segment) {
continue;
}
if pattern_segment != path_segment {
return false;
}
}
_ => return false,
}
}
}
fn is_route_param(segment: &str) -> bool {
segment.starts_with('{') && segment.ends_with('}')
}
const KNOWN_HTTP_ROUTES: &[&str] = &[
// Keep this allowlist in sync with Cot route declarations. Unknown paths are
// intentionally skipped so bot traffic cannot create high-cardinality labels.
"/",
"/admin",
"/swagger",
"/swagger/openapi.json",
"/share/track/{id}",
"/share/release/{id}",
"/share/playlist/{token}",
"/metrics",
"/login",
"/logout",
"/set-lang",
"/auth/oidc/start",
"/auth/oidc/callback",
"/api/me",
"/admin/setup",
"/admin/v2",
"/admin/v2/api/dashboard",
"/admin/v2/api/reviews",
"/admin/v2/api/reviews/bulk",
"/admin/v2/api/users",
"/admin/v2/api/users/{id}",
"/admin/v2/api/reviews/{id}/approve",
"/admin/v2/api/jobs",
"/admin/v2/api/jobs/metadata_backfill/run-options",
"/admin/v2/api/jobs/artwork_backfill/run-options",
"/admin/v2/api/jobs/{name}/run",
"/admin/v2/api/settings",
"/admin/v2/api/settings/probe",
"/admin/v2/api/jobs/{name}/toggle",
"/admin/v2/api/jobs/{name}/runs",
"/admin/v2/api/jobs/{name}/runs/{run_id}",
"/admin/v2/api/library",
"/admin/v2/api/library/item",
"/admin/v2/api/library/item/detail",
"/admin/v2/api/library/item/image",
"/admin/v2/api/library/item/upload-image",
"/admin/v2/api/library/bulk",
"/admin/debug",
"/admin/settings",
"/admin/settings/probe",
"/admin/users",
"/admin/users/new",
"/admin/users/{id}/edit",
"/admin/users/{id}/delete",
"/admin/artists",
"/admin/artists/new",
"/admin/artists/{id}/edit",
"/admin/artists/{id}/delete",
"/admin/artists/{id}/available-covers",
"/admin/artists/{id}/set-image",
"/admin/artists/{id}/upload-image",
"/admin/releases",
"/admin/releases/new",
"/admin/releases/{id}/edit",
"/admin/releases/{id}/delete",
"/admin/media-files",
"/admin/media-files/{id}/delete",
"/admin/jobs",
"/admin/jobs/metadata_backfill/run-options",
"/admin/jobs/{name}/run",
"/admin/jobs/{name}/toggle",
"/admin/jobs/{name}/cron",
"/admin/jobs/{name}/runs/{run_id}",
"/admin/jobs/{name}",
"/admin/reviews/clear",
"/admin/reviews/bulk",
"/admin/reviews",
"/admin/reviews/{id}",
"/admin/reviews/{id}/approve",
"/admin/reviews/{id}/reject",
"/admin/reviews/{id}/requeue",
"/api/player/me",
"/api/player/lastfm/status",
"/api/player/lastfm/connect",
"/api/player/lastfm/callback",
"/api/player/lastfm/disconnect",
"/api/player/lastfm/now-playing",
"/api/player/lastfm/scrobble",
"/api/player/agent-queue",
"/api/player/torrents",
"/api/player/torrents/session/{id}",
"/api/player/torrents/preview",
"/api/player/uploads/local",
"/api/player/uploads/tracks",
"/api/player/uploads/tracks/{track_id}",
"/api/player/uploads/bulk-tracks",
"/api/player/uploads/releases/{id}",
"/api/player/uploads/reviews/{id}",
"/api/player/uploads/reviews/{id}/approve",
"/api/player/torrents/{id}/start",
"/api/player/torrents/{id}/pause",
"/api/player/torrents/{id}/status",
"/api/player/artists",
"/api/player/artists/{id}",
"/api/player/releases/{id}",
"/api/player/radio/{kind}/{id}",
"/api/player/playlists",
"/api/player/share-playlist",
"/api/player/share-playlist/{id}",
"/api/player/playlists/{id}",
"/api/player/playlists/{id}/tracks",
"/api/player/likes",
"/api/player/likes/toggle/{track_id}",
"/api/player/likes/release/{id}",
"/api/player/follows",
"/api/player/follows/toggle/{id}",
"/api/player/stream/{track_id}",
"/api/player/cover/{media_file_id}/{variant}",
"/api/player/cover/{media_file_id}",
"/api/player/devices/heartbeat",
"/api/player/devices/poll",
"/api/player/devices/active",
"/api/player/devices/command",
"/api/player/jams/users",
"/api/player/jams",
"/api/player/jams/join",
"/api/player/jams/invite",
"/api/player/jams/leave",
"/api/player/state",
"/api/player/history",
"/api/player/search",
"/api/player/tracks-by-ids",
];
#[cfg(test)]
mod tests {
use super::known_http_route;
#[test]
fn known_http_route_matches_declared_dynamic_routes() {
assert_eq!(
known_http_route("/api/player/stream/42"),
Some("/api/player/stream/{track_id}")
);
assert_eq!(
known_http_route("/admin/jobs/metadata_backfill/runs/123"),
Some("/admin/jobs/{name}/runs/{run_id}")
);
assert_eq!(
known_http_route("/share/playlist/abcDEF123"),
Some("/share/playlist/{token}")
);
assert_eq!(
known_http_route("/share/release/42"),
Some("/share/release/{id}")
);
}
#[test]
fn known_http_route_skips_unknown_bot_paths() {
assert_eq!(known_http_route("/wp-login.php"), None);
assert_eq!(
known_http_route("/api/player/not-a-real-endpoint/123"),
None
);
assert_eq!(known_http_route("/static/random-bot-path.js"), None);
}
#[test]
fn known_http_route_uses_stable_canonical_labels() {
assert_eq!(known_http_route("/admin/"), Some("/admin"));
assert_eq!(known_http_route("/login/"), Some("/login"));
}
}
fn normalize_model_label(value: &str) -> String {
+42
View File
@@ -1910,6 +1910,47 @@ pub mod db_migrations {
&[Operation::custom(create_external_metadata_ids).build()];
}
// -- M0037: Shared playlist snapshots ------------------------------------
#[cot::db::migrations::migration_op]
async fn create_playlist_share_links(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE TABLE IF NOT EXISTS furumusic__playlist_share_link (
token VARCHAR(64) PRIMARY KEY,
creator_user_id BIGINT NOT NULL,
title TEXT NOT NULL,
track_ids_json TEXT NOT NULL,
created_at VARCHAR(32) NOT NULL
)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX IF NOT EXISTS idx_playlist_share_link_creator
ON furumusic__playlist_share_link (creator_user_id, created_at DESC)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0037CreatePlaylistShareLinks;
impl migrations::Migration for M0037CreatePlaylistShareLinks {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0037_create_playlist_share_links";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0036_create_external_metadata_ids",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_playlist_share_links).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -1937,5 +1978,6 @@ pub mod db_migrations {
&M0034CreateArtworkLookupState,
&M0035CreateEntityGenreTags,
&M0036CreateExternalMetadataIds,
&M0037CreatePlaylistShareLinks,
];
}
+6 -1
View File
@@ -430,8 +430,13 @@ pub async fn oidc_callback_handler(
}
};
let redirect_to = auth::get_post_login_redirect(&session)
.await?
.unwrap_or_else(|| "/".to_string());
// Log the user in.
auth::login(&session, user.id_val()).await?;
auth::clear_post_login_redirect(&session).await?;
crate::metrics::record_auth_attempt("oidc", "success", "ok");
crate::metrics::record_session_created("oidc");
@@ -453,7 +458,7 @@ pub async fn oidc_callback_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(auth::redirect("/"))
Ok(auth::redirect(&redirect_to))
}
// ---------------------------------------------------------------------------
+13
View File
@@ -289,6 +289,19 @@ pub(super) struct PlaylistDetail {
pub(super) tracks: Vec<TrackItem>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct ShareLinkResponse {
pub(super) token: String,
pub(super) url: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct PlaylistShareDetail {
pub(super) token: String,
pub(super) title: String,
pub(super) tracks: Vec<TrackItem>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub(super) struct SearchResults {
pub(super) artists: Vec<ArtistCard>,
+165 -2
View File
@@ -3533,13 +3533,127 @@ async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Resul
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let mut track_map: HashMap<i64, TrackItem> = build_track_items(tracks, pool)
let track_map: HashMap<i64, TrackItem> = build_track_items(tracks, pool)
.await?
.into_iter()
.map(|track| (track.id, track))
.collect();
Ok(ids.iter().filter_map(|id| track_map.remove(id)).collect())
Ok(ids
.iter()
.filter_map(|id| track_map.get(id).cloned())
.collect())
}
// ---------------------------------------------------------------------------
// POST /api/player/share-playlist
// ---------------------------------------------------------------------------
async fn create_playlist_share_handler(
session: Session,
db: Database,
pool: &sqlx::PgPool,
Json(body): Json<CreatePlaylistShareRequest>,
) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
let ids: Vec<i64> = body
.track_ids
.into_iter()
.filter(|id| *id > 0)
.take(500)
.collect();
if ids.is_empty() {
return Ok(json_error(StatusCode::BAD_REQUEST, "playlist is empty"));
}
let tracks = load_track_items_by_ids(pool, &ids).await?;
if tracks.is_empty() {
return Ok(json_error(
StatusCode::BAD_REQUEST,
"playlist has no visible tracks",
));
}
let visible_ids: Vec<i64> = tracks.iter().map(|track| track.id).collect();
let track_ids_json =
serde_json::to_string(&visible_ids).map_err(|e| cot::Error::internal(e.to_string()))?;
let title = body
.title
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("Shared queue")
.chars()
.take(160)
.collect::<String>();
let token = uuid::Uuid::new_v4().simple().to_string();
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
sqlx::query(
r#"INSERT INTO furumusic__playlist_share_link
(token, creator_user_id, title, track_ids_json, created_at)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(&token)
.bind(user.id)
.bind(&title)
.bind(&track_ids_json)
.bind(&now)
.execute(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(ShareLinkResponse {
token: token.clone(),
url: format!("/share/playlist/{token}"),
})
.into_response()
}
// ---------------------------------------------------------------------------
// GET /api/player/share-playlist/{id}
// ---------------------------------------------------------------------------
async fn playlist_share_detail_handler(
session: Session,
db: Database,
pool: &sqlx::PgPool,
path: Path<PathStringId>,
) -> cot::Result<cot::response::Response> {
let Some(_user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
let token = path.0.id.trim().to_string();
let share: Option<(String, String)> = sqlx::query_as(
r#"SELECT title::text, track_ids_json::text
FROM furumusic__playlist_share_link
WHERE token = $1"#,
)
.bind(&token)
.fetch_optional(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let Some((title, track_ids_json)) = share else {
return Ok(json_error(
StatusCode::NOT_FOUND,
"shared playlist not found",
));
};
let ids: Vec<i64> = serde_json::from_str(&track_ids_json).unwrap_or_default();
let tracks = load_track_items_by_ids(pool, &ids).await?;
Json(PlaylistShareDetail {
token,
title,
tracks,
})
.into_response()
}
/// Return the virtual "Likes" playlist for a given user.
@@ -6840,6 +6954,55 @@ impl App for PlayerApp {
}),
"player_playlists",
),
// -- Shared playlist snapshots --
Route::with_handler_and_name(
"/share-playlist",
post({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, json: Json<CreatePlaylistShareRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
create_playlist_share_handler(session, db, pg_pool, json).await
}
}
}),
"player_share_playlist",
),
Route::with_handler_and_name(
"/share-playlist/{id}",
get({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session, db: Database, path: Path<PathStringId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("player pool")
})
.await;
playlist_share_detail_handler(session, db, pg_pool, path).await
}
}
}),
"player_share_playlist_detail",
),
// -- Playlist detail (get, update, delete) --
Route::with_handler_and_name(
"/playlists/{id}",
+6
View File
@@ -45,6 +45,12 @@ pub(super) struct RemoveTrackRequest {
pub(super) track_id: i64,
}
#[derive(Debug, Deserialize)]
pub(super) struct CreatePlaylistShareRequest {
pub(super) track_ids: Vec<i64>,
pub(super) title: Option<String>,
}
#[derive(Debug, Deserialize)]
pub(super) struct PaginationQuery {
pub(super) page: Option<i32>,