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
+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}",