Compare commits

...

9 Commits

Author SHA1 Message Date
Ultradesu 652c6a470d Fix queue display
Build and Publish / Build and Publish Docker Image (push) Successful in 2m33s
2026-06-08 17:59:56 +01:00
Ultradesu 1c54782dd7 Fix queue display 2026-06-08 17:59:46 +01:00
Ultradesu 8fa06038fe Added MacOS client support
Build and Publish / Build and Publish Docker Image (push) Successful in 2m32s
2026-06-08 16:36:30 +01:00
Ultradesu 6b69cc0fc0 Bump~
Build and Publish / Build and Publish Docker Image (push) Successful in 2m34s
2026-06-08 11:23:10 +01:00
Ultradesu 624cadab64 Fixed sone jobs 2026-06-08 11:22:52 +01:00
ab a8756c95de Bump
Build and Publish / Build and Publish Docker Image (push) Successful in 4m9s
2026-06-05 13:21:55 +03:00
Ultradesu 952d11e6f5 added mobile auth support 2026-06-05 03:37:51 +03:00
Ultradesu df16713aa2 bump
Build and Publish / Build and Publish Docker Image (push) Successful in 2m37s
2026-06-03 17:49:13 +03:00
Ultradesu 3a9240b82c 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.
2026-06-03 17:48:30 +03:00
24 changed files with 4490 additions and 506 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.2.15"
version = "0.4.2"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.3.0"
version = "0.4.3"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+3
View File
@@ -1324,6 +1324,9 @@ impl App for AdminApp {
all.extend(cot::db::migrations::wrap_migrations(
crate::scheduler::db_migrations::MIGRATIONS,
));
all.extend(cot::db::migrations::wrap_migrations(
crate::auth::db_migrations::MIGRATIONS,
));
all
}
}
+4 -10
View File
@@ -96,17 +96,11 @@ fn generate_missing_variants_sync(
image::ExtendedColorType::Rgb8,
);
match result {
Ok(()) => crate::metrics::record_agent_cover_variant(
variant.name,
"ok",
start.elapsed(),
),
Ok(()) => {
crate::metrics::record_agent_cover_variant(variant.name, "ok", start.elapsed())
}
Err(err) => {
crate::metrics::record_agent_cover_variant(
variant.name,
"error",
start.elapsed(),
);
crate::metrics::record_agent_cover_variant(variant.name, "error", start.elapsed());
return Err(err.into());
}
}
+523 -9
View File
@@ -1,14 +1,27 @@
use std::marker::PhantomData;
use cot::aide::openapi::{
MediaType, Operation, ReferenceOr, RequestBody, Response as OpenApiResponse, SchemaObject,
StatusCode as OpenApiStatusCode,
};
use cot::auth::PasswordVerificationResult;
use cot::common_types::Password;
use cot::db::Database;
use cot::http::StatusCode;
use cot::http::header::CONTENT_TYPE;
use cot::json::Json;
use cot::openapi::{AsApiOperation, RouteContext};
use cot::response::IntoResponse;
use cot::router::method::openapi::api_get;
use cot::router::method::openapi::{api_get, api_post};
use cot::router::{Route, Router};
use cot::session::Session;
use cot::{App, Body};
use schemars::JsonSchema;
use serde::Serialize;
use cot::{App, Body, RequestHandler};
use schemars::{JsonSchema, SchemaGenerator};
use serde::{Deserialize, Serialize};
use crate::auth;
use crate::config::AppConfig;
use crate::user::User;
// ---------------------------------------------------------------------------
// JSON error helper
@@ -23,6 +36,199 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re
.expect("valid response")
}
#[derive(Clone, Copy)]
struct DocumentedJsonHandler<H, Req, Res> {
handler: H,
summary: &'static str,
_marker: PhantomData<fn(Req) -> Res>,
}
#[derive(Clone, Copy)]
struct DocumentedResponseHandler<H, Res> {
handler: H,
summary: &'static str,
_marker: PhantomData<fn() -> Res>,
}
fn documented_json_handler<Req, Res, H>(
handler: H,
summary: &'static str,
) -> DocumentedJsonHandler<H, Req, Res> {
DocumentedJsonHandler {
handler,
summary,
_marker: PhantomData,
}
}
fn documented_response_handler<Res, H>(
handler: H,
summary: &'static str,
) -> DocumentedResponseHandler<H, Res> {
DocumentedResponseHandler {
handler,
summary,
_marker: PhantomData,
}
}
impl<HandlerParams, H, Req, Res> RequestHandler<HandlerParams>
for DocumentedJsonHandler<H, Req, Res>
where
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
{
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
self.handler.handle(request).await
}
}
impl<HandlerParams, H, Res> RequestHandler<HandlerParams> for DocumentedResponseHandler<H, Res>
where
H: RequestHandler<HandlerParams> + Clone + Send + Sync + 'static,
{
async fn handle(&self, request: cot::request::Request) -> cot::Result<cot::response::Response> {
self.handler.handle(request).await
}
}
impl<H, Req, Res> AsApiOperation for DocumentedJsonHandler<H, Req, Res>
where
Req: JsonSchema,
Res: JsonSchema,
{
fn as_api_operation(
&self,
_route_context: &RouteContext<'_>,
schema_generator: &mut SchemaGenerator,
) -> Option<Operation> {
let mut operation = Operation {
summary: Some(self.summary.to_owned()),
..Default::default()
};
let mut request_body = RequestBody {
required: true,
..Default::default()
};
request_body.content.insert(
"application/json".to_owned(),
MediaType {
schema: Some(SchemaObject {
json_schema: Req::json_schema(schema_generator),
external_docs: None,
example: None,
}),
..Default::default()
},
);
operation.request_body = Some(ReferenceOr::Item(request_body));
let responses = operation.responses.get_or_insert_default();
let mut ok = OpenApiResponse {
description: "OK".to_owned(),
..Default::default()
};
ok.content.insert(
"application/json".to_owned(),
MediaType {
schema: Some(SchemaObject {
json_schema: Res::json_schema(schema_generator),
external_docs: None,
example: None,
}),
..Default::default()
},
);
responses
.responses
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
Some(operation)
}
}
impl<H, Res> AsApiOperation for DocumentedResponseHandler<H, Res>
where
Res: JsonSchema,
{
fn as_api_operation(
&self,
_route_context: &RouteContext<'_>,
schema_generator: &mut SchemaGenerator,
) -> Option<Operation> {
let mut operation = Operation {
summary: Some(self.summary.to_owned()),
..Default::default()
};
add_json_response::<Res>(&mut operation, schema_generator);
Some(operation)
}
}
fn add_json_response<Res: JsonSchema>(
operation: &mut Operation,
schema_generator: &mut SchemaGenerator,
) {
let responses = operation.responses.get_or_insert_default();
let mut ok = OpenApiResponse {
description: "OK".to_owned(),
..Default::default()
};
ok.content.insert(
"application/json".to_owned(),
MediaType {
schema: Some(SchemaObject {
json_schema: Res::json_schema(schema_generator),
external_docs: None,
example: None,
}),
..Default::default()
},
);
responses
.responses
.insert(OpenApiStatusCode::Code(200), ReferenceOr::Item(ok));
}
fn is_json_content_type(value: &str) -> bool {
value
.split(';')
.next()
.map(str::trim)
.is_some_and(|media_type| media_type.eq_ignore_ascii_case("application/json"))
}
async fn parse_json_request<T>(
request: cot::request::Request,
) -> cot::Result<Result<T, cot::response::Response>>
where
T: for<'de> Deserialize<'de>,
{
let content_type = request
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or_default();
if !is_json_content_type(content_type) {
return Ok(Err(json_error(
StatusCode::UNSUPPORTED_MEDIA_TYPE,
"expected application/json",
)));
}
let bytes = request.into_body().into_bytes().await?;
let body = match serde_json::from_slice::<T>(&bytes) {
Ok(body) => body,
Err(_) => {
return Ok(Err(json_error(
StatusCode::BAD_REQUEST,
"invalid JSON body",
)));
}
};
Ok(Ok(body))
}
// ---------------------------------------------------------------------------
// GET /api/me
// ---------------------------------------------------------------------------
@@ -34,8 +240,85 @@ struct MeResponse {
role: String,
}
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_session_user(&session, &db).await else {
#[derive(Debug, Serialize, JsonSchema)]
struct AuthUserResponse {
id: i64,
name: String,
role: String,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AuthTokenResponse {
access_token: String,
refresh_token: String,
token_type: String,
expires_in_seconds: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AuthLoginResponse {
user: AuthUserResponse,
tokens: AuthTokenResponse,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct PasswordLoginRequest {
username: String,
password: String,
device_name: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct RefreshRequest {
refresh_token: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct SsoExchangeRequest {
code: String,
device_name: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct LogoutRequest {
refresh_token: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct LogoutResponse {
revoked: bool,
}
fn user_response(user: auth::AuthenticatedUser) -> AuthUserResponse {
AuthUserResponse {
id: user.id,
name: user.name,
role: user.role.code().to_owned(),
}
}
fn token_response(tokens: auth::ApiTokenPair) -> AuthTokenResponse {
AuthTokenResponse {
access_token: tokens.access_token,
refresh_token: tokens.refresh_token,
token_type: tokens.token_type.to_owned(),
expires_in_seconds: tokens.expires_in_seconds,
}
}
fn login_response(user: auth::AuthenticatedUser, tokens: auth::ApiTokenPair) -> AuthLoginResponse {
AuthLoginResponse {
user: user_response(user),
tokens: token_response(tokens),
}
}
async fn me_handler(
auth_ctx: auth::AuthContext,
session: Session,
db: Database,
) -> cot::Result<cot::response::Response> {
let Some(user) = auth::get_request_user(&auth_ctx, &session, &db).await else {
return Ok(json_error(
cot::http::StatusCode::UNAUTHORIZED,
"not authenticated",
@@ -50,6 +333,146 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response
.into_response()
}
async fn password_login_handler(
db: Database,
raw_request: cot::request::Request,
) -> cot::Result<cot::response::Response> {
let request = match parse_json_request::<PasswordLoginRequest>(raw_request).await? {
Ok(request) => request,
Err(response) => return Ok(response),
};
let (config, _) = AppConfig::load_with_db(&db).await;
if !config.auth_password_enabled {
crate::metrics::record_auth_attempt("api_password", "failure", "disabled");
return Ok(json_error(
StatusCode::FORBIDDEN,
"password login is disabled",
));
}
let user = match User::get_by_username(&db, request.username.trim()).await {
Ok(Some(user)) if user.is_active() => user,
_ => {
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid username or password",
));
}
};
let Some(hash) = user.password_ref() else {
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
return Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid username or password",
));
};
match hash.verify(&Password::new(&request.password)) {
PasswordVerificationResult::Ok | PasswordVerificationResult::OkObsolete(_) => {
let auth_user = auth::AuthenticatedUser {
id: user.id_val(),
name: {
let display = user.display_name_str();
if display.is_empty() {
user.username_str().to_owned()
} else {
display
}
},
role: user.role(),
};
let tokens =
auth::create_api_session(&db, user.id_val(), request.device_name.as_deref())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
crate::metrics::record_auth_attempt("api_password", "success", "ok");
crate::metrics::record_session_created("api_password");
Json(login_response(auth_user, tokens)).into_response()
}
PasswordVerificationResult::Invalid => {
crate::metrics::record_auth_attempt("api_password", "failure", "bad_credentials");
Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid username or password",
))
}
}
}
async fn refresh_handler(
db: Database,
raw_request: cot::request::Request,
) -> cot::Result<cot::response::Response> {
let request = match parse_json_request::<RefreshRequest>(raw_request).await? {
Ok(request) => request,
Err(response) => return Ok(response),
};
match auth::refresh_api_session(&db, request.refresh_token.trim()).await {
Ok(Some(tokens)) => Json(token_response(tokens)).into_response(),
Ok(None) => Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid refresh token",
)),
Err(err) => Err(cot::Error::internal(err.to_string())),
}
}
async fn sso_exchange_handler(
db: Database,
raw_request: cot::request::Request,
) -> cot::Result<cot::response::Response> {
let request = match parse_json_request::<SsoExchangeRequest>(raw_request).await? {
Ok(request) => request,
Err(response) => return Ok(response),
};
match auth::exchange_mobile_code_for_api_session(
&db,
request.code.trim(),
request.device_name.as_deref(),
)
.await
{
Ok(Some((user, tokens))) => {
crate::metrics::record_auth_attempt("api_sso_exchange", "success", "ok");
crate::metrics::record_session_created("api_sso_exchange");
Json(login_response(user, tokens)).into_response()
}
Ok(None) => {
crate::metrics::record_auth_attempt("api_sso_exchange", "failure", "bad_code");
Ok(json_error(
StatusCode::UNAUTHORIZED,
"invalid SSO exchange code",
))
}
Err(err) => Err(cot::Error::internal(err.to_string())),
}
}
async fn logout_handler(
auth_ctx: auth::AuthContext,
db: Database,
raw_request: cot::request::Request,
) -> cot::Result<cot::response::Response> {
let request = match parse_json_request::<LogoutRequest>(raw_request).await? {
Ok(request) => request,
Err(response) => return Ok(response),
};
let revoked = auth::revoke_api_session(
&db,
auth_ctx.bearer_token(),
request.refresh_token.as_deref().map(str::trim),
)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(LogoutResponse { revoked }).into_response()
}
// ---------------------------------------------------------------------------
// App
// ---------------------------------------------------------------------------
@@ -62,10 +485,101 @@ impl App for ApiApp {
}
fn router(&self) -> Router {
Router::with_urls([Route::with_api_handler_and_name(
Router::with_urls([
Route::with_api_handler_and_name(
"/me",
api_get(me_handler),
api_get(documented_response_handler::<MeResponse, _>(
me_handler,
"Get the current authenticated user",
)),
"api_me",
)])
),
Route::with_api_handler_and_name(
"/auth/password",
api_post(documented_json_handler::<
PasswordLoginRequest,
AuthLoginResponse,
_,
>(
password_login_handler,
"Log in with username and password",
)),
"api_auth_password",
),
Route::with_api_handler_and_name(
"/auth/refresh",
api_post(documented_json_handler::<
RefreshRequest,
AuthTokenResponse,
_,
>(
refresh_handler, "Refresh an API token pair"
)),
"api_auth_refresh",
),
Route::with_api_handler_and_name(
"/auth/sso/exchange",
api_post(documented_json_handler::<
SsoExchangeRequest,
AuthLoginResponse,
_,
>(
sso_exchange_handler,
"Exchange a mobile SSO code for API tokens",
)),
"api_auth_sso_exchange",
),
Route::with_api_handler_and_name(
"/auth/logout",
api_post(documented_json_handler::<LogoutRequest, LogoutResponse, _>(
logout_handler,
"Revoke an API session",
)),
"api_auth_logout",
),
])
}
}
#[cfg(test)]
mod tests {
use cot::aide::openapi::{PathItem, ReferenceOr};
use super::*;
fn assert_get_path(paths: &cot::aide::openapi::Paths, path: &str) {
assert!(matches!(
paths.paths.get(path),
Some(ReferenceOr::Item(PathItem { get: Some(_), .. }))
));
}
fn assert_post_path(paths: &cot::aide::openapi::Paths, path: &str) {
assert!(matches!(
paths.paths.get(path),
Some(ReferenceOr::Item(PathItem { post: Some(_), .. }))
));
}
#[test]
fn openapi_includes_auth_routes() {
let openapi = ApiApp.router().as_api();
let paths = openapi.paths.expect("OpenAPI paths");
assert_get_path(&paths, "/me");
assert_post_path(&paths, "/auth/password");
assert_post_path(&paths, "/auth/refresh");
assert_post_path(&paths, "/auth/sso/exchange");
assert_post_path(&paths, "/auth/logout");
let Some(ReferenceOr::Item(PathItem {
post: Some(operation),
..
})) = paths.paths.get("/auth/password")
else {
panic!("password auth path should be documented as POST");
};
assert!(operation.request_body.is_some());
assert!(operation.responses.is_some());
}
}
+588 -6
View File
@@ -1,7 +1,13 @@
use chrono::{Duration, Utc};
use cot::Body;
use cot::db::Database;
use cot::db::{Auto, Database, LimitedString, Model};
use cot::http::header::AUTHORIZATION;
use cot::request::RequestHead;
use cot::request::extractors::FromRequestHead;
use cot::response::IntoResponse;
use cot::session::Session;
use serde::Serialize;
use sha2::{Digest, Sha256};
use crate::user::User;
@@ -37,6 +43,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 {
@@ -45,11 +52,7 @@ pub struct AuthenticatedUser {
pub role: Role,
}
/// Read `user_id` from the session, fetch the `User` from DB, return
/// `AuthenticatedUser` if the user exists and is active.
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
let user = User::get_by_id(db, user_id).await.ok()??;
fn authenticated_user_from_user(user: User) -> Option<AuthenticatedUser> {
if !user.is_active() {
return None;
}
@@ -69,6 +72,362 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option<Authen
})
}
/// Read `user_id` from the session, fetch the `User` from DB, return
/// `AuthenticatedUser` if the user exists and is active.
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
let user = User::get_by_id(db, user_id).await.ok()??;
authenticated_user_from_user(user)
}
// ---------------------------------------------------------------------------
// API bearer-token auth
// ---------------------------------------------------------------------------
const ACCESS_TOKEN_PREFIX: &str = "furu_at_";
const REFRESH_TOKEN_PREFIX: &str = "furu_rt_";
const MOBILE_EXCHANGE_CODE_PREFIX: &str = "furu_mx_";
const ACCESS_TOKEN_TTL_MINUTES: i64 = 15;
const REFRESH_TOKEN_TTL_DAYS: i64 = 60;
const MOBILE_EXCHANGE_CODE_TTL_MINUTES: i64 = 3;
#[derive(Debug, Clone, Default)]
pub struct AuthContext {
bearer_token: Option<String>,
}
impl AuthContext {
pub fn bearer_token(&self) -> Option<&str> {
self.bearer_token.as_deref()
}
}
impl FromRequestHead for AuthContext {
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
let bearer_token = head
.headers
.get(AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(parse_bearer_token)
.map(str::to_owned);
Ok(Self { bearer_token })
}
}
fn parse_bearer_token(header: &str) -> Option<&str> {
let header = header.trim();
let (scheme, token) = header.split_once(' ')?;
if !scheme.eq_ignore_ascii_case("Bearer") {
return None;
}
let token = token.trim();
if token.is_empty() || token.len() > 512 {
return None;
}
Some(token)
}
#[derive(Debug, Serialize)]
pub struct ApiTokenPair {
pub access_token: String,
pub refresh_token: String,
pub token_type: &'static str,
pub expires_in_seconds: i64,
}
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct ApiSession {
#[model(primary_key)]
id: Auto<i64>,
user_id: i64,
device_name: Option<String>,
access_token_hash: LimitedString<128>,
refresh_token_hash: LimitedString<128>,
access_expires_at: String,
refresh_expires_at: String,
created_at: String,
last_used_at: Option<String>,
revoked_at: Option<String>,
}
#[derive(Debug, Clone)]
#[cot::db::model]
pub struct MobileExchangeCode {
#[model(primary_key)]
id: Auto<i64>,
code_hash: LimitedString<128>,
user_id: i64,
created_at: String,
expires_at: String,
consumed_at: Option<String>,
}
impl ApiSession {
pub async fn create_for_user(
db: &Database,
user_id: i64,
device_name: Option<&str>,
) -> cot::db::Result<ApiTokenPair> {
let tokens = fresh_token_pair();
let now = now_iso();
let mut session = Self {
id: Auto::auto(),
user_id,
device_name: device_name.and_then(normalize_device_name),
access_token_hash: LimitedString::new(&token_hash(&tokens.access_token)).unwrap(),
refresh_token_hash: LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap(),
access_expires_at: access_expires_at(),
refresh_expires_at: refresh_expires_at(),
created_at: now.clone(),
last_used_at: Some(now),
revoked_at: None,
};
session.insert(db).await?;
Ok(tokens)
}
async fn find_by_access_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
return Ok(None);
};
cot::db::query!(ApiSession, $access_token_hash == hash)
.get(db)
.await
}
async fn find_by_refresh_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
return Ok(None);
};
cot::db::query!(ApiSession, $refresh_token_hash == hash)
.get(db)
.await
}
fn is_revoked(&self) -> bool {
self.revoked_at.is_some()
}
fn access_token_valid(&self) -> bool {
!self.is_revoked() && self.access_expires_at > now_iso()
}
fn refresh_token_valid(&self) -> bool {
!self.is_revoked() && self.refresh_expires_at > now_iso()
}
async fn rotate(&mut self, db: &Database) -> cot::db::Result<ApiTokenPair> {
let tokens = fresh_token_pair();
self.access_token_hash = LimitedString::new(&token_hash(&tokens.access_token)).unwrap();
self.refresh_token_hash = LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap();
self.access_expires_at = access_expires_at();
self.refresh_expires_at = refresh_expires_at();
self.last_used_at = Some(now_iso());
self.save(db).await?;
Ok(tokens)
}
async fn revoke(&mut self, db: &Database) -> cot::db::Result<()> {
if self.revoked_at.is_none() {
self.revoked_at = Some(now_iso());
self.save(db).await?;
}
Ok(())
}
}
pub async fn create_api_session(
db: &Database,
user_id: i64,
device_name: Option<&str>,
) -> cot::db::Result<ApiTokenPair> {
ApiSession::create_for_user(db, user_id, device_name).await
}
pub async fn get_bearer_user(db: &Database, token: &str) -> Option<AuthenticatedUser> {
let session = ApiSession::find_by_access_token(db, token).await.ok()??;
if !session.access_token_valid() {
return None;
}
let user = User::get_by_id(db, session.user_id).await.ok()??;
authenticated_user_from_user(user)
}
pub async fn get_request_user(
auth: &AuthContext,
session: &Session,
db: &Database,
) -> Option<AuthenticatedUser> {
if let Some(token) = auth.bearer_token() {
return get_bearer_user(db, token).await;
}
get_session_user(session, db).await
}
pub async fn refresh_api_session(
db: &Database,
refresh_token: &str,
) -> cot::db::Result<Option<ApiTokenPair>> {
let Some(mut session) = ApiSession::find_by_refresh_token(db, refresh_token).await? else {
return Ok(None);
};
if !session.refresh_token_valid() {
session.revoke(db).await?;
return Ok(None);
}
let Some(user) = User::get_by_id(db, session.user_id).await? else {
session.revoke(db).await?;
return Ok(None);
};
if !user.is_active() {
session.revoke(db).await?;
return Ok(None);
}
Ok(Some(session.rotate(db).await?))
}
pub async fn revoke_api_session(
db: &Database,
access_token: Option<&str>,
refresh_token: Option<&str>,
) -> cot::db::Result<bool> {
let mut session = if let Some(token) = access_token {
ApiSession::find_by_access_token(db, token).await?
} else {
None
};
if session.is_none() {
if let Some(token) = refresh_token {
session = ApiSession::find_by_refresh_token(db, token).await?;
}
}
let Some(mut session) = session else {
return Ok(false);
};
session.revoke(db).await?;
Ok(true)
}
impl MobileExchangeCode {
pub async fn create_for_user(db: &Database, user_id: i64) -> cot::db::Result<String> {
let code = random_token(MOBILE_EXCHANGE_CODE_PREFIX);
let now = now_iso();
let mut row = Self {
id: Auto::auto(),
code_hash: LimitedString::new(&token_hash(&code)).unwrap(),
user_id,
created_at: now,
expires_at: mobile_exchange_code_expires_at(),
consumed_at: None,
};
row.insert(db).await?;
Ok(code)
}
async fn find_by_code(db: &Database, code: &str) -> cot::db::Result<Option<Self>> {
let Ok(hash) = LimitedString::<128>::new(&token_hash(code)) else {
return Ok(None);
};
cot::db::query!(MobileExchangeCode, $code_hash == hash)
.get(db)
.await
}
fn is_valid(&self) -> bool {
self.consumed_at.is_none() && self.expires_at > now_iso()
}
async fn consume(&mut self, db: &Database) -> cot::db::Result<()> {
self.consumed_at = Some(now_iso());
self.save(db).await
}
}
pub async fn create_mobile_exchange_code(db: &Database, user_id: i64) -> cot::db::Result<String> {
MobileExchangeCode::create_for_user(db, user_id).await
}
pub async fn exchange_mobile_code_for_api_session(
db: &Database,
code: &str,
device_name: Option<&str>,
) -> cot::db::Result<Option<(AuthenticatedUser, ApiTokenPair)>> {
let Some(mut exchange_code) = MobileExchangeCode::find_by_code(db, code).await? else {
return Ok(None);
};
if !exchange_code.is_valid() {
return Ok(None);
}
let Some(user) = User::get_by_id(db, exchange_code.user_id).await? else {
exchange_code.consume(db).await?;
return Ok(None);
};
let Some(auth_user) = authenticated_user_from_user(user) else {
exchange_code.consume(db).await?;
return Ok(None);
};
exchange_code.consume(db).await?;
let tokens = ApiSession::create_for_user(db, auth_user.id, device_name).await?;
Ok(Some((auth_user, tokens)))
}
fn fresh_token_pair() -> ApiTokenPair {
ApiTokenPair {
access_token: random_token(ACCESS_TOKEN_PREFIX),
refresh_token: random_token(REFRESH_TOKEN_PREFIX),
token_type: "Bearer",
expires_in_seconds: ACCESS_TOKEN_TTL_MINUTES * 60,
}
}
fn random_token(prefix: &str) -> String {
format!(
"{prefix}{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
fn token_hash(token: &str) -> String {
let digest = Sha256::digest(token.as_bytes());
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
fn normalize_device_name(name: &str) -> Option<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.chars().take(255).collect())
}
fn now_iso() -> String {
Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
}
fn access_expires_at() -> String {
(Utc::now() + Duration::minutes(ACCESS_TOKEN_TTL_MINUTES))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
}
fn refresh_expires_at() -> String {
(Utc::now() + Duration::days(REFRESH_TOKEN_TTL_DAYS))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
}
fn mobile_exchange_code_expires_at() -> String {
(Utc::now() + Duration::minutes(MOBILE_EXCHANGE_CODE_TTL_MINUTES))
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string()
}
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
/// `Err(response)` — a redirect to `/login` or a 403.
pub async fn require_admin_or_redirect(
@@ -103,6 +462,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
@@ -121,6 +517,192 @@ pub fn redirect(location: &str) -> cot::response::Response {
.expect("valid response")
}
// ---------------------------------------------------------------------------
// Migrations
// ---------------------------------------------------------------------------
pub mod db_migrations {
use cot::db::migrations::{self, Field, Operation, SyncDynMigration};
use cot::db::{DatabaseField, Identifier, LimitedString};
#[derive(Debug, Copy, Clone)]
pub struct M0038CreateApiSession;
impl migrations::Migration for M0038CreateApiSession {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0038_create_api_session";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0003_create_user",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__api_session"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("device_name"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("access_token_hash"),
<LimitedString<128> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("refresh_token_hash"),
<LimitedString<128> as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("access_expires_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("refresh_expires_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("created_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("last_used_at"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
Field::new(
Identifier::new("revoked_at"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
])
.build()];
}
#[cot::db::migrations::migration_op]
async fn create_api_session_indexes(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_api_session_access_token_hash \
ON furumusic__api_session (access_token_hash)",
)
.await?;
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_api_session_refresh_token_hash \
ON furumusic__api_session (refresh_token_hash)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX idx_api_session_user_id \
ON furumusic__api_session (user_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0039CreateApiSessionIndexes;
impl migrations::Migration for M0039CreateApiSessionIndexes {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0039_create_api_session_indexes";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0038_create_api_session",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_api_session_indexes).build()];
}
#[derive(Debug, Copy, Clone)]
pub struct M0040CreateMobileExchangeCode;
impl migrations::Migration for M0040CreateMobileExchangeCode {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0040_create_mobile_exchange_code";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0039_create_api_session_indexes",
)];
const OPERATIONS: &'static [Operation] = &[Operation::create_model()
.table_name(Identifier::new("furumusic__mobile_exchange_code"))
.fields(&[
Field::new(Identifier::new("id"), <i64 as DatabaseField>::TYPE)
.primary_key()
.auto(),
Field::new(
Identifier::new("code_hash"),
<LimitedString<128> as DatabaseField>::TYPE,
),
Field::new(Identifier::new("user_id"), <i64 as DatabaseField>::TYPE),
Field::new(
Identifier::new("created_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("expires_at"),
<String as DatabaseField>::TYPE,
),
Field::new(
Identifier::new("consumed_at"),
<String as DatabaseField>::TYPE,
)
.set_null(true),
])
.build()];
}
#[cot::db::migrations::migration_op]
async fn create_mobile_exchange_code_indexes(
ctx: migrations::MigrationContext<'_>,
) -> cot::db::Result<()> {
ctx.db
.raw(
"CREATE UNIQUE INDEX idx_mobile_exchange_code_hash \
ON furumusic__mobile_exchange_code (code_hash)",
)
.await?;
ctx.db
.raw(
"CREATE INDEX idx_mobile_exchange_code_user_id \
ON furumusic__mobile_exchange_code (user_id)",
)
.await?;
Ok(())
}
#[derive(Debug, Copy, Clone)]
pub struct M0041CreateMobileExchangeCodeIndexes;
impl migrations::Migration for M0041CreateMobileExchangeCodeIndexes {
const APP_NAME: &'static str = "furumusic";
const MIGRATION_NAME: &'static str = "m_0041_create_mobile_exchange_code_indexes";
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
&[migrations::MigrationDependency::migration(
"furumusic",
"m_0040_create_mobile_exchange_code",
)];
const OPERATIONS: &'static [Operation] =
&[Operation::custom(create_mobile_exchange_code_indexes).build()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0038CreateApiSession,
&M0039CreateApiSessionIndexes,
&M0040CreateMobileExchangeCode,
&M0041CreateMobileExchangeCodeIndexes,
];
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
+42
View File
@@ -338,10 +338,52 @@ impl AppConfig {
pub fn load() -> Self {
let mut cfg = Self::default();
cfg.apply_env_overrides();
cfg.apply_startup_db_overrides();
cfg.apply_env_overrides();
cfg.normalize_host_paths();
cfg
}
fn apply_startup_db_overrides(&mut self) {
if self.database_url.is_empty() {
return;
}
if tokio::runtime::Handle::try_current().is_ok() {
tracing::warn!("skipping startup DB config load from inside an existing Tokio runtime");
return;
}
let database_url = self.database_url.clone();
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
tracing::warn!("failed to create runtime for startup DB config load");
return;
};
let result = runtime.block_on(async move {
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(1)
.connect(&database_url)
.await?;
sqlx::query_scalar::<_, String>(
"SELECT value FROM furumusic__config_entry WHERE key = 'swagger_enabled'",
)
.fetch_optional(&pool)
.await
});
match result {
Ok(Some(value)) => match value.parse::<bool>() {
Ok(value) => self.swagger_enabled = value,
Err(_) => tracing::warn!("ignoring invalid DB config value for swagger_enabled"),
},
Ok(None) => {}
Err(err) => tracing::warn!("failed to read startup DB config overrides: {err}"),
}
}
/// Build config with full 3-layer resolution (default → DB → env) and
/// track the source of each field.
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
+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" , "Назад";
+431
View File
@@ -0,0 +1,431 @@
use std::collections::BTreeSet;
use std::io::ErrorKind;
use sqlx::PgPool;
use crate::scheduler::{Job, JobContext, JobLog};
const SAMPLE_LOG_LIMIT: usize = 50;
pub struct ArchiveCleanupJob;
#[derive(Debug, sqlx::FromRow)]
struct TrackFileRow {
track_id: i64,
track_title: String,
release_id: i64,
release_title: Option<String>,
media_file_id: Option<i64>,
file_type: Option<String>,
file_path: Option<String>,
}
#[derive(Debug)]
struct MissingTrack {
track_id: i64,
track_title: String,
release_id: i64,
release_title: Option<String>,
media_file_id: Option<i64>,
file_path: Option<String>,
reason: MissingReason,
}
#[derive(Debug)]
enum MissingReason {
MissingMediaRow,
InvalidMediaType(String),
EmptyPath,
MissingFile,
NotRegularFile,
}
#[derive(Debug, Default)]
struct DeleteStats {
playback_states_cleared: u64,
playlist_entries_deleted: u64,
likes_deleted: u64,
play_history_deleted: u64,
popularity_history_deleted: u64,
scrobble_outbox_deleted: u64,
track_genres_deleted: u64,
entity_tags_deleted: u64,
external_ids_deleted: u64,
track_artists_deleted: u64,
tracks_deleted: u64,
media_files_deleted: u64,
}
#[async_trait::async_trait]
impl Job for ArchiveCleanupJob {
fn name(&self) -> &'static str {
"archive_cleanup"
}
fn description(&self) -> &'static str {
"Clean stale archive records, starting with tracks whose audio files are missing"
}
fn default_cron(&self) -> &'static str {
// Daily at 04:45.
"0 45 4 * * *"
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
run_missing_audio_cleanup(ctx, log).await
}
}
async fn run_missing_audio_cleanup(ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
let storage_dir = ctx.config.agent_storage_dir.trim();
if storage_dir.is_empty() {
log.warn("Archive cleanup: agent_storage_dir is not configured, skipping file checks");
return Ok(());
}
let rows = sqlx::query_as::<_, TrackFileRow>(
r#"SELECT t.id AS track_id,
t.title::text AS track_title,
t.release_id,
r.title::text AS release_title,
mf.id AS media_file_id,
mf.file_type::text AS file_type,
mf.file_path::text AS file_path
FROM furumusic__track t
LEFT JOIN furumusic__release r ON r.id = t.release_id
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
ORDER BY t.id"#,
)
.fetch_all(&ctx.pool)
.await?;
if rows.is_empty() {
log.info("Archive cleanup: no tracks found");
return Ok(());
}
log.info(&format!(
"Archive cleanup: checking {} track audio reference(s)",
rows.len()
));
let mut missing_tracks = Vec::new();
let mut skipped_io_errors = 0u64;
for row in rows {
let Some(media_file_id) = row.media_file_id else {
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingMediaRow));
continue;
};
let file_type = row.file_type.clone();
match file_type.as_deref() {
Some("audio") => {}
Some(file_type) => {
missing_tracks.push(MissingTrack::from_row(
row,
MissingReason::InvalidMediaType(file_type.to_owned()),
));
continue;
}
None => {
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingMediaRow));
continue;
}
}
let Some(file_path) = row
.file_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty())
else {
missing_tracks.push(MissingTrack::from_row(row, MissingReason::EmptyPath));
continue;
};
let absolute_path = crate::media_paths::resolve_media_file_path(storage_dir, file_path);
match tokio::fs::metadata(&absolute_path).await {
Ok(meta) if meta.is_file() => {}
Ok(_) => {
missing_tracks.push(MissingTrack::from_row(row, MissingReason::NotRegularFile));
}
Err(err) if err.kind() == ErrorKind::NotFound => {
missing_tracks.push(MissingTrack::from_row(row, MissingReason::MissingFile));
}
Err(err) => {
skipped_io_errors += 1;
log.warn(&format!(
"Archive cleanup: skipping track {} media_file_id={media_file_id}; cannot inspect {}: {err}",
row.track_id,
absolute_path.display()
));
}
}
}
if missing_tracks.is_empty() {
log.info(&format!(
"Archive cleanup: all checked tracks have readable audio files; skipped_io_errors={skipped_io_errors}"
));
return Ok(());
}
for (index, track) in missing_tracks.iter().take(SAMPLE_LOG_LIMIT).enumerate() {
log.warn(&format!(
"Archive cleanup: deleting stale track {} \"{}\"{}{} ({})",
track.track_id,
track.track_title,
track
.release_title
.as_deref()
.map(|title| format!(" from \"{title}\""))
.unwrap_or_default(),
track
.file_path
.as_deref()
.map(|path| format!(", path={path}"))
.unwrap_or_default(),
track.reason
));
if index + 1 == SAMPLE_LOG_LIMIT && missing_tracks.len() > SAMPLE_LOG_LIMIT {
log.warn(&format!(
"Archive cleanup: suppressing per-track logs for remaining {} stale track(s)",
missing_tracks.len() - SAMPLE_LOG_LIMIT
));
}
}
let track_ids = unique_sorted(
missing_tracks
.iter()
.map(|track| track.track_id)
.collect::<Vec<_>>(),
);
let media_file_ids = unique_sorted(
missing_tracks
.iter()
.filter_map(|track| track.media_file_id)
.collect::<Vec<_>>(),
);
let release_ids = unique_sorted(
missing_tracks
.iter()
.map(|track| track.release_id)
.collect::<Vec<_>>(),
);
let stats =
delete_tracks_and_unreferenced_audio_media(&ctx.pool, &track_ids, &media_file_ids).await?;
let empty_release_count = count_empty_releases(&ctx.pool, &release_ids).await?;
log.info(&format!(
"Archive cleanup: deleted {} track(s), {} unreferenced audio media_file row(s); cleared playback_states={}, playlist_entries={}, likes={}, play_history={}, popularity_history={}, scrobble_outbox={}, track_genres={}, entity_tags={}, external_ids={}, track_artists={}; skipped_io_errors={skipped_io_errors}; empty_releases_left={empty_release_count}",
stats.tracks_deleted,
stats.media_files_deleted,
stats.playback_states_cleared,
stats.playlist_entries_deleted,
stats.likes_deleted,
stats.play_history_deleted,
stats.popularity_history_deleted,
stats.scrobble_outbox_deleted,
stats.track_genres_deleted,
stats.entity_tags_deleted,
stats.external_ids_deleted,
stats.track_artists_deleted,
));
Ok(())
}
impl MissingTrack {
fn from_row(row: TrackFileRow, reason: MissingReason) -> Self {
Self {
track_id: row.track_id,
track_title: row.track_title,
release_id: row.release_id,
release_title: row.release_title,
media_file_id: row.media_file_id,
file_path: row.file_path,
reason,
}
}
}
impl std::fmt::Display for MissingReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingMediaRow => f.write_str("missing media_file row"),
Self::InvalidMediaType(file_type) => write!(f, "invalid media_file type {file_type:?}"),
Self::EmptyPath => f.write_str("empty media file path"),
Self::MissingFile => f.write_str("audio file not found on disk"),
Self::NotRegularFile => f.write_str("audio path is not a regular file"),
}
}
}
fn unique_sorted(values: Vec<i64>) -> Vec<i64> {
values
.into_iter()
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
async fn delete_tracks_and_unreferenced_audio_media(
pool: &PgPool,
track_ids: &[i64],
media_file_ids: &[i64],
) -> anyhow::Result<DeleteStats> {
if track_ids.is_empty() {
return Ok(DeleteStats::default());
}
let mut tx = pool.begin().await?;
let mut stats = DeleteStats::default();
stats.playback_states_cleared = sqlx::query(
r#"UPDATE furumusic__playback_state
SET current_track_id = NULL
WHERE current_track_id = ANY($1)"#,
)
.bind(track_ids)
.execute(&mut *tx)
.await?
.rows_affected();
stats.playlist_entries_deleted =
delete_track_rows(&mut tx, "furumusic__playlist_track", track_ids).await?;
stats.likes_deleted =
delete_track_rows(&mut tx, "furumusic__user_liked_track", track_ids).await?;
stats.play_history_deleted =
delete_track_rows(&mut tx, "furumusic__play_history", track_ids).await?;
stats.popularity_history_deleted =
delete_track_rows(&mut tx, "furumusic__track_popularity_history", track_ids).await?;
stats.scrobble_outbox_deleted =
delete_track_rows(&mut tx, "furumusic__lastfm_scrobble_outbox", track_ids).await?;
stats.track_genres_deleted =
delete_track_rows(&mut tx, "furumusic__track_genre", track_ids).await?;
stats.entity_tags_deleted = sqlx::query(
r#"DELETE FROM furumusic__entity_genre_tag
WHERE entity_kind = 'track'
AND entity_id = ANY($1)"#,
)
.bind(track_ids)
.execute(&mut *tx)
.await?
.rows_affected();
stats.external_ids_deleted = sqlx::query(
r#"DELETE FROM furumusic__external_metadata_id
WHERE entity_kind = 'track'
AND entity_id = ANY($1)"#,
)
.bind(track_ids)
.execute(&mut *tx)
.await?
.rows_affected();
stats.track_artists_deleted =
delete_track_rows(&mut tx, "furumusic__track_artist", track_ids).await?;
stats.tracks_deleted = sqlx::query("DELETE FROM furumusic__track WHERE id = ANY($1)")
.bind(track_ids)
.execute(&mut *tx)
.await?
.rows_affected();
if !media_file_ids.is_empty() {
stats.media_files_deleted = sqlx::query(
r#"DELETE FROM furumusic__media_file mf
WHERE mf.id = ANY($1)
AND mf.file_type = 'audio'
AND NOT EXISTS (
SELECT 1
FROM furumusic__track t
WHERE t.audio_file_id = mf.id
OR t.cover_file_id = mf.id
)
AND NOT EXISTS (
SELECT 1
FROM furumusic__release r
WHERE r.cover_file_id = mf.id
)
AND NOT EXISTS (
SELECT 1
FROM furumusic__artist a
WHERE a.image_file_id = mf.id
)
AND NOT EXISTS (
SELECT 1
FROM furumusic__playlist p
WHERE p.cover_file_id = mf.id
)"#,
)
.bind(media_file_ids)
.execute(&mut *tx)
.await?
.rows_affected();
}
tx.commit().await?;
Ok(stats)
}
async fn delete_track_rows(
tx: &mut sqlx::Transaction<'_, sqlx::Postgres>,
table: &str,
track_ids: &[i64],
) -> anyhow::Result<u64> {
let sql = format!("DELETE FROM {table} WHERE track_id = ANY($1)");
Ok(sqlx::query(&sql)
.bind(track_ids)
.execute(&mut **tx)
.await?
.rows_affected())
}
async fn count_empty_releases(pool: &PgPool, release_ids: &[i64]) -> anyhow::Result<i64> {
if release_ids.is_empty() {
return Ok(0);
}
let count = sqlx::query_scalar::<_, i64>(
r#"SELECT COUNT(*)
FROM furumusic__release r
WHERE r.id = ANY($1)
AND NOT EXISTS (
SELECT 1
FROM furumusic__track t
WHERE t.release_id = r.id
)"#,
)
.bind(release_ids)
.fetch_one(pool)
.await?;
Ok(count)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unique_sorted_deduplicates_ids() {
assert_eq!(unique_sorted(vec![3, 1, 3, 2, 1]), vec![1, 2, 3]);
}
#[test]
fn missing_reason_display_is_stable() {
assert_eq!(
MissingReason::InvalidMediaType("cover_art".to_owned()).to_string(),
"invalid media_file type \"cover_art\""
);
assert_eq!(
MissingReason::MissingFile.to_string(),
"audio file not found on disk"
);
}
}
+5 -1
View File
@@ -128,7 +128,11 @@ impl Job for InboxDiscoverJob {
v
}
Err(e) => {
crate::metrics::record_agent_file_hash(hash_start.elapsed(), 0, "error");
crate::metrics::record_agent_file_hash(
hash_start.elapsed(),
0,
"error",
);
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
continue;
}
+12 -2
View File
@@ -494,7 +494,12 @@ async fn process_folder_batch(
.await
{
Ok(Ok(results)) => {
crate::metrics::record_agent_rag("artist", "ok", rag_start.elapsed(), results.len());
crate::metrics::record_agent_rag(
"artist",
"ok",
rag_start.elapsed(),
results.len(),
);
for a in results {
if !all_similar_artists
.iter()
@@ -525,7 +530,12 @@ async fn process_folder_batch(
.await
{
Ok(Ok(results)) => {
crate::metrics::record_agent_rag("release", "ok", rag_start.elapsed(), results.len());
crate::metrics::record_agent_rag(
"release",
"ok",
rag_start.elapsed(),
results.len(),
);
for r in results {
if !all_similar_releases
.iter()
+1
View File
@@ -1,3 +1,4 @@
pub mod archive_cleanup;
pub mod artwork_backfill;
pub mod inbox_discover;
pub mod inbox_process;
+157 -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};
@@ -52,6 +52,7 @@ fn build_registry() -> Arc<JobRegistry> {
registry.register(jobs::inbox_discover::InboxDiscoverJob);
registry.register(jobs::inbox_process::InboxProcessJob);
registry.register(jobs::inbox_process::FileProcessJob);
registry.register(jobs::archive_cleanup::ArchiveCleanupJob);
registry.register(jobs::artwork_backfill::ArtworkBackfillJob);
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
@@ -63,15 +64,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 +172,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 +224,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 +304,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 +341,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()
}
@@ -255,6 +379,15 @@ impl App for FuruApp {
}
};
let (live_config, _) = AppConfig::load_with_db(&db).await;
if !live_config.auth_password_enabled {
metrics::record_auth_attempt("password", "failure", "disabled");
let msg = i18n.t.login_disabled.to_owned();
return login_page_handler(i18n, &config, db, msg)
.await?
.into_response();
}
// Try to authenticate
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
{
@@ -263,23 +396,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?
@@ -301,6 +435,16 @@ impl App for FuruApp {
get(oidc::oidc_callback_handler),
"oidc_callback",
),
Route::with_handler_and_name(
"/auth/mobile/oidc/start",
get(oidc::oidc_mobile_start_handler),
"mobile_oidc_start",
),
Route::with_handler_and_name(
"/auth/mobile/oidc/callback",
get(oidc::oidc_mobile_callback_handler),
"mobile_oidc_callback",
),
])
}
}
+309 -50
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,14 +80,15 @@ 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");
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",
@@ -97,11 +98,15 @@ where
],
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;
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))
}
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 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,
];
}
+608 -4
View File
@@ -4,6 +4,7 @@ use std::sync::LazyLock;
use std::time::Instant;
use cot::db::Database;
use cot::request::extractors::UrlQuery;
use cot::session::Session;
use openidconnect::core::{CoreClient, CoreProviderMetadata};
use openidconnect::{
@@ -54,6 +55,13 @@ const SESSION_NONCE: &str = "oidc_nonce";
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
const SESSION_MOBILE_CSRF_STATE: &str = "mobile_oidc_csrf_state";
const SESSION_MOBILE_NONCE: &str = "mobile_oidc_nonce";
const SESSION_MOBILE_PKCE_VERIFIER: &str = "mobile_oidc_pkce_verifier";
const SESSION_MOBILE_PROVIDER_REDIRECT_URI: &str = "mobile_oidc_provider_redirect_uri";
const SESSION_MOBILE_APP_REDIRECT_URI: &str = "mobile_oidc_app_redirect_uri";
const DEFAULT_MOBILE_REDIRECT_URI: &str = "furumi://auth/callback";
// ---------------------------------------------------------------------------
// Provider cache
// ---------------------------------------------------------------------------
@@ -247,13 +255,23 @@ pub struct OidcCallbackQuery {
state: String,
}
#[derive(Deserialize)]
pub struct MobileOidcStartQuery {
redirect_uri: Option<String>,
}
#[derive(Deserialize)]
pub struct MobileOidcCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
pub async fn oidc_callback_handler(
i18n: I18n,
db: Database,
session: Session,
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
OidcCallbackQuery,
>,
UrlQuery(query): UrlQuery<OidcCallbackQuery>,
) -> cot::Result<cot::response::Response> {
let (config, _) = AppConfig::load_with_db(&db).await;
@@ -430,8 +448,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 +476,297 @@ pub async fn oidc_callback_handler(
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(auth::redirect("/"))
Ok(auth::redirect(&redirect_to))
}
// ---------------------------------------------------------------------------
// Mobile OIDC flow
// ---------------------------------------------------------------------------
pub async fn oidc_mobile_start_handler(
origin: RequestOrigin,
db: Database,
session: Session,
UrlQuery(query): UrlQuery<MobileOidcStartQuery>,
) -> cot::Result<cot::response::Response> {
let Some(app_redirect_uri) = safe_mobile_redirect_uri(query.redirect_uri.as_deref()) else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "bad_redirect_uri");
return Ok(text_response(
cot::http::StatusCode::BAD_REQUEST,
"invalid mobile redirect_uri",
));
};
let (config, _) = AppConfig::load_with_db(&db).await;
if !config.auth_sso_enabled
|| config.oidc_issuer.is_empty()
|| config.oidc_client_id.is_empty()
|| config.oidc_client_secret.is_empty()
{
tracing::warn!("Mobile OIDC start requested but SSO is not configured");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
return Ok(mobile_redirect_error(
&app_redirect_uri,
"sso_not_configured",
));
}
let http = oidc_http_client();
let client = match get_or_refresh_provider(&config, &http).await {
Ok(c) => c,
Err(e) => {
tracing::error!("Mobile OIDC provider error: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
}
};
let provider_redirect_uri = format!("{}/auth/mobile/oidc/callback", origin.0);
let redirect_url = RedirectUrl::new(provider_redirect_uri.clone())
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI: {e}")))?;
let client = client.set_redirect_uri(redirect_url);
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
let (auth_url, csrf_state, nonce) = client
.authorize_url(
openidconnect::AuthenticationFlow::<openidconnect::core::CoreResponseType>::AuthorizationCode,
CsrfToken::new_random,
Nonce::new_random,
)
.add_scope(Scope::new("email".to_string()))
.add_scope(Scope::new("profile".to_string()))
.set_pkce_challenge(pkce_challenge)
.url();
session
.insert(SESSION_MOBILE_CSRF_STATE, csrf_state.secret().clone())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_MOBILE_NONCE, nonce.secret().clone())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_MOBILE_PKCE_VERIFIER, pkce_verifier.secret().clone())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(
SESSION_MOBILE_PROVIDER_REDIRECT_URI,
provider_redirect_uri.clone(),
)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
session
.insert(SESSION_MOBILE_APP_REDIRECT_URI, app_redirect_uri)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
tracing::info!(
auth_url = %auth_url,
provider_redirect_uri = %provider_redirect_uri,
"Mobile OIDC start: redirecting to provider",
);
Ok(auth::redirect(auth_url.as_str()))
}
pub async fn oidc_mobile_callback_handler(
db: Database,
session: Session,
UrlQuery(query): UrlQuery<MobileOidcCallbackQuery>,
) -> cot::Result<cot::response::Response> {
let app_redirect_uri = mobile_app_redirect_uri_from_session(&session).await?;
if query.error.is_some() {
tracing::warn!("Mobile OIDC callback returned provider error");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_denied");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_denied"));
}
let Some(code) = query.code else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_code");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_code"));
};
let Some(state) = query.state else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
};
let saved_csrf: Option<String> = session
.get(SESSION_MOBILE_CSRF_STATE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let saved_nonce: Option<String> = session
.get(SESSION_MOBILE_NONCE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let saved_pkce: Option<String> = session
.get(SESSION_MOBILE_PKCE_VERIFIER)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let provider_redirect_uri: Option<String> = session
.get(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let Some(saved_csrf) = saved_csrf else {
tracing::warn!("Mobile OIDC callback: no CSRF state in session");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
};
if state != saved_csrf {
tracing::warn!("Mobile OIDC callback: CSRF state mismatch");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "csrf");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "csrf"));
}
let Some(nonce_str) = saved_nonce else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_nonce");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_nonce"));
};
let Some(pkce_str) = saved_pkce else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_pkce");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_pkce"));
};
let Some(provider_redirect_uri) = provider_redirect_uri else {
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_redirect_uri");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(
&app_redirect_uri,
"missing_redirect_uri",
));
};
let (config, _) = AppConfig::load_with_db(&db).await;
if !config.auth_sso_enabled
|| config.oidc_issuer.is_empty()
|| config.oidc_client_id.is_empty()
|| config.oidc_client_secret.is_empty()
{
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(
&app_redirect_uri,
"sso_not_configured",
));
}
let http = oidc_http_client();
let client = match get_or_refresh_provider(&config, &http).await {
Ok(c) => c,
Err(e) => {
tracing::error!("Mobile OIDC provider error during callback: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
}
};
let redirect_url = RedirectUrl::new(provider_redirect_uri)
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI from session: {e}")))?;
let client = client.set_redirect_uri(redirect_url);
let token_request = match client.exchange_code(AuthorizationCode::new(code)) {
Ok(req) => req,
Err(e) => {
tracing::error!("Mobile OIDC token endpoint not configured: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_config");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
let token_response = token_request
.set_pkce_verifier(PkceCodeVerifier::new(pkce_str))
.request_async(&http)
.await;
let token_response = match token_response {
Ok(t) => t,
Err(e) => {
tracing::error!("Mobile OIDC token exchange failed: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_exchange");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
use openidconnect::TokenResponse;
let id_token = match token_response.id_token() {
Some(t) => t,
None => {
tracing::error!("Mobile OIDC response missing ID token");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_id_token");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
let nonce = Nonce::new(nonce_str);
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
Ok(c) => c,
Err(e) => {
tracing::error!("Mobile OIDC ID token verification failed: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "id_token_verify");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
let sub = claims.subject().to_string();
let issuer = claims.issuer().to_string();
let email = claims.email().map(|e| e.to_string());
let name = claims
.name()
.and_then(|n| n.get(None))
.map(|n| n.to_string());
let groups = extract_groups_from_jwt(&id_token.to_string());
if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) {
tracing::warn!(
"Mobile OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
config.oidc_user_groups,
config.oidc_admin_groups,
);
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_in_group");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "access_denied"));
}
let user = match provision_user(
&db,
&issuer,
&sub,
email.as_deref(),
name.as_deref(),
&groups,
&config.oidc_admin_groups,
)
.await
{
Ok(u) => u,
Err(e) => {
tracing::error!("Mobile OIDC user provisioning failed: {e}");
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provisioning");
clear_mobile_oidc_session(&session).await?;
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
}
};
let exchange_code = auth::create_mobile_exchange_code(&db, user.id_val())
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
clear_mobile_oidc_session(&session).await?;
crate::metrics::record_auth_attempt("mobile_oidc", "success", "ok");
crate::metrics::record_session_created("mobile_oidc");
Ok(mobile_redirect_success(&app_redirect_uri, &exchange_code))
}
// ---------------------------------------------------------------------------
@@ -611,6 +924,259 @@ fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Respon
Ok(auth::redirect(&format!("/login?error={encoded}")))
}
fn text_response(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
cot::http::Response::builder()
.status(status)
.body(cot::Body::fixed(message.to_owned()))
.expect("valid response")
}
async fn mobile_app_redirect_uri_from_session(session: &Session) -> cot::Result<String> {
let saved: Option<String> = session
.get(SESSION_MOBILE_APP_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(safe_mobile_redirect_uri(saved.as_deref())
.unwrap_or_else(|| DEFAULT_MOBILE_REDIRECT_URI.to_owned()))
}
async fn clear_mobile_oidc_session(session: &Session) -> cot::Result<()> {
let _: Option<String> = session
.remove(SESSION_MOBILE_CSRF_STATE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_MOBILE_NONCE)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_MOBILE_PKCE_VERIFIER)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let _: Option<String> = session
.remove(SESSION_MOBILE_APP_REDIRECT_URI)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Ok(())
}
fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
let value = raw
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(DEFAULT_MOBILE_REDIRECT_URI);
if value.len() > 2048 || value.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
return None;
}
let lower = value.to_ascii_lowercase();
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
return Some(value.to_owned());
}
None
}
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "code", code);
mobile_deep_link_page(
"success",
"Sign-in complete",
"Furumi should open automatically. You can close this window after the app opens.",
None,
&deep_link,
)
}
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
let deep_link = append_query_param(app_redirect_uri, "error", error);
mobile_deep_link_page(
"error",
"Sign-in failed",
"Furumi should open automatically and show the sign-in error. You can close this window after the app opens.",
Some(error),
&deep_link,
)
}
fn mobile_deep_link_page(
state: &str,
title: &str,
message: &str,
detail: Option<&str>,
deep_link: &str,
) -> cot::response::Response {
let state_class = html_escape(state);
let title_html = html_escape(title);
let message_html = html_escape(message);
let detail_html = detail
.map(|value| format!(r#"<p class="detail">Reason: {}</p>"#, html_escape(value)))
.unwrap_or_default();
let deep_link_html = html_escape(deep_link);
let deep_link_js =
serde_json::to_string(deep_link).expect("serializing URL string cannot fail");
let html = format!(
r#"<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title_html}</title>
<style>
:root {{
color-scheme: light dark;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #101114;
color: #f5f2ea;
}}
body {{
min-height: 100vh;
margin: 0;
display: grid;
place-items: center;
padding: 24px;
box-sizing: border-box;
}}
main {{
width: min(420px, 100%);
text-align: center;
}}
.mark {{
width: 54px;
height: 54px;
margin: 0 auto 18px;
border-radius: 999px;
display: grid;
place-items: center;
font-size: 18px;
font-weight: 700;
background: #2f7d52;
color: white;
}}
.mark.error {{
background: #9d3d42;
}}
h1 {{
margin: 0 0 10px;
font-size: 26px;
line-height: 1.15;
letter-spacing: 0;
}}
p {{
margin: 0;
color: #c9c2b7;
font-size: 15px;
line-height: 1.55;
}}
.detail {{
margin-top: 12px;
color: #f1b3b7;
overflow-wrap: anywhere;
}}
a {{
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
margin-top: 24px;
padding: 0 18px;
border-radius: 8px;
background: #e8d8a8;
color: #17150f;
font-weight: 700;
text-decoration: none;
}}
.hint {{
margin-top: 14px;
font-size: 13px;
color: #89847c;
}}
</style>
</head>
<body>
<main>
<div class="mark {state_class}" aria-hidden="true">{mark}</div>
<h1>{title_html}</h1>
<p>{message_html}</p>
{detail_html}
<a href="{deep_link_html}">Open Furumi</a>
<p class="hint">If nothing happens, use the button above.</p>
</main>
<script>
const deepLink = {deep_link_js};
window.setTimeout(() => {{
window.location.href = deepLink;
}}, 100);
window.setTimeout(() => {{
window.close();
}}, 1800);
</script>
</body>
</html>"#,
mark = if state == "error" { "!" } else { "OK" }
);
cot::http::Response::builder()
.status(cot::http::StatusCode::OK)
.header(cot::http::header::CONTENT_TYPE, "text/html; charset=utf-8")
.header(cot::http::header::CACHE_CONTROL, "no-store")
.body(cot::Body::fixed(html))
.expect("valid response")
}
fn append_query_param(uri: &str, key: &str, value: &str) -> String {
let (base, fragment) = uri.split_once('#').unwrap_or((uri, ""));
let separator = if base.contains('?') { '&' } else { '?' };
let mut out = format!("{base}{separator}{key}={}", urlencoded(value));
if !fragment.is_empty() {
out.push('#');
out.push_str(fragment);
}
out
}
fn html_escape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'&' => out.push_str("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
'\'' => out.push_str("&#39;"),
_ => out.push(ch),
}
}
out
}
fn extract_groups_from_jwt(token: &str) -> Vec<String> {
use base64::Engine;
let Some(payload_b64) = token.split('.').nth(1) else {
return Vec::new();
};
let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(payload_b64)
.or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload_b64))
else {
return Vec::new();
};
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&payload_bytes) else {
return Vec::new();
};
let Some(arr) = value.get("groups").and_then(|value| value.as_array()) else {
return Vec::new();
};
arr.iter()
.filter_map(|value| value.as_str().map(String::from))
.collect()
}
/// Minimal percent-encoding for query parameter values.
fn urlencoded(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 2);
@@ -627,3 +1193,41 @@ fn urlencoded(s: &str) -> String {
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mobile_oidc_append_query_param_preserves_fragment() {
assert_eq!(
append_query_param("furumi://auth/callback#done", "code", "a b"),
"furumi://auth/callback?code=a%20b#done"
);
assert_eq!(
append_query_param("furumi://auth/callback?desktop=1", "error", "oidc_error"),
"furumi://auth/callback?desktop=1&error=oidc_error"
);
}
#[test]
fn mobile_oidc_html_escape_escapes_page_values() {
assert_eq!(
html_escape(r#"<tag attr="x&y">'text'</tag>"#),
"&lt;tag attr=&quot;x&amp;y&quot;&gt;&#39;text&#39;&lt;/tag&gt;"
);
}
#[test]
fn mobile_oidc_redirect_uri_allows_only_furumi_schemes() {
assert_eq!(
safe_mobile_redirect_uri(Some("furumi://auth/callback")).as_deref(),
Some("furumi://auth/callback")
);
assert_eq!(
safe_mobile_redirect_uri(Some("furumusic://auth/callback")).as_deref(),
Some("furumusic://auth/callback")
);
assert!(safe_mobile_redirect_uri(Some("https://example.com/callback")).is_none());
}
}
+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>,
+675 -172
View File
File diff suppressed because it is too large Load Diff
+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>,
+1
View File
@@ -1372,6 +1372,7 @@ async fn run_scheduled_job(
if !live_config.agent_enabled
&& job_name != "lastfm_popularity"
&& job_name != "lastfm_scrobble"
&& job_name != "archive_cleanup"
&& job_name != "artwork_backfill"
{
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
+3
View File
@@ -722,6 +722,9 @@
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(item.track || item.track_id, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([item.track_id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
+450 -4
View File
@@ -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
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,8 @@ document.addEventListener('alpine:init', () => {
_playLocal(track, options = {}) {
this.currentTrack = track;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this._localSourceTrackId = track.id;
this._historyRecorded = false;
this._resetPlaybackTracking();
audio.src = track.stream_url;
@@ -626,6 +855,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 +869,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 +891,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 +912,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 +1003,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 +1093,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;
@@ -874,6 +1139,7 @@ document.addEventListener('alpine:init', () => {
_mirrorRemoteTrack(track, playing, positionSeconds = null) {
if (!track) return;
this.currentTrack = track;
Alpine.store('queue')?.syncCurrentIndexToTrack(track);
this.isPlaying = !!playing;
if (positionSeconds !== null) this.currentTime = Math.max(0, Number(positionSeconds || 0));
this.duration = Number(track.duration_seconds || this.duration || 0);
@@ -894,6 +1160,7 @@ document.addEventListener('alpine:init', () => {
const track = state.track || queue?.tracks?.[queue.currentIndex] || null;
if (track) {
this.currentTrack = track;
queue?.syncCurrentIndexToTrack(track);
}
this.shuffle = !!state.shuffle;
this.repeatMode = state.repeat_mode || 'off';
@@ -906,8 +1173,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 +1362,8 @@ document.addEventListener('alpine:init', () => {
: tracks[idx];
if (currentTrack) {
this.currentTrack = currentTrack;
queue.syncCurrentIndexToTrack(currentTrack);
this._localSourceTrackId = currentTrack.id;
this._historyRecorded = false;
this._resetPlaybackTracking();
audio.src = currentTrack.stream_url;
@@ -1177,6 +1495,7 @@ document.addEventListener('alpine:init', () => {
jamUsers: [],
jamSelectedUsers: [],
jamSearching: false,
jamLocalPlayback: false,
remoteHintVisible: false,
remoteHintDeviceName: '',
_remoteHintShown: false,
@@ -1184,6 +1503,7 @@ document.addEventListener('alpine:init', () => {
_pollTimer: null,
_jamSearchTimer: null,
_stateRefreshTick: 0,
_lastPlaybackState: null,
init() {
this.id = this._ensureId();
@@ -1239,6 +1559,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 +1568,11 @@ document.addEventListener('alpine:init', () => {
}
if (player && !this.isActive()) {
if (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 +1582,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 +1594,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 +1680,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 +1920,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 +1954,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');
},
});
@@ -1613,6 +1980,56 @@ document.addEventListener('alpine:init', () => {
return this.tracks.slice(start, start + limit);
},
effectiveCurrentIndex() {
const currentTrack = Alpine.store('player')?.currentTrack || null;
if (currentTrack?.id) {
return this.tracks.findIndex(track => Number(track?.id) === Number(currentTrack.id));
}
if (!this.tracks.length) return -1;
return Math.max(0, Math.min(Number(this.currentIndex || 0), this.tracks.length - 1));
},
queueItemState(index) {
const current = this.effectiveCurrentIndex();
if (current < 0) return 'upcoming';
if (index < current) return 'played';
if (index === current) return 'current';
return 'upcoming';
},
displayItems() {
const current = this.effectiveCurrentIndex();
const playerTrack = Alpine.store('player')?.currentTrack || null;
const items = this.tracks.map((track, index) => ({
track,
index,
key: `${index}-${track?.id || 'track'}`,
state: current >= 0
? (index < current ? 'played' : (index === current ? 'current' : 'upcoming'))
: 'upcoming',
synthetic: false,
}));
if (playerTrack?.id && current < 0) {
items.unshift({
track: playerTrack,
index: -1,
key: `current-${playerTrack.id}`,
state: 'current',
synthetic: true,
});
}
return items;
},
syncCurrentIndexToTrack(track) {
if (!track?.id || !this.tracks.length) return -1;
const index = this.tracks.findIndex(item => Number(item?.id) === Number(track.id));
if (index >= 0) this.currentIndex = index;
return index;
},
addToEnd(tracks) {
const items = this._tracksForQueueAdd(tracks);
if (!items.length) return;
@@ -1881,6 +2298,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 +2377,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 +2755,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}`);
+84 -49
View File
@@ -411,17 +411,6 @@
<template x-if="!artist.image_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
</template>
<button class="artist-follow-card-btn"
:class="{ followed: $store.follows.has(artist.id) }"
@click.stop="$store.follows.toggle(artist.id)"
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
</svg>
</button>
</div>
<div class="search-artist-name" x-text="artist.name"></div>
</div>
@@ -504,6 +493,9 @@
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
@@ -725,6 +717,9 @@
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
@@ -780,6 +775,7 @@
</div>
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
<div class="release-actions">
<div class="release-action-row release-meta-actions">
<button class="release-action-btn secondary"
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
:title="$store.library.releaseInfo($store.library.currentRelease)"
@@ -787,6 +783,15 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="16" x2="12" y2="12"/><line x1="12" y1="8" x2="12.01" y2="8"/></svg>
{{ t.player_info }}
</button>
<button class="release-action-btn secondary release-share-btn"
@click.stop="$store.sharing.copyRelease($store.library.currentRelease, $event.currentTarget)"
title="{{ t.player_share }}"
aria-label="{{ t.player_share }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
{{ t.player_share }}
</button>
</div>
<div class="release-action-row release-playback-actions">
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
{{ t.player_play }}
@@ -802,6 +807,7 @@
</div>
</div>
</div>
</div>
<!-- Track list -->
<div class="track-list-header">
<span>#</span>
@@ -812,7 +818,8 @@
</div>
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
<div class="track-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
:data-shared-track-id="track.id"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, 'shared-target': $store.sharing.isSharedTrack(track) }"
@dblclick="$store.queue.playRelease([track], 0)">
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
<div class="track-info">
@@ -846,6 +853,9 @@
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
@@ -919,6 +929,9 @@
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button>
<button class="track-action-btn track-share-btn" @click.stop="$store.sharing.copyTrack(track, $event.currentTarget)" title="{{ t.player_share_track }}" aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="track-action-btn playlist-add-btn" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
</button>
@@ -938,45 +951,51 @@
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
<div class="queue-header">
<h3>{{ t.player_queue }}</h3>
<div class="queue-header-actions">
<button class="queue-share-btn" @click="$store.sharing.copyQueue($event.currentTarget)" :disabled="$store.queue.tracks.length === 0" title="{{ t.player_share_queue }}" aria-label="{{ t.player_share_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
<button class="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
</div>
</div>
<div class="queue-tracks">
<template x-if="$store.queue.tracks.length === 0">
<template x-if="$store.queue.displayItems().length === 0">
<div class="empty-state">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
<p>{{ t.player_queue_empty }}</p>
</div>
</template>
<template x-for="(track, idx) in $store.queue.tracks" :key="idx + '-' + track.id">
<template x-for="item in $store.queue.displayItems()" :key="item.key">
<div class="queue-track"
:data-queue-index="idx"
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx, 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }"
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''"
@click="$store.queue.playFromIndex(idx)"
draggable="true"
@dragstart="$store.queue._dragIdx = idx; $event.dataTransfer.effectAllowed = 'move'"
:data-queue-index="item.index"
:class="{ active: item.state === 'current', current: item.state === 'current', played: item.state === 'played', dragging: $store.queue._dragIdx === item.index, synthetic: item.synthetic, 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
:style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)"
:draggable="!item.synthetic"
@dragstart="if (item.synthetic) { $event.preventDefault(); } else { $store.queue._dragIdx = item.index; $event.dataTransfer.effectAllowed = 'move'; }"
@dragend="$store.queue._dragIdx = null; document.querySelectorAll('.drag-over').forEach(el => el.classList.remove('drag-over'))"
@dragover.prevent="$event.dataTransfer.dropEffect = 'move'; $event.currentTarget.classList.add('drag-over')"
@dragleave="$event.currentTarget.classList.remove('drag-over')"
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if ($store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, idx); $store.queue._dragIdx = null; }">
@drop.prevent="$event.currentTarget.classList.remove('drag-over'); if (!item.synthetic && $store.queue._dragIdx !== null) { $store.queue.moveTrack($store.queue._dragIdx, item.index); $store.queue._dragIdx = null; }">
<div class="queue-drag-handle"
x-show="!item.synthetic"
@mousedown.stop
@click.stop
@pointerdown.stop="$store.queue.startPointerReorder($event, idx)">
@pointerdown.stop="$store.queue.startPointerReorder($event, item.index)">
<svg viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="6" r="1.5"/><circle cx="15" cy="6" r="1.5"/><circle cx="9" cy="12" r="1.5"/><circle cx="15" cy="12" r="1.5"/><circle cx="9" cy="18" r="1.5"/><circle cx="15" cy="18" r="1.5"/></svg>
</div>
<div class="queue-track-cover">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy">
<template x-if="item.track.cover_url">
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template>
</div>
<div class="queue-track-info">
<div class="queue-track-title" x-text="track.title"></div>
<div class="queue-track-title" x-text="item.track.title"></div>
<div class="queue-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
@@ -986,15 +1005,15 @@
</div>
<div class="queue-track-actions">
<button class="queue-track-remove info-btn popularity-info-btn"
:class="{ 'has-popularity': $store.library.hasPopularity(track), 'no-popularity': !$store.library.hasPopularity(track) }"
:style="$store.library.popularityStyle(track)"
@click.stop="$store.library.openTrackInfo(track)"
:title="$store.library.trackInfoTitle(track)"
:class="{ 'has-popularity': $store.library.hasPopularity(item.track), 'no-popularity': !$store.library.hasPopularity(item.track) }"
:style="$store.library.popularityStyle(item.track)"
@click.stop="$store.library.openTrackInfo(item.track)"
:title="$store.library.trackInfoTitle(item.track)"
aria-label="{{ t.player_track_info }}">
<span x-show="$store.library.hasPopularity(track)" x-text="$store.library.popularityLabel(track)"></span>
<span x-show="!$store.library.hasPopularity(track)" class="info-letter">i</span>
<span x-show="$store.library.hasPopularity(item.track)" x-text="$store.library.popularityLabel(item.track)"></span>
<span x-show="!$store.library.hasPopularity(item.track)" class="info-letter">i</span>
</button>
<button class="queue-track-remove" @click.stop="$store.queue.remove(idx)" title="{{ t.player_remove }}">
<button class="queue-track-remove" x-show="!item.synthetic" @click.stop="$store.queue.remove(item.index)" title="{{ t.player_remove }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
@@ -1015,7 +1034,7 @@
</button>
<div class="player-now-playing">
<template x-if="$store.player.currentTrack">
<div style="display:flex;align-items:center;gap:12px;overflow:hidden">
<div class="player-current-track">
<div class="player-cover"
@click.stop="$store.mobile.openPlayerFullscreen()">
<template x-if="$store.player.currentTrack.cover_url">
@@ -1035,6 +1054,12 @@
aria-label="{{ t.player_like }}">
<svg viewBox="0 0 24 24" :fill="$store.likes.has($store.player.currentTrack.id) ? 'currentColor' : 'none'" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 000-7.78z"/></svg>
</button>
<button class="track-action-btn player-current-share"
@click.stop="$store.sharing.copyTrack($store.player.currentTrack, $event.currentTarget)"
title="{{ t.player_share_track }}"
aria-label="{{ t.player_share_track }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 10.6l6.8-3.9M8.6 13.4l6.8 3.9"/></svg>
</button>
</div>
<div class="player-track-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
@@ -1060,7 +1085,7 @@
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="{{ t.player_shuffle }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 3 21 3 21 8"/><line x1="4" y1="20" x2="21" y2="3"/><polyline points="21 16 21 21 16 21"/><line x1="15" y1="15" x2="21" y2="21"/><line x1="4" y1="4" x2="9" y2="9"/></svg>
</button>
<button class="player-btn" @click="$store.player.prev()" title="{{ t.player_previous }}">
<button class="player-btn player-btn-prev" @click="$store.player.prev()" title="{{ t.player_previous }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
@@ -1071,7 +1096,7 @@
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
</template>
</button>
<button class="player-btn" @click="$store.player.next()" title="{{ t.player_next }}">
<button class="player-btn player-btn-next" @click="$store.player.next()" title="{{ t.player_next }}">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
</button>
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="{{ t.player_repeat }}">
@@ -1085,7 +1110,9 @@
</div>
<div class="player-timeline">
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
<div class="progress-bar" @click="$store.player.seekFromClick($event)">
<div class="progress-bar"
@pointerdown.prevent="$store.player.startProgressDrag($event)"
aria-label="{{ t.player_duration }}">
<div class="progress-bar-fill" :style="'width:' + $store.player.progress + '%'">
<div class="progress-bar-thumb"></div>
</div>
@@ -1222,6 +1249,14 @@
</button>
<div class="jam-create-panel" x-show="$store.devices.jamPanelOpen" x-transition x-cloak>
<div class="jam-panel-title" x-text="$store.devices.jamPanelMode === 'manage' ? 'Invite listeners' : 'Start Jam'"></div>
<label class="jam-local-playback-toggle"
x-show="$store.devices.jamPanelMode === 'manage' && $store.devices.canPlayJamLocally()"
x-cloak>
<input type="checkbox"
:checked="$store.devices.jamLocalPlayback"
@change="$store.devices.setJamLocalPlayback($event.target.checked)">
<span>{{ t.player_jam_play_on_this_device }}</span>
</label>
<div class="jam-selected-users" x-show="$store.devices.jamSelectedUsers.length > 0">
<template x-for="user in $store.devices.jamSelectedUsers" :key="user.id">
<button class="jam-user-chip" @click="$store.devices.removeJamInvitee(user.id)">
@@ -1258,27 +1293,27 @@
</div>
<div class="mobile-expanded-queue">
<div class="mobile-expanded-queue-title">{{ t.player_queue }}</div>
<template x-if="$store.queue.upcoming().length === 0">
<template x-if="$store.queue.displayItems().length === 0">
<div class="mobile-expanded-queue-empty">{{ t.player_queue_empty }}</div>
</template>
<template x-for="(track, idx) in $store.queue.upcoming()" :key="'mobile-expanded-queue-' + track.id + '-' + idx">
<template x-for="item in $store.queue.displayItems()" :key="'mobile-expanded-queue-' + item.key">
<button class="mobile-expanded-queue-row"
:class="{ 'foreign-jam-track': $store.queue.isForeignJamTrack(track) }"
:style="$store.queue.isForeignJamTrack(track) ? $store.queue.contributorStyle(track) : ''"
:class="{ current: item.state === 'current', played: item.state === 'played', 'foreign-jam-track': $store.queue.isForeignJamTrack(item.track) }"
:style="$store.queue.isForeignJamTrack(item.track) ? $store.queue.contributorStyle(item.track) : ''"
type="button"
@click="$store.queue.playFromIndex($store.queue.currentIndex + idx + 1)">
@click="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
<div class="mobile-expanded-queue-cover">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.title" loading="lazy">
<template x-if="item.track.cover_url">
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<template x-if="!item.track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
</template>
</div>
<div class="mobile-expanded-queue-info">
<div class="mobile-expanded-queue-name" x-text="track.title"></div>
<div class="mobile-expanded-queue-name" x-text="item.track.title"></div>
<div class="mobile-expanded-queue-artist">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(item.track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<span x-text="artist.label"></span>
@@ -1286,7 +1321,7 @@
</template>
</div>
</div>
<span class="mobile-expanded-queue-time" x-text="formatTime(track.duration_seconds)"></span>
<span class="mobile-expanded-queue-time" x-text="formatTime(item.track.duration_seconds)"></span>
</button>
</template>
</div>
+389 -47
View File
@@ -735,6 +735,26 @@ button.user-stat:hover {
.track-row:hover { background: var(--bg-hover); }
.track-row.playing { color: var(--accent); }
.track-row.playing .track-num { color: var(--accent); }
.track-row.shared-target {
background: rgba(29, 185, 84, 0.12);
box-shadow: inset 3px 0 0 var(--accent);
animation: shared-track-pulse 1.2s ease;
}
.track-row.shared-target .track-num,
.track-row.shared-target .track-title {
color: var(--accent);
}
@keyframes shared-track-pulse {
0% {
background: rgba(29, 185, 84, 0.28);
box-shadow: inset 3px 0 0 var(--accent), 0 0 0 1px rgba(29, 185, 84, 0.18);
}
100% {
background: rgba(29, 185, 84, 0.12);
box-shadow: inset 3px 0 0 var(--accent);
}
}
.track-num {
font-size: 14px;
@@ -953,6 +973,14 @@ button.user-stat:hover {
display: flex;
gap: 8px;
margin-top: 12px;
flex-wrap: wrap;
}
.release-action-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.release-action-btn {
@@ -1080,6 +1108,88 @@ button.user-stat:hover {
.queue-header h3 { font-size: 14px; font-weight: 600; }
.queue-header-actions {
display: flex;
align-items: center;
gap: 8px;
}
.queue-share-btn {
width: 30px;
height: 30px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary);
color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.queue-share-btn:hover:not(:disabled) {
color: var(--text-primary);
background: var(--bg-hover);
}
.queue-share-btn:disabled {
opacity: 0.45;
cursor: default;
}
.queue-share-btn svg {
width: 15px;
height: 15px;
}
.track-share-btn.share-copy-flash,
.queue-share-btn.share-copy-flash,
.player-current-share.share-copy-flash,
.release-share-btn.share-copy-flash {
animation: share-copy-flash 0.72s ease;
}
.track-share-btn.share-copy-failed,
.queue-share-btn.share-copy-failed,
.player-current-share.share-copy-failed,
.release-share-btn.share-copy-failed {
animation: share-copy-failed 0.72s ease;
}
@keyframes share-copy-flash {
0% {
background: rgba(29,185,84,0.28);
border-color: rgba(29,185,84,0.52);
color: #c9ffd9;
transform: scale(1);
}
35% {
background: rgba(29,185,84,0.34);
border-color: rgba(29,185,84,0.72);
color: #d9ffe5;
transform: scale(1.08);
}
100% {
background: inherit;
border-color: inherit;
color: inherit;
transform: scale(1);
}
}
@keyframes share-copy-failed {
0%, 100% {
color: inherit;
transform: scale(1);
}
35% {
background: rgba(229,96,96,0.2);
border-color: rgba(229,96,96,0.5);
color: #ffc7c7;
transform: scale(1.05);
}
}
.queue-clear-btn {
background: rgba(229, 96, 96, 0.13);
border: 1px solid rgba(229, 96, 96, 0.18);
@@ -1114,7 +1224,22 @@ button.user-stat:hover {
}
.queue-track:hover { background: var(--bg-hover); }
.queue-track.active { background: var(--bg-active); }
.queue-track.active,
.queue-track.current { background: var(--bg-active); }
.queue-track.played {
color: var(--text-subdued);
opacity: 0.58;
}
.queue-track.played:hover {
opacity: 0.78;
}
.queue-track.played .queue-track-cover {
filter: grayscale(1);
opacity: 0.72;
}
.queue-track.synthetic .queue-drag-handle {
display: none;
}
.queue-track.foreign-jam-track {
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 78%);
}
@@ -1149,7 +1274,9 @@ button.user-stat:hover {
text-overflow: ellipsis;
}
.queue-track.active .queue-track-title { color: var(--accent); }
.queue-track.active .queue-track-title,
.queue-track.current .queue-track-title { color: var(--accent); }
.queue-track.played .queue-track-title { color: var(--text-subdued); }
.queue-track-artist {
font-size: 11px;
@@ -1239,6 +1366,13 @@ button.user-stat:hover {
.player-now-playing > div { min-width: 0; }
.player-current-track {
display: flex;
align-items: center;
gap: 12px;
overflow: hidden;
}
.player-cover {
width: 56px;
height: 56px;
@@ -1282,6 +1416,18 @@ button.user-stat:hover {
flex: 0 0 auto;
}
.player-current-share {
width: 24px;
height: 24px;
padding: 4px;
flex: 0 0 auto;
}
.player-current-share svg {
width: 15px;
height: 15px;
}
.player-track-artist {
font-size: 11px;
color: var(--text-subdued);
@@ -1373,24 +1519,44 @@ button.user-stat:hover {
.progress-bar {
flex: 1;
height: 4px;
background: var(--bg-active);
border-radius: 2px;
height: 28px;
background: transparent;
border-radius: 6px;
cursor: pointer;
position: relative;
touch-action: none;
display: flex;
align-items: center;
}
.progress-bar:hover { height: 6px; }
.progress-bar::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 4px;
border-radius: 2px;
background: var(--bg-active);
transform: translateY(-50%);
}
.progress-bar:hover::before { height: 6px; }
.progress-bar-fill {
height: 100%;
height: 4px;
background: var(--text-primary);
border-radius: 2px;
position: relative;
z-index: 1;
pointer-events: none;
transition: width 0.1s linear;
}
.progress-bar:hover .progress-bar-fill { background: var(--accent); }
.progress-bar:hover .progress-bar-fill {
height: 6px;
background: var(--accent);
}
.progress-bar-thumb {
width: 12px;
@@ -1405,7 +1571,8 @@ button.user-stat:hover {
transition: opacity 0.15s;
}
.progress-bar:hover .progress-bar-thumb { opacity: 1; }
.progress-bar:hover .progress-bar-thumb,
.progress-bar:active .progress-bar-thumb { opacity: 1; }
.player-right {
display: flex;
@@ -1722,6 +1889,34 @@ button.user-stat:hover {
font-weight: 750;
}
.jam-local-playback-toggle {
min-height: 30px;
margin: -1px 0 7px;
padding: 5px 6px;
border-radius: 4px;
background: rgba(82,145,255,0.055);
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.jam-local-playback-toggle:hover {
background: rgba(82,145,255,0.085);
color: var(--text-primary);
}
.jam-local-playback-toggle input {
width: 14px;
height: 14px;
margin: 0;
accent-color: var(--accent);
flex: 0 0 auto;
}
.jam-selected-users {
display: flex;
flex-wrap: wrap;
@@ -3936,7 +4131,7 @@ button.user-stat:hover {
@media (max-width: 900px), (pointer: coarse) and (max-width: 1024px) {
:root {
--player-height: 168px;
--player-height: 214px;
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
}
@@ -4109,12 +4304,13 @@ button.user-stat:hover {
.player-bar {
position: relative;
grid-template-columns: auto minmax(0, 1fr);
grid-template-rows: 62px 58px;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: 62px 58px 44px;
grid-template-areas:
"now now"
"buttons actions";
gap: 4px 10px;
"now"
"buttons"
"actions";
gap: 4px;
align-items: center;
padding: 34px 12px calc(9px + var(--safe-bottom));
touch-action: auto;
@@ -4123,15 +4319,19 @@ button.user-stat:hover {
.player-now-playing {
grid-area: now;
justify-content: center;
text-align: center;
justify-content: stretch;
text-align: left;
min-width: 0;
}
.player-now-playing > div {
flex-direction: row;
justify-content: center;
gap: 10px !important;
.player-bar:not(.mobile-expanded) .player-current-track {
display: grid;
grid-template-columns: 52px minmax(0, 1fr) 30px 30px;
grid-template-rows: repeat(3, auto);
align-items: center;
justify-content: stretch;
column-gap: 10px;
row-gap: 1px;
width: 100%;
max-width: 620px;
margin: 0 auto;
@@ -4144,23 +4344,64 @@ button.user-stat:hover {
cursor: pointer;
}
.player-track-info {
width: min(58vw, 360px);
max-width: 360px;
.player-bar:not(.mobile-expanded) .player-track-info {
display: contents;
}
.player-track-title-row {
justify-content: center;
.player-bar:not(.mobile-expanded) .player-track-title-row {
display: contents;
}
.player-current-like {
display: none;
.player-bar:not(.mobile-expanded) .player-current-like,
.player-bar:not(.mobile-expanded) .player-current-share {
display: flex;
width: 30px;
height: 30px;
flex: 0 0 30px;
padding: 5px;
align-self: center;
justify-self: end;
}
.player-track-title,
.player-track-artist,
.player-track-release {
text-align: center;
.player-bar:not(.mobile-expanded) .player-current-like svg,
.player-bar:not(.mobile-expanded) .player-current-share svg {
width: 17px;
height: 17px;
}
.player-bar:not(.mobile-expanded) .player-track-title,
.player-bar:not(.mobile-expanded) .player-track-artist,
.player-bar:not(.mobile-expanded) .player-track-release {
grid-column: 2;
min-width: 0;
text-align: left;
}
.player-bar:not(.mobile-expanded) .player-cover {
grid-column: 1;
grid-row: 1 / span 3;
}
.player-bar:not(.mobile-expanded) .player-track-title {
grid-row: 1;
}
.player-bar:not(.mobile-expanded) .player-track-artist {
grid-row: 2;
}
.player-bar:not(.mobile-expanded) .player-track-release {
grid-row: 3;
}
.player-bar:not(.mobile-expanded) .player-current-share {
grid-column: 3;
grid-row: 1 / span 3;
}
.player-bar:not(.mobile-expanded) .player-current-like {
grid-column: 4;
grid-row: 1 / span 3;
}
.player-track-release {
@@ -4178,7 +4419,7 @@ button.user-stat:hover {
.player-buttons {
grid-area: buttons;
justify-self: start;
justify-self: center;
gap: 4px;
}
@@ -4193,11 +4434,31 @@ button.user-stat:hover {
padding: 7px;
}
.player-btn-prev {
min-width: 40px;
min-height: 40px;
}
.player-btn-next {
min-width: 50px;
min-height: 50px;
}
.player-btn svg {
width: 22px;
height: 22px;
}
.player-bar:not(.mobile-expanded) .player-btn-prev svg {
width: 28px;
height: 28px;
}
.player-bar:not(.mobile-expanded) .player-btn-next svg {
width: 34px;
height: 34px;
}
.player-btn-play {
width: 56px;
height: 56px;
@@ -4238,7 +4499,12 @@ button.user-stat:hover {
background: rgba(29, 185, 84, 0.18);
}
.player-bar:not(.mobile-expanded) .progress-bar::before {
display: none;
}
.player-bar:not(.mobile-expanded) .progress-bar-fill {
height: 100%;
border-radius: 0;
background: var(--accent);
}
@@ -4267,8 +4533,8 @@ button.user-stat:hover {
.player-version-chip {
display: block;
position: absolute;
right: 12px;
bottom: calc(12px + var(--safe-bottom));
right: 8px;
bottom: calc(2px + var(--safe-bottom));
width: auto;
max-width: none;
margin-top: 0;
@@ -4286,9 +4552,9 @@ button.user-stat:hover {
.player-right {
grid-area: actions;
justify-self: end;
justify-self: stretch;
display: grid;
grid-template-columns: minmax(132px, 1fr) 40px 40px;
grid-template-columns: minmax(0, 1fr) 40px 40px;
align-items: center;
gap: 6px;
width: 100%;
@@ -4297,12 +4563,19 @@ button.user-stat:hover {
.volume-control {
display: grid;
grid-template-columns: 30px minmax(112px, 1fr);
grid-template-columns: 30px minmax(0, 1fr);
align-items: center;
gap: 6px;
justify-self: center;
width: min(80vw, 360px);
min-width: 0;
}
.player-right > .queue-toggle-btn,
.player-right > .device-picker {
justify-self: end;
}
.volume-btn {
min-width: 30px;
min-height: 36px;
@@ -4335,11 +4608,13 @@ button.user-stat:hover {
}
.progress-bar {
height: 6px;
height: 34px;
border-radius: 999px;
}
.progress-bar::before,
.progress-bar-fill {
height: 6px;
border-radius: 999px;
}
@@ -4482,6 +4757,12 @@ button.user-stat:hover {
height: 38px;
}
.player-bar.mobile-expanded .mobile-full-player .player-current-share {
display: flex;
width: 38px;
height: 38px;
}
.player-bar.mobile-expanded .mobile-full-player .player-track-release {
margin-top: 4px;
font-size: 13px;
@@ -4512,11 +4793,26 @@ button.user-stat:hover {
min-height: 56px;
}
.player-bar.mobile-expanded .mobile-full-player .player-btn-prev {
min-width: 52px;
min-height: 52px;
}
.player-bar.mobile-expanded .mobile-full-player .player-btn-next {
min-width: 64px;
min-height: 64px;
}
.player-bar.mobile-expanded .mobile-full-player .player-btn svg {
width: 28px;
height: 28px;
}
.player-bar.mobile-expanded .mobile-full-player .player-btn-next svg {
width: 32px;
height: 32px;
}
.player-bar.mobile-expanded .mobile-full-player .player-btn-play {
width: 72px;
height: 72px;
@@ -4532,10 +4828,15 @@ button.user-stat:hover {
}
.player-bar.mobile-expanded .mobile-full-player .progress-bar {
height: 7px;
height: 36px;
border-radius: 999px;
}
.player-bar.mobile-expanded .mobile-full-player .progress-bar::before,
.player-bar.mobile-expanded .mobile-full-player .progress-bar-fill {
height: 7px;
}
.player-bar.mobile-expanded .mobile-full-player .progress-bar-thumb {
opacity: 1;
}
@@ -4641,6 +4942,28 @@ button.user-stat:hover {
background: var(--bg-hover);
}
.mobile-expanded-queue-row.current {
background: var(--bg-active);
}
.mobile-expanded-queue-row.current .mobile-expanded-queue-name {
color: var(--accent);
}
.mobile-expanded-queue-row.played {
color: var(--text-subdued);
opacity: 0.56;
}
.mobile-expanded-queue-row.played:active {
opacity: 0.74;
}
.mobile-expanded-queue-row.played .mobile-expanded-queue-cover {
filter: grayscale(1);
opacity: 0.72;
}
.mobile-expanded-queue-row.foreign-jam-track {
background: linear-gradient(90deg, var(--jam-contributor-bg, rgba(82,145,255,0.12)), transparent 82%);
}
@@ -4698,7 +5021,7 @@ button.user-stat:hover {
@media (max-width: 560px) {
:root {
--player-height: 170px;
--player-height: 214px;
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
}
@@ -4810,11 +5133,19 @@ button.user-stat:hover {
}
.release-actions {
flex-wrap: wrap;
align-items: flex-start;
flex-direction: column;
gap: 7px;
margin-top: 8px;
}
.release-action-row {
gap: 6px;
}
.release-action-btn {
padding: 8px 12px;
white-space: nowrap;
}
.track-list-header {
@@ -5172,7 +5503,7 @@ button.user-stat:hover {
gap: 8px;
padding-left: 10px;
padding-right: 10px;
grid-template-rows: 60px 58px;
grid-template-rows: 60px 56px 42px;
}
.player-track-title { font-size: 12px; }
@@ -5181,14 +5512,15 @@ button.user-stat:hover {
.player-buttons { gap: 2px; }
.player-right {
grid-template-columns: minmax(68px, 1fr) 34px 34px;
grid-template-columns: minmax(0, 1fr) 34px 34px;
width: 100%;
gap: 3px;
gap: 4px;
}
.volume-control {
grid-template-columns: 22px minmax(44px, 1fr);
gap: 3px;
grid-template-columns: 22px minmax(0, 1fr);
width: min(80vw, 320px);
gap: 4px;
}
.player-version-chip {
@@ -5224,6 +5556,16 @@ button.user-stat:hover {
padding: 7px;
}
.player-btn-prev {
min-width: 40px;
min-height: 40px;
}
.player-btn-next {
min-width: 50px;
min-height: 50px;
}
.player-btn svg {
width: 22px;
height: 22px;