Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0c120c0868 | |||
| d9d0fbb7d1 | |||
| 71d6556ba8 | |||
| 0ac59eb0ca | |||
| 652c6a470d | |||
| 1c54782dd7 | |||
| 8fa06038fe | |||
| 6b69cc0fc0 | |||
| 624cadab64 | |||
| a8756c95de | |||
| 952d11e6f5 | |||
| df16713aa2 | |||
| 3a9240b82c | |||
| d31dce3ece | |||
| 1e1453e465 | |||
| d2a8f301b8 | |||
| 0a4f78acfa | |||
| f716c22f86 | |||
| a1dafaa5f2 | |||
| c244b3d4d8 | |||
| 27ee56c5b7 | |||
| 88b5c7f7d1 | |||
| a60432610f | |||
| e1a4b6267f | |||
| 496c501076 | |||
| dedddc7cd8 | |||
| 97c82b4ba2 | |||
| de7626a6a9 | |||
| fb7d0c7e1a | |||
| c3b70dc16c | |||
| 66bb127d43 | |||
| 1bb5a2f973 | |||
| 8073ac9a97 | |||
| ec7c5c9049 | |||
| d1113effa5 |
Generated
+2
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.2.2"
|
||||
version = "0.4.6"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
@@ -1441,6 +1441,7 @@ dependencies = [
|
||||
"symphonia",
|
||||
"tokio",
|
||||
"tokio-cron-scheduler",
|
||||
"tower",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"uuid",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.2.3"
|
||||
version = "0.4.7"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
@@ -11,6 +11,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
openidconnect = "4.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||
tokio = { version = "1", features = ["sync", "fs", "io-util"] }
|
||||
tower = "0.5"
|
||||
base64 = "0.22"
|
||||
serde_json = "1"
|
||||
tracing = "0.1"
|
||||
|
||||
@@ -227,6 +227,55 @@ impl App for AdminApp {
|
||||
},
|
||||
"admin_v2_reviews_bulk",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/users",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
get(move |session: Session, db: Database,
|
||||
query: UrlQuery<v2::UsersQuery>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
})
|
||||
.await;
|
||||
v2::users(session, db, pg_pool, query.0).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_v2_users",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/users/{id}",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
get(move |session: Session, db: Database, path: Path<PathId>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
})
|
||||
.await;
|
||||
v2::user_detail(session, db, pg_pool, path.0.id).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_v2_user_detail",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/reviews/{id}/approve",
|
||||
{
|
||||
@@ -282,6 +331,58 @@ impl App for AdminApp {
|
||||
},
|
||||
"admin_v2_jobs",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/jobs/metadata_backfill/run-options",
|
||||
cot::router::method::post({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session,
|
||||
db: Database,
|
||||
json: Json<v2::MetadataBackfillRunRequest>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
})
|
||||
.await;
|
||||
v2::run_metadata_backfill(session, db, pg_pool, json).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_v2_metadata_backfill_run_options",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/jobs/artwork_backfill/run-options",
|
||||
cot::router::method::post({
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
move |session: Session,
|
||||
db: Database,
|
||||
json: Json<v2::ArtworkBackfillRunRequest>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
})
|
||||
.await;
|
||||
v2::run_artwork_backfill(session, db, pg_pool, json).await
|
||||
}
|
||||
}
|
||||
}),
|
||||
"admin_v2_artwork_backfill_run_options",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/jobs/{name}/run",
|
||||
cot::router::method::post({
|
||||
@@ -454,6 +555,32 @@ impl App for AdminApp {
|
||||
},
|
||||
"admin_v2_library_item_detail",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/library/tracks/search",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
get(move |session: Session,
|
||||
db: Database,
|
||||
query: UrlQuery<v2::TrackSearchQuery>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
})
|
||||
.await;
|
||||
v2::track_search(session, db, pg_pool, query.0).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_v2_library_tracks_search",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/library/item/image",
|
||||
{
|
||||
@@ -1223,6 +1350,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
|
||||
}
|
||||
}
|
||||
|
||||
+1418
-10
File diff suppressed because it is too large
Load Diff
@@ -1402,6 +1402,8 @@ pub struct MetadataBackfillForm {
|
||||
audio_sample_rate: Option<String>,
|
||||
audio_bit_depth: Option<String>,
|
||||
duration_seconds: Option<String>,
|
||||
local_genres: Option<String>,
|
||||
lastfm_tags: Option<String>,
|
||||
mode: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1468,6 +1470,9 @@ pub async fn metadata_backfill_run(
|
||||
audio_sample_rate: data.audio_sample_rate.is_some(),
|
||||
audio_bit_depth: data.audio_bit_depth.is_some(),
|
||||
duration_seconds: data.duration_seconds.is_some(),
|
||||
local_genres: data.local_genres.is_some(),
|
||||
lastfm_tags: data.lastfm_tags.is_some(),
|
||||
musicbrainz_tags: true,
|
||||
overwrite: data.mode.as_deref() == Some("overwrite"),
|
||||
};
|
||||
|
||||
|
||||
+13
-2
@@ -128,13 +128,21 @@ pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<C
|
||||
match tokio::fs::read(img_path).await {
|
||||
Ok(data) if !data.is_empty() => {
|
||||
let mime = mime_for_image(img_path);
|
||||
crate::metrics::record_agent_cover_lookup("folder", "ok", data.len());
|
||||
return Some(CoverImage {
|
||||
data,
|
||||
mime_type: mime,
|
||||
source: CoverSource::FolderFile(img_path.clone()),
|
||||
});
|
||||
}
|
||||
_ => continue,
|
||||
Ok(_) => {
|
||||
crate::metrics::record_agent_cover_lookup("folder", "empty", 0);
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
crate::metrics::record_agent_cover_lookup("folder", "error", 0);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,10 +151,12 @@ pub async fn find_best_cover(folder: &Path, audio_files: &[PathBuf]) -> Option<C
|
||||
let path = audio_path.to_path_buf();
|
||||
let result = tokio::task::spawn_blocking(move || extract_embedded_cover(&path)).await;
|
||||
if let Ok(Some(cover)) = result {
|
||||
crate::metrics::record_agent_cover_lookup("embedded", "ok", cover.data.len());
|
||||
return Some(cover);
|
||||
}
|
||||
}
|
||||
|
||||
crate::metrics::record_agent_cover_lookup("none", "not_found", 0);
|
||||
None
|
||||
}
|
||||
|
||||
@@ -370,7 +380,8 @@ pub async fn save_cover_to_storage(
|
||||
}
|
||||
|
||||
let ext = extension_for_mime(&cover.mime_type);
|
||||
let filename = format!("cover.{ext}");
|
||||
let hash_prefix: String = hash.chars().take(12).collect();
|
||||
let filename = format!("cover-{hash_prefix}.{ext}");
|
||||
|
||||
let artist_dir = sanitize_dir_name(artist_name);
|
||||
let album_dir = sanitize_dir_name(release_title);
|
||||
|
||||
@@ -74,6 +74,7 @@ fn generate_missing_variants_sync(
|
||||
|
||||
let mut created = 0usize;
|
||||
for variant in variants {
|
||||
let start = std::time::Instant::now();
|
||||
let path = variant_path(original_path, *variant);
|
||||
if path.exists() {
|
||||
continue;
|
||||
@@ -88,12 +89,21 @@ fn generate_missing_variants_sync(
|
||||
.to_rgb8();
|
||||
let mut output = Vec::new();
|
||||
let mut encoder = JpegEncoder::new_with_quality(&mut output, variant.quality);
|
||||
encoder.encode(
|
||||
let result = encoder.encode(
|
||||
&resized,
|
||||
resized.width(),
|
||||
resized.height(),
|
||||
image::ExtendedColorType::Rgb8,
|
||||
)?;
|
||||
);
|
||||
match result {
|
||||
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());
|
||||
return Err(err.into());
|
||||
}
|
||||
}
|
||||
std::fs::write(path, output)?;
|
||||
created += 1;
|
||||
}
|
||||
|
||||
+30
-2
@@ -330,6 +330,7 @@ pub async fn normalize_batch(
|
||||
// If over 80% of context limit and more than 1 file, split
|
||||
let limit_80 = context_limit * 80 / 100;
|
||||
if estimated > limit_80 && files.len() > 1 {
|
||||
crate::metrics::record_agent_llm_split("estimated_context");
|
||||
tracing::info!(
|
||||
estimated_tokens = estimated,
|
||||
context_limit,
|
||||
@@ -419,6 +420,7 @@ pub async fn normalize_batch(
|
||||
|| err_str.contains("length")
|
||||
|| err_str.contains("token");
|
||||
if is_context_error {
|
||||
crate::metrics::record_agent_llm_split("context_error");
|
||||
tracing::warn!(
|
||||
file_count = files.len(),
|
||||
"LLM error suggests context overflow, splitting batch: {e}"
|
||||
@@ -466,14 +468,40 @@ pub async fn normalize_batch(
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
Err(e) => {
|
||||
crate::metrics::record_agent_llm(
|
||||
llm_model,
|
||||
"error",
|
||||
std::time::Duration::from_millis(duration_ms),
|
||||
0,
|
||||
0,
|
||||
files.len(),
|
||||
Some(estimated),
|
||||
);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let prompt_tokens = usage.prompt_tokens.unwrap_or(0) as u64;
|
||||
let completion_tokens = usage.completion_tokens.unwrap_or(0) as u64;
|
||||
crate::metrics::record_agent_llm(
|
||||
&resp_model,
|
||||
"ok",
|
||||
std::time::Duration::from_millis(duration_ms),
|
||||
prompt_tokens,
|
||||
completion_tokens,
|
||||
files.len(),
|
||||
Some(estimated),
|
||||
);
|
||||
|
||||
// Parse batch response
|
||||
let results = parse_batch_response(&response_text, &files)?;
|
||||
let results = match parse_batch_response(&response_text, &files) {
|
||||
Ok(results) => results,
|
||||
Err(error) => {
|
||||
crate::metrics::record_agent_llm_parse_failure(&resp_model);
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
Ok(BatchNormalizeResult {
|
||||
results,
|
||||
|
||||
+523
-9
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+592
-6
@@ -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;
|
||||
}
|
||||
@@ -61,6 +64,7 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option<Authen
|
||||
display
|
||||
}
|
||||
};
|
||||
crate::metrics::record_active_user(user.id_val());
|
||||
Some(AuthenticatedUser {
|
||||
id: user.id_val(),
|
||||
name,
|
||||
@@ -68,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(
|
||||
@@ -75,9 +435,11 @@ pub async fn require_admin_or_redirect(
|
||||
db: &Database,
|
||||
) -> Result<AuthenticatedUser, cot::response::Response> {
|
||||
let Some(user) = get_session_user(session, db).await else {
|
||||
crate::metrics::record_authorization_denied("unauthenticated");
|
||||
return Err(redirect("/login"));
|
||||
};
|
||||
if user.role != Role::Admin {
|
||||
crate::metrics::record_authorization_denied("forbidden");
|
||||
return Err("Forbidden"
|
||||
.with_status(cot::http::StatusCode::FORBIDDEN)
|
||||
.into_response()
|
||||
@@ -96,9 +458,47 @@ pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
|
||||
.insert(SESSION_USER_ID, user_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
crate::metrics::record_active_user(user_id);
|
||||
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
|
||||
@@ -117,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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -272,6 +272,8 @@ translations! {
|
||||
// Player UI
|
||||
player_library: "Library" , "Библиотека";
|
||||
player_artists: "Artists" , "Артисты";
|
||||
player_global_library: "Global" , "Global";
|
||||
player_featured_only_artists: "Featured only" , "Только фиты";
|
||||
player_release: "Release" , "Релиз";
|
||||
player_releases: "Releases" , "Релизы";
|
||||
player_tracks: "Tracks" , "Треки";
|
||||
@@ -298,6 +300,7 @@ translations! {
|
||||
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
||||
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
||||
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
||||
player_active_device: "Active device" , "Активный девайс";
|
||||
player_no_results: "No results found" , "Ничего не найдено";
|
||||
player_new_playlist: "New Playlist" , "Новый плейлист";
|
||||
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
|
||||
@@ -346,10 +349,20 @@ translations! {
|
||||
player_lastfm_connect_failed: "Could not open Last.fm connection" , "Не удалось открыть подключение Last.fm";
|
||||
player_lastfm_disconnect_failed: "Could not disconnect Last.fm" , "Не удалось отвязать Last.fm";
|
||||
player_play: "Play" , "Играть";
|
||||
player_listen: "Listen" , "Слушать";
|
||||
player_listen_artist: "Listen to artist" , "Слушать артиста";
|
||||
player_start_radio: "Start radio" , "Запустить радио";
|
||||
player_radio_failed: "Could not start radio" , "Не удалось запустить радио";
|
||||
player_played_at: "Played" , "Прослушано";
|
||||
player_like: "Like" , "Лайк";
|
||||
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" , "Назад";
|
||||
@@ -360,6 +373,7 @@ translations! {
|
||||
player_repeat: "Repeat" , "Повтор";
|
||||
player_volume: "Volume" , "Громкость";
|
||||
player_appears_on: "Appears on" , "Участвует в";
|
||||
player_top_tracks: "Popular tracks" , "Популярные треки";
|
||||
player_albums: "Albums" , "Альбомы";
|
||||
player_eps: "EPs" , "EP";
|
||||
player_singles: "Singles" , "Синглы";
|
||||
@@ -381,7 +395,71 @@ translations! {
|
||||
player_saved_torrents: "Saved torrents" , "Сохранённые торренты";
|
||||
player_refresh: "Refresh" , "Обновить";
|
||||
player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет";
|
||||
player_import: "Import" , "Импорт";
|
||||
player_upload: "Upload" , "Загрузить";
|
||||
player_my_uploads: "My uploads" , "Мои загрузки";
|
||||
player_my_uploaded_tracks: "My uploaded tracks" , "Мои загруженные треки";
|
||||
player_no_uploaded_tracks: "No uploaded tracks yet" , "Загруженных треков пока нет";
|
||||
player_needs_approval: "Needs approval" , "Нужно подтверждение";
|
||||
player_pending_or_failed: "pending or failed" , "ожидают или с ошибкой";
|
||||
player_no_tracks_need_approval: "No tracks need approval" , "Нет треков для подтверждения";
|
||||
player_queued_processing: "Queued / processing" , "В очереди / обработке";
|
||||
player_showing: "Showing" , "Показано";
|
||||
player_status: "Status" , "Статус";
|
||||
player_file: "File" , "Файл";
|
||||
player_created: "Created" , "Создано";
|
||||
player_updated: "Updated" , "Обновлено";
|
||||
player_error: "Error" , "Ошибка";
|
||||
player_pending: "Pending" , "Ожидает";
|
||||
player_artist: "Artist" , "Артист";
|
||||
player_album: "Album" , "Альбом";
|
||||
player_album_artists: "Album artists" , "Артисты альбома";
|
||||
player_featured: "Featured" , "При участии";
|
||||
player_featured_short: "feat." , "уч.";
|
||||
player_track_number: "Track #" , "Трек #";
|
||||
player_disc_number: "Disc #" , "Диск #";
|
||||
player_genre: "Genre" , "Жанр";
|
||||
player_notes: "Notes" , "Заметки";
|
||||
player_type_unchanged: "Type unchanged" , "Тип без изменений";
|
||||
player_visibility_unchanged: "Visibility unchanged" , "Видимость без изменений";
|
||||
player_visible: "Visible" , "Видимый";
|
||||
player_hidden: "Hidden" , "Скрыт";
|
||||
player_no_year: "no year" , "год неизвестен";
|
||||
player_apply: "Apply" , "Применить";
|
||||
player_edit: "Edit" , "Редактировать";
|
||||
player_edit_release: "Edit release" , "Редактировать релиз";
|
||||
player_edit_track: "Edit track" , "Редактировать трек";
|
||||
player_edit_metadata: "Edit metadata" , "Редактировать метаданные";
|
||||
player_metadata: "Metadata" , "Метаданные";
|
||||
player_release_metadata: "Release metadata" , "Метаданные релиза";
|
||||
player_track_metadata: "Track metadata" , "Метаданные трека";
|
||||
player_approve_metadata: "Approve metadata" , "Подтвердить метаданные";
|
||||
player_delete_review: "Delete review" , "Удалить проверку";
|
||||
player_approve: "Approve" , "Подтвердить";
|
||||
player_save_track: "Save track" , "Сохранить трек";
|
||||
player_save_release: "Save release" , "Сохранить релиз";
|
||||
player_artists_placeholder: "Artist, Artist" , "Артист, артист";
|
||||
player_artist_featured_placeholder: "Artist, Featured Artist" , "Артист, приглашённый артист";
|
||||
player_release_type_album: "Album" , "Альбом";
|
||||
player_release_type_single: "Single" , "Сингл";
|
||||
player_release_type_ep: "EP" , "EP";
|
||||
player_release_type_compilation: "Compilation" , "Сборник";
|
||||
player_release_type_mixtape: "Mixtape" , "Микстейп";
|
||||
player_release_type_live: "Live" , "Концерт";
|
||||
player_release_type_soundtrack: "Soundtrack" , "Саундтрек";
|
||||
player_release_type_remix: "Remix" , "Ремикс";
|
||||
player_release_type_demo: "Demo" , "Демо";
|
||||
player_failed_load_uploaded_tracks: "Failed to load uploaded tracks" , "Не удалось загрузить загруженные треки";
|
||||
player_failed_save_track: "Failed to save track" , "Не удалось сохранить трек";
|
||||
player_track_metadata_saved: "Track metadata saved" , "Метаданные трека сохранены";
|
||||
player_failed_save_release: "Failed to save release" , "Не удалось сохранить релиз";
|
||||
player_release_metadata_saved: "Release metadata saved" , "Метаданные релиза сохранены";
|
||||
player_failed_delete_review: "Failed to delete review" , "Не удалось удалить проверку";
|
||||
player_review_deleted: "Review deleted" , "Проверка удалена";
|
||||
player_failed_approve_review: "Failed to approve review" , "Не удалось подтвердить проверку";
|
||||
player_track_approved_imported: "Track approved and imported" , "Трек подтверждён и импортирован";
|
||||
player_failed_update_selected_tracks: "Failed to update selected tracks" , "Не удалось обновить выбранные треки";
|
||||
player_selected_tracks_updated: "Selected tracks updated" , "Выбранные треки обновлены";
|
||||
player_choose_saved_or_add_torrent: "Choose a saved item or upload new files." , "Выберите сохранённый элемент или загрузите новые файлы.";
|
||||
player_local_files: "Local audio files" , "Локальные аудиофайлы";
|
||||
player_torrent_file: "Torrent file" , "Torrent-файл";
|
||||
@@ -409,6 +487,7 @@ translations! {
|
||||
player_seen: "seen" , "видели";
|
||||
player_eta: "eta" , "осталось";
|
||||
player_loading_history: "Loading history..." , "Загрузка истории...";
|
||||
player_loading_more: "Loading more..." , "Загружаю ещё...";
|
||||
player_failed_load_history: "Failed to load history" , "Не удалось загрузить историю";
|
||||
player_total_plays: "total plays" , "прослушиваний всего";
|
||||
player_play_history: "Play history" , "История прослушиваний";
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
+525
-26
@@ -15,12 +15,19 @@ pub struct ArtworkBackfillJob;
|
||||
const LASTFM_REQUEST_DELAY: std::time::Duration = std::time::Duration::from_millis(1200);
|
||||
const MAX_LASTFM_RELEASE_LOOKUPS: i64 = 200;
|
||||
const MAX_LASTFM_ARTIST_LOOKUPS: i64 = 200;
|
||||
const MAX_CAA_RELEASE_LOOKUPS: i64 = 200;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct ArtworkBackfillOptions {
|
||||
pub overwrite_existing: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct ReleaseCandidate {
|
||||
id: i64,
|
||||
title: String,
|
||||
artist_name: Option<String>,
|
||||
track_title: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
@@ -36,6 +43,13 @@ struct ArtworkRefCandidate {
|
||||
file_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct ArtistImageRepairCandidate {
|
||||
id: i64,
|
||||
name: String,
|
||||
media_file_id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LastfmAlbumResponse {
|
||||
album: Option<LastfmImageContainer>,
|
||||
@@ -85,7 +99,10 @@ struct ArtworkStats {
|
||||
broken_release_refs_cleared: u64,
|
||||
broken_track_refs_cleared: u64,
|
||||
broken_artist_refs_cleared: u64,
|
||||
remote_artist_album_fallback_refs_cleared: u64,
|
||||
release_local_assigned: u64,
|
||||
release_caa_assigned: u64,
|
||||
release_caa_not_found: u64,
|
||||
release_lastfm_assigned: u64,
|
||||
release_lastfm_not_found: u64,
|
||||
release_skipped_no_audio: u64,
|
||||
@@ -114,12 +131,37 @@ impl Job for ArtworkBackfillJob {
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||
run_with_options(
|
||||
ctx,
|
||||
log,
|
||||
ArtworkBackfillOptions {
|
||||
overwrite_existing: false,
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_with_options(
|
||||
ctx: &JobContext,
|
||||
log: &mut JobLog,
|
||||
options: ArtworkBackfillOptions,
|
||||
) -> anyhow::Result<()> {
|
||||
let storage_dir = ctx.config.agent_storage_dir.trim();
|
||||
if storage_dir.is_empty() {
|
||||
log.warn("agent_storage_dir is not configured, skipping artwork backfill");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log.info(&format!(
|
||||
"Artwork backfill options: mode={}",
|
||||
if options.overwrite_existing {
|
||||
"overwrite_existing"
|
||||
} else {
|
||||
"missing_only"
|
||||
}
|
||||
));
|
||||
|
||||
let client = Client::builder()
|
||||
.user_agent(format!(
|
||||
"furumusic-artwork-backfill/{}",
|
||||
@@ -127,6 +169,8 @@ impl Job for ArtworkBackfillJob {
|
||||
))
|
||||
.timeout(std::time::Duration::from_secs(20))
|
||||
.build()?;
|
||||
let musicbrainz_client =
|
||||
crate::jobs::musicbrainz::MusicBrainzClient::new("furumusic-artwork-backfill")?;
|
||||
let mut stats = ArtworkStats::default();
|
||||
|
||||
let normalized_paths =
|
||||
@@ -140,25 +184,40 @@ impl Job for ArtworkBackfillJob {
|
||||
}
|
||||
|
||||
repair_missing_artwork_refs(ctx, log, storage_dir, &mut stats).await?;
|
||||
backfill_release_local(ctx, log, storage_dir, &mut stats).await?;
|
||||
repair_remote_artist_album_fallback_refs(ctx, log, &mut stats).await?;
|
||||
backfill_release_local(ctx, log, storage_dir, options, &mut stats).await?;
|
||||
backfill_release_coverartarchive(
|
||||
ctx,
|
||||
log,
|
||||
storage_dir,
|
||||
&musicbrainz_client,
|
||||
options,
|
||||
&mut stats,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let api_key = ctx.config.lastfm_api_key.trim();
|
||||
if api_key.is_empty() {
|
||||
log.warn("lastfm_api_key is not configured; skipping Last.fm artwork fallback");
|
||||
} else {
|
||||
backfill_release_lastfm(ctx, log, storage_dir, api_key, &client, &mut stats).await?;
|
||||
backfill_artist_lastfm(ctx, log, storage_dir, api_key, &client, &mut stats).await?;
|
||||
backfill_release_lastfm(ctx, log, storage_dir, api_key, &client, options, &mut stats)
|
||||
.await?;
|
||||
backfill_artist_lastfm(ctx, log, storage_dir, api_key, &client, options, &mut stats)
|
||||
.await?;
|
||||
}
|
||||
|
||||
backfill_artist_album_fallbacks(ctx, log, &mut stats).await?;
|
||||
backfill_artist_album_fallbacks(ctx, log, options, &mut stats).await?;
|
||||
repair_cover_variants(ctx, log, storage_dir, &mut stats).await?;
|
||||
|
||||
log.info(&format!(
|
||||
"Artwork backfill complete: broken_release_refs_cleared={}, broken_track_refs_cleared={}, broken_artist_refs_cleared={}, release_local_assigned={}, release_lastfm_assigned={}, release_lastfm_not_found={}, release_skipped_no_audio={}, artist_lastfm_assigned={}, artist_lastfm_not_found={}, artist_album_fallback_assigned={}, variants_created={}, variants_unchanged={}, variants_missing_original={}, failed={}",
|
||||
"Artwork backfill complete: broken_release_refs_cleared={}, broken_track_refs_cleared={}, broken_artist_refs_cleared={}, remote_artist_album_fallback_refs_cleared={}, release_local_assigned={}, release_caa_assigned={}, release_caa_not_found={}, release_lastfm_assigned={}, release_lastfm_not_found={}, release_skipped_no_audio={}, artist_lastfm_assigned={}, artist_lastfm_not_found={}, artist_album_fallback_assigned={}, variants_created={}, variants_unchanged={}, variants_missing_original={}, failed={}",
|
||||
stats.broken_release_refs_cleared,
|
||||
stats.broken_track_refs_cleared,
|
||||
stats.broken_artist_refs_cleared,
|
||||
stats.remote_artist_album_fallback_refs_cleared,
|
||||
stats.release_local_assigned,
|
||||
stats.release_caa_assigned,
|
||||
stats.release_caa_not_found,
|
||||
stats.release_lastfm_assigned,
|
||||
stats.release_lastfm_not_found,
|
||||
stats.release_skipped_no_audio,
|
||||
@@ -172,7 +231,6 @@ impl Job for ArtworkBackfillJob {
|
||||
));
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn repair_missing_artwork_refs(
|
||||
ctx: &JobContext,
|
||||
@@ -186,6 +244,71 @@ async fn repair_missing_artwork_refs(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn repair_remote_artist_album_fallback_refs(
|
||||
ctx: &JobContext,
|
||||
log: &mut JobLog,
|
||||
stats: &mut ArtworkStats,
|
||||
) -> anyhow::Result<()> {
|
||||
let rows = sqlx::query_as::<_, ArtistImageRepairCandidate>(
|
||||
r#"SELECT DISTINCT a.id,
|
||||
a.name::text AS name,
|
||||
a.image_file_id AS media_file_id
|
||||
FROM furumusic__artist a
|
||||
JOIN furumusic__media_file mf ON mf.id = a.image_file_id
|
||||
WHERE a.image_file_id IS NOT NULL
|
||||
AND a.is_hidden = false
|
||||
AND mf.file_path NOT LIKE '%/__artist_image__/%'
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM furumusic__release r
|
||||
JOIN furumusic__artwork_lookup_state s
|
||||
ON s.entity_kind = 'release'
|
||||
AND s.entity_id = r.id
|
||||
AND s.source IN ('lastfm', 'coverartarchive')
|
||||
AND s.status = 'found'
|
||||
WHERE r.cover_file_id = a.image_file_id
|
||||
AND r.is_hidden = false
|
||||
)
|
||||
ORDER BY a.id"#,
|
||||
)
|
||||
.fetch_all(&ctx.pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log.info(&format!(
|
||||
"Artist fallback repair pass: clearing {} remote release cover fallback(s)",
|
||||
rows.len()
|
||||
));
|
||||
|
||||
for row in rows {
|
||||
let result = sqlx::query(
|
||||
r#"UPDATE furumusic__artist
|
||||
SET image_file_id = NULL,
|
||||
updated_at = $3
|
||||
WHERE id = $1
|
||||
AND image_file_id = $2"#,
|
||||
)
|
||||
.bind(row.id)
|
||||
.bind(row.media_file_id)
|
||||
.bind(now_iso())
|
||||
.execute(&ctx.pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() > 0 {
|
||||
stats.remote_artist_album_fallback_refs_cleared += 1;
|
||||
log.warn(&format!(
|
||||
"Artist {} \"{}\": cleared remote release cover fallback (media_file_id={})",
|
||||
row.id, row.name, row.media_file_id
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn repair_missing_release_cover_refs(
|
||||
ctx: &JobContext,
|
||||
log: &mut JobLog,
|
||||
@@ -360,6 +483,7 @@ async fn backfill_release_local(
|
||||
ctx: &JobContext,
|
||||
log: &mut JobLog,
|
||||
storage_dir: &str,
|
||||
options: ArtworkBackfillOptions,
|
||||
stats: &mut ArtworkStats,
|
||||
) -> anyhow::Result<()> {
|
||||
let releases = sqlx::query_as::<_, ReleaseCandidate>(
|
||||
@@ -372,17 +496,26 @@ async fn backfill_release_local(
|
||||
WHERE ra.release_id = r.id
|
||||
ORDER BY ra.position
|
||||
LIMIT 1
|
||||
) AS artist_name
|
||||
) AS artist_name,
|
||||
(
|
||||
SELECT t.title::text
|
||||
FROM furumusic__track t
|
||||
WHERE t.release_id = r.id
|
||||
AND t.is_hidden = false
|
||||
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
|
||||
LIMIT 1
|
||||
) AS track_title
|
||||
FROM furumusic__release r
|
||||
WHERE r.cover_file_id IS NULL
|
||||
WHERE ($1 OR r.cover_file_id IS NULL)
|
||||
AND r.is_hidden = false
|
||||
ORDER BY r.id"#,
|
||||
)
|
||||
.bind(options.overwrite_existing)
|
||||
.fetch_all(&ctx.pool)
|
||||
.await?;
|
||||
|
||||
if releases.is_empty() {
|
||||
log.info("Release local artwork pass: all visible releases already have covers");
|
||||
log.info("Release local artwork pass: no eligible releases need local cover lookup");
|
||||
return Ok(());
|
||||
}
|
||||
log.info(&format!(
|
||||
@@ -451,7 +584,7 @@ async fn backfill_release_local(
|
||||
.await
|
||||
{
|
||||
Ok(cover_file_id) => {
|
||||
cover_art::assign_cover_to_release(&ctx.pool, release.id, cover_file_id).await?;
|
||||
assign_cover_to_release(&ctx.pool, release.id, cover_file_id, options).await?;
|
||||
stats.release_local_assigned += 1;
|
||||
log.info(&format!(
|
||||
"Release {} \"{}\": assigned local cover from {source_desc}",
|
||||
@@ -471,12 +604,12 @@ async fn backfill_release_local(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backfill_release_lastfm(
|
||||
async fn backfill_release_coverartarchive(
|
||||
ctx: &JobContext,
|
||||
log: &mut JobLog,
|
||||
storage_dir: &str,
|
||||
api_key: &str,
|
||||
client: &Client,
|
||||
client: &crate::jobs::musicbrainz::MusicBrainzClient,
|
||||
options: ArtworkBackfillOptions,
|
||||
stats: &mut ArtworkStats,
|
||||
) -> anyhow::Result<()> {
|
||||
let failed_cutoff = cutoff_iso(1);
|
||||
@@ -502,25 +635,285 @@ async fn backfill_release_lastfm(
|
||||
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, ta.position
|
||||
LIMIT 1
|
||||
)
|
||||
) AS artist_name
|
||||
) AS artist_name,
|
||||
(
|
||||
SELECT t.title::text
|
||||
FROM furumusic__track t
|
||||
WHERE t.release_id = r.id
|
||||
AND t.is_hidden = false
|
||||
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
|
||||
LIMIT 1
|
||||
) AS track_title
|
||||
FROM furumusic__release r
|
||||
LEFT JOIN furumusic__artwork_lookup_state s
|
||||
ON s.entity_kind = 'release'
|
||||
AND s.entity_id = r.id
|
||||
AND s.source = 'lastfm'
|
||||
WHERE r.cover_file_id IS NULL
|
||||
AND s.source = 'coverartarchive'
|
||||
WHERE ($3 OR r.cover_file_id IS NULL)
|
||||
AND r.is_hidden = false
|
||||
AND (
|
||||
s.entity_id IS NULL
|
||||
$3
|
||||
OR s.entity_id IS NULL
|
||||
OR s.status = 'failed' AND s.last_attempt_at < $1
|
||||
OR s.status = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
|
||||
OR s.status = 'found' AND s.last_attempt_at < $1
|
||||
)
|
||||
ORDER BY s.last_attempt_at NULLS FIRST, r.id
|
||||
LIMIT $3"#,
|
||||
LIMIT $4"#,
|
||||
)
|
||||
.bind(&failed_cutoff)
|
||||
.bind(¬_found_cutoff)
|
||||
.bind(options.overwrite_existing)
|
||||
.bind(MAX_CAA_RELEASE_LOOKUPS)
|
||||
.fetch_all(&ctx.pool)
|
||||
.await?;
|
||||
|
||||
if releases.is_empty() {
|
||||
log.info("Release Cover Art Archive pass: no eligible releases need lookup");
|
||||
return Ok(());
|
||||
}
|
||||
log.info(&format!(
|
||||
"Release Cover Art Archive pass: looking up {} release(s)",
|
||||
releases.len()
|
||||
));
|
||||
|
||||
for (index, release) in releases.iter().enumerate() {
|
||||
let Some(artist_name) = release
|
||||
.artist_name
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
else {
|
||||
stats.release_caa_not_found += 1;
|
||||
record_coverartarchive_lookup_state(
|
||||
&ctx.pool,
|
||||
"release",
|
||||
release.id,
|
||||
"not_found",
|
||||
Some("release has no primary artist for MusicBrainz lookup"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
continue;
|
||||
};
|
||||
|
||||
log.info(&format!(
|
||||
"Release Cover Art Archive {}/{}: release {} \"{}\" by \"{}\"",
|
||||
index + 1,
|
||||
releases.len(),
|
||||
release.id,
|
||||
release.title,
|
||||
artist_name
|
||||
));
|
||||
|
||||
let release_mbid = crate::jobs::musicbrainz::load_or_search_release_mbid(
|
||||
&ctx.pool,
|
||||
client,
|
||||
release.id,
|
||||
artist_name,
|
||||
&release.title,
|
||||
release.track_title.as_deref(),
|
||||
)
|
||||
.await;
|
||||
let (release_mbid, release_group_mbid) = match release_mbid {
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
stats.failed += 1;
|
||||
record_coverartarchive_lookup_state(
|
||||
&ctx.pool,
|
||||
"release",
|
||||
release.id,
|
||||
"failed",
|
||||
Some(&err.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
log.warn(&format!(
|
||||
"Release {} \"{}\": MusicBrainz lookup for cover art failed: {err}",
|
||||
release.id, release.title
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if release_mbid.is_none() && release_group_mbid.is_none() {
|
||||
stats.release_caa_not_found += 1;
|
||||
record_coverartarchive_lookup_state(
|
||||
&ctx.pool,
|
||||
"release",
|
||||
release.id,
|
||||
"not_found",
|
||||
Some("MusicBrainz release match not found"),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
match client
|
||||
.fetch_cover_art_front_url(release_mbid.as_deref(), release_group_mbid.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(Some(image_url)) => {
|
||||
match download_remote_cover(client.http_client(), &image_url).await {
|
||||
Ok(cover) => match cover_art::save_cover_to_storage(
|
||||
&ctx.db,
|
||||
&ctx.pool,
|
||||
storage_dir,
|
||||
artist_name,
|
||||
&release.title,
|
||||
&cover,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(cover_file_id) => {
|
||||
assign_cover_to_release(&ctx.pool, release.id, cover_file_id, options)
|
||||
.await?;
|
||||
record_coverartarchive_lookup_state(
|
||||
&ctx.pool,
|
||||
"release",
|
||||
release.id,
|
||||
"found",
|
||||
None,
|
||||
Some(&image_url),
|
||||
)
|
||||
.await?;
|
||||
stats.release_caa_assigned += 1;
|
||||
log.info(&format!(
|
||||
"Release {} \"{}\": assigned Cover Art Archive cover",
|
||||
release.id, release.title
|
||||
));
|
||||
}
|
||||
Err(err) => {
|
||||
stats.failed += 1;
|
||||
record_coverartarchive_lookup_state(
|
||||
&ctx.pool,
|
||||
"release",
|
||||
release.id,
|
||||
"failed",
|
||||
Some(&err.to_string()),
|
||||
Some(&image_url),
|
||||
)
|
||||
.await?;
|
||||
log.warn(&format!(
|
||||
"Release {} \"{}\": failed to save Cover Art Archive cover: {err}",
|
||||
release.id, release.title
|
||||
));
|
||||
}
|
||||
},
|
||||
Err(err) => {
|
||||
stats.failed += 1;
|
||||
record_coverartarchive_lookup_state(
|
||||
&ctx.pool,
|
||||
"release",
|
||||
release.id,
|
||||
"failed",
|
||||
Some(&err.to_string()),
|
||||
Some(&image_url),
|
||||
)
|
||||
.await?;
|
||||
log.warn(&format!(
|
||||
"Release {} \"{}\": failed to download Cover Art Archive cover: {err}",
|
||||
release.id, release.title
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
stats.release_caa_not_found += 1;
|
||||
record_coverartarchive_lookup_state(
|
||||
&ctx.pool,
|
||||
"release",
|
||||
release.id,
|
||||
"not_found",
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Err(err) => {
|
||||
stats.failed += 1;
|
||||
record_coverartarchive_lookup_state(
|
||||
&ctx.pool,
|
||||
"release",
|
||||
release.id,
|
||||
"failed",
|
||||
Some(&err.to_string()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
log.warn(&format!(
|
||||
"Release {} \"{}\": Cover Art Archive lookup failed: {err}",
|
||||
release.id, release.title
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backfill_release_lastfm(
|
||||
ctx: &JobContext,
|
||||
log: &mut JobLog,
|
||||
storage_dir: &str,
|
||||
api_key: &str,
|
||||
client: &Client,
|
||||
options: ArtworkBackfillOptions,
|
||||
stats: &mut ArtworkStats,
|
||||
) -> anyhow::Result<()> {
|
||||
let failed_cutoff = cutoff_iso(1);
|
||||
let not_found_cutoff = cutoff_iso(30);
|
||||
let releases = sqlx::query_as::<_, ReleaseCandidate>(
|
||||
r#"SELECT r.id,
|
||||
r.title::text AS title,
|
||||
COALESCE(
|
||||
(
|
||||
SELECT a.name::text
|
||||
FROM furumusic__release_artist ra
|
||||
JOIN furumusic__artist a ON a.id = ra.artist_id
|
||||
WHERE ra.release_id = r.id
|
||||
ORDER BY ra.position
|
||||
LIMIT 1
|
||||
),
|
||||
(
|
||||
SELECT a.name::text
|
||||
FROM furumusic__track t
|
||||
JOIN furumusic__track_artist ta ON ta.track_id = t.id
|
||||
JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||
WHERE t.release_id = r.id AND ta.role <> 'featuring'
|
||||
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, ta.position
|
||||
LIMIT 1
|
||||
)
|
||||
) AS artist_name,
|
||||
(
|
||||
SELECT t.title::text
|
||||
FROM furumusic__track t
|
||||
WHERE t.release_id = r.id
|
||||
AND t.is_hidden = false
|
||||
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
|
||||
LIMIT 1
|
||||
) AS track_title
|
||||
FROM furumusic__release r
|
||||
LEFT JOIN furumusic__artwork_lookup_state s
|
||||
ON s.entity_kind = 'release'
|
||||
AND s.entity_id = r.id
|
||||
AND s.source = 'lastfm'
|
||||
WHERE ($3 OR r.cover_file_id IS NULL)
|
||||
AND r.is_hidden = false
|
||||
AND (
|
||||
$3
|
||||
OR s.entity_id IS NULL
|
||||
OR s.status = 'failed' AND s.last_attempt_at < $1
|
||||
OR s.status = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
|
||||
OR s.status = 'found' AND s.last_attempt_at < $1
|
||||
)
|
||||
ORDER BY s.last_attempt_at NULLS FIRST, r.id
|
||||
LIMIT $4"#,
|
||||
)
|
||||
.bind(&failed_cutoff)
|
||||
.bind(¬_found_cutoff)
|
||||
.bind(options.overwrite_existing)
|
||||
.bind(MAX_LASTFM_RELEASE_LOOKUPS)
|
||||
.fetch_all(&ctx.pool)
|
||||
.await?;
|
||||
@@ -580,7 +973,7 @@ async fn backfill_release_lastfm(
|
||||
.await
|
||||
{
|
||||
Ok(cover_file_id) => {
|
||||
cover_art::assign_cover_to_release(&ctx.pool, release.id, cover_file_id)
|
||||
assign_cover_to_release(&ctx.pool, release.id, cover_file_id, options)
|
||||
.await?;
|
||||
record_lookup_state(
|
||||
&ctx.pool,
|
||||
@@ -680,12 +1073,37 @@ async fn backfill_release_lastfm(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn assign_cover_to_release(
|
||||
pool: &sqlx::PgPool,
|
||||
release_id: i64,
|
||||
cover_file_id: i64,
|
||||
options: ArtworkBackfillOptions,
|
||||
) -> anyhow::Result<()> {
|
||||
if options.overwrite_existing {
|
||||
sqlx::query(
|
||||
r#"UPDATE furumusic__release
|
||||
SET cover_file_id = $1,
|
||||
updated_at = $3
|
||||
WHERE id = $2"#,
|
||||
)
|
||||
.bind(cover_file_id)
|
||||
.bind(release_id)
|
||||
.bind(now_iso())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
} else {
|
||||
cover_art::assign_cover_to_release(pool, release_id, cover_file_id).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn backfill_artist_lastfm(
|
||||
ctx: &JobContext,
|
||||
log: &mut JobLog,
|
||||
storage_dir: &str,
|
||||
api_key: &str,
|
||||
client: &Client,
|
||||
options: ArtworkBackfillOptions,
|
||||
stats: &mut ArtworkStats,
|
||||
) -> anyhow::Result<()> {
|
||||
let failed_cutoff = cutoff_iso(1);
|
||||
@@ -699,21 +1117,25 @@ async fn backfill_artist_lastfm(
|
||||
AND s.entity_id = a.id
|
||||
AND s.source = 'lastfm'
|
||||
WHERE (
|
||||
$3
|
||||
OR
|
||||
a.image_file_id IS NULL
|
||||
OR mf.file_path NOT LIKE '%/__artist_image__/%'
|
||||
)
|
||||
AND a.is_hidden = false
|
||||
AND (
|
||||
s.entity_id IS NULL
|
||||
$3
|
||||
OR s.entity_id IS NULL
|
||||
OR s.status = 'failed' AND s.last_attempt_at < $1
|
||||
OR s.status = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
|
||||
OR s.status = 'found' AND s.last_attempt_at < $1
|
||||
)
|
||||
ORDER BY s.last_attempt_at NULLS FIRST, a.id
|
||||
LIMIT $3"#,
|
||||
LIMIT $4"#,
|
||||
)
|
||||
.bind(&failed_cutoff)
|
||||
.bind(¬_found_cutoff)
|
||||
.bind(options.overwrite_existing)
|
||||
.bind(MAX_LASTFM_ARTIST_LOOKUPS)
|
||||
.fetch_all(&ctx.pool)
|
||||
.await?;
|
||||
@@ -755,6 +1177,8 @@ async fn backfill_artist_lastfm(
|
||||
updated_at = $3
|
||||
WHERE id = $2
|
||||
AND (
|
||||
$4
|
||||
OR
|
||||
image_file_id IS NULL
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
@@ -767,6 +1191,7 @@ async fn backfill_artist_lastfm(
|
||||
.bind(image_file_id)
|
||||
.bind(artist.id)
|
||||
.bind(now_iso())
|
||||
.bind(options.overwrite_existing)
|
||||
.execute(&ctx.pool)
|
||||
.await?;
|
||||
record_lookup_state(
|
||||
@@ -826,7 +1251,7 @@ async fn backfill_artist_lastfm(
|
||||
artist.id, artist.name
|
||||
));
|
||||
stats.artist_lastfm_not_found += 1;
|
||||
match assign_artist_album_fallback(ctx, artist.id).await {
|
||||
match assign_artist_album_fallback(ctx, artist.id, options).await {
|
||||
Ok(Some(media_file_id)) => {
|
||||
stats.artist_album_fallback_assigned += 1;
|
||||
log.info(&format!(
|
||||
@@ -892,31 +1317,53 @@ async fn backfill_artist_lastfm(
|
||||
async fn backfill_artist_album_fallbacks(
|
||||
ctx: &JobContext,
|
||||
log: &mut JobLog,
|
||||
options: ArtworkBackfillOptions,
|
||||
stats: &mut ArtworkStats,
|
||||
) -> anyhow::Result<()> {
|
||||
let artists = sqlx::query_as::<_, ArtistCandidate>(
|
||||
r#"SELECT a.id, a.name::text AS name
|
||||
FROM furumusic__artist a
|
||||
WHERE a.image_file_id IS NULL
|
||||
WHERE ($1 OR a.image_file_id IS NULL)
|
||||
AND a.is_hidden = false
|
||||
AND EXISTS (
|
||||
SELECT 1
|
||||
FROM furumusic__release_artist ra
|
||||
JOIN furumusic__release r ON r.id = ra.release_id
|
||||
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
|
||||
WHERE ra.artist_id = a.id
|
||||
AND r.cover_file_id IS NOT NULL
|
||||
AND mf.file_type = 'cover_art'
|
||||
AND r.is_hidden = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM furumusic__artwork_lookup_state s
|
||||
WHERE s.entity_kind = 'release'
|
||||
AND s.entity_id = r.id
|
||||
AND s.source IN ('lastfm', 'coverartarchive')
|
||||
AND s.status = 'found'
|
||||
)
|
||||
UNION
|
||||
SELECT 1
|
||||
FROM furumusic__track_artist ta
|
||||
JOIN furumusic__track t ON t.id = ta.track_id
|
||||
JOIN furumusic__release r ON r.id = t.release_id
|
||||
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
|
||||
WHERE ta.artist_id = a.id
|
||||
AND r.cover_file_id IS NOT NULL
|
||||
AND mf.file_type = 'cover_art'
|
||||
AND r.is_hidden = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM furumusic__artwork_lookup_state s
|
||||
WHERE s.entity_kind = 'release'
|
||||
AND s.entity_id = r.id
|
||||
AND s.source IN ('lastfm', 'coverartarchive')
|
||||
AND s.status = 'found'
|
||||
)
|
||||
)
|
||||
ORDER BY a.id"#,
|
||||
)
|
||||
.bind(options.overwrite_existing)
|
||||
.fetch_all(&ctx.pool)
|
||||
.await?;
|
||||
|
||||
@@ -931,7 +1378,7 @@ async fn backfill_artist_album_fallbacks(
|
||||
));
|
||||
|
||||
for artist in artists {
|
||||
match assign_artist_album_fallback(ctx, artist.id).await {
|
||||
match assign_artist_album_fallback(ctx, artist.id, options).await {
|
||||
Ok(Some(media_file_id)) => {
|
||||
stats.artist_album_fallback_assigned += 1;
|
||||
log.info(&format!(
|
||||
@@ -1016,6 +1463,7 @@ async fn repair_cover_variants(
|
||||
async fn assign_artist_album_fallback(
|
||||
ctx: &JobContext,
|
||||
artist_id: i64,
|
||||
options: ArtworkBackfillOptions,
|
||||
) -> anyhow::Result<Option<i64>> {
|
||||
let media_file_id: Option<i64> = sqlx::query_scalar(
|
||||
r#"SELECT media_file_id
|
||||
@@ -1023,17 +1471,37 @@ async fn assign_artist_album_fallback(
|
||||
SELECT DISTINCT r.cover_file_id AS media_file_id
|
||||
FROM furumusic__release r
|
||||
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
||||
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
|
||||
WHERE ra.artist_id = $1
|
||||
AND r.cover_file_id IS NOT NULL
|
||||
AND mf.file_type = 'cover_art'
|
||||
AND r.is_hidden = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM furumusic__artwork_lookup_state s
|
||||
WHERE s.entity_kind = 'release'
|
||||
AND s.entity_id = r.id
|
||||
AND s.source IN ('lastfm', 'coverartarchive')
|
||||
AND s.status = 'found'
|
||||
)
|
||||
UNION
|
||||
SELECT DISTINCT r.cover_file_id AS media_file_id
|
||||
FROM furumusic__release r
|
||||
JOIN furumusic__track t ON t.release_id = r.id
|
||||
JOIN furumusic__track_artist ta ON ta.track_id = t.id
|
||||
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
|
||||
WHERE ta.artist_id = $1
|
||||
AND r.cover_file_id IS NOT NULL
|
||||
AND mf.file_type = 'cover_art'
|
||||
AND r.is_hidden = false
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM furumusic__artwork_lookup_state s
|
||||
WHERE s.entity_kind = 'release'
|
||||
AND s.entity_id = r.id
|
||||
AND s.source IN ('lastfm', 'coverartarchive')
|
||||
AND s.status = 'found'
|
||||
)
|
||||
) covers
|
||||
ORDER BY random()
|
||||
LIMIT 1"#,
|
||||
@@ -1051,11 +1519,12 @@ async fn assign_artist_album_fallback(
|
||||
SET image_file_id = $1,
|
||||
updated_at = $3
|
||||
WHERE id = $2
|
||||
AND image_file_id IS NULL"#,
|
||||
AND ($4 OR image_file_id IS NULL)"#,
|
||||
)
|
||||
.bind(media_file_id)
|
||||
.bind(artist_id)
|
||||
.bind(now_iso())
|
||||
.bind(options.overwrite_existing)
|
||||
.execute(&ctx.pool)
|
||||
.await?;
|
||||
|
||||
@@ -1324,6 +1793,36 @@ async fn record_lookup_state(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn record_coverartarchive_lookup_state(
|
||||
pool: &sqlx::PgPool,
|
||||
entity_kind: &str,
|
||||
entity_id: i64,
|
||||
status: &str,
|
||||
error: Option<&str>,
|
||||
source_url: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO furumusic__artwork_lookup_state
|
||||
(entity_kind, entity_id, source, status, attempt_count, last_attempt_at, last_error, source_url)
|
||||
VALUES ($1, $2, 'coverartarchive', $3, 1, $4, $5, $6)
|
||||
ON CONFLICT (entity_kind, entity_id, source) DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
attempt_count = furumusic__artwork_lookup_state.attempt_count + 1,
|
||||
last_attempt_at = EXCLUDED.last_attempt_at,
|
||||
last_error = EXCLUDED.last_error,
|
||||
source_url = EXCLUDED.source_url"#,
|
||||
)
|
||||
.bind(entity_kind)
|
||||
.bind(entity_id)
|
||||
.bind(status)
|
||||
.bind(now_iso())
|
||||
.bind(error)
|
||||
.bind(source_url)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn reset_lookup_state(
|
||||
pool: &sqlx::PgPool,
|
||||
entity_kind: &str,
|
||||
@@ -1333,7 +1832,7 @@ async fn reset_lookup_state(
|
||||
r#"DELETE FROM furumusic__artwork_lookup_state
|
||||
WHERE entity_kind = $1
|
||||
AND entity_id = $2
|
||||
AND source = 'lastfm'"#,
|
||||
AND source IN ('lastfm', 'coverartarchive')"#,
|
||||
)
|
||||
.bind(entity_kind)
|
||||
.bind(entity_id)
|
||||
|
||||
@@ -29,6 +29,8 @@ impl Job for InboxDiscoverJob {
|
||||
}
|
||||
|
||||
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||
let run_start = std::time::Instant::now();
|
||||
let run_outcome = "completed";
|
||||
// Prevent overlapping discover runs
|
||||
if DISCOVER_RUNNING
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
@@ -44,6 +46,19 @@ impl Job for InboxDiscoverJob {
|
||||
}
|
||||
}
|
||||
let _guard = Guard;
|
||||
struct MetricsGuard {
|
||||
start: std::time::Instant,
|
||||
outcome: &'static str,
|
||||
}
|
||||
impl Drop for MetricsGuard {
|
||||
fn drop(&mut self) {
|
||||
crate::metrics::record_agent_discover_run(self.outcome, self.start.elapsed());
|
||||
}
|
||||
}
|
||||
let mut metrics_guard = MetricsGuard {
|
||||
start: run_start,
|
||||
outcome: run_outcome,
|
||||
};
|
||||
|
||||
let config = &ctx.config;
|
||||
|
||||
@@ -97,6 +112,7 @@ impl Job for InboxDiscoverJob {
|
||||
|
||||
// Compute SHA-256 hash
|
||||
let path_clone = file_path.to_path_buf();
|
||||
let hash_start = std::time::Instant::now();
|
||||
let (hash, file_size) =
|
||||
match tokio::task::spawn_blocking(move || -> anyhow::Result<(String, i64)> {
|
||||
let data = std::fs::read(&path_clone)?;
|
||||
@@ -107,8 +123,16 @@ impl Job for InboxDiscoverJob {
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(v) => v,
|
||||
Ok(v) => {
|
||||
crate::metrics::record_agent_file_hash(hash_start.elapsed(), v.1, "ok");
|
||||
v
|
||||
}
|
||||
Err(e) => {
|
||||
crate::metrics::record_agent_file_hash(
|
||||
hash_start.elapsed(),
|
||||
0,
|
||||
"error",
|
||||
);
|
||||
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
||||
continue;
|
||||
}
|
||||
@@ -125,13 +149,18 @@ impl Job for InboxDiscoverJob {
|
||||
|
||||
// Extract raw metadata
|
||||
let path_for_meta = file_path.to_path_buf();
|
||||
let metadata_start = std::time::Instant::now();
|
||||
let raw_meta = match tokio::task::spawn_blocking(move || {
|
||||
crate::agent::metadata::extract(&path_for_meta)
|
||||
})
|
||||
.await?
|
||||
{
|
||||
Ok(m) => m,
|
||||
Ok(m) => {
|
||||
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "ok");
|
||||
m
|
||||
}
|
||||
Err(e) => {
|
||||
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "error");
|
||||
log.warn(&format!(
|
||||
"Failed to extract metadata from {}: {e}",
|
||||
file_path.display()
|
||||
@@ -189,6 +218,12 @@ impl Job for InboxDiscoverJob {
|
||||
"Discovered {} new files, skipped {} (hash known), skipped {} (already queued)",
|
||||
discovered, skipped_hash, skipped_existing
|
||||
));
|
||||
crate::metrics::record_agent_discover_files(
|
||||
audio_files.len() as u64,
|
||||
discovered,
|
||||
skipped_hash,
|
||||
skipped_existing,
|
||||
);
|
||||
|
||||
// Trigger inbox_process in background if new files were discovered
|
||||
// and no orchestrator is already running
|
||||
@@ -219,6 +254,7 @@ impl Job for InboxDiscoverJob {
|
||||
}
|
||||
}
|
||||
|
||||
metrics_guard.outcome = run_outcome;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+166
-40
@@ -17,28 +17,99 @@ pub fn is_orchestrator_running() -> bool {
|
||||
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
||||
/// Returns true if the lock was acquired (no other orchestrator is running).
|
||||
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
|
||||
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
||||
struct OrchestratorAdvisoryGuard {
|
||||
conn: Option<sqlx::pool::PoolConnection<sqlx::Postgres>>,
|
||||
}
|
||||
|
||||
impl Drop for OrchestratorAdvisoryGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(mut conn) = self.conn.take() else {
|
||||
return;
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
match sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.fetch_one(pool)
|
||||
.fetch_one(&mut *conn)
|
||||
.await
|
||||
{
|
||||
Ok(acquired) => acquired,
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to acquire advisory lock: {e}");
|
||||
false
|
||||
Ok(true) => tracing::info!("inbox_process: advisory lock released"),
|
||||
Ok(false) => tracing::warn!(
|
||||
"inbox_process: advisory lock was not held by the guard connection"
|
||||
),
|
||||
Err(e) => tracing::error!("inbox_process: failed to release advisory lock: {e}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the PostgreSQL advisory lock for the orchestrator.
|
||||
async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
|
||||
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
|
||||
async fn connection_holds_orchestrator_lock(
|
||||
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||
) -> anyhow::Result<bool> {
|
||||
let holds_lock = sqlx::query_scalar::<_, bool>(
|
||||
r#"
|
||||
SELECT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_locks
|
||||
WHERE locktype = 'advisory'
|
||||
AND pid = pg_backend_pid()
|
||||
AND mode = 'ExclusiveLock'
|
||||
AND granted
|
||||
AND objsubid = 1
|
||||
AND classid::bigint = (($1::bigint >> 32) & 4294967295::bigint)
|
||||
AND objid::bigint = ($1::bigint & 4294967295::bigint)
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.execute(pool)
|
||||
.await;
|
||||
.fetch_one(&mut **conn)
|
||||
.await?;
|
||||
|
||||
Ok(holds_lock)
|
||||
}
|
||||
|
||||
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
||||
///
|
||||
/// The guard owns the same pooled connection that acquired the session-level
|
||||
/// lock. This matters because PostgreSQL session advisory locks must be
|
||||
/// released on the same connection, not just through the same pool.
|
||||
async fn try_acquire_orchestrator_lock(
|
||||
pool: &sqlx::PgPool,
|
||||
) -> anyhow::Result<Option<OrchestratorAdvisoryGuard>> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
|
||||
// Older versions acquired the lock through the pool and could return a
|
||||
// locked idle connection. If we get such a connection, drain that stale
|
||||
// re-entrant lock before taking the new guarded lock.
|
||||
let mut cleaned_stale_locks = 0u8;
|
||||
while connection_holds_orchestrator_lock(&mut conn).await? {
|
||||
let unlocked = sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
cleaned_stale_locks = cleaned_stale_locks.saturating_add(u8::from(unlocked));
|
||||
if cleaned_stale_locks >= 8 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if cleaned_stale_locks > 0 {
|
||||
tracing::warn!(
|
||||
count = cleaned_stale_locks,
|
||||
"inbox_process: released stale advisory lock(s) from an idle pooled connection"
|
||||
);
|
||||
}
|
||||
|
||||
let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||
.fetch_one(&mut *conn)
|
||||
.await?;
|
||||
|
||||
if acquired {
|
||||
Ok(Some(OrchestratorAdvisoryGuard { conn: Some(conn) }))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
||||
@@ -83,10 +154,10 @@ impl Job for InboxProcessJob {
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
log.info(
|
||||
log.warn(
|
||||
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
|
||||
);
|
||||
return Ok(());
|
||||
anyhow::bail!("another inbox_process orchestrator is already running in this process");
|
||||
}
|
||||
struct AtomicGuard;
|
||||
impl Drop for AtomicGuard {
|
||||
@@ -98,27 +169,16 @@ impl Job for InboxProcessJob {
|
||||
let _atomic_guard = AtomicGuard;
|
||||
|
||||
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
|
||||
if !try_acquire_orchestrator_lock(&ctx.pool).await {
|
||||
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
|
||||
return Ok(());
|
||||
let _advisory_guard = match try_acquire_orchestrator_lock(&ctx.pool).await? {
|
||||
Some(guard) => guard,
|
||||
None => {
|
||||
log.warn("Another inbox_process orchestrator holds the advisory lock");
|
||||
anyhow::bail!(
|
||||
"inbox_process advisory lock is held by another database session; no in-process orchestrator is running"
|
||||
);
|
||||
}
|
||||
tracing::info!("inbox_process: advisory lock acquired");
|
||||
let pool_for_unlock = ctx.pool.clone();
|
||||
struct AdvisoryGuard {
|
||||
pool: sqlx::PgPool,
|
||||
}
|
||||
impl Drop for AdvisoryGuard {
|
||||
fn drop(&mut self) {
|
||||
let pool = self.pool.clone();
|
||||
tokio::spawn(async move {
|
||||
release_orchestrator_lock(&pool).await;
|
||||
tracing::info!("inbox_process: advisory lock released");
|
||||
});
|
||||
}
|
||||
}
|
||||
let _advisory_guard = AdvisoryGuard {
|
||||
pool: pool_for_unlock,
|
||||
};
|
||||
tracing::info!("inbox_process: advisory lock acquired");
|
||||
|
||||
let config = Arc::clone(&ctx.config);
|
||||
let mut total_ok = 0u64;
|
||||
@@ -307,12 +367,18 @@ async fn process_folder_batch(
|
||||
|
||||
// Extract metadata (with 60s timeout)
|
||||
let path_for_meta = file_path.to_path_buf();
|
||||
let metadata_start = std::time::Instant::now();
|
||||
let meta_future =
|
||||
tokio::task::spawn_blocking(move || crate::agent::metadata::extract(&path_for_meta));
|
||||
let raw_meta =
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(60), meta_future).await {
|
||||
Ok(Ok(Ok(m))) => m,
|
||||
Ok(Ok(Ok(m))) => {
|
||||
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "ok");
|
||||
m
|
||||
}
|
||||
Ok(Ok(Err(e))) => {
|
||||
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "error");
|
||||
crate::metrics::record_agent_failed("metadata");
|
||||
let msg = format!("{filename}: metadata error: {e}");
|
||||
log.error(&msg);
|
||||
let _ = review.set_failed(db, &msg).await;
|
||||
@@ -320,6 +386,8 @@ async fn process_folder_batch(
|
||||
continue;
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "panic");
|
||||
crate::metrics::record_agent_failed("metadata");
|
||||
let msg = format!("{filename}: metadata panic: {e}");
|
||||
log.error(&msg);
|
||||
let _ = review.set_failed(db, &msg).await;
|
||||
@@ -327,6 +395,8 @@ async fn process_folder_batch(
|
||||
continue;
|
||||
}
|
||||
Err(_) => {
|
||||
crate::metrics::record_agent_metadata(metadata_start.elapsed(), "timeout");
|
||||
crate::metrics::record_agent_failed("metadata");
|
||||
let msg = format!("{filename}: metadata timeout (60s)");
|
||||
log.error(&msg);
|
||||
let _ = review.set_failed(db, &msg).await;
|
||||
@@ -416,6 +486,7 @@ async fn process_folder_batch(
|
||||
// Lookup all unique artist queries
|
||||
let mut all_similar_artists = Vec::new();
|
||||
for q in &artist_queries {
|
||||
let rag_start = std::time::Instant::now();
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
crate::agent::rag::find_similar_artists(pool, q, 5),
|
||||
@@ -423,6 +494,12 @@ async fn process_folder_batch(
|
||||
.await
|
||||
{
|
||||
Ok(Ok(results)) => {
|
||||
crate::metrics::record_agent_rag(
|
||||
"artist",
|
||||
"ok",
|
||||
rag_start.elapsed(),
|
||||
results.len(),
|
||||
);
|
||||
for a in results {
|
||||
if !all_similar_artists
|
||||
.iter()
|
||||
@@ -432,13 +509,20 @@ async fn process_folder_batch(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => log.warn(&format!("RAG artist lookup failed for \"{q}\": {e}")),
|
||||
Err(_) => log.warn(&format!("RAG artist lookup timed out for \"{q}\"")),
|
||||
Ok(Err(e)) => {
|
||||
crate::metrics::record_agent_rag("artist", "error", rag_start.elapsed(), 0);
|
||||
log.warn(&format!("RAG artist lookup failed for \"{q}\": {e}"))
|
||||
}
|
||||
Err(_) => {
|
||||
crate::metrics::record_agent_rag("artist", "timeout", rag_start.elapsed(), 0);
|
||||
log.warn(&format!("RAG artist lookup timed out for \"{q}\""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut all_similar_releases = Vec::new();
|
||||
for q in &album_queries {
|
||||
let rag_start = std::time::Instant::now();
|
||||
match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(30),
|
||||
crate::agent::rag::find_similar_releases(pool, q, 5),
|
||||
@@ -446,6 +530,12 @@ async fn process_folder_batch(
|
||||
.await
|
||||
{
|
||||
Ok(Ok(results)) => {
|
||||
crate::metrics::record_agent_rag(
|
||||
"release",
|
||||
"ok",
|
||||
rag_start.elapsed(),
|
||||
results.len(),
|
||||
);
|
||||
for r in results {
|
||||
if !all_similar_releases
|
||||
.iter()
|
||||
@@ -455,8 +545,14 @@ async fn process_folder_batch(
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => log.warn(&format!("RAG release lookup failed for \"{q}\": {e}")),
|
||||
Err(_) => log.warn(&format!("RAG release lookup timed out for \"{q}\"")),
|
||||
Ok(Err(e)) => {
|
||||
crate::metrics::record_agent_rag("release", "error", rag_start.elapsed(), 0);
|
||||
log.warn(&format!("RAG release lookup failed for \"{q}\": {e}"))
|
||||
}
|
||||
Err(_) => {
|
||||
crate::metrics::record_agent_rag("release", "timeout", rag_start.elapsed(), 0);
|
||||
log.warn(&format!("RAG release lookup timed out for \"{q}\""))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,11 +643,14 @@ async fn process_folder_batch(
|
||||
let batch_result = match llm_result {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
crate::metrics::record_agent_failed("llm");
|
||||
crate::metrics::record_agent_folder_batch("failed", file_count, batch_start.elapsed());
|
||||
let err_msg = format!("Batch LLM call failed: {e}");
|
||||
log.error(&err_msg);
|
||||
// Mark all files as failed
|
||||
for mut p in prepared {
|
||||
let _ = p.review.set_failed(db, &err_msg).await;
|
||||
crate::metrics::record_agent_file_processed("failed", "failed");
|
||||
}
|
||||
let total_fail_count = failed_reviews.len() as u64 + file_count as u64;
|
||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||
@@ -612,6 +711,7 @@ async fn process_folder_batch(
|
||||
|
||||
let result_json = serde_json::to_string(normalized).unwrap_or_default();
|
||||
let confidence = normalized.confidence.unwrap_or(0.0);
|
||||
crate::metrics::observe_agent_confidence(confidence);
|
||||
|
||||
let feat = if normalized.featured_artists.is_empty() {
|
||||
String::new()
|
||||
@@ -655,9 +755,12 @@ async fn process_folder_batch(
|
||||
{
|
||||
Ok(()) => {
|
||||
let _ = p.review.set_auto_approved(db).await;
|
||||
crate::metrics::record_agent_file_processed("ok", "auto_approved");
|
||||
ok_count += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
crate::metrics::record_agent_failed("finalize");
|
||||
crate::metrics::record_agent_file_processed("failed", "failed");
|
||||
let msg = format!("{filename}: finalize failed: {e}");
|
||||
log.error(&msg);
|
||||
let _ = p.review.set_failed(db, &msg).await;
|
||||
@@ -671,6 +774,7 @@ async fn process_folder_batch(
|
||||
)
|
||||
.unwrap();
|
||||
let _ = p.review.save(db).await;
|
||||
crate::metrics::record_agent_file_processed("ok", "pending_review");
|
||||
log.info(&format!(
|
||||
"{filename}: manual review (confidence {confidence} < {})",
|
||||
config.agent_confidence_threshold,
|
||||
@@ -682,9 +786,11 @@ async fn process_folder_batch(
|
||||
let duration_ms = batch_start.elapsed().as_millis() as i64;
|
||||
if fail_count == 0 {
|
||||
let _ = run.set_completed(db, duration_ms, &log.output()).await;
|
||||
crate::metrics::record_agent_folder_batch("completed", file_count, batch_start.elapsed());
|
||||
} else {
|
||||
let msg = format!("{fail_count} file(s) failed");
|
||||
let _ = run.set_failed(db, duration_ms, &log.output(), &msg).await;
|
||||
crate::metrics::record_agent_folder_batch("failed", file_count, batch_start.elapsed());
|
||||
}
|
||||
|
||||
(ok_count, fail_count)
|
||||
@@ -878,6 +984,26 @@ pub async fn finalize_approved(
|
||||
.await;
|
||||
}
|
||||
|
||||
let approved_genre = normalized
|
||||
.genre
|
||||
.as_deref()
|
||||
.or_else(|| context.get("raw_genre").and_then(|v| v.as_str()))
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty());
|
||||
if let Some(genre) = approved_genre {
|
||||
if let Err(err) =
|
||||
crate::jobs::metadata_backfill::save_approved_track_genres(pool, track.id_val(), genre)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
track_id = track.id_val(),
|
||||
genre,
|
||||
error = %err,
|
||||
"failed to save approved track genre metadata"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Cover art: if the release has no cover yet, try to find one
|
||||
if release.cover_file_id.is_none() {
|
||||
let source_folder = Path::new(input_path_str).parent().unwrap_or(Path::new("."));
|
||||
|
||||
+1183
-13
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
||||
pub mod archive_cleanup;
|
||||
pub mod artwork_backfill;
|
||||
pub mod inbox_discover;
|
||||
pub mod inbox_process;
|
||||
pub mod lastfm_popularity;
|
||||
pub mod lastfm_scrobble;
|
||||
pub mod metadata_backfill;
|
||||
pub mod musicbrainz;
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
|
||||
@@ -0,0 +1,587 @@
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use reqwest::{Client, StatusCode};
|
||||
use serde::Deserialize;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const MUSICBRAINZ_BASE_URL: &str = "https://musicbrainz.org/ws/2";
|
||||
const COVER_ART_ARCHIVE_BASE_URL: &str = "https://coverartarchive.org";
|
||||
const MUSICBRAINZ_REQUEST_DELAY: Duration = Duration::from_millis(1100);
|
||||
const MUSICBRAINZ_TAG_LIMIT: usize = 12;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MusicBrainzTag {
|
||||
pub name: String,
|
||||
pub weight: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MusicBrainzArtistMatch {
|
||||
pub mbid: String,
|
||||
pub score: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MusicBrainzReleaseMatch {
|
||||
pub mbid: String,
|
||||
pub release_group_mbid: Option<String>,
|
||||
pub score: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MusicBrainzReleaseTags {
|
||||
pub release_group_mbid: Option<String>,
|
||||
pub tags: Vec<MusicBrainzTag>,
|
||||
}
|
||||
|
||||
pub struct MusicBrainzClient {
|
||||
client: Client,
|
||||
last_musicbrainz_request: Mutex<Option<Instant>>,
|
||||
}
|
||||
|
||||
pub async fn load_external_id(
|
||||
pool: &sqlx::PgPool,
|
||||
entity_kind: &str,
|
||||
entity_id: i64,
|
||||
id_kind: &str,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
let value = sqlx::query_scalar::<_, String>(
|
||||
r#"SELECT external_id::text
|
||||
FROM furumusic__external_metadata_id
|
||||
WHERE entity_kind = $1
|
||||
AND entity_id = $2
|
||||
AND source = 'musicbrainz'
|
||||
AND id_kind = $3
|
||||
LIMIT 1"#,
|
||||
)
|
||||
.bind(entity_kind)
|
||||
.bind(entity_id)
|
||||
.bind(id_kind)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
pub async fn save_external_id(
|
||||
pool: &sqlx::PgPool,
|
||||
entity_kind: &str,
|
||||
entity_id: i64,
|
||||
id_kind: &str,
|
||||
external_id: &str,
|
||||
confidence: f64,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"INSERT INTO furumusic__external_metadata_id
|
||||
(entity_kind, entity_id, source, id_kind, external_id, confidence, updated_at)
|
||||
VALUES ($1, $2, 'musicbrainz', $3, $4, $5, $6)
|
||||
ON CONFLICT (entity_kind, entity_id, source, id_kind) DO UPDATE SET
|
||||
external_id = EXCLUDED.external_id,
|
||||
confidence = EXCLUDED.confidence,
|
||||
updated_at = EXCLUDED.updated_at"#,
|
||||
)
|
||||
.bind(entity_kind)
|
||||
.bind(entity_id)
|
||||
.bind(id_kind)
|
||||
.bind(external_id)
|
||||
.bind(confidence)
|
||||
.bind(now_iso())
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_or_search_release_mbid(
|
||||
pool: &sqlx::PgPool,
|
||||
client: &MusicBrainzClient,
|
||||
release_id: i64,
|
||||
artist_name: &str,
|
||||
release_title: &str,
|
||||
representative_track_title: Option<&str>,
|
||||
) -> anyhow::Result<(Option<String>, Option<String>)> {
|
||||
let release_mbid = load_external_id(pool, "release", release_id, "release").await?;
|
||||
let release_group_mbid = load_external_id(pool, "release", release_id, "release_group").await?;
|
||||
if release_mbid.is_some() || release_group_mbid.is_some() {
|
||||
return Ok((release_mbid, release_group_mbid));
|
||||
}
|
||||
|
||||
let found = match client.search_release(artist_name, release_title).await? {
|
||||
Some(found) => Some(found),
|
||||
None => {
|
||||
if let Some(track_title) =
|
||||
representative_track_title.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
client
|
||||
.search_release_by_recording(artist_name, track_title)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let Some(found) = found else {
|
||||
return Ok((None, None));
|
||||
};
|
||||
save_external_id(
|
||||
pool,
|
||||
"release",
|
||||
release_id,
|
||||
"release",
|
||||
&found.mbid,
|
||||
found.score as f64 / 100.0,
|
||||
)
|
||||
.await?;
|
||||
if let Some(group_mbid) = found.release_group_mbid.as_deref() {
|
||||
save_external_id(
|
||||
pool,
|
||||
"release",
|
||||
release_id,
|
||||
"release_group",
|
||||
group_mbid,
|
||||
found.score as f64 / 100.0,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
Ok((Some(found.mbid), found.release_group_mbid))
|
||||
}
|
||||
|
||||
impl MusicBrainzClient {
|
||||
pub fn new(user_agent_prefix: &str) -> anyhow::Result<Self> {
|
||||
let client = Client::builder()
|
||||
.user_agent(format!(
|
||||
"{}/{} (musicbrainz.org/doc/MusicBrainz_API)",
|
||||
user_agent_prefix,
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.timeout(Duration::from_secs(20))
|
||||
.build()?;
|
||||
Ok(Self {
|
||||
client,
|
||||
last_musicbrainz_request: Mutex::new(None),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn http_client(&self) -> &Client {
|
||||
&self.client
|
||||
}
|
||||
|
||||
pub async fn search_artist(
|
||||
&self,
|
||||
name: &str,
|
||||
) -> anyhow::Result<Option<MusicBrainzArtistMatch>> {
|
||||
let query = format!("artist:\"{}\"", escape_search_value(name));
|
||||
let response: Option<ArtistSearchResponse> = self
|
||||
.get_musicbrainz_json(
|
||||
"artist",
|
||||
&[("query", query.as_str()), ("fmt", "json"), ("limit", "5")],
|
||||
)
|
||||
.await?;
|
||||
let Some(response) = response else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(response
|
||||
.artists
|
||||
.into_iter()
|
||||
.filter(|artist| artist.id.trim().len() == 36)
|
||||
.max_by_key(|artist| artist.score.unwrap_or(0))
|
||||
.and_then(|artist| {
|
||||
let score = artist.score.unwrap_or(0);
|
||||
(score >= 70).then_some(MusicBrainzArtistMatch {
|
||||
mbid: artist.id,
|
||||
score,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn search_release(
|
||||
&self,
|
||||
artist: &str,
|
||||
title: &str,
|
||||
) -> anyhow::Result<Option<MusicBrainzReleaseMatch>> {
|
||||
let query = format!(
|
||||
"release:\"{}\" AND artist:\"{}\"",
|
||||
escape_search_value(title),
|
||||
escape_search_value(artist)
|
||||
);
|
||||
let response: Option<ReleaseSearchResponse> = self
|
||||
.get_musicbrainz_json(
|
||||
"release",
|
||||
&[("query", query.as_str()), ("fmt", "json"), ("limit", "5")],
|
||||
)
|
||||
.await?;
|
||||
let Some(response) = response else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(response
|
||||
.releases
|
||||
.into_iter()
|
||||
.filter(|release| release.id.trim().len() == 36)
|
||||
.max_by_key(|release| release.score.unwrap_or(0))
|
||||
.and_then(|release| {
|
||||
let score = release.score.unwrap_or(0);
|
||||
(score >= 70).then_some(MusicBrainzReleaseMatch {
|
||||
mbid: release.id,
|
||||
release_group_mbid: release.release_group.map(|group| group.id),
|
||||
score,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn search_release_by_recording(
|
||||
&self,
|
||||
artist: &str,
|
||||
track_title: &str,
|
||||
) -> anyhow::Result<Option<MusicBrainzReleaseMatch>> {
|
||||
let query = format!(
|
||||
"recording:\"{}\" AND artist:\"{}\"",
|
||||
escape_search_value(track_title),
|
||||
escape_search_value(artist)
|
||||
);
|
||||
let response: Option<RecordingSearchResponse> = self
|
||||
.get_musicbrainz_json(
|
||||
"recording",
|
||||
&[("query", query.as_str()), ("fmt", "json"), ("limit", "5")],
|
||||
)
|
||||
.await?;
|
||||
let Some(response) = response else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(response
|
||||
.recordings
|
||||
.into_iter()
|
||||
.flat_map(|recording| {
|
||||
let recording_score = recording.score.unwrap_or(0);
|
||||
recording
|
||||
.releases
|
||||
.into_iter()
|
||||
.filter(move |release| release.id.trim().len() == 36)
|
||||
.map(move |release| (recording_score, release))
|
||||
})
|
||||
.max_by_key(|(score, _)| *score)
|
||||
.and_then(|(score, release)| {
|
||||
(score >= 70).then_some(MusicBrainzReleaseMatch {
|
||||
mbid: release.id,
|
||||
release_group_mbid: release.release_group.map(|group| group.id),
|
||||
score,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn lookup_artist_tags(&self, mbid: &str) -> anyhow::Result<Vec<MusicBrainzTag>> {
|
||||
let response: Option<TaggedEntityResponse> = self
|
||||
.get_musicbrainz_json(
|
||||
&format!("artist/{mbid}"),
|
||||
&[("inc", "tags+genres"), ("fmt", "json")],
|
||||
)
|
||||
.await?;
|
||||
Ok(response.map(tags_from_entity).unwrap_or_default())
|
||||
}
|
||||
|
||||
pub async fn lookup_release_tags(&self, mbid: &str) -> anyhow::Result<MusicBrainzReleaseTags> {
|
||||
let response: Option<ReleaseLookupResponse> = self
|
||||
.get_musicbrainz_json(
|
||||
&format!("release/{mbid}"),
|
||||
&[("inc", "tags+genres+release-groups"), ("fmt", "json")],
|
||||
)
|
||||
.await?;
|
||||
let Some(response) = response else {
|
||||
return Ok(MusicBrainzReleaseTags {
|
||||
release_group_mbid: None,
|
||||
tags: Vec::new(),
|
||||
});
|
||||
};
|
||||
|
||||
let mut tags = tags_from_parts(response.tags, response.genres);
|
||||
let release_group_mbid = response
|
||||
.release_group
|
||||
.as_ref()
|
||||
.map(|group| group.id.clone());
|
||||
if let Some(group_mbid) = release_group_mbid.as_deref() {
|
||||
let group_response: Option<TaggedEntityResponse> = self
|
||||
.get_musicbrainz_json(
|
||||
&format!("release-group/{group_mbid}"),
|
||||
&[("inc", "tags+genres"), ("fmt", "json")],
|
||||
)
|
||||
.await?;
|
||||
merge_tags(
|
||||
&mut tags,
|
||||
group_response.map(tags_from_entity).unwrap_or_default(),
|
||||
);
|
||||
}
|
||||
tags.sort_by(|a, b| {
|
||||
b.weight
|
||||
.total_cmp(&a.weight)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
tags.truncate(MUSICBRAINZ_TAG_LIMIT);
|
||||
|
||||
Ok(MusicBrainzReleaseTags {
|
||||
release_group_mbid,
|
||||
tags,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_cover_art_front_url(
|
||||
&self,
|
||||
release_mbid: Option<&str>,
|
||||
release_group_mbid: Option<&str>,
|
||||
) -> anyhow::Result<Option<String>> {
|
||||
if let Some(mbid) = release_mbid {
|
||||
if let Some(url) = self.cover_art_front_url("release", mbid).await? {
|
||||
return Ok(Some(url));
|
||||
}
|
||||
}
|
||||
if let Some(mbid) = release_group_mbid {
|
||||
if let Some(url) = self.cover_art_front_url("release-group", mbid).await? {
|
||||
return Ok(Some(url));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn get_musicbrainz_json<T>(
|
||||
&self,
|
||||
path: &str,
|
||||
query: &[(&str, &str)],
|
||||
) -> anyhow::Result<Option<T>>
|
||||
where
|
||||
T: for<'de> Deserialize<'de>,
|
||||
{
|
||||
self.wait_for_musicbrainz_slot().await;
|
||||
let url = format!("{MUSICBRAINZ_BASE_URL}/{}", path.trim_start_matches('/'));
|
||||
let response = self.client.get(url).query(query).send().await?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS
|
||||
|| response.status() == StatusCode::SERVICE_UNAVAILABLE
|
||||
{
|
||||
anyhow::bail!(
|
||||
"MusicBrainz rate limit or service unavailable: {}",
|
||||
response.status()
|
||||
);
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
Ok(Some(response.json::<T>().await?))
|
||||
}
|
||||
|
||||
async fn cover_art_front_url(&self, kind: &str, mbid: &str) -> anyhow::Result<Option<String>> {
|
||||
let url = format!("{COVER_ART_ARCHIVE_BASE_URL}/{kind}/{mbid}");
|
||||
let response = self.client.get(url).send().await?;
|
||||
if response.status() == StatusCode::NOT_FOUND {
|
||||
return Ok(None);
|
||||
}
|
||||
if response.status() == StatusCode::TOO_MANY_REQUESTS
|
||||
|| response.status() == StatusCode::SERVICE_UNAVAILABLE
|
||||
{
|
||||
anyhow::bail!("Cover Art Archive unavailable: {}", response.status());
|
||||
}
|
||||
let response = response.error_for_status()?;
|
||||
let body = response.json::<CoverArtArchiveResponse>().await?;
|
||||
Ok(best_cover_art_url(body.images))
|
||||
}
|
||||
|
||||
async fn wait_for_musicbrainz_slot(&self) {
|
||||
let mut last = self.last_musicbrainz_request.lock().await;
|
||||
if let Some(previous) = *last {
|
||||
let elapsed = previous.elapsed();
|
||||
if elapsed < MUSICBRAINZ_REQUEST_DELAY {
|
||||
tokio::time::sleep(MUSICBRAINZ_REQUEST_DELAY - elapsed).await;
|
||||
}
|
||||
}
|
||||
*last = Some(Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
fn now_iso() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArtistSearchResponse {
|
||||
#[serde(default)]
|
||||
artists: Vec<ArtistSearchItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ArtistSearchItem {
|
||||
id: String,
|
||||
score: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseSearchResponse {
|
||||
#[serde(default)]
|
||||
releases: Vec<ReleaseSearchItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseSearchItem {
|
||||
id: String,
|
||||
score: Option<i32>,
|
||||
#[serde(rename = "release-group")]
|
||||
release_group: Option<MusicBrainzIdRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RecordingSearchResponse {
|
||||
#[serde(default)]
|
||||
recordings: Vec<RecordingSearchItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RecordingSearchItem {
|
||||
score: Option<i32>,
|
||||
#[serde(default)]
|
||||
releases: Vec<RecordingReleaseItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RecordingReleaseItem {
|
||||
id: String,
|
||||
#[serde(rename = "release-group")]
|
||||
release_group: Option<MusicBrainzIdRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseLookupResponse {
|
||||
#[serde(default)]
|
||||
tags: Vec<MusicBrainzTagItem>,
|
||||
#[serde(default)]
|
||||
genres: Vec<MusicBrainzTagItem>,
|
||||
#[serde(rename = "release-group")]
|
||||
release_group: Option<MusicBrainzIdRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct TaggedEntityResponse {
|
||||
#[serde(default)]
|
||||
tags: Vec<MusicBrainzTagItem>,
|
||||
#[serde(default)]
|
||||
genres: Vec<MusicBrainzTagItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MusicBrainzIdRef {
|
||||
id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct MusicBrainzTagItem {
|
||||
name: String,
|
||||
count: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CoverArtArchiveResponse {
|
||||
#[serde(default)]
|
||||
images: Vec<CoverArtArchiveImage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CoverArtArchiveImage {
|
||||
image: Option<String>,
|
||||
front: Option<bool>,
|
||||
approved: Option<bool>,
|
||||
#[serde(default)]
|
||||
types: Vec<String>,
|
||||
thumbnails: Option<CoverArtArchiveThumbnails>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CoverArtArchiveThumbnails {
|
||||
#[serde(rename = "1200")]
|
||||
size_1200: Option<String>,
|
||||
#[serde(rename = "500")]
|
||||
size_500: Option<String>,
|
||||
large: Option<String>,
|
||||
}
|
||||
|
||||
fn escape_search_value(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
fn tags_from_entity(entity: TaggedEntityResponse) -> Vec<MusicBrainzTag> {
|
||||
tags_from_parts(entity.tags, entity.genres)
|
||||
}
|
||||
|
||||
fn tags_from_parts(
|
||||
tags: Vec<MusicBrainzTagItem>,
|
||||
genres: Vec<MusicBrainzTagItem>,
|
||||
) -> Vec<MusicBrainzTag> {
|
||||
let mut result = Vec::new();
|
||||
merge_items(&mut result, genres, 2.0);
|
||||
merge_items(&mut result, tags, 1.0);
|
||||
result.sort_by(|a, b| {
|
||||
b.weight
|
||||
.total_cmp(&a.weight)
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
});
|
||||
result.truncate(MUSICBRAINZ_TAG_LIMIT);
|
||||
result
|
||||
}
|
||||
|
||||
fn merge_items(result: &mut Vec<MusicBrainzTag>, items: Vec<MusicBrainzTagItem>, multiplier: f64) {
|
||||
for item in items {
|
||||
let name = item.name.trim();
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let weight = item.count.unwrap_or(1).max(1) as f64 * multiplier;
|
||||
if let Some(existing) = result
|
||||
.iter_mut()
|
||||
.find(|tag| tag.name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
existing.weight = existing.weight.max(weight);
|
||||
} else {
|
||||
result.push(MusicBrainzTag {
|
||||
name: name.to_string(),
|
||||
weight,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_tags(result: &mut Vec<MusicBrainzTag>, extra: Vec<MusicBrainzTag>) {
|
||||
for tag in extra {
|
||||
if let Some(existing) = result
|
||||
.iter_mut()
|
||||
.find(|candidate| candidate.name.eq_ignore_ascii_case(&tag.name))
|
||||
{
|
||||
existing.weight = existing.weight.max(tag.weight);
|
||||
} else {
|
||||
result.push(tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn best_cover_art_url(mut images: Vec<CoverArtArchiveImage>) -> Option<String> {
|
||||
images.sort_by_key(|image| {
|
||||
let front = image.front.unwrap_or(false)
|
||||
|| image
|
||||
.types
|
||||
.iter()
|
||||
.any(|value| value.eq_ignore_ascii_case("front"));
|
||||
let approved = image.approved.unwrap_or(false);
|
||||
(u8::from(front), u8::from(approved))
|
||||
});
|
||||
images
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find_map(|image| {
|
||||
image
|
||||
.thumbnails
|
||||
.as_ref()
|
||||
.and_then(|thumbs| {
|
||||
thumbs
|
||||
.size_1200
|
||||
.as_deref()
|
||||
.or(thumbs.size_500.as_deref())
|
||||
.or(thumbs.large.as_deref())
|
||||
})
|
||||
.map(str::to_string)
|
||||
.or(image.image)
|
||||
})
|
||||
.filter(|url| !url.trim().is_empty())
|
||||
}
|
||||
+211
-8
@@ -7,6 +7,7 @@ mod i18n;
|
||||
mod jobs;
|
||||
mod lastfm;
|
||||
mod media_paths;
|
||||
mod metrics;
|
||||
mod music;
|
||||
mod oidc;
|
||||
mod player;
|
||||
@@ -28,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};
|
||||
@@ -51,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);
|
||||
@@ -62,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,
|
||||
@@ -130,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -139,12 +196,93 @@ async fn logout_handler(session: Session) -> cot::Result<cot::response::Response
|
||||
Ok(auth::redirect("/login"))
|
||||
}
|
||||
|
||||
async fn metrics_handler(
|
||||
config: Arc<AppConfig>,
|
||||
pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>>,
|
||||
) -> cot::Result<cot::http::Response<Body>> {
|
||||
if config.database_url.is_empty() {
|
||||
return Ok(cot::http::Response::builder()
|
||||
.status(cot::http::StatusCode::SERVICE_UNAVAILABLE)
|
||||
.header(cot::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")
|
||||
.body(Body::fixed("furumusic_metrics_unavailable 1\n"))
|
||||
.expect("valid response"));
|
||||
}
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(2)
|
||||
.connect(&config.database_url)
|
||||
.await
|
||||
.expect("metrics pool")
|
||||
})
|
||||
.await;
|
||||
let body = metrics::render(pg_pool, &config).await;
|
||||
Ok(cot::http::Response::builder()
|
||||
.status(cot::http::StatusCode::OK)
|
||||
.header(cot::http::header::CONTENT_TYPE, "text/plain; version=0.0.4")
|
||||
.body(Body::fixed(body))
|
||||
.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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct FuruApp {
|
||||
config: Arc<AppConfig>,
|
||||
pool: Arc<tokio::sync::OnceCell<sqlx::PgPool>>,
|
||||
}
|
||||
|
||||
impl App for FuruApp {
|
||||
@@ -166,23 +304,52 @@ 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({
|
||||
let config = Arc::clone(&self.config);
|
||||
let pool = Arc::clone(&self.pool);
|
||||
move || {
|
||||
let config = Arc::clone(&config);
|
||||
let pool = Arc::clone(&pool);
|
||||
async move { metrics_handler(config, pool).await }
|
||||
}
|
||||
}),
|
||||
"metrics",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/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()
|
||||
}
|
||||
@@ -200,6 +367,11 @@ impl App for FuruApp {
|
||||
let data = match result {
|
||||
FormResult::Ok(data) => data,
|
||||
FormResult::ValidationError(_) => {
|
||||
metrics::record_auth_attempt(
|
||||
"password",
|
||||
"failure",
|
||||
"validation_error",
|
||||
);
|
||||
let msg = i18n.t.login_invalid.to_owned();
|
||||
return login_page_handler(i18n, &config, db, msg)
|
||||
.await?
|
||||
@@ -207,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
|
||||
{
|
||||
@@ -215,14 +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?;
|
||||
return Ok(auth::redirect("/"));
|
||||
auth::clear_post_login_redirect(&session).await?;
|
||||
metrics::record_auth_attempt(
|
||||
"password", "success", "ok",
|
||||
);
|
||||
metrics::record_session_created("password");
|
||||
return Ok(auth::redirect(&redirect_to));
|
||||
}
|
||||
PasswordVerificationResult::Invalid => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
metrics::record_auth_attempt("password", "failure", "bad_credentials");
|
||||
let msg = i18n.t.login_invalid.to_owned();
|
||||
login_page_handler(i18n, &config, db, msg)
|
||||
.await?
|
||||
@@ -244,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",
|
||||
),
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -332,6 +533,7 @@ impl Project for FuruProject {
|
||||
context: &cot::project::MiddlewareContext,
|
||||
) -> cot::project::RootHandler {
|
||||
handler
|
||||
.middleware(metrics::MetricsLayer)
|
||||
.middleware(StaticFilesMiddleware::from_context(context))
|
||||
.middleware(SessionMiddleware::from_context(context))
|
||||
.build()
|
||||
@@ -361,6 +563,7 @@ impl Project for FuruProject {
|
||||
apps.register_with_views(
|
||||
FuruApp {
|
||||
config: Arc::clone(&self.app_config),
|
||||
pool: Arc::new(tokio::sync::OnceCell::new()),
|
||||
},
|
||||
"",
|
||||
);
|
||||
|
||||
+1110
File diff suppressed because it is too large
Load Diff
@@ -1815,6 +1815,142 @@ pub mod db_migrations {
|
||||
&[Operation::custom(create_artwork_lookup_state).build()];
|
||||
}
|
||||
|
||||
// -- M0035: Weighted metadata tags for tracks, releases, and artists ----
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_entity_genre_tags(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__entity_genre_tag (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entity_kind VARCHAR(32) NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
genre_id BIGINT NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
weight DOUBLE PRECISION NOT NULL DEFAULT 1,
|
||||
updated_at VARCHAR(32) NOT NULL,
|
||||
UNIQUE(entity_kind, entity_id, genre_id, source)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_genre_tag_entity
|
||||
ON furumusic__entity_genre_tag (entity_kind, entity_id, source)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_entity_genre_tag_genre
|
||||
ON furumusic__entity_genre_tag (genre_id, entity_kind)",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0035CreateEntityGenreTags;
|
||||
|
||||
impl migrations::Migration for M0035CreateEntityGenreTags {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0035_create_entity_genre_tags";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0034_create_artwork_lookup_state",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[Operation::custom(create_entity_genre_tags).build()];
|
||||
}
|
||||
|
||||
// -- M0036: External metadata identifiers --------------------------------
|
||||
|
||||
#[cot::db::migrations::migration_op]
|
||||
async fn create_external_metadata_ids(
|
||||
ctx: migrations::MigrationContext<'_>,
|
||||
) -> cot::db::Result<()> {
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE TABLE IF NOT EXISTS furumusic__external_metadata_id (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
entity_kind VARCHAR(32) NOT NULL,
|
||||
entity_id BIGINT NOT NULL,
|
||||
source VARCHAR(32) NOT NULL,
|
||||
id_kind VARCHAR(64) NOT NULL,
|
||||
external_id VARCHAR(255) NOT NULL,
|
||||
confidence DOUBLE PRECISION NOT NULL DEFAULT 1,
|
||||
updated_at VARCHAR(32) NOT NULL,
|
||||
UNIQUE(entity_kind, entity_id, source, id_kind)
|
||||
)",
|
||||
)
|
||||
.await?;
|
||||
ctx.db
|
||||
.raw(
|
||||
"CREATE INDEX IF NOT EXISTS idx_external_metadata_id_lookup
|
||||
ON furumusic__external_metadata_id (source, id_kind, external_id)",
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct M0036CreateExternalMetadataIds;
|
||||
|
||||
impl migrations::Migration for M0036CreateExternalMetadataIds {
|
||||
const APP_NAME: &'static str = "furumusic";
|
||||
const MIGRATION_NAME: &'static str = "m_0036_create_external_metadata_ids";
|
||||
const DEPENDENCIES: &'static [migrations::MigrationDependency] =
|
||||
&[migrations::MigrationDependency::migration(
|
||||
"furumusic",
|
||||
"m_0035_create_entity_genre_tags",
|
||||
)];
|
||||
const OPERATIONS: &'static [Operation] =
|
||||
&[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,
|
||||
@@ -1840,5 +1976,8 @@ pub mod db_migrations {
|
||||
&M0032CreateLastfmTrackPopularity,
|
||||
&M0033CreateLastfmScrobbling,
|
||||
&M0034CreateArtworkLookupState,
|
||||
&M0035CreateEntityGenreTags,
|
||||
&M0036CreateExternalMetadataIds,
|
||||
&M0037CreatePlaylistShareLinks,
|
||||
];
|
||||
}
|
||||
|
||||
+692
-4
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -172,6 +180,7 @@ pub async fn oidc_start_handler(
|
||||
|| config.oidc_client_secret.is_empty()
|
||||
{
|
||||
tracing::warn!("OIDC start requested but SSO is not configured");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "not_configured");
|
||||
return redirect_login_with_error(i18n.t.login_sso_disabled);
|
||||
}
|
||||
|
||||
@@ -180,6 +189,7 @@ pub async fn oidc_start_handler(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC provider error: {e}");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "provider_error");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
@@ -245,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;
|
||||
|
||||
@@ -276,19 +296,23 @@ pub async fn oidc_callback_handler(
|
||||
// Validate CSRF state.
|
||||
let Some(saved_csrf) = saved_csrf else {
|
||||
tracing::warn!("OIDC callback: no CSRF state in session");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "missing_state");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
};
|
||||
if query.state != saved_csrf {
|
||||
tracing::warn!("OIDC callback: CSRF state mismatch");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "csrf");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
|
||||
let Some(nonce_str) = saved_nonce else {
|
||||
tracing::warn!("OIDC callback: no nonce in session");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "missing_nonce");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
};
|
||||
let Some(pkce_str) = saved_pkce else {
|
||||
tracing::warn!("OIDC callback: no PKCE verifier in session");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "missing_pkce");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
};
|
||||
|
||||
@@ -300,6 +324,7 @@ pub async fn oidc_callback_handler(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC provider error during callback: {e}");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "provider_error");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
@@ -318,6 +343,7 @@ pub async fn oidc_callback_handler(
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC token endpoint not configured: {e}");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "token_config");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
@@ -330,6 +356,7 @@ pub async fn oidc_callback_handler(
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC token exchange failed: {e}");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "token_exchange");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
@@ -340,6 +367,7 @@ pub async fn oidc_callback_handler(
|
||||
Some(t) => t,
|
||||
None => {
|
||||
tracing::error!("OIDC response missing ID token");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "missing_id_token");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
@@ -348,6 +376,7 @@ pub async fn oidc_callback_handler(
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC ID token verification failed: {e}");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "id_token_verify");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
@@ -395,6 +424,7 @@ pub async fn oidc_callback_handler(
|
||||
config.oidc_user_groups,
|
||||
config.oidc_admin_groups,
|
||||
);
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "not_in_group");
|
||||
return redirect_login_with_error(i18n.t.login_access_denied);
|
||||
}
|
||||
|
||||
@@ -413,12 +443,20 @@ pub async fn oidc_callback_handler(
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
tracing::error!("OIDC user provisioning failed: {e}");
|
||||
crate::metrics::record_auth_attempt("oidc", "failure", "provisioning");
|
||||
return redirect_login_with_error(i18n.t.login_oidc_error);
|
||||
}
|
||||
};
|
||||
|
||||
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");
|
||||
|
||||
// Clear OIDC session keys.
|
||||
let _: Option<String> = session
|
||||
@@ -438,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))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -596,6 +924,307 @@ 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());
|
||||
}
|
||||
if is_loopback_http_redirect(&lower) {
|
||||
return Some(value.to_owned());
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// RFC 8252 §7.3: native apps without a custom URL scheme (the CLI client)
|
||||
/// receive the callback on a loopback listener with an ephemeral port.
|
||||
fn is_loopback_http_redirect(lower: &str) -> bool {
|
||||
let Some(rest) = lower.strip_prefix("http://") else {
|
||||
return false;
|
||||
};
|
||||
let host_port = rest.split(['/', '?', '#']).next().unwrap_or("");
|
||||
let Some((host, port)) = host_port.rsplit_once(':') else {
|
||||
return false;
|
||||
};
|
||||
matches!(host, "127.0.0.1" | "localhost" | "[::1]")
|
||||
&& !port.is_empty()
|
||||
&& port.len() <= 5
|
||||
&& port.bytes().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
|
||||
let deep_link = append_query_param(app_redirect_uri, "code", code);
|
||||
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
|
||||
return auth::redirect(&deep_link);
|
||||
}
|
||||
mobile_deep_link_page(
|
||||
"success",
|
||||
"Sign-in complete",
|
||||
"Furumi should open automatically. If it doesn't, use the button or copy the code below.",
|
||||
None,
|
||||
Some(code),
|
||||
&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);
|
||||
if is_loopback_http_redirect(&app_redirect_uri.to_ascii_lowercase()) {
|
||||
return auth::redirect(&deep_link);
|
||||
}
|
||||
mobile_deep_link_page(
|
||||
"error",
|
||||
"Sign-in failed",
|
||||
"Furumi should open automatically and show the sign-in error.",
|
||||
Some(error),
|
||||
None,
|
||||
&deep_link,
|
||||
)
|
||||
}
|
||||
|
||||
fn mobile_deep_link_page(
|
||||
state: &str,
|
||||
title: &str,
|
||||
message: &str,
|
||||
detail: Option<&str>,
|
||||
code: 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 code_html = code
|
||||
.map(|value| {
|
||||
format!(
|
||||
r#"<p class="hint">Signing in from a terminal? Paste this code there:</p>
|
||||
<input class="code" readonly value="{}" onclick="this.select()">"#,
|
||||
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;
|
||||
}}
|
||||
.code {{
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 10px 12px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid #3a3c42;
|
||||
border-radius: 8px;
|
||||
background: #1a1c20;
|
||||
color: #e8d8a8;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}}
|
||||
</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>
|
||||
{code_html}
|
||||
</main>
|
||||
<script>
|
||||
const deepLink = {deep_link_js};
|
||||
window.setTimeout(() => {{
|
||||
window.location.href = deepLink;
|
||||
}}, 100);
|
||||
</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("&"),
|
||||
'<' => out.push_str("<"),
|
||||
'>' => out.push_str(">"),
|
||||
'"' => out.push_str("""),
|
||||
'\'' => out.push_str("'"),
|
||||
_ => 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);
|
||||
@@ -612,3 +1241,62 @@ 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>"#),
|
||||
"<tag attr="x&y">'text'</tag>"
|
||||
);
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mobile_oidc_redirect_uri_allows_loopback_http() {
|
||||
assert_eq!(
|
||||
safe_mobile_redirect_uri(Some("http://127.0.0.1:8753/callback")).as_deref(),
|
||||
Some("http://127.0.0.1:8753/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
safe_mobile_redirect_uri(Some("http://localhost:1234/callback")).as_deref(),
|
||||
Some("http://localhost:1234/callback")
|
||||
);
|
||||
assert_eq!(
|
||||
safe_mobile_redirect_uri(Some("http://[::1]:1234/callback")).as_deref(),
|
||||
Some("http://[::1]:1234/callback")
|
||||
);
|
||||
// Non-loopback hosts, missing ports and https stay rejected.
|
||||
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1/callback")).is_none());
|
||||
assert!(safe_mobile_redirect_uri(Some("http://evil.com:80/callback")).is_none());
|
||||
assert!(safe_mobile_redirect_uri(Some("https://127.0.0.1:80/callback")).is_none());
|
||||
assert!(safe_mobile_redirect_uri(Some("http://127.0.0.1:notaport/x")).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
+197
-2
@@ -16,6 +16,7 @@ pub(super) struct Paginated<T: Serialize> {
|
||||
pub(super) total: i64,
|
||||
pub(super) page: i32,
|
||||
pub(super) per_page: i32,
|
||||
pub(super) has_more: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
@@ -36,17 +37,18 @@ pub(super) struct ArtistDetail {
|
||||
pub(super) image_url: Option<String>,
|
||||
pub(super) total_track_count: i64,
|
||||
pub(super) total_play_count: i64,
|
||||
pub(super) top_tracks: Vec<TrackItem>,
|
||||
pub(super) releases: Vec<ReleaseCard>,
|
||||
pub(super) featured_tracks: Vec<ArtistAppearanceTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub(super) struct ArtistRef {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub(super) struct TrackItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
@@ -141,6 +143,7 @@ pub(super) struct PlaybackStateDto {
|
||||
pub(super) struct DeviceHeartbeatRequest {
|
||||
pub(super) device_id: String,
|
||||
pub(super) user_agent: Option<String>,
|
||||
pub(super) current_jam_id: Option<String>,
|
||||
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||
}
|
||||
|
||||
@@ -153,6 +156,7 @@ pub(super) struct DeviceSelectRequest {
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct DeviceCommandRequest {
|
||||
pub(super) target_device_id: Option<String>,
|
||||
pub(super) jam_id: Option<String>,
|
||||
pub(super) command: String,
|
||||
#[serde(default)]
|
||||
pub(super) payload: serde_json::Value,
|
||||
@@ -168,6 +172,66 @@ pub(super) struct PlayerDeviceDto {
|
||||
pub(super) last_seen_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayerJamDto {
|
||||
pub(super) id: String,
|
||||
pub(super) name: String,
|
||||
pub(super) host_user_id: i64,
|
||||
pub(super) host_name: String,
|
||||
pub(super) is_owner: bool,
|
||||
pub(super) is_member: bool,
|
||||
pub(super) is_pending: bool,
|
||||
pub(super) is_active: bool,
|
||||
pub(super) member_count: i64,
|
||||
pub(super) host_last_seen_ms: i64,
|
||||
pub(super) host_device_online: bool,
|
||||
pub(super) members: Vec<PlayerJamMemberDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayerJamMemberDto {
|
||||
pub(super) user_id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) is_joined: bool,
|
||||
pub(super) is_current_user: bool,
|
||||
pub(super) last_seen_ms: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct PlayerJamCreateRequest {
|
||||
pub(super) device_id: String,
|
||||
#[serde(default)]
|
||||
pub(super) invitee_user_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct PlayerJamInviteRequest {
|
||||
pub(super) jam_id: String,
|
||||
pub(super) device_id: String,
|
||||
#[serde(default)]
|
||||
pub(super) invitee_user_ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct PlayerJamJoinRequest {
|
||||
pub(super) jam_id: String,
|
||||
pub(super) device_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct PlayerJamLeaveRequest {
|
||||
pub(super) jam_id: String,
|
||||
pub(super) device_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayerJamUserDto {
|
||||
pub(super) id: i64,
|
||||
pub(super) username: String,
|
||||
pub(super) display_name: Option<String>,
|
||||
pub(super) email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayerDeviceCommandDto {
|
||||
pub(super) id: String,
|
||||
@@ -196,6 +260,8 @@ pub(super) struct PlayerDevicesResponse {
|
||||
pub(super) device_id: String,
|
||||
pub(super) active_device_id: Option<String>,
|
||||
pub(super) devices: Vec<PlayerDeviceDto>,
|
||||
pub(super) jams: Vec<PlayerJamDto>,
|
||||
pub(super) current_jam_id: Option<String>,
|
||||
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||
}
|
||||
|
||||
@@ -204,6 +270,8 @@ pub(super) struct PlayerDevicePollResponse {
|
||||
pub(super) device_id: String,
|
||||
pub(super) active_device_id: Option<String>,
|
||||
pub(super) devices: Vec<PlayerDeviceDto>,
|
||||
pub(super) jams: Vec<PlayerJamDto>,
|
||||
pub(super) current_jam_id: Option<String>,
|
||||
pub(super) commands: Vec<PlayerDeviceCommandDto>,
|
||||
pub(super) playback_state: Option<PlayerDevicePlaybackStateDto>,
|
||||
}
|
||||
@@ -221,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>,
|
||||
@@ -238,6 +319,7 @@ pub(super) struct UserStats {
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserProfile {
|
||||
pub(super) id: i64,
|
||||
pub(super) name: String,
|
||||
pub(super) role: String,
|
||||
pub(super) stats: UserStats,
|
||||
@@ -278,12 +360,125 @@ pub(super) struct AgentQueueStatus {
|
||||
pub(super) processing_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub(super) struct UserUploadTrack {
|
||||
pub(super) track: TrackItem,
|
||||
pub(super) media_file_id: i64,
|
||||
pub(super) is_hidden: bool,
|
||||
pub(super) release_is_hidden: bool,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) uploaded_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserUploadRelease {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) is_hidden: bool,
|
||||
pub(super) artists: Vec<ArtistRef>,
|
||||
pub(super) tracks: Vec<UserUploadTrack>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserUploadReviewFields {
|
||||
pub(super) title: String,
|
||||
pub(super) artist: String,
|
||||
pub(super) album: String,
|
||||
pub(super) year: String,
|
||||
pub(super) track_number: String,
|
||||
pub(super) genre: String,
|
||||
pub(super) featured_artists: Vec<String>,
|
||||
pub(super) release_type: String,
|
||||
pub(super) notes: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserUploadReviewItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) status: String,
|
||||
pub(super) filename: String,
|
||||
pub(super) created_at: String,
|
||||
pub(super) updated_at: String,
|
||||
pub(super) error_message: Option<String>,
|
||||
pub(super) fields: UserUploadReviewFields,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserUploadQueueItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) status: String,
|
||||
pub(super) filename: String,
|
||||
pub(super) created_at: String,
|
||||
pub(super) updated_at: String,
|
||||
pub(super) error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct UserUploadsPage {
|
||||
pub(super) tracks: Vec<UserUploadTrack>,
|
||||
pub(super) releases: Vec<UserUploadRelease>,
|
||||
pub(super) pending: Vec<UserUploadReviewItem>,
|
||||
pub(super) queued: Vec<UserUploadQueueItem>,
|
||||
pub(super) pending_total: i64,
|
||||
pub(super) queued_total: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct UserUploadTrackUpdateRequest {
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) artist_names: Option<Vec<String>>,
|
||||
pub(super) featured_artist_names: Option<Vec<String>>,
|
||||
pub(super) release_title: Option<String>,
|
||||
pub(super) release_type: Option<String>,
|
||||
pub(super) release_year: Option<String>,
|
||||
pub(super) track_number: Option<String>,
|
||||
pub(super) disc_number: Option<String>,
|
||||
pub(super) is_hidden: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct UserUploadReleaseUpdateRequest {
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) artist_names: Option<Vec<String>>,
|
||||
pub(super) release_type: Option<String>,
|
||||
pub(super) year: Option<String>,
|
||||
pub(super) is_hidden: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct UserUploadBulkTrackUpdateRequest {
|
||||
pub(super) track_ids: Vec<i64>,
|
||||
pub(super) artist_names: Option<Vec<String>>,
|
||||
pub(super) featured_artist_names: Option<Vec<String>>,
|
||||
pub(super) release_title: Option<String>,
|
||||
pub(super) release_type: Option<String>,
|
||||
pub(super) release_year: Option<String>,
|
||||
pub(super) is_hidden: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub(super) struct UserUploadReviewUpdateRequest {
|
||||
pub(super) title: Option<String>,
|
||||
pub(super) artist: Option<String>,
|
||||
pub(super) album: Option<String>,
|
||||
pub(super) year: Option<String>,
|
||||
pub(super) track_number: Option<String>,
|
||||
pub(super) genre: Option<String>,
|
||||
pub(super) featured_artists: Option<Vec<String>>,
|
||||
pub(super) release_type: Option<String>,
|
||||
pub(super) notes: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub(super) struct PlayHistoryItem {
|
||||
pub(super) id: i64,
|
||||
pub(super) track_id: i64,
|
||||
pub(super) track_title: String,
|
||||
pub(super) release_title: Option<String>,
|
||||
pub(super) track: TrackItem,
|
||||
pub(super) played_at: String,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
|
||||
+3662
-352
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,11 @@ pub(super) struct TracksByIdsRequest {
|
||||
pub(super) ids: Vec<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct UserUploadsQuery {
|
||||
pub(super) limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct CreatePlaylistRequest {
|
||||
pub(super) title: String,
|
||||
@@ -40,10 +45,17 @@ 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>,
|
||||
pub(super) limit: Option<i32>,
|
||||
pub(super) mine: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -56,12 +68,24 @@ pub(super) struct PathStringId {
|
||||
pub(super) id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathRadioSeed {
|
||||
pub(super) kind: String,
|
||||
pub(super) id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SearchQuery {
|
||||
pub(super) q: String,
|
||||
pub(super) limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct JamUserSearchQuery {
|
||||
pub(super) q: Option<String>,
|
||||
pub(super) limit: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct PathTrackId {
|
||||
pub(super) track_id: i64,
|
||||
|
||||
+101
-29
@@ -12,6 +12,14 @@ pub(super) struct CountRow {
|
||||
pub(super) count: i64,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlayerJamUserRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) username: String,
|
||||
pub(super) display_name: Option<String>,
|
||||
pub(super) email: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReleaseRow {
|
||||
pub(super) id: i64,
|
||||
@@ -28,30 +36,6 @@ pub(super) struct ArtistBriefRow {
|
||||
pub(super) name: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct TrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) release_id: i64,
|
||||
pub(super) release_title: String,
|
||||
pub(super) release_year: Option<i32>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
pub(super) lastfm_listeners: Option<i64>,
|
||||
pub(super) lastfm_playcount: Option<i64>,
|
||||
pub(super) lastfm_rating: Option<f64>,
|
||||
pub(super) lastfm_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct TrackArtistRow {
|
||||
pub(super) track_id: i64,
|
||||
@@ -60,6 +44,13 @@ pub(super) struct TrackArtistRow {
|
||||
pub(super) role: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct ReleaseArtistRefRow {
|
||||
pub(super) release_id: i64,
|
||||
pub(super) artist_id: i64,
|
||||
pub(super) artist_name: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct MediaFileRow {
|
||||
pub(super) file_path: String,
|
||||
@@ -124,6 +115,70 @@ pub(super) struct PlaylistTrackRow {
|
||||
pub(super) lastfm_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct UploadedTrackRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) release_id: i64,
|
||||
pub(super) release_title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) release_year: Option<i32>,
|
||||
pub(super) release_is_hidden: bool,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
pub(super) lastfm_listeners: Option<i64>,
|
||||
pub(super) lastfm_playcount: Option<i64>,
|
||||
pub(super) lastfm_rating: Option<f64>,
|
||||
pub(super) lastfm_updated_at: Option<String>,
|
||||
pub(super) media_file_id: i64,
|
||||
pub(super) is_hidden: bool,
|
||||
pub(super) year: Option<i32>,
|
||||
pub(super) uploaded_at: String,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct UserUploadQueueRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) status: String,
|
||||
pub(super) input_path: Option<String>,
|
||||
pub(super) created_at: String,
|
||||
pub(super) updated_at: String,
|
||||
pub(super) error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct UserUploadReviewRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) status: String,
|
||||
pub(super) input_path: Option<String>,
|
||||
pub(super) result_json: Option<String>,
|
||||
pub(super) context_json: Option<String>,
|
||||
pub(super) created_at: String,
|
||||
pub(super) updated_at: String,
|
||||
pub(super) error_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct UploadTrackEditRow {
|
||||
pub(super) release_id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) is_hidden: bool,
|
||||
pub(super) release_title: String,
|
||||
pub(super) release_type: String,
|
||||
pub(super) release_year: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct AppearanceTrackRow {
|
||||
pub(super) id: i64,
|
||||
@@ -197,14 +252,31 @@ pub(super) struct ReleaseUploaderRow {
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
pub(super) struct PlayHistoryRow {
|
||||
pub(super) id: i64,
|
||||
pub(super) track_id: i64,
|
||||
pub(super) track_title: String,
|
||||
pub(super) release_title: Option<String>,
|
||||
pub(super) struct PlayHistoryTrackRow {
|
||||
pub(super) history_id: i64,
|
||||
pub(super) played_at: String,
|
||||
pub(super) duration_listened: Option<i32>,
|
||||
pub(super) completed: bool,
|
||||
pub(super) id: i64,
|
||||
pub(super) title: String,
|
||||
pub(super) track_number: Option<i32>,
|
||||
pub(super) disc_number: Option<i32>,
|
||||
pub(super) duration_seconds: f64,
|
||||
pub(super) cover_file_id: Option<i64>,
|
||||
pub(super) release_cover_file_id: Option<i64>,
|
||||
pub(super) release_id: i64,
|
||||
pub(super) release_title: String,
|
||||
pub(super) release_year: Option<i32>,
|
||||
pub(super) uploader_name: String,
|
||||
pub(super) audio_format: Option<String>,
|
||||
pub(super) audio_bitrate: Option<i32>,
|
||||
pub(super) audio_sample_rate: Option<i32>,
|
||||
pub(super) audio_bit_depth: Option<i32>,
|
||||
pub(super) file_size_bytes: Option<i64>,
|
||||
pub(super) lastfm_listeners: Option<i64>,
|
||||
pub(super) lastfm_playcount: Option<i64>,
|
||||
pub(super) lastfm_rating: Option<f64>,
|
||||
pub(super) lastfm_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
|
||||
@@ -200,6 +200,12 @@ impl JobRun {
|
||||
duration_ms: i64,
|
||||
log: &str,
|
||||
) -> cot::db::Result<()> {
|
||||
crate::metrics::record_scheduler_job(
|
||||
self.job_name.as_str(),
|
||||
self.trigger.as_str(),
|
||||
"completed",
|
||||
duration_ms,
|
||||
);
|
||||
self.status = LimitedString::new("completed").unwrap();
|
||||
self.finished_at = Some(now_iso().to_string());
|
||||
self.duration_ms = Some(duration_ms);
|
||||
@@ -214,6 +220,12 @@ impl JobRun {
|
||||
log: &str,
|
||||
error: &str,
|
||||
) -> cot::db::Result<()> {
|
||||
crate::metrics::record_scheduler_job(
|
||||
self.job_name.as_str(),
|
||||
self.trigger.as_str(),
|
||||
"failed",
|
||||
duration_ms,
|
||||
);
|
||||
self.status = LimitedString::new("failed").unwrap();
|
||||
self.finished_at = Some(now_iso().to_string());
|
||||
self.duration_ms = Some(duration_ms);
|
||||
@@ -1360,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");
|
||||
|
||||
+23
-2
@@ -777,7 +777,7 @@ impl TorrentService {
|
||||
|
||||
self.ensure_memory_job(pool, uploader_user_id, id).await?;
|
||||
|
||||
let (torrent_bytes, output_dir) = {
|
||||
let (torrent_bytes, output_dir, selected_size) = {
|
||||
let mut jobs = self.jobs.lock().await;
|
||||
let job = jobs.get_mut(id).context("torrent job not found")?;
|
||||
if job.user_id != uploader_user_id {
|
||||
@@ -800,7 +800,11 @@ impl TorrentService {
|
||||
job.error = None;
|
||||
job.completed_at = None;
|
||||
job.updated_at = now_string();
|
||||
(job.torrent_bytes.clone(), job.output_dir.clone())
|
||||
(
|
||||
job.torrent_bytes.clone(),
|
||||
job.output_dir.clone(),
|
||||
job.selected_size(),
|
||||
)
|
||||
};
|
||||
|
||||
tokio::fs::create_dir_all(&output_dir).await?;
|
||||
@@ -846,6 +850,7 @@ impl TorrentService {
|
||||
let service = Arc::clone(self);
|
||||
let pool = pool.clone();
|
||||
let id = id.to_string();
|
||||
let download_start = std::time::Instant::now();
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = handle.wait_until_completed().await {
|
||||
if service.is_paused(&id).await {
|
||||
@@ -853,6 +858,11 @@ impl TorrentService {
|
||||
}
|
||||
service.stop_torrent(&handle).await;
|
||||
service.fail_job(&pool, &id, err.to_string()).await;
|
||||
crate::metrics::record_torrent_download(
|
||||
"failed",
|
||||
selected_size,
|
||||
download_start.elapsed(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
service.stop_torrent(&handle).await;
|
||||
@@ -861,6 +871,17 @@ impl TorrentService {
|
||||
.await
|
||||
{
|
||||
service.fail_job(&pool, &id, err.to_string()).await;
|
||||
crate::metrics::record_torrent_download(
|
||||
"failed",
|
||||
selected_size,
|
||||
download_start.elapsed(),
|
||||
);
|
||||
} else {
|
||||
crate::metrics::record_torrent_download(
|
||||
"completed",
|
||||
selected_size,
|
||||
download_start.elapsed(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -46,6 +46,12 @@
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="duration_seconds" checked> duration_seconds
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="local_genres" checked> local genres from files
|
||||
</label>
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center; margin-right:1rem; margin-bottom:.4rem;">
|
||||
<input type="checkbox" name="lastfm_tags" checked> Last.fm tags
|
||||
</label>
|
||||
</fieldset>
|
||||
<div style="display:flex; gap:1rem; align-items:center; margin-bottom:.9rem;">
|
||||
<label style="display:inline-flex; gap:.35rem; align-items:center;">
|
||||
|
||||
+1413
-104
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,13 @@
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-actions" x-show="$store.info.modal.actions && $store.info.modal.actions.length">
|
||||
<template x-for="(action, idx) in $store.info.modal.actions" :key="action.label + '-' + idx">
|
||||
<button class="info-action-btn" type="button" @click.stop.prevent="$store.info.runAction(idx)" :disabled="action.busy === true">
|
||||
<span x-text="action.busy ? '{{ t.player_resolving }}' : action.label"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="info-modal-body">
|
||||
<template x-if="$store.info.modal.rows && $store.info.modal.rows.length">
|
||||
<table class="info-table">
|
||||
@@ -112,6 +119,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="torrent-tabs">
|
||||
<button class="torrent-tab-btn"
|
||||
:class="{ active: $store.torrents.activeTab === 'import' }"
|
||||
@click="$store.torrents.showImportTab()">{{ t.player_import }}</button>
|
||||
<button class="torrent-tab-btn"
|
||||
:class="{ active: $store.torrents.activeTab === 'uploads' }"
|
||||
@click="$store.torrents.showUploadsTab()">
|
||||
<span>{{ t.player_my_uploads }}</span>
|
||||
<span class="torrent-tab-count"
|
||||
x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0"
|
||||
x-text="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if="$store.torrents.activeTab === 'import'">
|
||||
<div class="torrent-manager-layout">
|
||||
<aside class="torrent-manager-sidebar">
|
||||
<div class="torrent-manager-title">
|
||||
@@ -319,6 +341,297 @@
|
||||
</template>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.torrents.activeTab === 'uploads'">
|
||||
<section class="upload-manager-panel">
|
||||
<div class="upload-manager-head">
|
||||
<div>
|
||||
<h4>{{ t.player_my_uploaded_tracks }}</h4>
|
||||
<p x-text="$store.torrents.uploadSummary()"></p>
|
||||
</div>
|
||||
<button class="modal-btn modal-btn-ghost"
|
||||
@click="$store.torrents.loadUploads()"
|
||||
:disabled="$store.torrents.uploadHasEditorOpen()">{{ t.player_refresh }}</button>
|
||||
</div>
|
||||
|
||||
<template x-if="$store.torrents.uploadLoaded && $store.torrents.uploadTracks.length === 0 && $store.torrents.uploadPending.length === 0 && $store.torrents.uploadQueued.length === 0">
|
||||
<div class="empty-state torrent-workspace-empty">
|
||||
<p>{{ t.player_no_uploaded_tracks }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="upload-manager-grid">
|
||||
<aside class="upload-review-column">
|
||||
<div class="upload-panel-card">
|
||||
<div class="upload-panel-title">{{ t.player_needs_approval }}</div>
|
||||
<p class="upload-panel-subtitle" x-text="$store.torrents.uploadPendingTotal + ' {{ t.player_pending_or_failed }}'"></p>
|
||||
<template x-if="$store.torrents.uploadPending.length === 0">
|
||||
<div class="upload-mini-empty">{{ t.player_no_tracks_need_approval }}</div>
|
||||
</template>
|
||||
<div class="upload-review-list">
|
||||
<template x-for="item in $store.torrents.uploadPending" :key="item.id">
|
||||
<button class="upload-review-row" :class="{ active: $store.torrents.uploadReviewEditId === item.id, failed: item.status === 'failed' }" @click="$store.torrents.editUploadReview(item)">
|
||||
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="$store.torrents.uploadStatusLabel(item.status)"></span>
|
||||
<span class="upload-review-name" x-text="item.filename"></span>
|
||||
<span class="upload-review-error" x-show="item.error_message" x-text="item.error_message"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="$store.torrents.uploadQueuedTotal > 0">
|
||||
<div class="upload-panel-card upload-queue-panel">
|
||||
<div class="upload-panel-title upload-panel-title-row">
|
||||
<span>{{ t.player_queued_processing }}</span>
|
||||
<div class="upload-queue-nav" x-show="$store.torrents.uploadQueued.length > $store.torrents.uploadQueuePageSize">
|
||||
<span class="upload-queue-range" x-text="$store.torrents.uploadQueueRangeText()"></span>
|
||||
<button type="button"
|
||||
class="upload-queue-nav-btn"
|
||||
@click="$store.torrents.uploadQueuePrev()"
|
||||
:disabled="!$store.torrents.uploadQueueCanPrev()"
|
||||
title="{{ t.player_previous }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
<button type="button"
|
||||
class="upload-queue-nav-btn"
|
||||
@click="$store.torrents.uploadQueueNext()"
|
||||
:disabled="!$store.torrents.uploadQueueCanNext()"
|
||||
title="{{ t.player_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<template x-for="item in $store.torrents.compactQueuedUploads()" :key="item.id">
|
||||
<div class="upload-queue-row" :title="$store.torrents.uploadQueueTooltip(item)">
|
||||
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="$store.torrents.uploadStatusLabel(item.status)"></span>
|
||||
<span class="upload-queue-name" x-text="item.filename"></span>
|
||||
</div>
|
||||
</template>
|
||||
<div class="upload-mini-empty" x-show="$store.torrents.uploadQueuedTotal > $store.torrents.uploadQueued.length" x-text="$store.torrents.uploadQueueOverflowText()"></div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
<section class="upload-library-column">
|
||||
<div class="upload-bulk-bar" x-show="$store.torrents.uploadSelectedCount() > 0">
|
||||
<div class="upload-bulk-title" x-text="$store.torrents.uploadSelectedCount() + ' {{ t.player_selected }}'"></div>
|
||||
<input type="text" placeholder="{{ t.player_artists }}" x-model="$store.torrents.uploadBulkDraft.artists">
|
||||
<input type="text" placeholder="{{ t.player_featured }}" x-model="$store.torrents.uploadBulkDraft.featured_artists">
|
||||
<input type="text" placeholder="{{ t.player_album }}" x-model="$store.torrents.uploadBulkDraft.release_title">
|
||||
<input type="number" placeholder="{{ t.player_year }}" x-model="$store.torrents.uploadBulkDraft.release_year">
|
||||
<select x-model="$store.torrents.uploadBulkDraft.release_type">
|
||||
<option value="">{{ t.player_type_unchanged }}</option><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option>
|
||||
</select>
|
||||
<select x-model="$store.torrents.uploadBulkDraft.hidden">
|
||||
<option value="">{{ t.player_visibility_unchanged }}</option><option value="false">{{ t.player_visible }}</option><option value="true">{{ t.player_hidden }}</option>
|
||||
</select>
|
||||
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadBulkEdit()" :disabled="$store.torrents.uploadBulkSaving">{{ t.player_apply }}</button>
|
||||
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearUploadSelection()">{{ t.player_clear }}</button>
|
||||
</div>
|
||||
<div class="upload-release-tree">
|
||||
<template x-for="group in $store.torrents.uploadArtistGroups()" :key="group.key">
|
||||
<section class="upload-artist-group">
|
||||
<div class="upload-artist-row">
|
||||
<div class="upload-artist-name" x-text="group.name"></div>
|
||||
<div class="upload-artist-meta" x-text="group.releases.length + ' {{ t.player_releases_count }} - ' + group.trackCount + ' {{ t.player_tracks_count }}'"></div>
|
||||
</div>
|
||||
<template x-for="release in group.releases" :key="release.id">
|
||||
<div class="upload-release-node" :class="{ hidden: release.is_hidden }">
|
||||
<div class="upload-release-row">
|
||||
<button class="torrent-tree-check" :class="$store.torrents.uploadReleaseSelectionState(release)" @click="$store.torrents.toggleUploadReleaseSelection(release)">
|
||||
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'checked'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template>
|
||||
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'partial'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="5" y1="12" x2="19" y2="12"/></svg></template>
|
||||
</button>
|
||||
<button class="torrent-tree-toggle" :class="{ expanded: $store.torrents.uploadReleaseExpanded(release.id) }" @click="$store.torrents.toggleUploadRelease(release.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
|
||||
<div class="upload-release-main">
|
||||
<div class="upload-release-title"><span x-text="release.title"></span><span class="upload-hidden-pill" x-show="release.is_hidden">{{ t.player_hidden }}</span></div>
|
||||
<div class="upload-track-meta"><span x-text="$store.torrents.uploadReleaseArtistsText(release)"></span><span>-</span><span x-text="release.year || T.noYear"></span><span>-</span><span x-text="release.tracks.length + ' {{ t.player_tracks_count }}'"></span></div>
|
||||
</div>
|
||||
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUploadRelease(release)">{{ t.player_edit_release }}</button>
|
||||
</div>
|
||||
<div class="upload-track-children" x-show="$store.torrents.uploadReleaseExpanded(release.id)">
|
||||
<template x-for="item in release.tracks" :key="item.track.id">
|
||||
<div class="upload-tree-track" :class="{ hidden: item.is_hidden, selected: $store.torrents.selectedUploadTracks.has(item.track.id) }">
|
||||
<button class="torrent-tree-check" :class="{ checked: $store.torrents.selectedUploadTracks.has(item.track.id) }" @click="$store.torrents.toggleUploadTrackSelection(item.track.id)">
|
||||
<template x-if="$store.torrents.selectedUploadTracks.has(item.track.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template>
|
||||
</button>
|
||||
<div class="upload-track-main">
|
||||
<div class="upload-track-title"><span x-text="item.track.track_number ? item.track.track_number + '. ' + item.track.title : item.track.title"></span><span class="upload-hidden-pill" x-show="item.is_hidden">{{ t.player_hidden }}</span></div>
|
||||
<div class="upload-track-meta"><span x-text="$store.torrents.uploadArtistsText(item)"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="T.featuredShort"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="$store.torrents.uploadFeaturedArtistsText(item)"></span></div>
|
||||
</div>
|
||||
<div class="upload-track-actions">
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$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="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">{{ t.player_edit }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<template x-if="$store.torrents.uploadHasEditorOpen()">
|
||||
<div class="upload-editor-backdrop" @click.self="$store.torrents.closeUploadEditor()">
|
||||
<aside class="upload-editor-drawer">
|
||||
<div class="upload-editor-head">
|
||||
<div>
|
||||
<div class="upload-panel-title" x-text="$store.torrents.uploadEditorKicker()"></div>
|
||||
<h4 x-text="$store.torrents.uploadEditorTitle()"></h4>
|
||||
</div>
|
||||
<button class="track-action-btn" @click="$store.torrents.closeUploadEditor()" title="{{ t.player_close }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||||
<path d="M18 6L6 18M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template x-if="$store.torrents.uploadReviewDraft">
|
||||
<div class="upload-editor-form">
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_title }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.title"></label>
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_artist }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.artist"></label>
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.album"></label>
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_featured }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.featured_artists" placeholder="{{ t.player_artists_placeholder }}"></label>
|
||||
<label class="upload-field upload-field-compact"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadReviewDraft.year"></label>
|
||||
<label class="upload-field upload-field-compact"><span>{{ t.player_track_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadReviewDraft.track_number"></label>
|
||||
<label class="upload-field upload-field-compact"><span>{{ t.player_genre }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.genre"></label>
|
||||
<label class="upload-field upload-field-compact">
|
||||
<span>{{ t.player_type }}</span>
|
||||
<select x-model="$store.torrents.uploadReviewDraft.release_type">
|
||||
<option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="upload-field upload-field-wide"><span>{{ t.player_notes }}</span><textarea rows="4" x-model="$store.torrents.uploadReviewDraft.notes"></textarea></label>
|
||||
<div class="upload-editor-actions">
|
||||
<button class="modal-btn modal-btn-danger" @click="$store.torrents.deleteUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">{{ t.player_delete_review }}</button>
|
||||
<button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">{{ t.player_approve }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.torrents.uploadReleaseDraft">
|
||||
<div class="upload-editor-form">
|
||||
<label class="upload-field upload-field-wide"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.title"></label>
|
||||
<label class="upload-field upload-field-wide"><span>{{ t.player_album_artists }}</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.artists"></label>
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadReleaseDraft.year"></label>
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_type }}</span><select x-model="$store.torrents.uploadReleaseDraft.release_type"><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option></select></label>
|
||||
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadReleaseDraft.is_hidden"><span>{{ t.player_hidden }}</span></label>
|
||||
<div class="upload-editor-actions">
|
||||
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadReleaseEdit()" :disabled="$store.torrents.uploadReleaseSavingId === $store.torrents.uploadReleaseEditId">{{ t.player_save_release }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.torrents.uploadDraft">
|
||||
<div class="upload-editor-form">
|
||||
<label class="upload-field upload-field-wide"><span>{{ t.player_title }}</span><input type="text" x-model="$store.torrents.uploadDraft.title"></label>
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_artists }}</span><input type="text" x-model="$store.torrents.uploadDraft.artists"></label>
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_featured }}</span><input type="text" x-model="$store.torrents.uploadDraft.featured_artists"></label>
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadDraft.release_title"></label>
|
||||
<label class="upload-field upload-field-half"><span>{{ t.player_type }}</span><select x-model="$store.torrents.uploadDraft.release_type"><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option></select></label>
|
||||
<label class="upload-field upload-field-compact"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadDraft.release_year"></label>
|
||||
<label class="upload-field upload-field-compact"><span>{{ t.player_track_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"></label>
|
||||
<label class="upload-field upload-field-compact"><span>{{ t.player_disc_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number"></label>
|
||||
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden"><span>{{ t.player_hidden }}</span></label>
|
||||
<div class="upload-editor-actions">
|
||||
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadEdit()" :disabled="$store.torrents.uploadSavingId === $store.torrents.uploadEditId">{{ t.player_save_track }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</aside>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="upload-track-list" x-show="false">
|
||||
<template x-for="item in $store.torrents.uploadTracks" :key="item.track.id">
|
||||
<div class="upload-track-card" :class="{ hidden: item.is_hidden }">
|
||||
<template x-if="$store.torrents.uploadEditId !== item.track.id">
|
||||
<div class="upload-track-display">
|
||||
<div class="upload-track-main">
|
||||
<div class="upload-track-title">
|
||||
<span x-text="item.track.title"></span>
|
||||
<span class="upload-hidden-pill" x-show="item.is_hidden">{{ t.player_hidden }}</span>
|
||||
</div>
|
||||
<div class="upload-track-meta">
|
||||
<span x-text="$store.torrents.uploadArtistsText(item)"></span>
|
||||
<span>·</span>
|
||||
<span x-text="item.track.release_title"></span>
|
||||
<span x-show="item.track.track_number">·</span>
|
||||
<span x-show="item.track.track_number" x-text="'#' + item.track.track_number"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="upload-track-actions">
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$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="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">{{ t.player_edit }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="$store.torrents.uploadEditId === item.track.id">
|
||||
<div class="upload-edit-form">
|
||||
<label>
|
||||
<span>{{ t.player_title }}</span>
|
||||
<input type="text" x-model="$store.torrents.uploadDraft.title">
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t.player_artists }}</span>
|
||||
<input type="text" x-model="$store.torrents.uploadDraft.artists" placeholder="{{ t.player_artist_featured_placeholder }}">
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t.player_release }}</span>
|
||||
<input type="text" x-model="$store.torrents.uploadDraft.release_title">
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t.player_type }}</span>
|
||||
<select x-model="$store.torrents.uploadDraft.release_type">
|
||||
<option value="album">{{ t.player_release_type_album }}</option>
|
||||
<option value="single">{{ t.player_release_type_single }}</option>
|
||||
<option value="ep">{{ t.player_release_type_ep }}</option>
|
||||
<option value="compilation">{{ t.player_release_type_compilation }}</option>
|
||||
<option value="mixtape">{{ t.player_release_type_mixtape }}</option>
|
||||
<option value="live">{{ t.player_release_type_live }}</option>
|
||||
<option value="soundtrack">{{ t.player_release_type_soundtrack }}</option>
|
||||
<option value="remix">{{ t.player_release_type_remix }}</option>
|
||||
<option value="demo">{{ t.player_release_type_demo }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t.player_year }}</span>
|
||||
<input type="number" x-model="$store.torrents.uploadDraft.release_year">
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t.player_track_number }}</span>
|
||||
<input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number">
|
||||
</label>
|
||||
<label>
|
||||
<span>{{ t.player_disc_number }}</span>
|
||||
<input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number">
|
||||
</label>
|
||||
<label class="upload-hidden-toggle">
|
||||
<input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden">
|
||||
<span>{{ t.player_hidden }}</span>
|
||||
</label>
|
||||
<div class="upload-edit-actions">
|
||||
<button class="modal-btn modal-btn-primary"
|
||||
@click="$store.torrents.saveUploadEdit()"
|
||||
:disabled="$store.torrents.uploadSavingId === item.track.id">{{ t.player_save }}</button>
|
||||
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.cancelUploadEdit()">{{ t.player_cancel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -327,25 +640,96 @@
|
||||
<template x-if="$store.history.modal">
|
||||
<div class="modal-overlay" @click.self="$store.history.close()">
|
||||
<div class="modal-box history-modal">
|
||||
<div class="history-head">
|
||||
<div>
|
||||
<h3>{{ t.player_play_history }}</h3>
|
||||
<p class="torrent-message" :class="{ error: $store.history.error }"
|
||||
x-text="$store.history.message"></p>
|
||||
</div>
|
||||
<button class="mobile-list-action" @click="$store.history.close()" title="{{ t.player_close }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="history-list">
|
||||
<template x-if="!$store.history.loading && $store.history.items.length === 0">
|
||||
<div class="empty-state" style="padding:32px 16px">
|
||||
<p>{{ t.player_no_plays_yet }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="item in $store.history.items" :key="item.id">
|
||||
<div class="history-row">
|
||||
<div style="min-width:0">
|
||||
<div class="history-title" x-text="item.track_title"></div>
|
||||
<div class="history-release" x-text="item.release_title || '{{ t.player_unknown_release }}'"></div>
|
||||
<template x-if="$store.history.items.length > 0">
|
||||
<div class="history-table-head">
|
||||
<span></span>
|
||||
<span>{{ t.player_title }}</span>
|
||||
<span>{{ t.player_played_at }}</span>
|
||||
<span></span>
|
||||
<span style="text-align:right">{{ t.player_listened }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template x-for="(item, idx) in $store.history.items" :key="item.id">
|
||||
<div class="history-row track-row"
|
||||
:class="{ playing: $store.player.currentTrack && item.track && $store.player.currentTrack.id === item.track.id }"
|
||||
@dblclick="$store.history.playFrom(idx)">
|
||||
<button class="history-cover"
|
||||
@click.stop="$store.history.playFrom(idx)"
|
||||
:title="item.track?.title || item.track_title">
|
||||
<template x-if="item.track && item.track.cover_url">
|
||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!item.track || !item.track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
</template>
|
||||
</button>
|
||||
<div class="track-info">
|
||||
<div class="track-title" x-text="item.track?.title || item.track_title"></div>
|
||||
<div class="track-artists-inline">
|
||||
<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.history.close(); $store.library.openArtist(artist.id)" x-text="artist.label"></a>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="!item.track || !$store.library.trackArtistLinks(item.track).length">
|
||||
<span x-text="item.release_title || '{{ t.player_unknown_release }}'"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="history-release-line">
|
||||
<a class="artist-link"
|
||||
x-show="item.track && item.track.release_id"
|
||||
@click.stop="$store.history.close(); $store.library.openRelease(item.track.release_id)"
|
||||
x-text="item.track?.release_title || item.release_title || '{{ t.player_unknown_release }}'"></a>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="history-date" x-text="$store.history.date(item.played_at)"></div>
|
||||
<div class="history-duration" x-text="$store.history.duration(item.duration_listened)"></div>
|
||||
<div class="track-actions">
|
||||
<button class="track-action-btn info-btn popularity-info-btn"
|
||||
: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(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="like-btn" :class="{ liked: $store.likes.has(item.track_id) }" @click.stop="$store.likes.toggle(item.track_id)" title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(item.track_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 queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<span class="history-duration" x-text="$store.history.duration(item.duration_listened)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
+1653
-32
File diff suppressed because it is too large
Load Diff
+383
-84
@@ -60,7 +60,13 @@
|
||||
:class="{ active: $store.library.view === 'artists' }"
|
||||
@click="$store.library.goArtists()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
{{ t.player_artists }}
|
||||
{{ t.player_global_library }}
|
||||
</div>
|
||||
<div class="sidebar-nav-item"
|
||||
:class="{ active: $store.library.view === 'my_uploads' }"
|
||||
@click="$store.library.goMyUploads()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||
{{ t.player_my_uploads }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="sidebar-section">
|
||||
@@ -168,7 +174,13 @@
|
||||
:class="{ active: $store.library.view === 'artists' }"
|
||||
@click="$store.library.goArtists(); $store.mobile.closeLibrary()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
|
||||
{{ t.player_artists }}
|
||||
{{ t.player_global_library }}
|
||||
</div>
|
||||
<div class="sidebar-nav-item"
|
||||
:class="{ active: $store.library.view === 'my_uploads' }"
|
||||
@click="$store.library.goMyUploads(); $store.mobile.closeLibrary()">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
|
||||
{{ t.player_my_uploads }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -399,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>
|
||||
@@ -483,19 +484,19 @@
|
||||
<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>
|
||||
</button>
|
||||
<button class="track-action-btn play-btn" @click.stop="$store.library.playSearchTrack(idx)" title="{{ t.player_play }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.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" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<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" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<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>
|
||||
</div>
|
||||
@@ -510,11 +511,17 @@
|
||||
</template>
|
||||
|
||||
<!-- Artists Grid -->
|
||||
<template x-if="$store.library.view === 'artists'">
|
||||
<template x-if="$store.library.view === 'artists' || $store.library.view === 'my_uploads'">
|
||||
<div>
|
||||
<h1 class="section-title">{{ t.player_artists }}</h1>
|
||||
<h1 class="section-title" x-text="$store.library.view === 'my_uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></h1>
|
||||
<div class="card-grid">
|
||||
<template x-for="artist in $store.library.artists" :key="artist.id">
|
||||
<template x-for="(artist, idx) in $store.library.artists" :key="artist.id">
|
||||
<div class="artist-grid-entry">
|
||||
<div class="artist-section-divider"
|
||||
x-show="$store.library.shouldShowFeaturedSeparator(idx)"
|
||||
x-cloak>
|
||||
<span>{{ t.player_featured_only_artists }}</span>
|
||||
</div>
|
||||
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||||
<div class="card-img">
|
||||
<template x-if="artist.image_url">
|
||||
@@ -538,11 +545,25 @@
|
||||
<div class="card-title" x-text="artist.name"></div>
|
||||
<div class="card-subtitle" x-text="artist.release_count + ' {{ t.player_releases_count }} · ' + artist.track_count + ' {{ t.player_tracks_count }}'"></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<template x-if="$store.library.loading">
|
||||
<template x-if="!$store.library.loading && $store.library.artists.length === 0">
|
||||
<div class="empty-state">
|
||||
<p x-text="$store.library.view === 'my_uploads' ? '{{ t.player_no_uploaded_tracks }}' : '{{ t.artists_empty }}'"></p>
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="$store.library.loading && $store.library.artists.length === 0">
|
||||
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||
</template>
|
||||
<div class="scroll-load-indicator"
|
||||
x-show="$store.library.loading && $store.library.artists.length > 0"
|
||||
x-cloak
|
||||
role="status"
|
||||
aria-live="polite">
|
||||
<div class="scroll-load-spinner"></div>
|
||||
<span>{{ t.player_loading_more }}</span>
|
||||
</div>
|
||||
<div id="artist-sentinel" style="height:1px"></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -551,7 +572,8 @@
|
||||
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
|
||||
<div>
|
||||
<div class="breadcrumb">
|
||||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
||||
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
|
||||
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
|
||||
<span>/</span>
|
||||
<span x-text="$store.library.currentArtist.name"></span>
|
||||
</div>
|
||||
@@ -573,8 +595,17 @@
|
||||
<span>•</span>
|
||||
<span x-text="$store.library.currentArtist.total_play_count + ' {{ t.player_plays_count }}'"></span>
|
||||
</div>
|
||||
<div class="release-actions">
|
||||
<button class="release-action-btn secondary"
|
||||
<div class="release-actions artist-actions">
|
||||
<button class="release-action-btn primary artist-listen-action"
|
||||
:disabled="!($store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length)"
|
||||
@click="$store.library.playArtistTopTracks()"
|
||||
title="{{ t.player_listen_artist }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||
<path d="M8 5v14l11-7z"/>
|
||||
</svg>
|
||||
<span>{{ t.player_listen }}</span>
|
||||
</button>
|
||||
<button class="release-action-btn secondary artist-follow-action"
|
||||
:class="{ followed: $store.follows.has($store.library.currentArtist.id) }"
|
||||
@click="$store.follows.toggle($store.library.currentArtist.id)"
|
||||
:title="$store.follows.has($store.library.currentArtist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||||
@@ -589,6 +620,83 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<template x-if="$store.library.currentArtist.top_tracks && $store.library.currentArtist.top_tracks.length > 0">
|
||||
<section class="artist-release-group">
|
||||
<h2 class="artist-release-group-title">{{ t.player_top_tracks }}</h2>
|
||||
<div class="track-list-header">
|
||||
<span>#</span>
|
||||
<span>{{ t.player_title }}</span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentArtist.top_tracks" :key="track.id">
|
||||
<div class="track-row artist-appearance-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
@dblclick="$store.queue.playRelease($store.library.currentArtist.top_tracks, idx)">
|
||||
<span class="track-num" x-text="idx + 1"></span>
|
||||
<div class="track-info">
|
||||
<button class="artist-appearance-cover"
|
||||
type="button"
|
||||
@click.stop="$store.library.openRelease(track.release_id)"
|
||||
:title="track.release_title"
|
||||
aria-label="{{ t.player_release }}">
|
||||
<template x-if="track.cover_url">
|
||||
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
</template>
|
||||
</button>
|
||||
<div class="artist-appearance-copy">
|
||||
<div class="track-title">
|
||||
<span x-text="track.title"></span>
|
||||
<span style="color:var(--text-subdued)"> · </span>
|
||||
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
|
||||
</div>
|
||||
<div class="track-artists-inline">
|
||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(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>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<div class="track-actions">
|
||||
<button class="track-action-btn 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)"
|
||||
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>
|
||||
</button>
|
||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.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 queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<span class="track-duration" x-text="formatTime(track.duration_seconds)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<template x-for="group in $store.library.artistReleaseGroups()" :key="group.type">
|
||||
<section class="artist-release-group">
|
||||
<h2 class="artist-release-group-title" x-text="group.label"></h2>
|
||||
@@ -633,11 +741,24 @@
|
||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||
</div>
|
||||
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
|
||||
<div class="track-row"
|
||||
<div class="track-row artist-appearance-row"
|
||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
|
||||
<span class="track-num" x-text="idx + 1"></span>
|
||||
<div class="track-info">
|
||||
<button class="artist-appearance-cover"
|
||||
type="button"
|
||||
@click.stop="$store.library.openRelease(track.release_id)"
|
||||
:title="track.release_title"
|
||||
aria-label="{{ t.player_release }}">
|
||||
<template x-if="track.cover_url">
|
||||
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
||||
</template>
|
||||
<template x-if="!track.cover_url">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
|
||||
</template>
|
||||
</button>
|
||||
<div class="artist-appearance-copy">
|
||||
<div class="track-title">
|
||||
<span x-text="track.title"></span>
|
||||
<span style="color:var(--text-subdued)"> · </span>
|
||||
@@ -652,6 +773,7 @@
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span></span>
|
||||
<div class="track-actions">
|
||||
<button class="track-action-btn info-btn popularity-info-btn"
|
||||
@@ -663,19 +785,19 @@
|
||||
<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>
|
||||
</button>
|
||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)" title="{{ t.player_play }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.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" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<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" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<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>
|
||||
</div>
|
||||
@@ -691,7 +813,8 @@
|
||||
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
|
||||
<div>
|
||||
<div class="breadcrumb">
|
||||
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
|
||||
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
|
||||
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
|
||||
<span>/</span>
|
||||
<template x-if="$store.library.currentRelease.artists.length > 0">
|
||||
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
|
||||
@@ -729,6 +852,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)"
|
||||
@@ -736,6 +860,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 }}
|
||||
@@ -751,6 +884,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Track list -->
|
||||
<div class="track-list-header">
|
||||
<span>#</span>
|
||||
@@ -761,8 +895,9 @@
|
||||
</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 }"
|
||||
@dblclick="$store.queue.playRelease($store.library.currentRelease.tracks, idx)">
|
||||
: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">
|
||||
<div class="track-title" x-text="track.title"></div>
|
||||
@@ -786,19 +921,19 @@
|
||||
<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>
|
||||
</button>
|
||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentRelease.tracks, idx)" title="{{ t.player_play }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.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" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<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" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<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>
|
||||
</div>
|
||||
@@ -862,19 +997,19 @@
|
||||
<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>
|
||||
</button>
|
||||
<button class="track-action-btn play-btn" @click.stop="$store.queue.playRelease($store.library.currentPlaylist.tracks, idx)" title="{{ t.player_play }}">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||
</button>
|
||||
<button class="like-btn" :class="{ liked: $store.likes.has(track.id) }" @click.stop="$store.likes.toggle(track.id)" title="{{ t.player_like }}">
|
||||
<svg viewBox="0 0 24 24" :fill="$store.likes.has(track.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" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h8M5 18h14"/><path d="M17 10l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
<button class="track-action-btn queue-insert-btn queue-next-btn" @click.stop="$store.queue.addNextInQueue([track])" title="{{ t.player_play_next }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg>
|
||||
</button>
|
||||
<button class="track-action-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"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
<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" @click.stop="$store.playlists.showPicker([track.id])" title="{{ t.player_add_to_playlist }}">
|
||||
<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>
|
||||
</div>
|
||||
@@ -893,40 +1028,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"
|
||||
:class="{ active: idx === $store.queue.currentIndex, dragging: $store.queue._dragIdx === idx }"
|
||||
@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; }">
|
||||
<div class="queue-drag-handle" @mousedown.stop>
|
||||
@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, 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>
|
||||
@@ -936,15 +1082,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>
|
||||
@@ -955,11 +1101,19 @@
|
||||
</div>
|
||||
|
||||
<!-- Player Bar -->
|
||||
<div class="player-bar">
|
||||
<div class="player-bar"
|
||||
:class="{ 'mobile-expanded': $store.mobile.playerExpanded }">
|
||||
<div class="mobile-full-player">
|
||||
<button class="mobile-player-collapse-btn" type="button" @click.stop="$store.mobile.closePlayerFullscreen()" title="{{ t.player_close }}" aria-label="{{ t.player_close }}">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||||
<path d="M6 9l6 6 6-6"/>
|
||||
</svg>
|
||||
</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-cover">
|
||||
<div class="player-current-track">
|
||||
<div class="player-cover"
|
||||
@click.stop="$store.mobile.openPlayerFullscreen()">
|
||||
<template x-if="$store.player.currentTrack.cover_url">
|
||||
<img :src="$store.player.currentTrack.cover_url" :alt="$store.player.currentTrack.title">
|
||||
</template>
|
||||
@@ -977,6 +1131,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">
|
||||
@@ -989,6 +1149,9 @@
|
||||
<span class="player-release-year" x-text="' · ' + $store.player.currentTrack.release_year"></span>
|
||||
</template>
|
||||
</div>
|
||||
<div class="player-track-release" x-show="$store.player.currentTrack.release_title">
|
||||
<a class="artist-link" @click.stop="$store.player.currentTrack.release_id && $store.library.openRelease($store.player.currentTrack.release_id)" x-text="$store.player.currentTrack.release_title"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -999,7 +1162,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()">
|
||||
@@ -1010,7 +1173,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 }}">
|
||||
@@ -1024,11 +1187,15 @@
|
||||
</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>
|
||||
</div>
|
||||
<div class="player-progress-strip-times"
|
||||
x-text="'-' + formatTime(Math.max(0, $store.player.duration - $store.player.currentTime)) + ' / ' + formatTime($store.player.duration)"></div>
|
||||
<span class="player-time" x-text="formatTime($store.player.duration)"></span>
|
||||
</div>
|
||||
<div class="player-version-chip">v{{ t.app_version() }}</div>
|
||||
@@ -1050,7 +1217,7 @@
|
||||
<div class="volume-slider"
|
||||
@pointerdown.prevent="$store.player.startVolumeDrag($event)"
|
||||
aria-label="{{ t.player_volume }}">
|
||||
<div class="volume-slider-fill" :style="'width:' + ($store.player.volume * 100) + '%'">
|
||||
<div class="volume-slider-fill" :style="'width:' + $store.player.volumeSliderPercent() + '%'">
|
||||
<div class="volume-slider-thumb"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1059,6 +1226,16 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
||||
</button>
|
||||
<div class="device-picker" @click.outside="$store.devices.open = false">
|
||||
<template x-if="$store.devices.remoteHintVisible">
|
||||
<button class="remote-playback-hint"
|
||||
type="button"
|
||||
@click="$store.devices.dismissRemoteHint()"
|
||||
x-transition
|
||||
x-cloak>
|
||||
<span class="remote-playback-hint-text" x-text="$store.devices.remoteHintText()"></span>
|
||||
<span class="remote-playback-hint-progress"></span>
|
||||
</button>
|
||||
</template>
|
||||
<button class="queue-toggle-btn device-toggle-btn"
|
||||
:class="{ active: $store.devices.isActive() }"
|
||||
@click="$store.devices.toggle()"
|
||||
@@ -1069,11 +1246,18 @@
|
||||
<path d="M8 20h8"/>
|
||||
<path d="M12 16v4"/>
|
||||
</svg>
|
||||
<span class="jam-member-squares"
|
||||
x-show="$store.devices.activeJamMembers().length > 0"
|
||||
x-cloak>
|
||||
<template x-for="member in $store.devices.activeJamMembers().slice(0, 8)" :key="'device-jam-member-' + member.user_id">
|
||||
<span class="jam-member-square" :style="$store.devices.userColorStyle(member.user_id, member.name)"></span>
|
||||
</template>
|
||||
</span>
|
||||
</button>
|
||||
<div class="device-popover" x-show="$store.devices.open" x-transition x-cloak>
|
||||
<template x-for="device in $store.devices.devices" :key="device.id">
|
||||
<button class="device-row"
|
||||
:class="{ active: device.is_active }"
|
||||
:class="{ active: device.is_active, 'current-device': device.is_current }"
|
||||
@click="$store.devices.select(device.id)">
|
||||
<span class="device-row-icon">
|
||||
<template x-if="device.kind === 'phone'">
|
||||
@@ -1101,7 +1285,122 @@
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<template x-if="$store.devices.jams.length > 0">
|
||||
<div class="device-section-label jam-section-label">Jams</div>
|
||||
</template>
|
||||
<template x-for="jam in $store.devices.jams" :key="jam.id">
|
||||
<button class="device-row jam-row"
|
||||
:class="{ active: jam.is_active, pending: jam.is_pending }"
|
||||
@click="$store.devices.handleJamRowClick(jam)">
|
||||
<span class="device-row-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<circle cx="8" cy="8" r="3"/>
|
||||
<circle cx="16" cy="8" r="3"/>
|
||||
<path d="M4 20v-1a4 4 0 014-4h0a4 4 0 014 4v1"/>
|
||||
<path d="M12 20v-1a4 4 0 014-4h0a4 4 0 014 4v1"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="device-row-main">
|
||||
<span class="device-row-name" x-text="jam.name"></span>
|
||||
<span class="device-row-current"
|
||||
x-text="!jam.host_device_online ? 'Host offline' : (jam.is_pending ? 'Invite pending' : (jam.is_owner ? 'Your Jam' : 'Shared queue'))"></span>
|
||||
</span>
|
||||
<span class="device-row-check" x-show="jam.is_active">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</template>
|
||||
<button class="device-row start-jam-row" x-show="!$store.devices.hasJoinedJam()" @click="$store.devices.openJamPanel()">
|
||||
<span class="device-row-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14"/>
|
||||
<path d="M5 12h14"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="device-row-main">
|
||||
<span class="device-row-name">Start Jam</span>
|
||||
<span class="device-row-current">Invite listeners</span>
|
||||
</span>
|
||||
</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)">
|
||||
<span x-text="user.display_name || user.username"></span>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<input class="jam-user-search"
|
||||
type="search"
|
||||
placeholder="Search users"
|
||||
x-model="$store.devices.jamQuery"
|
||||
@input="$store.devices.queueJamSearch()">
|
||||
<div class="jam-search-results" x-show="$store.devices.jamUsers.length > 0">
|
||||
<template x-for="user in $store.devices.jamUsers" :key="user.id">
|
||||
<button class="jam-search-row" @click="$store.devices.addJamInvitee(user)">
|
||||
<span x-text="user.display_name || user.username"></span>
|
||||
<small x-text="user.email || user.username"></small>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
<div class="jam-panel-actions">
|
||||
<button class="jam-create-btn"
|
||||
:disabled="$store.devices.jamSelectedUsers.length === 0 && $store.devices.jamPanelMode === 'manage'"
|
||||
@click="$store.devices.submitJamPanel()"
|
||||
x-text="$store.devices.jamPanelMode === 'manage' ? 'Invite' : 'Create Jam'"></button>
|
||||
<button class="jam-leave-btn"
|
||||
x-show="$store.devices.jamPanelMode === 'manage'"
|
||||
@click="$store.devices.leaveJam($store.devices.jamPanelJamId)">Leave</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mobile-expanded-queue">
|
||||
<div class="mobile-expanded-queue-title">{{ t.player_queue }}</div>
|
||||
<template x-if="$store.queue.displayItems().length === 0">
|
||||
<div class="mobile-expanded-queue-empty">{{ t.player_queue_empty }}</div>
|
||||
</template>
|
||||
<template x-for="item in $store.queue.displayItems()" :key="'mobile-expanded-queue-' + item.key">
|
||||
<button class="mobile-expanded-queue-row"
|
||||
: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="item.index >= 0 ? $store.queue.playFromIndex(item.index) : $store.player.play(item.track)">
|
||||
<div class="mobile-expanded-queue-cover">
|
||||
<template x-if="item.track.cover_url">
|
||||
<img :src="item.track.cover_url" :alt="item.track.title" loading="lazy">
|
||||
</template>
|
||||
<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="item.track.title"></div>
|
||||
<div class="mobile-expanded-queue-artist">
|
||||
<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>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<span class="mobile-expanded-queue-time" x-text="formatTime(item.track.duration_seconds)"></span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+2314
-104
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user