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:48:30 +03:00
parent d31dce3ece
commit 3a9240b82c
13 changed files with 1550 additions and 163 deletions
+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?