diff --git a/src/auth.rs b/src/auth.rs index a7908f2..d0fa26c 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -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> { + let location: Option = 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 = session + .remove(SESSION_POST_LOGIN_REDIRECT) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Ok(()) +} + +fn safe_internal_redirect(location: &str) -> Option { + 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 diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index ca879f2..da4bee1 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -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" , "Назад"; diff --git a/src/main.rs b/src/main.rs index 414a8a4..fd79d02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 { // Handlers // --------------------------------------------------------------------------- -async fn index(session: Session, db: Database, i18n: I18n) -> cot::Result { +#[derive(Deserialize)] +struct IndexQuery { + track: Option, + release: Option, + playlist_share: Option, +} + +async fn index( + session: Session, + db: Database, + i18n: I18n, + UrlQuery(query): UrlQuery, +) -> cot::Result { 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 { + 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, +} + +#[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, +) -> cot::Result { + 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, +) -> cot::Result { + 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, +) -> cot::Result { + 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| 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| { 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? diff --git a/src/metrics.rs b/src/metrics.rs index 2f91773..bd3ecef 100644 --- a/src/metrics.rs +++ b/src/metrics.rs @@ -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::().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::().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 { diff --git a/src/music/mod.rs b/src/music/mod.rs index 36a8ad4..a602e86 100644 --- a/src/music/mod.rs +++ b/src/music/mod.rs @@ -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, ]; } diff --git a/src/oidc.rs b/src/oidc.rs index f624d65..f584a0d 100644 --- a/src/oidc.rs +++ b/src/oidc.rs @@ -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)) } // --------------------------------------------------------------------------- diff --git a/src/player/dto.rs b/src/player/dto.rs index f0614b2..98df521 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -289,6 +289,19 @@ pub(super) struct PlaylistDetail { pub(super) tracks: Vec, } +#[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, +} + #[derive(Debug, Serialize, JsonSchema)] pub(super) struct SearchResults { pub(super) artists: Vec, diff --git a/src/player/mod.rs b/src/player/mod.rs index c38ac2f..c1bf466 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -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 = build_track_items(tracks, pool) + let track_map: HashMap = 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, +) -> cot::Result { + let Some(user) = auth::get_session_user(&session, &db).await else { + return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated")); + }; + + let ids: Vec = 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 = 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::(); + 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, +) -> cot::Result { + 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 = 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| { + 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| { + 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}", diff --git a/src/player/queries.rs b/src/player/queries.rs index bb28f5a..426360d 100644 --- a/src/player/queries.rs +++ b/src/player/queries.rs @@ -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, + pub(super) title: Option, +} + #[derive(Debug, Deserialize)] pub(super) struct PaginationQuery { pub(super) page: Option, diff --git a/templates/player/modals.html b/templates/player/modals.html index 29bb653..db8f73b 100644 --- a/templates/player/modals.html +++ b/templates/player/modals.html @@ -722,6 +722,9 @@ + diff --git a/templates/player/scripts.html b/templates/player/scripts.html index f2a52d6..95fc091 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -44,6 +44,10 @@ const T = { lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}", startRadio: "{{ t.player_start_radio }}", radioFailed: "{{ t.player_radio_failed }}", + share: "{{ t.player_share }}", + shareTrack: "{{ t.player_share_track }}", + shareQueue: "{{ t.player_share_queue }}", + sharedPlaylist: "{{ t.player_shared_playlist }}", connectionLost: "{{ t.player_connection_lost }}", connectionLostDetail: "{{ t.player_connection_lost_detail }}", activeDevice: "{{ t.player_active_device }}", @@ -285,6 +289,204 @@ document.addEventListener('alpine:init', () => { }, }); + Alpine.store('sharing', { + _handledInitialLink: false, + sharedTrackId: null, + + absoluteUrl(path) { + return new URL(path, window.location.origin).href; + }, + + async copyText(text) { + try { + if (navigator.clipboard?.writeText && window.isSecureContext) { + await navigator.clipboard.writeText(text); + return true; + } + } catch {} + + const textarea = document.createElement('textarea'); + textarea.value = text; + textarea.setAttribute('readonly', ''); + textarea.style.position = 'fixed'; + textarea.style.left = '-9999px'; + textarea.style.top = '0'; + document.body.appendChild(textarea); + textarea.select(); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch {} + textarea.remove(); + return ok; + }, + + async copyUrl(path, trigger = null) { + const copied = await this.copyText(this.absoluteUrl(path)); + this.flashTrigger(trigger, copied); + return copied; + }, + + copyTrack(track, trigger = null) { + const id = Number(track?.id || track); + if (!id) return; + this.copyUrl(`/share/track/${id}`, trigger); + }, + + async copyQueue(trigger = null) { + const queue = Alpine.store('queue'); + return this.copyTracks(queue?.tracks || [], T.sharedPlaylist, trigger); + }, + + async copyRelease(release, trigger = null) { + const id = Number(release?.id || release || 0); + if (!id) return; + return this.copyUrl(`/share/release/${id}`, trigger); + }, + + async copyTracks(tracks, title = T.sharedPlaylist, trigger = null) { + const trackIds = (tracks || []).map(track => Number(track.id)).filter(Boolean); + if (!trackIds.length) return; + try { + const res = await fetch('/api/player/share-playlist', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ track_ids: trackIds, title }), + }); + if (!res.ok) throw new Error('share failed'); + const data = await res.json(); + await this.copyUrl(data.url || `/share/playlist/${data.token}`, trigger); + } catch (err) { + this.flashTrigger(trigger, false); + console.warn(err); + } + }, + + flashTrigger(trigger, copied) { + if (!trigger) return; + const className = copied ? 'share-copy-flash' : 'share-copy-failed'; + trigger.classList.remove('share-copy-flash', 'share-copy-failed'); + void trigger.offsetWidth; + trigger.classList.add(className); + window.setTimeout(() => trigger.classList.remove(className), 720); + }, + + markSharedTrack(trackId) { + const id = Number(trackId || 0); + this.sharedTrackId = id > 0 ? id : null; + }, + + isSharedTrack(track) { + const id = Number(track?.id || track || 0); + return id > 0 && id === Number(this.sharedTrackId || 0); + }, + + focusSharedTrack(trackId) { + const run = () => this.scrollSharedTrackIntoView(trackId); + requestAnimationFrame(run); + setTimeout(run, 120); + setTimeout(run, 360); + setTimeout(run, 720); + }, + + scrollSharedTrackIntoView(trackId) { + const id = Number(trackId || 0); + if (!id) return false; + const row = document.querySelector(`#center-scroll [data-shared-track-id="${id}"]`); + const container = document.getElementById('center-scroll'); + if (!row || !container) return false; + + const containerRect = container.getBoundingClientRect(); + const rowRect = row.getBoundingClientRect(); + const top = container.scrollTop + + rowRect.top + - containerRect.top + - ((container.clientHeight - rowRect.height) / 2); + container.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); + return true; + }, + + async handleInitialShareLinks() { + if (this._handledInitialLink) return; + this._handledInitialLink = true; + + const params = new URLSearchParams(window.location.search); + const trackId = Number(params.get('track') || 0); + const releaseId = Number(params.get('release') || 0); + const playlistToken = (params.get('playlist_share') || '').trim(); + + if (trackId > 0) { + await this.openSharedTrack(trackId); + this._clearShareQuery(); + } else if (releaseId > 0) { + await this.openSharedRelease(releaseId); + this._clearShareQuery(); + } else if (playlistToken) { + await this.openSharedPlaylist(playlistToken); + this._clearShareQuery(); + } + }, + + async openSharedTrack(trackId) { + try { + const res = await fetch('/api/player/tracks-by-ids', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ids: [Number(trackId)] }), + }); + if (!res.ok) throw new Error('track load failed'); + const tracks = await res.json(); + const track = Array.isArray(tracks) ? tracks[0] : null; + if (!track) return; + this.markSharedTrack(track.id); + if (track.release_id) { + await Alpine.store('library').openRelease(track.release_id, { focusSharedTrackId: track.id }); + } + const queue = Alpine.store('queue'); + queue.tracks = [track]; + queue.currentIndex = 0; + Alpine.store('player')._playLocal(track, { paused: true }); + this.focusSharedTrack(track.id); + } catch (err) { + console.warn(err); + } + }, + + async openSharedRelease(releaseId) { + const id = Number(releaseId || 0); + if (!id) return; + this.markSharedTrack(null); + await Alpine.store('library').openRelease(id); + }, + + async openSharedPlaylist(token) { + try { + const res = await fetch(`/api/player/share-playlist/${encodeURIComponent(token)}`); + if (!res.ok) throw new Error('playlist load failed'); + const data = await res.json(); + const tracks = Array.isArray(data.tracks) ? data.tracks : []; + if (!tracks.length) return; + const queue = Alpine.store('queue'); + queue.tracks = tracks; + queue.currentIndex = 0; + Alpine.store('player')._playLocal(tracks[0], { paused: true }); + Alpine.store('library').showSharedPlaylist(data); + } catch (err) { + console.warn(err); + } + }, + + _clearShareQuery() { + const url = new URL(window.location.href); + url.searchParams.delete('track'); + url.searchParams.delete('release'); + url.searchParams.delete('playlist_share'); + const query = url.searchParams.toString(); + const next = `${url.pathname}${query ? '?' + query : ''}${window.location.hash}`; + history.replaceState({}, '', next); + }, + }); + // ----------------------------------------------------------------------- // User store // ----------------------------------------------------------------------- @@ -510,11 +712,18 @@ document.addEventListener('alpine:init', () => { _playbackStartedAt: null, _listenedSeconds: 0, _lastAudioTime: 0, + _localSourceTrackId: null, _remoteExecuting: false, _remoteStateBaseTime: 0, _remoteStateReceivedAt: 0, _remoteStateTimer: null, + _hasInitialShareLink() { + const params = new URLSearchParams(window.location.search); + return Number(params.get('track') || 0) > 0 + || (params.get('playlist_share') || '').trim().length > 0; + }, + init() { audio.volume = this.volume; @@ -528,6 +737,10 @@ document.addEventListener('alpine:init', () => { audio.addEventListener('ended', () => { this._trackListenedDelta(); this._recordHistory(true); + if (Alpine.store('devices')?.shouldPlayJamLocally()) { + this.isPlaying = false; + return; + } this.next(); }); @@ -555,7 +768,9 @@ document.addEventListener('alpine:init', () => { }, 250); // Restore state - this._restoreState(); + if (!this._hasInitialShareLink()) { + this._restoreState(); + } // Save state on page unload window.addEventListener('beforeunload', () => { @@ -567,6 +782,12 @@ document.addEventListener('alpine:init', () => { if (!track) return; if (this._shouldSendRemote()) { this._mirrorRemoteTrack(track, true, 0); + if (this._shouldMirrorRemoteLocally()) { + this._playLocal(track, { + position_seconds: 0, + paused: false, + }); + } this._sendRemote('play_track', this._remotePlaybackPayload(track, { position_seconds: 0, paused: false, @@ -583,6 +804,12 @@ document.addEventListener('alpine:init', () => { const track = queue.tracks[idx]; if (this._shouldSendRemote()) { this._mirrorRemoteTrack(track, true, 0); + if (this._shouldMirrorRemoteLocally()) { + this._playLocal(track, { + position_seconds: 0, + paused: false, + }); + } this._sendRemote('play_from_index', this._remotePlaybackPayload(track, { index: idx, position_seconds: 0, @@ -595,6 +822,7 @@ document.addEventListener('alpine:init', () => { _playLocal(track, options = {}) { this.currentTrack = track; + this._localSourceTrackId = track.id; this._historyRecorded = false; this._resetPlaybackTracking(); audio.src = track.stream_url; @@ -626,6 +854,9 @@ document.addEventListener('alpine:init', () => { this.isPlaying = false; this._remoteStateBaseTime = this.currentTime; this._remoteStateReceivedAt = Date.now(); + if (this._shouldMirrorRemoteLocally()) { + this._pauseLocal(); + } this._sendRemote('pause'); return; } @@ -637,6 +868,9 @@ document.addEventListener('alpine:init', () => { this.isPlaying = true; this._remoteStateBaseTime = this.currentTime; this._remoteStateReceivedAt = Date.now(); + if (this._shouldMirrorRemoteLocally()) { + audio.play().catch(() => {}); + } this._sendRemote('resume'); return; } @@ -656,6 +890,10 @@ document.addEventListener('alpine:init', () => { this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0; this._remoteStateBaseTime = nextTime; this._remoteStateReceivedAt = Date.now(); + if (this._shouldMirrorRemoteLocally() && this._localSourceTrackId === this.currentTrack?.id) { + audio.currentTime = nextTime; + this._lastAudioTime = audio.currentTime || 0; + } this._sendRemote('seek', { time: nextTime }); return; } @@ -673,13 +911,35 @@ document.addEventListener('alpine:init', () => { seekFromClick(event) { const bar = event.currentTarget; + this._setProgressFromClientX(event.clientX, bar); + }, + + _setProgressFromClientX(clientX, bar) { const rect = bar.getBoundingClientRect(); - const pct = (event.clientX - rect.left) / rect.width; + const pct = rect.width > 0 ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) : 0; if (this.duration > 0) { this.seek(pct * this.duration); } }, + startProgressDrag(event) { + const bar = event.currentTarget; + this._setProgressFromClientX(event.clientX, bar); + bar.setPointerCapture?.(event.pointerId); + + const move = (e) => { + this._setProgressFromClientX(e.clientX, bar); + }; + const stop = () => { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', stop); + window.removeEventListener('pointercancel', stop); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', stop); + window.addEventListener('pointercancel', stop); + }, + next() { if (this._shouldSendRemote()) { this._sendRemote('next', { @@ -742,7 +1002,7 @@ document.addEventListener('alpine:init', () => { setVolume(v) { this._setVolumeLocal(v); - if (this._shouldSendRemote()) { + if (this._shouldSendRemote() && !this._shouldMirrorRemoteLocally()) { this._sendRemote('set_volume', { volume: this.volume }); } }, @@ -832,6 +1092,10 @@ document.addEventListener('alpine:init', () => { return !!devices && !this._remoteExecuting && !devices.isActive(); }, + _shouldMirrorRemoteLocally() { + return !!Alpine.store('devices')?.shouldPlayJamLocally(); + }, + _sendRemote(command, payload = {}) { const devices = Alpine.store('devices'); if (!devices) return false; @@ -906,8 +1170,57 @@ document.addEventListener('alpine:init', () => { this._updateMediaSession(); }, + _syncLocalPlaybackState(state) { + if (!state) return; + const queue = Alpine.store('queue'); + const tracks = Array.isArray(state.tracks) ? state.tracks.filter(Boolean) : []; + if (queue && tracks.length > 0) { + queue.tracks = queue._tracksWithJamDefaults(tracks); + queue.currentIndex = Math.max(0, Math.min(Number(state.index || 0), queue.tracks.length - 1)); + } + + const track = state.track || queue?.tracks?.[queue.currentIndex] || null; + if (!track) return; + + const desiredTime = Math.max(0, Number(state.position_seconds || 0)); + const duration = Number(state.duration_seconds || track.duration_seconds || this.duration || 0); + this.currentTrack = track; + this.shuffle = !!state.shuffle; + this.repeatMode = state.repeat_mode || 'off'; + this.duration = duration; + this._remoteStateBaseTime = desiredTime; + this._remoteStateReceivedAt = Date.now(); + + if (this._localSourceTrackId !== track.id) { + this._playLocal(track, { + position_seconds: desiredTime, + paused: !!state.paused, + }); + } else { + const drift = Math.abs((audio.currentTime || 0) - desiredTime); + const driftLimit = state.paused ? 0.08 : 0.75; + if (drift > driftLimit) { + audio.currentTime = desiredTime; + this._lastAudioTime = audio.currentTime || 0; + } + if (state.paused) { + if (!audio.paused) audio.pause(); + this.isPlaying = false; + } else if (audio.paused) { + audio.play().catch(() => {}); + this.isPlaying = true; + } else { + this.isPlaying = true; + } + } + + this.currentTime = audio.currentTime || desiredTime; + this.progress = duration > 0 ? (this.currentTime / duration) * 100 : 0; + this._updateMediaSession(); + }, + _tickRemoteProgress(force = false) { - if (this._isLocalPlaybackDevice() || !this.currentTrack) return; + if (this._isLocalPlaybackDevice() || this._shouldMirrorRemoteLocally() || !this.currentTrack) return; if (!force && !this.isPlaying) return; let nextTime = Number(this._remoteStateBaseTime || 0); if (this.isPlaying && this._remoteStateReceivedAt > 0) { @@ -1046,6 +1359,7 @@ document.addEventListener('alpine:init', () => { : tracks[idx]; if (currentTrack) { this.currentTrack = currentTrack; + this._localSourceTrackId = currentTrack.id; this._historyRecorded = false; this._resetPlaybackTracking(); audio.src = currentTrack.stream_url; @@ -1177,6 +1491,7 @@ document.addEventListener('alpine:init', () => { jamUsers: [], jamSelectedUsers: [], jamSearching: false, + jamLocalPlayback: false, remoteHintVisible: false, remoteHintDeviceName: '', _remoteHintShown: false, @@ -1184,6 +1499,7 @@ document.addEventListener('alpine:init', () => { _pollTimer: null, _jamSearchTimer: null, _stateRefreshTick: 0, + _lastPlaybackState: null, init() { this.id = this._ensureId(); @@ -1239,6 +1555,7 @@ document.addEventListener('alpine:init', () => { }); if (!res.ok) return; const data = await res.json(); + if (data.playback_state) this._lastPlaybackState = data.playback_state; this._apply(data); const player = Alpine.store('player'); @@ -1247,7 +1564,11 @@ document.addEventListener('alpine:init', () => { } if (player && !this.isActive()) { if (data.playback_state) { - player._applyRemotePlaybackState(data.playback_state); + if (this.shouldPlayJamLocally()) { + player._syncLocalPlaybackState(data.playback_state); + } else { + player._applyRemotePlaybackState(data.playback_state); + } } else if (++this._stateRefreshTick % 8 === 0) { player._restoreState(); } @@ -1257,9 +1578,11 @@ document.addEventListener('alpine:init', () => { _apply(data) { const wasActive = this.isActive(); + const previousJamId = this.currentJamId; this.activeDeviceId = data.active_device_id || null; this.devices = Array.isArray(data.devices) ? data.devices : []; this.jams = Array.isArray(data.jams) ? data.jams : []; + if (data.playback_state) this._lastPlaybackState = data.playback_state; if (data.current_jam_id) { this.currentJamId = data.current_jam_id; sessionStorage.setItem('furu_player_jam_id', this.currentJamId); @@ -1267,6 +1590,9 @@ document.addEventListener('alpine:init', () => { this.currentJamId = null; sessionStorage.removeItem('furu_player_jam_id'); } + if (previousJamId !== this.currentJamId || !this.canPlayJamLocally()) { + this._setJamLocalPlayback(false, { pauseLocal: true }); + } if (wasActive && !this.isActive()) { Alpine.store('player')?._pauseLocal(); } @@ -1350,6 +1676,41 @@ document.addEventListener('alpine:init', () => { return !!jam && !jam.is_owner; }, + canPlayJamLocally() { + const jam = this.selectedJam(); + return !!jam && jam.is_member && !jam.is_owner && jam.host_device_online; + }, + + shouldPlayJamLocally() { + return this.jamLocalPlayback && this.canPlayJamLocally(); + }, + + setJamLocalPlayback(enabled) { + this._setJamLocalPlayback(enabled, { pauseLocal: true }); + if (!this.jamLocalPlayback) return; + + const player = Alpine.store('player'); + if (player && this._lastPlaybackState) { + player._syncLocalPlaybackState(this._lastPlaybackState); + } else if (player?.currentTrack) { + player._syncLocalPlaybackState(player._remotePlaybackPayload(player.currentTrack, { + position_seconds: player.currentTime || 0, + duration_seconds: player.duration || 0, + paused: !player.isPlaying, + })); + } + this.poll(); + }, + + _setJamLocalPlayback(enabled, options = {}) { + const next = !!enabled && this.canPlayJamLocally(); + const wasEnabled = this.jamLocalPlayback; + this.jamLocalPlayback = next; + if (wasEnabled && !next && options.pauseLocal) { + Alpine.store('player')?._pauseLocal(); + } + }, + isActive() { if (this.isControllingRemoteJam()) return false; return !this.activeDeviceId || this.activeDeviceId === this.id; @@ -1555,6 +1916,7 @@ document.addEventListener('alpine:init', () => { const data = await res.json(); this.currentJamId = jam.id; sessionStorage.setItem('furu_player_jam_id', jam.id); + if (data.playback_state) this._lastPlaybackState = data.playback_state; this._apply(data); Alpine.store('queue')?._ensureCurrentJamAttribution(); this.open = false; @@ -1588,6 +1950,7 @@ document.addEventListener('alpine:init', () => { this.currentJamId = null; this.jamPanelOpen = false; this.jamPanelJamId = null; + this._setJamLocalPlayback(false, { pauseLocal: true }); sessionStorage.removeItem('furu_player_jam_id'); }, }); @@ -1881,6 +2244,9 @@ document.addEventListener('alpine:init', () => { // Navigate to initial hash (if any) this._navigateFromHash({ fromHash: true, restoreScroll: true }); + this.$nextTick(() => { + Alpine.store('sharing')?.handleInitialShareLinks(); + }); }, _setHash(hash) { @@ -1957,7 +2323,11 @@ document.addEventListener('alpine:init', () => { }, _afterNavigation(options = {}) { - if (options.restoreScroll) { + if (options.focusSharedTrackId) { + this.$nextTick(() => { + Alpine.store('sharing')?.focusSharedTrack(options.focusSharedTrackId); + }); + } else if (options.restoreScroll) { this._restoreScrollPosition(this._activeHash); } else { this.$nextTick(() => { this._scrollToTop(); }); @@ -2331,6 +2701,28 @@ document.addEventListener('alpine:init', () => { this._afterNavigation(options); }, + showSharedPlaylist(share, options = {}) { + this._saveScrollPosition(this._activeHash); + this.searchQuery = ''; + this.searchResults = null; + this.currentArtist = null; + this.currentRelease = null; + this.currentPlaylist = { + id: 0, + title: share?.title || T.sharedPlaylist, + description: null, + is_own: false, + owner_name: null, + is_public: false, + is_saved: false, + kind: 'shared', + tracks: Array.isArray(share?.tracks) ? share.tracks : [], + }; + this.view = 'playlist_detail'; + this._previousView = 'artists'; + this._afterNavigation(options); + }, + async playRelease(releaseId) { try { const res = await fetch(`/api/player/releases/${releaseId}`); diff --git a/templates/player/shell.html b/templates/player/shell.html index 375dd4c..a31a50e 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -411,17 +411,6 @@ -
@@ -504,6 +493,9 @@ + @@ -725,6 +717,9 @@ + @@ -780,25 +775,36 @@
- - - - +
+ + +
+
+ + + +
@@ -812,7 +818,8 @@