Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b69cc0fc0 | |||
| 624cadab64 | |||
| a8756c95de | |||
| 952d11e6f5 | |||
| df16713aa2 | |||
| 3a9240b82c | |||
| d31dce3ece | |||
| 1e1453e465 | |||
| d2a8f301b8 | |||
| 0a4f78acfa | |||
| f716c22f86 | |||
| a1dafaa5f2 |
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.2.12"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.2.13"
|
version = "0.4.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,55 @@ impl App for AdminApp {
|
|||||||
},
|
},
|
||||||
"admin_v2_reviews_bulk",
|
"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(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/reviews/{id}/approve",
|
"/v2/api/reviews/{id}/approve",
|
||||||
{
|
{
|
||||||
@@ -308,6 +357,32 @@ impl App for AdminApp {
|
|||||||
}),
|
}),
|
||||||
"admin_v2_metadata_backfill_run_options",
|
"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(
|
Route::with_handler_and_name(
|
||||||
"/v2/api/jobs/{name}/run",
|
"/v2/api/jobs/{name}/run",
|
||||||
cot::router::method::post({
|
cot::router::method::post({
|
||||||
@@ -1249,6 +1324,9 @@ impl App for AdminApp {
|
|||||||
all.extend(cot::db::migrations::wrap_migrations(
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
crate::scheduler::db_migrations::MIGRATIONS,
|
crate::scheduler::db_migrations::MIGRATIONS,
|
||||||
));
|
));
|
||||||
|
all.extend(cot::db::migrations::wrap_migrations(
|
||||||
|
crate::auth::db_migrations::MIGRATIONS,
|
||||||
|
));
|
||||||
all
|
all
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+447
@@ -45,6 +45,13 @@ pub(super) struct LibraryQuery {
|
|||||||
pub(super) offset: Option<i64>,
|
pub(super) offset: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub(super) struct UsersQuery {
|
||||||
|
pub(super) search: Option<String>,
|
||||||
|
pub(super) limit: Option<i64>,
|
||||||
|
pub(super) offset: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct BulkReviewsRequest {
|
pub(super) struct BulkReviewsRequest {
|
||||||
action: String,
|
action: String,
|
||||||
@@ -76,10 +83,18 @@ pub struct MetadataBackfillRunRequest {
|
|||||||
local_genres: bool,
|
local_genres: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
lastfm_tags: bool,
|
lastfm_tags: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
musicbrainz_tags: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct ArtworkBackfillRunRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
overwrite_existing: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct UpdateLibraryItemRequest {
|
pub(super) struct UpdateLibraryItemRequest {
|
||||||
kind: String,
|
kind: String,
|
||||||
@@ -158,6 +173,68 @@ struct AdminDashboardDto {
|
|||||||
library: LibraryOverviewDto,
|
library: LibraryOverviewDto,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AdminUsersPageDto {
|
||||||
|
items: Vec<AdminUserRowDto>,
|
||||||
|
total: i64,
|
||||||
|
limit: i64,
|
||||||
|
offset: i64,
|
||||||
|
search: Option<String>,
|
||||||
|
online_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AdminUserRowDto {
|
||||||
|
id: i64,
|
||||||
|
username: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
role: String,
|
||||||
|
is_active: bool,
|
||||||
|
is_online: bool,
|
||||||
|
last_seen_ms: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AdminUserDetailDto {
|
||||||
|
user: AdminUserRowDto,
|
||||||
|
stats: AdminUserStatsDto,
|
||||||
|
recent_plays: Vec<AdminUserPlayDto>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AdminUserStatsDto {
|
||||||
|
plays: i64,
|
||||||
|
completed_plays: i64,
|
||||||
|
listened_seconds: i64,
|
||||||
|
liked_tracks: i64,
|
||||||
|
followed_artists: i64,
|
||||||
|
own_playlists: i64,
|
||||||
|
saved_playlists: i64,
|
||||||
|
uploaded_tracks: i64,
|
||||||
|
torrent_sessions: i64,
|
||||||
|
lastfm_connected: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
|
struct AdminUserPlayDto {
|
||||||
|
history_id: i64,
|
||||||
|
played_at: String,
|
||||||
|
duration_listened: Option<i32>,
|
||||||
|
completed: bool,
|
||||||
|
track_id: i64,
|
||||||
|
title: String,
|
||||||
|
artists: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: String,
|
||||||
|
release_year: Option<i32>,
|
||||||
|
cover_url: Option<String>,
|
||||||
|
track_duration_seconds: f64,
|
||||||
|
uploader_name: String,
|
||||||
|
audio_format: Option<String>,
|
||||||
|
audio_bitrate: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
struct OverviewStatsDto {
|
struct OverviewStatsDto {
|
||||||
tracks: i64,
|
tracks: i64,
|
||||||
@@ -227,6 +304,7 @@ struct MetadataTagDto {
|
|||||||
struct ReviewPageDto {
|
struct ReviewPageDto {
|
||||||
items: Vec<ReviewDto>,
|
items: Vec<ReviewDto>,
|
||||||
total: i64,
|
total: i64,
|
||||||
|
total_all: i64,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
offset: i64,
|
offset: i64,
|
||||||
status: Option<String>,
|
status: Option<String>,
|
||||||
@@ -645,6 +723,41 @@ pub async fn reviews(
|
|||||||
Json(page).into_response()
|
Json(page).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn users(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &PgPool,
|
||||||
|
query: UsersQuery,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
let page = load_admin_users_page(pool, query)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Json(page).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn user_detail(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = load_admin_user_detail(pool, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
match detail {
|
||||||
|
Some(detail) => Json(detail).into_response(),
|
||||||
|
None => Ok(json_error(StatusCode::NOT_FOUND, "user not found")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn bulk_reviews(
|
pub async fn bulk_reviews(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -972,6 +1085,7 @@ pub async fn run_metadata_backfill(
|
|||||||
duration_seconds: body.duration_seconds,
|
duration_seconds: body.duration_seconds,
|
||||||
local_genres: body.local_genres,
|
local_genres: body.local_genres,
|
||||||
lastfm_tags: body.lastfm_tags,
|
lastfm_tags: body.lastfm_tags,
|
||||||
|
musicbrainz_tags: body.musicbrainz_tags,
|
||||||
overwrite: body.overwrite,
|
overwrite: body.overwrite,
|
||||||
};
|
};
|
||||||
if !options.any_field() {
|
if !options.any_field() {
|
||||||
@@ -1019,6 +1133,56 @@ pub async fn run_metadata_backfill(
|
|||||||
Json(JobRunStartedDto { ok: true, run_id }).into_response()
|
Json(JobRunStartedDto { ok: true, run_id }).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn run_artwork_backfill(
|
||||||
|
session: Session,
|
||||||
|
db: Database,
|
||||||
|
pool: &PgPool,
|
||||||
|
Json(body): Json<ArtworkBackfillRunRequest>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
if let Err(response) = require_admin_json(&session, &db).await {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = crate::jobs::artwork_backfill::ArtworkBackfillOptions {
|
||||||
|
overwrite_existing: body.overwrite_existing,
|
||||||
|
};
|
||||||
|
let mut run = JobRun::create_running(&db, "artwork_backfill", "manual")
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(format!("failed to create job run: {e}")))?;
|
||||||
|
let run_id = run.id_val();
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let db_for_task = db.clone();
|
||||||
|
let pool_for_task = pool.clone();
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let ctx = scheduler::JobContext {
|
||||||
|
config: std::sync::Arc::new(live_config),
|
||||||
|
db: db_for_task.clone(),
|
||||||
|
pool: pool_for_task.clone(),
|
||||||
|
run_id,
|
||||||
|
registry: std::sync::Arc::new(JobRegistry::new()),
|
||||||
|
};
|
||||||
|
let mut log = scheduler::JobLog::with_live_flush(pool_for_task.clone(), run_id);
|
||||||
|
let result = crate::jobs::artwork_backfill::run_with_options(&ctx, &mut log, options).await;
|
||||||
|
let duration_ms = start.elapsed().as_millis() as i64;
|
||||||
|
match result {
|
||||||
|
Ok(()) => {
|
||||||
|
let _ = run
|
||||||
|
.set_completed(&db_for_task, duration_ms, &log.output())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
let _ = run
|
||||||
|
.set_failed(&db_for_task, duration_ms, &log.output(), &err.to_string())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Json(JobRunStartedDto { ok: true, run_id }).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn toggle_job(
|
pub async fn toggle_job(
|
||||||
session: Session,
|
session: Session,
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -1554,6 +1718,287 @@ async fn load_overview_stats(pool: &PgPool) -> anyhow::Result<OverviewStatsDto>
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct AdminUserSqlRow {
|
||||||
|
id: i64,
|
||||||
|
username: String,
|
||||||
|
display_name: Option<String>,
|
||||||
|
email: Option<String>,
|
||||||
|
role: String,
|
||||||
|
is_active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_admin_users_page(
|
||||||
|
pool: &PgPool,
|
||||||
|
query: UsersQuery,
|
||||||
|
) -> anyhow::Result<AdminUsersPageDto> {
|
||||||
|
let limit = query.limit.unwrap_or(40).clamp(10, 200);
|
||||||
|
let offset = query.offset.unwrap_or(0).max(0);
|
||||||
|
let search = query
|
||||||
|
.search
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_owned);
|
||||||
|
let pattern = search.as_ref().map(|value| format!("%{value}%"));
|
||||||
|
|
||||||
|
let mut count_qb =
|
||||||
|
QueryBuilder::<Postgres>::new("SELECT COUNT(*) FROM furumusic__user WHERE 1=1");
|
||||||
|
if let Some(pattern) = pattern.as_ref() {
|
||||||
|
count_qb.push(" AND (username ILIKE ");
|
||||||
|
count_qb.push_bind(pattern);
|
||||||
|
count_qb.push(" OR COALESCE(display_name, '') ILIKE ");
|
||||||
|
count_qb.push_bind(pattern);
|
||||||
|
count_qb.push(" OR COALESCE(email, '') ILIKE ");
|
||||||
|
count_qb.push_bind(pattern);
|
||||||
|
count_qb.push(")");
|
||||||
|
}
|
||||||
|
let total: i64 = count_qb.build_query_scalar().fetch_one(pool).await?;
|
||||||
|
|
||||||
|
let mut qb = QueryBuilder::<Postgres>::new(
|
||||||
|
"SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE 1=1",
|
||||||
|
);
|
||||||
|
if let Some(pattern) = pattern.as_ref() {
|
||||||
|
qb.push(" AND (username ILIKE ");
|
||||||
|
qb.push_bind(pattern);
|
||||||
|
qb.push(" OR COALESCE(display_name, '') ILIKE ");
|
||||||
|
qb.push_bind(pattern);
|
||||||
|
qb.push(" OR COALESCE(email, '') ILIKE ");
|
||||||
|
qb.push_bind(pattern);
|
||||||
|
qb.push(")");
|
||||||
|
}
|
||||||
|
qb.push(" ORDER BY username ASC LIMIT ");
|
||||||
|
qb.push_bind(limit);
|
||||||
|
qb.push(" OFFSET ");
|
||||||
|
qb.push_bind(offset);
|
||||||
|
let rows: Vec<AdminUserSqlRow> = qb.build_query_as().fetch_all(pool).await?;
|
||||||
|
|
||||||
|
let active = crate::metrics::active_user_last_seen_ms();
|
||||||
|
let online_cutoff_ms = 60_000;
|
||||||
|
let items = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| admin_user_row(row, &active, online_cutoff_ms))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let online_count = active
|
||||||
|
.values()
|
||||||
|
.filter(|last_seen_ms| **last_seen_ms <= online_cutoff_ms)
|
||||||
|
.count() as i64;
|
||||||
|
|
||||||
|
Ok(AdminUsersPageDto {
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
search,
|
||||||
|
online_count,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_admin_user_detail(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
) -> anyhow::Result<Option<AdminUserDetailDto>> {
|
||||||
|
let row = sqlx::query_as::<_, AdminUserSqlRow>(
|
||||||
|
"SELECT id, username::text, display_name, email, role::text, is_active FROM furumusic__user WHERE id = $1",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
let Some(row) = row else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let active = crate::metrics::active_user_last_seen_ms();
|
||||||
|
let user = admin_user_row(row, &active, 60_000);
|
||||||
|
let (
|
||||||
|
plays,
|
||||||
|
completed_plays,
|
||||||
|
listened_seconds,
|
||||||
|
liked_tracks,
|
||||||
|
followed_artists,
|
||||||
|
own_playlists,
|
||||||
|
saved_playlists,
|
||||||
|
uploaded_tracks,
|
||||||
|
torrent_sessions,
|
||||||
|
lastfm_connected,
|
||||||
|
) = tokio::try_join!(
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM furumusic__play_history WHERE user_id = $1 AND completed"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COALESCE(SUM(duration_listened), 0)::bigint FROM furumusic__play_history WHERE user_id = $1"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM furumusic__user_liked_track WHERE user_id = $1"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM furumusic__user_followed_artist WHERE user_id = $1"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM furumusic__playlist WHERE owner_id = $1"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM furumusic__saved_playlist WHERE user_id = $1"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(DISTINCT t.id) FROM furumusic__track t JOIN furumusic__media_file mf ON mf.id = t.audio_file_id WHERE mf.uploaded_by_user_id = $1"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
sqlx::query_scalar::<_, i64>(
|
||||||
|
"SELECT COUNT(*) FROM furumusic__torrent_session WHERE user_id = $1"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
sqlx::query_scalar::<_, bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM furumusic__lastfm_account WHERE user_id = $1 AND session_key <> '')"
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool),
|
||||||
|
)?;
|
||||||
|
let recent_plays = load_admin_user_recent_plays(pool, user_id, 30).await?;
|
||||||
|
|
||||||
|
Ok(Some(AdminUserDetailDto {
|
||||||
|
user,
|
||||||
|
stats: AdminUserStatsDto {
|
||||||
|
plays,
|
||||||
|
completed_plays,
|
||||||
|
listened_seconds,
|
||||||
|
liked_tracks,
|
||||||
|
followed_artists,
|
||||||
|
own_playlists,
|
||||||
|
saved_playlists,
|
||||||
|
uploaded_tracks,
|
||||||
|
torrent_sessions,
|
||||||
|
lastfm_connected,
|
||||||
|
},
|
||||||
|
recent_plays,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn load_admin_user_recent_plays(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: i64,
|
||||||
|
limit: i64,
|
||||||
|
) -> anyhow::Result<Vec<AdminUserPlayDto>> {
|
||||||
|
let rows = sqlx::query_as::<_, AdminUserPlaySqlRow>(
|
||||||
|
r#"SELECT ph.id AS history_id,
|
||||||
|
ph.played_at::text AS played_at,
|
||||||
|
ph.duration_listened,
|
||||||
|
ph.completed,
|
||||||
|
t.id AS track_id,
|
||||||
|
t.title::text AS title,
|
||||||
|
COALESCE(NULLIF(STRING_AGG(a.name::text, ', ' ORDER BY ta.position), ''), 'Unknown artist') AS artists,
|
||||||
|
t.release_id,
|
||||||
|
COALESCE(r.title::text, '') AS release_title,
|
||||||
|
r.year AS release_year,
|
||||||
|
t.cover_file_id,
|
||||||
|
r.cover_file_id AS release_cover_file_id,
|
||||||
|
t.duration_seconds AS track_duration_seconds,
|
||||||
|
COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name,
|
||||||
|
mf.audio_format,
|
||||||
|
mf.audio_bitrate
|
||||||
|
FROM furumusic__play_history ph
|
||||||
|
JOIN furumusic__track t ON t.id = ph.track_id
|
||||||
|
LEFT JOIN furumusic__release r ON r.id = t.release_id
|
||||||
|
LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
|
||||||
|
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main'
|
||||||
|
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id
|
||||||
|
WHERE ph.user_id = $1
|
||||||
|
GROUP BY ph.id, ph.played_at, ph.duration_listened, ph.completed, t.id, t.title,
|
||||||
|
t.release_id, r.title, r.year, t.cover_file_id, r.cover_file_id,
|
||||||
|
t.duration_seconds, mf.uploader_name, mf.audio_format, mf.audio_bitrate
|
||||||
|
ORDER BY ph.played_at DESC, ph.id DESC
|
||||||
|
LIMIT $2"#,
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| AdminUserPlayDto {
|
||||||
|
history_id: row.history_id,
|
||||||
|
played_at: row.played_at,
|
||||||
|
duration_listened: row.duration_listened,
|
||||||
|
completed: row.completed,
|
||||||
|
track_id: row.track_id,
|
||||||
|
title: row.title,
|
||||||
|
artists: row.artists,
|
||||||
|
release_id: row.release_id,
|
||||||
|
release_title: row.release_title,
|
||||||
|
release_year: row.release_year,
|
||||||
|
cover_url: admin_track_cover_url(row.cover_file_id, row.release_cover_file_id),
|
||||||
|
track_duration_seconds: row.track_duration_seconds,
|
||||||
|
uploader_name: row.uploader_name,
|
||||||
|
audio_format: row.audio_format,
|
||||||
|
audio_bitrate: row.audio_bitrate,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct AdminUserPlaySqlRow {
|
||||||
|
history_id: i64,
|
||||||
|
played_at: String,
|
||||||
|
duration_listened: Option<i32>,
|
||||||
|
completed: bool,
|
||||||
|
track_id: i64,
|
||||||
|
title: String,
|
||||||
|
artists: String,
|
||||||
|
release_id: i64,
|
||||||
|
release_title: String,
|
||||||
|
release_year: Option<i32>,
|
||||||
|
cover_file_id: Option<i64>,
|
||||||
|
release_cover_file_id: Option<i64>,
|
||||||
|
track_duration_seconds: f64,
|
||||||
|
uploader_name: String,
|
||||||
|
audio_format: Option<String>,
|
||||||
|
audio_bitrate: Option<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_track_cover_url(track_cover: Option<i64>, release_cover: Option<i64>) -> Option<String> {
|
||||||
|
track_cover
|
||||||
|
.or(release_cover)
|
||||||
|
.map(|id| format!("/api/player/cover/{id}/medium"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn admin_user_row(
|
||||||
|
row: AdminUserSqlRow,
|
||||||
|
active: &HashMap<i64, i64>,
|
||||||
|
online_cutoff_ms: i64,
|
||||||
|
) -> AdminUserRowDto {
|
||||||
|
let last_seen_ms = active.get(&row.id).copied();
|
||||||
|
AdminUserRowDto {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
display_name: row.display_name,
|
||||||
|
email: row.email,
|
||||||
|
role: row.role,
|
||||||
|
is_active: row.is_active,
|
||||||
|
is_online: last_seen_ms.is_some_and(|value| value <= online_cutoff_ms),
|
||||||
|
last_seen_ms,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn load_runtime_overview(config: &AppConfig) -> RuntimeOverviewDto {
|
fn load_runtime_overview(config: &AppConfig) -> RuntimeOverviewDto {
|
||||||
let llm_configured = !config.agent_llm_url.trim().is_empty();
|
let llm_configured = !config.agent_llm_url.trim().is_empty();
|
||||||
let agent_status = if !config.agent_enabled {
|
let agent_status = if !config.agent_enabled {
|
||||||
@@ -1724,6 +2169,7 @@ async fn load_review_page(pool: &PgPool, query: ReviewsQuery) -> anyhow::Result<
|
|||||||
let search_pattern = search.as_ref().map(|s| format!("%{s}%"));
|
let search_pattern = search.as_ref().map(|s| format!("%{s}%"));
|
||||||
|
|
||||||
let total = count_reviews(pool, status.clone(), search_pattern.clone()).await?;
|
let total = count_reviews(pool, status.clone(), search_pattern.clone()).await?;
|
||||||
|
let total_all = count_reviews(pool, None, search_pattern.clone()).await?;
|
||||||
let status_counts = load_review_status_counts(pool, None, search_pattern.clone()).await?;
|
let status_counts = load_review_status_counts(pool, None, search_pattern.clone()).await?;
|
||||||
|
|
||||||
let mut qb = QueryBuilder::<Postgres>::new(
|
let mut qb = QueryBuilder::<Postgres>::new(
|
||||||
@@ -1749,6 +2195,7 @@ async fn load_review_page(pool: &PgPool, query: ReviewsQuery) -> anyhow::Result<
|
|||||||
Ok(ReviewPageDto {
|
Ok(ReviewPageDto {
|
||||||
items,
|
items,
|
||||||
total,
|
total,
|
||||||
|
total_all,
|
||||||
limit,
|
limit,
|
||||||
offset,
|
offset,
|
||||||
status,
|
status,
|
||||||
|
|||||||
@@ -1472,6 +1472,7 @@ pub async fn metadata_backfill_run(
|
|||||||
duration_seconds: data.duration_seconds.is_some(),
|
duration_seconds: data.duration_seconds.is_some(),
|
||||||
local_genres: data.local_genres.is_some(),
|
local_genres: data.local_genres.is_some(),
|
||||||
lastfm_tags: data.lastfm_tags.is_some(),
|
lastfm_tags: data.lastfm_tags.is_some(),
|
||||||
|
musicbrainz_tags: true,
|
||||||
overwrite: data.mode.as_deref() == Some("overwrite"),
|
overwrite: data.mode.as_deref() == Some("overwrite"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -96,17 +96,11 @@ fn generate_missing_variants_sync(
|
|||||||
image::ExtendedColorType::Rgb8,
|
image::ExtendedColorType::Rgb8,
|
||||||
);
|
);
|
||||||
match result {
|
match result {
|
||||||
Ok(()) => crate::metrics::record_agent_cover_variant(
|
Ok(()) => {
|
||||||
variant.name,
|
crate::metrics::record_agent_cover_variant(variant.name, "ok", start.elapsed())
|
||||||
"ok",
|
}
|
||||||
start.elapsed(),
|
|
||||||
),
|
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
crate::metrics::record_agent_cover_variant(
|
crate::metrics::record_agent_cover_variant(variant.name, "error", start.elapsed());
|
||||||
variant.name,
|
|
||||||
"error",
|
|
||||||
start.elapsed(),
|
|
||||||
);
|
|
||||||
return Err(err.into());
|
return Err(err.into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+525
-11
@@ -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::db::Database;
|
||||||
|
use cot::http::StatusCode;
|
||||||
|
use cot::http::header::CONTENT_TYPE;
|
||||||
use cot::json::Json;
|
use cot::json::Json;
|
||||||
|
use cot::openapi::{AsApiOperation, RouteContext};
|
||||||
use cot::response::IntoResponse;
|
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::router::{Route, Router};
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use cot::{App, Body};
|
use cot::{App, Body, RequestHandler};
|
||||||
use schemars::JsonSchema;
|
use schemars::{JsonSchema, SchemaGenerator};
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::auth;
|
use crate::auth;
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::user::User;
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// JSON error helper
|
// JSON error helper
|
||||||
@@ -23,6 +36,199 @@ fn json_error(status: cot::http::StatusCode, message: &str) -> cot::response::Re
|
|||||||
.expect("valid response")
|
.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
|
// GET /api/me
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -34,8 +240,85 @@ struct MeResponse {
|
|||||||
role: String,
|
role: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response::Response> {
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
let Some(user) = auth::get_session_user(&session, &db).await else {
|
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(
|
return Ok(json_error(
|
||||||
cot::http::StatusCode::UNAUTHORIZED,
|
cot::http::StatusCode::UNAUTHORIZED,
|
||||||
"not authenticated",
|
"not authenticated",
|
||||||
@@ -50,6 +333,146 @@ async fn me_handler(session: Session, db: Database) -> cot::Result<cot::response
|
|||||||
.into_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
|
// App
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -62,10 +485,101 @@ impl App for ApiApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn router(&self) -> Router {
|
fn router(&self) -> Router {
|
||||||
Router::with_urls([Route::with_api_handler_and_name(
|
Router::with_urls([
|
||||||
"/me",
|
Route::with_api_handler_and_name(
|
||||||
api_get(me_handler),
|
"/me",
|
||||||
"api_me",
|
api_get(documented_response_handler::<MeResponse, _>(
|
||||||
)])
|
me_handler,
|
||||||
|
"Get the current authenticated user",
|
||||||
|
)),
|
||||||
|
"api_me",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/password",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
PasswordLoginRequest,
|
||||||
|
AuthLoginResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
password_login_handler,
|
||||||
|
"Log in with username and password",
|
||||||
|
)),
|
||||||
|
"api_auth_password",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/refresh",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
RefreshRequest,
|
||||||
|
AuthTokenResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
refresh_handler, "Refresh an API token pair"
|
||||||
|
)),
|
||||||
|
"api_auth_refresh",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/sso/exchange",
|
||||||
|
api_post(documented_json_handler::<
|
||||||
|
SsoExchangeRequest,
|
||||||
|
AuthLoginResponse,
|
||||||
|
_,
|
||||||
|
>(
|
||||||
|
sso_exchange_handler,
|
||||||
|
"Exchange a mobile SSO code for API tokens",
|
||||||
|
)),
|
||||||
|
"api_auth_sso_exchange",
|
||||||
|
),
|
||||||
|
Route::with_api_handler_and_name(
|
||||||
|
"/auth/logout",
|
||||||
|
api_post(documented_json_handler::<LogoutRequest, LogoutResponse, _>(
|
||||||
|
logout_handler,
|
||||||
|
"Revoke an API session",
|
||||||
|
)),
|
||||||
|
"api_auth_logout",
|
||||||
|
),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use cot::aide::openapi::{PathItem, ReferenceOr};
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn assert_get_path(paths: &cot::aide::openapi::Paths, path: &str) {
|
||||||
|
assert!(matches!(
|
||||||
|
paths.paths.get(path),
|
||||||
|
Some(ReferenceOr::Item(PathItem { get: Some(_), .. }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_post_path(paths: &cot::aide::openapi::Paths, path: &str) {
|
||||||
|
assert!(matches!(
|
||||||
|
paths.paths.get(path),
|
||||||
|
Some(ReferenceOr::Item(PathItem { post: Some(_), .. }))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn openapi_includes_auth_routes() {
|
||||||
|
let openapi = ApiApp.router().as_api();
|
||||||
|
let paths = openapi.paths.expect("OpenAPI paths");
|
||||||
|
|
||||||
|
assert_get_path(&paths, "/me");
|
||||||
|
assert_post_path(&paths, "/auth/password");
|
||||||
|
assert_post_path(&paths, "/auth/refresh");
|
||||||
|
assert_post_path(&paths, "/auth/sso/exchange");
|
||||||
|
assert_post_path(&paths, "/auth/logout");
|
||||||
|
|
||||||
|
let Some(ReferenceOr::Item(PathItem {
|
||||||
|
post: Some(operation),
|
||||||
|
..
|
||||||
|
})) = paths.paths.get("/auth/password")
|
||||||
|
else {
|
||||||
|
panic!("password auth path should be documented as POST");
|
||||||
|
};
|
||||||
|
assert!(operation.request_body.is_some());
|
||||||
|
assert!(operation.responses.is_some());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+588
-6
@@ -1,7 +1,13 @@
|
|||||||
|
use chrono::{Duration, Utc};
|
||||||
use cot::Body;
|
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::response::IntoResponse;
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
|
use serde::Serialize;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
use crate::user::User;
|
use crate::user::User;
|
||||||
|
|
||||||
@@ -37,6 +43,7 @@ impl Role {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const SESSION_USER_ID: &str = "user_id";
|
const SESSION_USER_ID: &str = "user_id";
|
||||||
|
const SESSION_POST_LOGIN_REDIRECT: &str = "post_login_redirect";
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AuthenticatedUser {
|
pub struct AuthenticatedUser {
|
||||||
@@ -45,11 +52,7 @@ pub struct AuthenticatedUser {
|
|||||||
pub role: Role,
|
pub role: Role,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `user_id` from the session, fetch the `User` from DB, return
|
fn authenticated_user_from_user(user: User) -> Option<AuthenticatedUser> {
|
||||||
/// `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()??;
|
|
||||||
if !user.is_active() {
|
if !user.is_active() {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -69,6 +72,362 @@ pub async fn get_session_user(session: &Session, db: &Database) -> Option<Authen
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read `user_id` from the session, fetch the `User` from DB, return
|
||||||
|
/// `AuthenticatedUser` if the user exists and is active.
|
||||||
|
pub async fn get_session_user(session: &Session, db: &Database) -> Option<AuthenticatedUser> {
|
||||||
|
let user_id: i64 = session.get(SESSION_USER_ID).await.ok()??;
|
||||||
|
let user = User::get_by_id(db, user_id).await.ok()??;
|
||||||
|
authenticated_user_from_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// API bearer-token auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const ACCESS_TOKEN_PREFIX: &str = "furu_at_";
|
||||||
|
const REFRESH_TOKEN_PREFIX: &str = "furu_rt_";
|
||||||
|
const MOBILE_EXCHANGE_CODE_PREFIX: &str = "furu_mx_";
|
||||||
|
const ACCESS_TOKEN_TTL_MINUTES: i64 = 15;
|
||||||
|
const REFRESH_TOKEN_TTL_DAYS: i64 = 60;
|
||||||
|
const MOBILE_EXCHANGE_CODE_TTL_MINUTES: i64 = 3;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct AuthContext {
|
||||||
|
bearer_token: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthContext {
|
||||||
|
pub fn bearer_token(&self) -> Option<&str> {
|
||||||
|
self.bearer_token.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FromRequestHead for AuthContext {
|
||||||
|
async fn from_request_head(head: &RequestHead) -> cot::Result<Self> {
|
||||||
|
let bearer_token = head
|
||||||
|
.headers
|
||||||
|
.get(AUTHORIZATION)
|
||||||
|
.and_then(|value| value.to_str().ok())
|
||||||
|
.and_then(parse_bearer_token)
|
||||||
|
.map(str::to_owned);
|
||||||
|
Ok(Self { bearer_token })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_bearer_token(header: &str) -> Option<&str> {
|
||||||
|
let header = header.trim();
|
||||||
|
let (scheme, token) = header.split_once(' ')?;
|
||||||
|
if !scheme.eq_ignore_ascii_case("Bearer") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let token = token.trim();
|
||||||
|
if token.is_empty() || token.len() > 512 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct ApiTokenPair {
|
||||||
|
pub access_token: String,
|
||||||
|
pub refresh_token: String,
|
||||||
|
pub token_type: &'static str,
|
||||||
|
pub expires_in_seconds: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cot::db::model]
|
||||||
|
pub struct ApiSession {
|
||||||
|
#[model(primary_key)]
|
||||||
|
id: Auto<i64>,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<String>,
|
||||||
|
access_token_hash: LimitedString<128>,
|
||||||
|
refresh_token_hash: LimitedString<128>,
|
||||||
|
access_expires_at: String,
|
||||||
|
refresh_expires_at: String,
|
||||||
|
created_at: String,
|
||||||
|
last_used_at: Option<String>,
|
||||||
|
revoked_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
#[cot::db::model]
|
||||||
|
pub struct MobileExchangeCode {
|
||||||
|
#[model(primary_key)]
|
||||||
|
id: Auto<i64>,
|
||||||
|
code_hash: LimitedString<128>,
|
||||||
|
user_id: i64,
|
||||||
|
created_at: String,
|
||||||
|
expires_at: String,
|
||||||
|
consumed_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ApiSession {
|
||||||
|
pub async fn create_for_user(
|
||||||
|
db: &Database,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
let tokens = fresh_token_pair();
|
||||||
|
let now = now_iso();
|
||||||
|
let mut session = Self {
|
||||||
|
id: Auto::auto(),
|
||||||
|
user_id,
|
||||||
|
device_name: device_name.and_then(normalize_device_name),
|
||||||
|
access_token_hash: LimitedString::new(&token_hash(&tokens.access_token)).unwrap(),
|
||||||
|
refresh_token_hash: LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap(),
|
||||||
|
access_expires_at: access_expires_at(),
|
||||||
|
refresh_expires_at: refresh_expires_at(),
|
||||||
|
created_at: now.clone(),
|
||||||
|
last_used_at: Some(now),
|
||||||
|
revoked_at: None,
|
||||||
|
};
|
||||||
|
session.insert(db).await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_access_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(ApiSession, $access_token_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_refresh_token(db: &Database, token: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(token)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(ApiSession, $refresh_token_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_revoked(&self) -> bool {
|
||||||
|
self.revoked_at.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_token_valid(&self) -> bool {
|
||||||
|
!self.is_revoked() && self.access_expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_token_valid(&self) -> bool {
|
||||||
|
!self.is_revoked() && self.refresh_expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn rotate(&mut self, db: &Database) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
let tokens = fresh_token_pair();
|
||||||
|
self.access_token_hash = LimitedString::new(&token_hash(&tokens.access_token)).unwrap();
|
||||||
|
self.refresh_token_hash = LimitedString::new(&token_hash(&tokens.refresh_token)).unwrap();
|
||||||
|
self.access_expires_at = access_expires_at();
|
||||||
|
self.refresh_expires_at = refresh_expires_at();
|
||||||
|
self.last_used_at = Some(now_iso());
|
||||||
|
self.save(db).await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn revoke(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
if self.revoked_at.is_none() {
|
||||||
|
self.revoked_at = Some(now_iso());
|
||||||
|
self.save(db).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_session(
|
||||||
|
db: &Database,
|
||||||
|
user_id: i64,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<ApiTokenPair> {
|
||||||
|
ApiSession::create_for_user(db, user_id, device_name).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_bearer_user(db: &Database, token: &str) -> Option<AuthenticatedUser> {
|
||||||
|
let session = ApiSession::find_by_access_token(db, token).await.ok()??;
|
||||||
|
if !session.access_token_valid() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let user = User::get_by_id(db, session.user_id).await.ok()??;
|
||||||
|
authenticated_user_from_user(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_request_user(
|
||||||
|
auth: &AuthContext,
|
||||||
|
session: &Session,
|
||||||
|
db: &Database,
|
||||||
|
) -> Option<AuthenticatedUser> {
|
||||||
|
if let Some(token) = auth.bearer_token() {
|
||||||
|
return get_bearer_user(db, token).await;
|
||||||
|
}
|
||||||
|
get_session_user(session, db).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn refresh_api_session(
|
||||||
|
db: &Database,
|
||||||
|
refresh_token: &str,
|
||||||
|
) -> cot::db::Result<Option<ApiTokenPair>> {
|
||||||
|
let Some(mut session) = ApiSession::find_by_refresh_token(db, refresh_token).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !session.refresh_token_valid() {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(user) = User::get_by_id(db, session.user_id).await? else {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !user.is_active() {
|
||||||
|
session.revoke(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
Ok(Some(session.rotate(db).await?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn revoke_api_session(
|
||||||
|
db: &Database,
|
||||||
|
access_token: Option<&str>,
|
||||||
|
refresh_token: Option<&str>,
|
||||||
|
) -> cot::db::Result<bool> {
|
||||||
|
let mut session = if let Some(token) = access_token {
|
||||||
|
ApiSession::find_by_access_token(db, token).await?
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if session.is_none() {
|
||||||
|
if let Some(token) = refresh_token {
|
||||||
|
session = ApiSession::find_by_refresh_token(db, token).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(mut session) = session else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
session.revoke(db).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MobileExchangeCode {
|
||||||
|
pub async fn create_for_user(db: &Database, user_id: i64) -> cot::db::Result<String> {
|
||||||
|
let code = random_token(MOBILE_EXCHANGE_CODE_PREFIX);
|
||||||
|
let now = now_iso();
|
||||||
|
let mut row = Self {
|
||||||
|
id: Auto::auto(),
|
||||||
|
code_hash: LimitedString::new(&token_hash(&code)).unwrap(),
|
||||||
|
user_id,
|
||||||
|
created_at: now,
|
||||||
|
expires_at: mobile_exchange_code_expires_at(),
|
||||||
|
consumed_at: None,
|
||||||
|
};
|
||||||
|
row.insert(db).await?;
|
||||||
|
Ok(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn find_by_code(db: &Database, code: &str) -> cot::db::Result<Option<Self>> {
|
||||||
|
let Ok(hash) = LimitedString::<128>::new(&token_hash(code)) else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
cot::db::query!(MobileExchangeCode, $code_hash == hash)
|
||||||
|
.get(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_valid(&self) -> bool {
|
||||||
|
self.consumed_at.is_none() && self.expires_at > now_iso()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn consume(&mut self, db: &Database) -> cot::db::Result<()> {
|
||||||
|
self.consumed_at = Some(now_iso());
|
||||||
|
self.save(db).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_mobile_exchange_code(db: &Database, user_id: i64) -> cot::db::Result<String> {
|
||||||
|
MobileExchangeCode::create_for_user(db, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn exchange_mobile_code_for_api_session(
|
||||||
|
db: &Database,
|
||||||
|
code: &str,
|
||||||
|
device_name: Option<&str>,
|
||||||
|
) -> cot::db::Result<Option<(AuthenticatedUser, ApiTokenPair)>> {
|
||||||
|
let Some(mut exchange_code) = MobileExchangeCode::find_by_code(db, code).await? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if !exchange_code.is_valid() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let Some(user) = User::get_by_id(db, exchange_code.user_id).await? else {
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let Some(auth_user) = authenticated_user_from_user(user) else {
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
exchange_code.consume(db).await?;
|
||||||
|
let tokens = ApiSession::create_for_user(db, auth_user.id, device_name).await?;
|
||||||
|
Ok(Some((auth_user, tokens)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fresh_token_pair() -> ApiTokenPair {
|
||||||
|
ApiTokenPair {
|
||||||
|
access_token: random_token(ACCESS_TOKEN_PREFIX),
|
||||||
|
refresh_token: random_token(REFRESH_TOKEN_PREFIX),
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in_seconds: ACCESS_TOKEN_TTL_MINUTES * 60,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn random_token(prefix: &str) -> String {
|
||||||
|
format!(
|
||||||
|
"{prefix}{}{}",
|
||||||
|
uuid::Uuid::new_v4().simple(),
|
||||||
|
uuid::Uuid::new_v4().simple()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_hash(token: &str) -> String {
|
||||||
|
let digest = Sha256::digest(token.as_bytes());
|
||||||
|
let mut out = String::with_capacity(digest.len() * 2);
|
||||||
|
for byte in digest {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_device_name(name: &str) -> Option<String> {
|
||||||
|
let trimmed = name.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(trimmed.chars().take(255).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_iso() -> String {
|
||||||
|
Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn access_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::minutes(ACCESS_TOKEN_TTL_MINUTES))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::days(REFRESH_TOKEN_TTL_DAYS))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_exchange_code_expires_at() -> String {
|
||||||
|
(Utc::now() + Duration::minutes(MOBILE_EXCHANGE_CODE_TTL_MINUTES))
|
||||||
|
.format("%Y-%m-%dT%H:%M:%SZ")
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
/// Return `Ok(user)` if the session belongs to an active admin, otherwise
|
||||||
/// `Err(response)` — a redirect to `/login` or a 403.
|
/// `Err(response)` — a redirect to `/login` or a 403.
|
||||||
pub async fn require_admin_or_redirect(
|
pub async fn require_admin_or_redirect(
|
||||||
@@ -103,6 +462,43 @@ pub async fn login(session: &Session, user_id: i64) -> cot::Result<()> {
|
|||||||
Ok(())
|
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.
|
/// Flush (destroy) the session.
|
||||||
pub async fn logout(session: &Session) -> cot::Result<()> {
|
pub async fn logout(session: &Session) -> cot::Result<()> {
|
||||||
session
|
session
|
||||||
@@ -121,6 +517,192 @@ pub fn redirect(location: &str) -> cot::response::Response {
|
|||||||
.expect("valid 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
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -338,10 +338,52 @@ impl AppConfig {
|
|||||||
pub fn load() -> Self {
|
pub fn load() -> Self {
|
||||||
let mut cfg = Self::default();
|
let mut cfg = Self::default();
|
||||||
cfg.apply_env_overrides();
|
cfg.apply_env_overrides();
|
||||||
|
cfg.apply_startup_db_overrides();
|
||||||
|
cfg.apply_env_overrides();
|
||||||
cfg.normalize_host_paths();
|
cfg.normalize_host_paths();
|
||||||
cfg
|
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
|
/// Build config with full 3-layer resolution (default → DB → env) and
|
||||||
/// track the source of each field.
|
/// track the source of each field.
|
||||||
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
pub async fn load_with_db(db: &Database) -> (Self, ConfigSources) {
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ translations! {
|
|||||||
// Player UI
|
// Player UI
|
||||||
player_library: "Library" , "Библиотека";
|
player_library: "Library" , "Библиотека";
|
||||||
player_artists: "Artists" , "Артисты";
|
player_artists: "Artists" , "Артисты";
|
||||||
|
player_global_library: "Global" , "Global";
|
||||||
|
player_featured_only_artists: "Featured only" , "Только фиты";
|
||||||
player_release: "Release" , "Релиз";
|
player_release: "Release" , "Релиз";
|
||||||
player_releases: "Releases" , "Релизы";
|
player_releases: "Releases" , "Релизы";
|
||||||
player_tracks: "Tracks" , "Треки";
|
player_tracks: "Tracks" , "Треки";
|
||||||
@@ -298,6 +300,7 @@ translations! {
|
|||||||
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
|
||||||
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
|
||||||
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
|
||||||
|
player_active_device: "Active device" , "Активный девайс";
|
||||||
player_no_results: "No results found" , "Ничего не найдено";
|
player_no_results: "No results found" , "Ничего не найдено";
|
||||||
player_new_playlist: "New Playlist" , "Новый плейлист";
|
player_new_playlist: "New Playlist" , "Новый плейлист";
|
||||||
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
|
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
|
||||||
@@ -355,6 +358,11 @@ translations! {
|
|||||||
player_add_to_queue: "Add to queue" , "Добавить в очередь";
|
player_add_to_queue: "Add to queue" , "Добавить в очередь";
|
||||||
player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди";
|
player_add_to_end_queue: "Add to end of queue" , "Добавить в конец очереди";
|
||||||
player_play_next: "Play next" , "Играть следующим";
|
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_queue: "Queue" , "Очередь";
|
||||||
player_next: "Next" , "Далее";
|
player_next: "Next" , "Далее";
|
||||||
player_previous: "Previous" , "Назад";
|
player_previous: "Previous" , "Назад";
|
||||||
@@ -478,6 +486,7 @@ translations! {
|
|||||||
player_seen: "seen" , "видели";
|
player_seen: "seen" , "видели";
|
||||||
player_eta: "eta" , "осталось";
|
player_eta: "eta" , "осталось";
|
||||||
player_loading_history: "Loading history..." , "Загрузка истории...";
|
player_loading_history: "Loading history..." , "Загрузка истории...";
|
||||||
|
player_loading_more: "Loading more..." , "Загружаю ещё...";
|
||||||
player_failed_load_history: "Failed to load history" , "Не удалось загрузить историю";
|
player_failed_load_history: "Failed to load history" , "Не удалось загрузить историю";
|
||||||
player_total_plays: "total plays" , "прослушиваний всего";
|
player_total_plays: "total plays" , "прослушиваний всего";
|
||||||
player_play_history: "Play history" , "История прослушиваний";
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+576
-77
@@ -15,12 +15,19 @@ pub struct ArtworkBackfillJob;
|
|||||||
const LASTFM_REQUEST_DELAY: std::time::Duration = std::time::Duration::from_millis(1200);
|
const LASTFM_REQUEST_DELAY: std::time::Duration = std::time::Duration::from_millis(1200);
|
||||||
const MAX_LASTFM_RELEASE_LOOKUPS: i64 = 200;
|
const MAX_LASTFM_RELEASE_LOOKUPS: i64 = 200;
|
||||||
const MAX_LASTFM_ARTIST_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)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
struct ReleaseCandidate {
|
struct ReleaseCandidate {
|
||||||
id: i64,
|
id: i64,
|
||||||
title: String,
|
title: String,
|
||||||
artist_name: Option<String>,
|
artist_name: Option<String>,
|
||||||
|
track_title: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, sqlx::FromRow)]
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
@@ -36,6 +43,13 @@ struct ArtworkRefCandidate {
|
|||||||
file_path: Option<String>,
|
file_path: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, sqlx::FromRow)]
|
||||||
|
struct ArtistImageRepairCandidate {
|
||||||
|
id: i64,
|
||||||
|
name: String,
|
||||||
|
media_file_id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct LastfmAlbumResponse {
|
struct LastfmAlbumResponse {
|
||||||
album: Option<LastfmImageContainer>,
|
album: Option<LastfmImageContainer>,
|
||||||
@@ -85,7 +99,10 @@ struct ArtworkStats {
|
|||||||
broken_release_refs_cleared: u64,
|
broken_release_refs_cleared: u64,
|
||||||
broken_track_refs_cleared: u64,
|
broken_track_refs_cleared: u64,
|
||||||
broken_artist_refs_cleared: u64,
|
broken_artist_refs_cleared: u64,
|
||||||
|
remote_artist_album_fallback_refs_cleared: u64,
|
||||||
release_local_assigned: u64,
|
release_local_assigned: u64,
|
||||||
|
release_caa_assigned: u64,
|
||||||
|
release_caa_not_found: u64,
|
||||||
release_lastfm_assigned: u64,
|
release_lastfm_assigned: u64,
|
||||||
release_lastfm_not_found: u64,
|
release_lastfm_not_found: u64,
|
||||||
release_skipped_no_audio: u64,
|
release_skipped_no_audio: u64,
|
||||||
@@ -114,66 +131,107 @@ impl Job for ArtworkBackfillJob {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
|
||||||
let storage_dir = ctx.config.agent_storage_dir.trim();
|
run_with_options(
|
||||||
if storage_dir.is_empty() {
|
ctx,
|
||||||
log.warn("agent_storage_dir is not configured, skipping artwork backfill");
|
log,
|
||||||
return Ok(());
|
ArtworkBackfillOptions {
|
||||||
}
|
overwrite_existing: false,
|
||||||
|
},
|
||||||
let client = Client::builder()
|
)
|
||||||
.user_agent(format!(
|
.await
|
||||||
"furumusic-artwork-backfill/{}",
|
|
||||||
env!("CARGO_PKG_VERSION")
|
|
||||||
))
|
|
||||||
.timeout(std::time::Duration::from_secs(20))
|
|
||||||
.build()?;
|
|
||||||
let mut stats = ArtworkStats::default();
|
|
||||||
|
|
||||||
let normalized_paths =
|
|
||||||
crate::media_paths::normalize_media_file_paths(&ctx.pool, storage_dir).await?;
|
|
||||||
if normalized_paths > 0 {
|
|
||||||
log.info(&format!(
|
|
||||||
"Media path normalization pass: rewrote {normalized_paths} media file path(s) to relative storage paths"
|
|
||||||
));
|
|
||||||
} else {
|
|
||||||
log.info("Media path normalization pass: all media file paths are already relative");
|
|
||||||
}
|
|
||||||
|
|
||||||
repair_missing_artwork_refs(ctx, log, storage_dir, &mut stats).await?;
|
|
||||||
backfill_release_local(ctx, log, storage_dir, &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_artist_album_fallbacks(ctx, log, &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={}",
|
|
||||||
stats.broken_release_refs_cleared,
|
|
||||||
stats.broken_track_refs_cleared,
|
|
||||||
stats.broken_artist_refs_cleared,
|
|
||||||
stats.release_local_assigned,
|
|
||||||
stats.release_lastfm_assigned,
|
|
||||||
stats.release_lastfm_not_found,
|
|
||||||
stats.release_skipped_no_audio,
|
|
||||||
stats.artist_lastfm_assigned,
|
|
||||||
stats.artist_lastfm_not_found,
|
|
||||||
stats.artist_album_fallback_assigned,
|
|
||||||
stats.variants_created,
|
|
||||||
stats.variants_unchanged,
|
|
||||||
stats.variants_missing_original,
|
|
||||||
stats.failed
|
|
||||||
));
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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/{}",
|
||||||
|
env!("CARGO_PKG_VERSION")
|
||||||
|
))
|
||||||
|
.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 =
|
||||||
|
crate::media_paths::normalize_media_file_paths(&ctx.pool, storage_dir).await?;
|
||||||
|
if normalized_paths > 0 {
|
||||||
|
log.info(&format!(
|
||||||
|
"Media path normalization pass: rewrote {normalized_paths} media file path(s) to relative storage paths"
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
log.info("Media path normalization pass: all media file paths are already relative");
|
||||||
|
}
|
||||||
|
|
||||||
|
repair_missing_artwork_refs(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, options, &mut stats)
|
||||||
|
.await?;
|
||||||
|
backfill_artist_lastfm(ctx, log, storage_dir, api_key, &client, options, &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={}, 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,
|
||||||
|
stats.artist_lastfm_assigned,
|
||||||
|
stats.artist_lastfm_not_found,
|
||||||
|
stats.artist_album_fallback_assigned,
|
||||||
|
stats.variants_created,
|
||||||
|
stats.variants_unchanged,
|
||||||
|
stats.variants_missing_original,
|
||||||
|
stats.failed
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn repair_missing_artwork_refs(
|
async fn repair_missing_artwork_refs(
|
||||||
ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
log: &mut JobLog,
|
log: &mut JobLog,
|
||||||
@@ -186,6 +244,71 @@ async fn repair_missing_artwork_refs(
|
|||||||
Ok(())
|
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(
|
async fn repair_missing_release_cover_refs(
|
||||||
ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
log: &mut JobLog,
|
log: &mut JobLog,
|
||||||
@@ -360,6 +483,7 @@ async fn backfill_release_local(
|
|||||||
ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
log: &mut JobLog,
|
log: &mut JobLog,
|
||||||
storage_dir: &str,
|
storage_dir: &str,
|
||||||
|
options: ArtworkBackfillOptions,
|
||||||
stats: &mut ArtworkStats,
|
stats: &mut ArtworkStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let releases = sqlx::query_as::<_, ReleaseCandidate>(
|
let releases = sqlx::query_as::<_, ReleaseCandidate>(
|
||||||
@@ -372,17 +496,26 @@ async fn backfill_release_local(
|
|||||||
WHERE ra.release_id = r.id
|
WHERE ra.release_id = r.id
|
||||||
ORDER BY ra.position
|
ORDER BY ra.position
|
||||||
LIMIT 1
|
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
|
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
|
AND r.is_hidden = false
|
||||||
ORDER BY r.id"#,
|
ORDER BY r.id"#,
|
||||||
)
|
)
|
||||||
|
.bind(options.overwrite_existing)
|
||||||
.fetch_all(&ctx.pool)
|
.fetch_all(&ctx.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if releases.is_empty() {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
@@ -451,7 +584,7 @@ async fn backfill_release_local(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(cover_file_id) => {
|
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;
|
stats.release_local_assigned += 1;
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Release {} \"{}\": assigned local cover from {source_desc}",
|
"Release {} \"{}\": assigned local cover from {source_desc}",
|
||||||
@@ -471,12 +604,12 @@ async fn backfill_release_local(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn backfill_release_lastfm(
|
async fn backfill_release_coverartarchive(
|
||||||
ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
log: &mut JobLog,
|
log: &mut JobLog,
|
||||||
storage_dir: &str,
|
storage_dir: &str,
|
||||||
api_key: &str,
|
client: &crate::jobs::musicbrainz::MusicBrainzClient,
|
||||||
client: &Client,
|
options: ArtworkBackfillOptions,
|
||||||
stats: &mut ArtworkStats,
|
stats: &mut ArtworkStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let failed_cutoff = cutoff_iso(1);
|
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
|
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, ta.position
|
||||||
LIMIT 1
|
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
|
FROM furumusic__release r
|
||||||
LEFT JOIN furumusic__artwork_lookup_state s
|
LEFT JOIN furumusic__artwork_lookup_state s
|
||||||
ON s.entity_kind = 'release'
|
ON s.entity_kind = 'release'
|
||||||
AND s.entity_id = r.id
|
AND s.entity_id = r.id
|
||||||
AND s.source = 'lastfm'
|
AND s.source = 'coverartarchive'
|
||||||
WHERE r.cover_file_id IS NULL
|
WHERE ($3 OR r.cover_file_id IS NULL)
|
||||||
AND r.is_hidden = false
|
AND r.is_hidden = false
|
||||||
AND (
|
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 = '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 = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
|
||||||
OR s.status = 'found' AND s.last_attempt_at < $1
|
OR s.status = 'found' AND s.last_attempt_at < $1
|
||||||
)
|
)
|
||||||
ORDER BY s.last_attempt_at NULLS FIRST, r.id
|
ORDER BY s.last_attempt_at NULLS FIRST, r.id
|
||||||
LIMIT $3"#,
|
LIMIT $4"#,
|
||||||
)
|
)
|
||||||
.bind(&failed_cutoff)
|
.bind(&failed_cutoff)
|
||||||
.bind(¬_found_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)
|
.bind(MAX_LASTFM_RELEASE_LOOKUPS)
|
||||||
.fetch_all(&ctx.pool)
|
.fetch_all(&ctx.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -580,7 +973,7 @@ async fn backfill_release_lastfm(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(cover_file_id) => {
|
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?;
|
.await?;
|
||||||
record_lookup_state(
|
record_lookup_state(
|
||||||
&ctx.pool,
|
&ctx.pool,
|
||||||
@@ -680,12 +1073,37 @@ async fn backfill_release_lastfm(
|
|||||||
Ok(())
|
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(
|
async fn backfill_artist_lastfm(
|
||||||
ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
log: &mut JobLog,
|
log: &mut JobLog,
|
||||||
storage_dir: &str,
|
storage_dir: &str,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
|
options: ArtworkBackfillOptions,
|
||||||
stats: &mut ArtworkStats,
|
stats: &mut ArtworkStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let failed_cutoff = cutoff_iso(1);
|
let failed_cutoff = cutoff_iso(1);
|
||||||
@@ -699,21 +1117,25 @@ async fn backfill_artist_lastfm(
|
|||||||
AND s.entity_id = a.id
|
AND s.entity_id = a.id
|
||||||
AND s.source = 'lastfm'
|
AND s.source = 'lastfm'
|
||||||
WHERE (
|
WHERE (
|
||||||
|
$3
|
||||||
|
OR
|
||||||
a.image_file_id IS NULL
|
a.image_file_id IS NULL
|
||||||
OR mf.file_path NOT LIKE '%/__artist_image__/%'
|
OR mf.file_path NOT LIKE '%/__artist_image__/%'
|
||||||
)
|
)
|
||||||
AND a.is_hidden = false
|
AND a.is_hidden = false
|
||||||
AND (
|
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 = '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 = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
|
||||||
OR s.status = 'found' AND s.last_attempt_at < $1
|
OR s.status = 'found' AND s.last_attempt_at < $1
|
||||||
)
|
)
|
||||||
ORDER BY s.last_attempt_at NULLS FIRST, a.id
|
ORDER BY s.last_attempt_at NULLS FIRST, a.id
|
||||||
LIMIT $3"#,
|
LIMIT $4"#,
|
||||||
)
|
)
|
||||||
.bind(&failed_cutoff)
|
.bind(&failed_cutoff)
|
||||||
.bind(¬_found_cutoff)
|
.bind(¬_found_cutoff)
|
||||||
|
.bind(options.overwrite_existing)
|
||||||
.bind(MAX_LASTFM_ARTIST_LOOKUPS)
|
.bind(MAX_LASTFM_ARTIST_LOOKUPS)
|
||||||
.fetch_all(&ctx.pool)
|
.fetch_all(&ctx.pool)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -755,6 +1177,8 @@ async fn backfill_artist_lastfm(
|
|||||||
updated_at = $3
|
updated_at = $3
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
AND (
|
AND (
|
||||||
|
$4
|
||||||
|
OR
|
||||||
image_file_id IS NULL
|
image_file_id IS NULL
|
||||||
OR EXISTS (
|
OR EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@@ -767,6 +1191,7 @@ async fn backfill_artist_lastfm(
|
|||||||
.bind(image_file_id)
|
.bind(image_file_id)
|
||||||
.bind(artist.id)
|
.bind(artist.id)
|
||||||
.bind(now_iso())
|
.bind(now_iso())
|
||||||
|
.bind(options.overwrite_existing)
|
||||||
.execute(&ctx.pool)
|
.execute(&ctx.pool)
|
||||||
.await?;
|
.await?;
|
||||||
record_lookup_state(
|
record_lookup_state(
|
||||||
@@ -826,7 +1251,7 @@ async fn backfill_artist_lastfm(
|
|||||||
artist.id, artist.name
|
artist.id, artist.name
|
||||||
));
|
));
|
||||||
stats.artist_lastfm_not_found += 1;
|
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)) => {
|
Ok(Some(media_file_id)) => {
|
||||||
stats.artist_album_fallback_assigned += 1;
|
stats.artist_album_fallback_assigned += 1;
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
@@ -892,31 +1317,53 @@ async fn backfill_artist_lastfm(
|
|||||||
async fn backfill_artist_album_fallbacks(
|
async fn backfill_artist_album_fallbacks(
|
||||||
ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
log: &mut JobLog,
|
log: &mut JobLog,
|
||||||
|
options: ArtworkBackfillOptions,
|
||||||
stats: &mut ArtworkStats,
|
stats: &mut ArtworkStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
let artists = sqlx::query_as::<_, ArtistCandidate>(
|
let artists = sqlx::query_as::<_, ArtistCandidate>(
|
||||||
r#"SELECT a.id, a.name::text AS name
|
r#"SELECT a.id, a.name::text AS name
|
||||||
FROM furumusic__artist a
|
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 a.is_hidden = false
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM furumusic__release_artist ra
|
FROM furumusic__release_artist ra
|
||||||
JOIN furumusic__release r ON r.id = ra.release_id
|
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
|
WHERE ra.artist_id = a.id
|
||||||
AND r.cover_file_id IS NOT NULL
|
AND r.cover_file_id IS NOT NULL
|
||||||
|
AND mf.file_type = 'cover_art'
|
||||||
AND r.is_hidden = false
|
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
|
UNION
|
||||||
SELECT 1
|
SELECT 1
|
||||||
FROM furumusic__track_artist ta
|
FROM furumusic__track_artist ta
|
||||||
JOIN furumusic__track t ON t.id = ta.track_id
|
JOIN furumusic__track t ON t.id = ta.track_id
|
||||||
JOIN furumusic__release r ON r.id = t.release_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
|
WHERE ta.artist_id = a.id
|
||||||
AND r.cover_file_id IS NOT NULL
|
AND r.cover_file_id IS NOT NULL
|
||||||
|
AND mf.file_type = 'cover_art'
|
||||||
AND r.is_hidden = false
|
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"#,
|
ORDER BY a.id"#,
|
||||||
)
|
)
|
||||||
|
.bind(options.overwrite_existing)
|
||||||
.fetch_all(&ctx.pool)
|
.fetch_all(&ctx.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -931,7 +1378,7 @@ async fn backfill_artist_album_fallbacks(
|
|||||||
));
|
));
|
||||||
|
|
||||||
for artist in artists {
|
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)) => {
|
Ok(Some(media_file_id)) => {
|
||||||
stats.artist_album_fallback_assigned += 1;
|
stats.artist_album_fallback_assigned += 1;
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
@@ -1016,6 +1463,7 @@ async fn repair_cover_variants(
|
|||||||
async fn assign_artist_album_fallback(
|
async fn assign_artist_album_fallback(
|
||||||
ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
artist_id: i64,
|
artist_id: i64,
|
||||||
|
options: ArtworkBackfillOptions,
|
||||||
) -> anyhow::Result<Option<i64>> {
|
) -> anyhow::Result<Option<i64>> {
|
||||||
let media_file_id: Option<i64> = sqlx::query_scalar(
|
let media_file_id: Option<i64> = sqlx::query_scalar(
|
||||||
r#"SELECT media_file_id
|
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
|
SELECT DISTINCT r.cover_file_id AS media_file_id
|
||||||
FROM furumusic__release r
|
FROM furumusic__release r
|
||||||
JOIN furumusic__release_artist ra ON ra.release_id = r.id
|
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
|
WHERE ra.artist_id = $1
|
||||||
AND r.cover_file_id IS NOT NULL
|
AND r.cover_file_id IS NOT NULL
|
||||||
|
AND mf.file_type = 'cover_art'
|
||||||
AND r.is_hidden = false
|
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
|
UNION
|
||||||
SELECT DISTINCT r.cover_file_id AS media_file_id
|
SELECT DISTINCT r.cover_file_id AS media_file_id
|
||||||
FROM furumusic__release r
|
FROM furumusic__release r
|
||||||
JOIN furumusic__track t ON t.release_id = r.id
|
JOIN furumusic__track t ON t.release_id = r.id
|
||||||
JOIN furumusic__track_artist ta ON ta.track_id = t.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
|
WHERE ta.artist_id = $1
|
||||||
AND r.cover_file_id IS NOT NULL
|
AND r.cover_file_id IS NOT NULL
|
||||||
|
AND mf.file_type = 'cover_art'
|
||||||
AND r.is_hidden = false
|
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
|
) covers
|
||||||
ORDER BY random()
|
ORDER BY random()
|
||||||
LIMIT 1"#,
|
LIMIT 1"#,
|
||||||
@@ -1051,11 +1519,12 @@ async fn assign_artist_album_fallback(
|
|||||||
SET image_file_id = $1,
|
SET image_file_id = $1,
|
||||||
updated_at = $3
|
updated_at = $3
|
||||||
WHERE id = $2
|
WHERE id = $2
|
||||||
AND image_file_id IS NULL"#,
|
AND ($4 OR image_file_id IS NULL)"#,
|
||||||
)
|
)
|
||||||
.bind(media_file_id)
|
.bind(media_file_id)
|
||||||
.bind(artist_id)
|
.bind(artist_id)
|
||||||
.bind(now_iso())
|
.bind(now_iso())
|
||||||
|
.bind(options.overwrite_existing)
|
||||||
.execute(&ctx.pool)
|
.execute(&ctx.pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -1324,6 +1793,36 @@ async fn record_lookup_state(
|
|||||||
Ok(())
|
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(
|
async fn reset_lookup_state(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
entity_kind: &str,
|
entity_kind: &str,
|
||||||
@@ -1333,7 +1832,7 @@ async fn reset_lookup_state(
|
|||||||
r#"DELETE FROM furumusic__artwork_lookup_state
|
r#"DELETE FROM furumusic__artwork_lookup_state
|
||||||
WHERE entity_kind = $1
|
WHERE entity_kind = $1
|
||||||
AND entity_id = $2
|
AND entity_id = $2
|
||||||
AND source = 'lastfm'"#,
|
AND source IN ('lastfm', 'coverartarchive')"#,
|
||||||
)
|
)
|
||||||
.bind(entity_kind)
|
.bind(entity_kind)
|
||||||
.bind(entity_id)
|
.bind(entity_id)
|
||||||
|
|||||||
@@ -128,7 +128,11 @@ impl Job for InboxDiscoverJob {
|
|||||||
v
|
v
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
crate::metrics::record_agent_file_hash(hash_start.elapsed(), 0, "error");
|
crate::metrics::record_agent_file_hash(
|
||||||
|
hash_start.elapsed(),
|
||||||
|
0,
|
||||||
|
"error",
|
||||||
|
);
|
||||||
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
log.warn(&format!("Failed to hash {}: {e}", file_path.display()));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-41
@@ -17,28 +17,99 @@ pub fn is_orchestrator_running() -> bool {
|
|||||||
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
|
struct OrchestratorAdvisoryGuard {
|
||||||
/// Returns true if the lock was acquired (no other orchestrator is running).
|
conn: Option<sqlx::pool::PoolConnection<sqlx::Postgres>>,
|
||||||
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
|
}
|
||||||
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
|
|
||||||
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
impl Drop for OrchestratorAdvisoryGuard {
|
||||||
.fetch_one(pool)
|
fn drop(&mut self) {
|
||||||
.await
|
let Some(mut conn) = self.conn.take() else {
|
||||||
{
|
return;
|
||||||
Ok(acquired) => acquired,
|
};
|
||||||
Err(e) => {
|
|
||||||
tracing::error!("Failed to acquire advisory lock: {e}");
|
tokio::spawn(async move {
|
||||||
false
|
match sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
|
||||||
}
|
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||||
|
.fetch_one(&mut *conn)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
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 connection_holds_orchestrator_lock(
|
||||||
async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
|
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
|
||||||
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
|
) -> 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)
|
||||||
|
.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)
|
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
|
||||||
.execute(pool)
|
.fetch_one(&mut *conn)
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
|
if acquired {
|
||||||
|
Ok(Some(OrchestratorAdvisoryGuard { conn: Some(conn) }))
|
||||||
|
} else {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
|
||||||
@@ -83,10 +154,10 @@ impl Job for InboxProcessJob {
|
|||||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||||
.is_err()
|
.is_err()
|
||||||
{
|
{
|
||||||
log.info(
|
log.warn(
|
||||||
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
|
"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;
|
struct AtomicGuard;
|
||||||
impl Drop for AtomicGuard {
|
impl Drop for AtomicGuard {
|
||||||
@@ -98,27 +169,16 @@ impl Job for InboxProcessJob {
|
|||||||
let _atomic_guard = AtomicGuard;
|
let _atomic_guard = AtomicGuard;
|
||||||
|
|
||||||
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
|
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
|
||||||
if !try_acquire_orchestrator_lock(&ctx.pool).await {
|
let _advisory_guard = match try_acquire_orchestrator_lock(&ctx.pool).await? {
|
||||||
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
|
Some(guard) => guard,
|
||||||
return Ok(());
|
None => {
|
||||||
}
|
log.warn("Another inbox_process orchestrator holds the advisory lock");
|
||||||
tracing::info!("inbox_process: advisory lock acquired");
|
anyhow::bail!(
|
||||||
let pool_for_unlock = ctx.pool.clone();
|
"inbox_process advisory lock is held by another database session; no in-process orchestrator is running"
|
||||||
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 config = Arc::clone(&ctx.config);
|
||||||
let mut total_ok = 0u64;
|
let mut total_ok = 0u64;
|
||||||
@@ -434,7 +494,12 @@ async fn process_folder_batch(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
crate::metrics::record_agent_rag("artist", "ok", rag_start.elapsed(), results.len());
|
crate::metrics::record_agent_rag(
|
||||||
|
"artist",
|
||||||
|
"ok",
|
||||||
|
rag_start.elapsed(),
|
||||||
|
results.len(),
|
||||||
|
);
|
||||||
for a in results {
|
for a in results {
|
||||||
if !all_similar_artists
|
if !all_similar_artists
|
||||||
.iter()
|
.iter()
|
||||||
@@ -465,7 +530,12 @@ async fn process_folder_batch(
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Ok(results)) => {
|
Ok(Ok(results)) => {
|
||||||
crate::metrics::record_agent_rag("release", "ok", rag_start.elapsed(), results.len());
|
crate::metrics::record_agent_rag(
|
||||||
|
"release",
|
||||||
|
"ok",
|
||||||
|
rag_start.elapsed(),
|
||||||
|
results.len(),
|
||||||
|
);
|
||||||
for r in results {
|
for r in results {
|
||||||
if !all_similar_releases
|
if !all_similar_releases
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
+522
-76
@@ -15,6 +15,7 @@ pub struct MetadataBackfillOptions {
|
|||||||
pub duration_seconds: bool,
|
pub duration_seconds: bool,
|
||||||
pub local_genres: bool,
|
pub local_genres: bool,
|
||||||
pub lastfm_tags: bool,
|
pub lastfm_tags: bool,
|
||||||
|
pub musicbrainz_tags: bool,
|
||||||
pub overwrite: bool,
|
pub overwrite: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,6 +27,7 @@ impl MetadataBackfillOptions {
|
|||||||
|| self.duration_seconds
|
|| self.duration_seconds
|
||||||
|| self.local_genres
|
|| self.local_genres
|
||||||
|| self.lastfm_tags
|
|| self.lastfm_tags
|
||||||
|
|| self.musicbrainz_tags
|
||||||
}
|
}
|
||||||
|
|
||||||
fn needs_file_scan(self) -> bool {
|
fn needs_file_scan(self) -> bool {
|
||||||
@@ -59,6 +61,7 @@ struct LastfmReleaseTagRow {
|
|||||||
id: i64,
|
id: i64,
|
||||||
title: String,
|
title: String,
|
||||||
artist_name: Option<String>,
|
artist_name: Option<String>,
|
||||||
|
track_title: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -84,6 +87,22 @@ struct LastfmTagStats {
|
|||||||
failed: u64,
|
failed: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct MusicBrainzTagStats {
|
||||||
|
considered: u64,
|
||||||
|
updated_entities: u64,
|
||||||
|
tags_saved: u64,
|
||||||
|
skipped_existing: u64,
|
||||||
|
not_found: u64,
|
||||||
|
failed: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum LastfmTagPassResult {
|
||||||
|
Completed,
|
||||||
|
RateLimited,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct MetadataBackfillJob;
|
pub struct MetadataBackfillJob;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -111,6 +130,7 @@ impl Job for MetadataBackfillJob {
|
|||||||
duration_seconds: true,
|
duration_seconds: true,
|
||||||
local_genres: true,
|
local_genres: true,
|
||||||
lastfm_tags: true,
|
lastfm_tags: true,
|
||||||
|
musicbrainz_tags: true,
|
||||||
overwrite: false,
|
overwrite: false,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -137,10 +157,11 @@ pub async fn run_with_options(
|
|||||||
let mut failed = 0u64;
|
let mut failed = 0u64;
|
||||||
|
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Metadata backfill options: file_scan={}, local_genres={}, lastfm_tags={}, mode={}",
|
"Metadata backfill options: file_scan={}, local_genres={}, lastfm_tags={}, musicbrainz_tags={}, mode={}",
|
||||||
options.needs_file_scan(),
|
options.needs_file_scan(),
|
||||||
options.local_genres,
|
options.local_genres,
|
||||||
options.lastfm_tags,
|
options.lastfm_tags,
|
||||||
|
options.musicbrainz_tags,
|
||||||
if options.overwrite {
|
if options.overwrite {
|
||||||
"overwrite"
|
"overwrite"
|
||||||
} else {
|
} else {
|
||||||
@@ -245,14 +266,9 @@ pub async fn run_with_options(
|
|||||||
let mut changed_tags = false;
|
let mut changed_tags = false;
|
||||||
if options.local_genres {
|
if options.local_genres {
|
||||||
if let (Some(track_id), Some(genre)) = (row.track_id, raw_meta.genre.as_deref()) {
|
if let (Some(track_id), Some(genre)) = (row.track_id, raw_meta.genre.as_deref()) {
|
||||||
let saved = save_track_tag_text(
|
let saved =
|
||||||
&ctx.pool,
|
save_track_tag_text(&ctx.pool, track_id, genre, "file", options.overwrite)
|
||||||
track_id,
|
.await?;
|
||||||
genre,
|
|
||||||
"file",
|
|
||||||
options.overwrite,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
if saved > 0 {
|
if saved > 0 {
|
||||||
local_tags_updated += saved;
|
local_tags_updated += saved;
|
||||||
changed_tags = true;
|
changed_tags = true;
|
||||||
@@ -306,14 +322,28 @@ pub async fn run_with_options(
|
|||||||
LastfmTagStats::default()
|
LastfmTagStats::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let musicbrainz_stats = if options.musicbrainz_tags {
|
||||||
|
log.info("Starting MusicBrainz tag backfill");
|
||||||
|
backfill_musicbrainz_tags(ctx, log, options.overwrite).await?
|
||||||
|
} else {
|
||||||
|
log.info("MusicBrainz tag backfill disabled for this run");
|
||||||
|
MusicBrainzTagStats::default()
|
||||||
|
};
|
||||||
|
|
||||||
log.info(&format!(
|
log.info(&format!(
|
||||||
"Metadata backfill complete: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {local_tags_updated} local tags saved, {unchanged} unchanged, {missing} missing, {failed} failed; Last.fm tags: considered={}, updated_entities={}, tags_saved={}, skipped_existing={}, not_found={}, failed={}",
|
"Metadata backfill complete: {scanned} scanned, {media_updated} media updated, {track_updated} tracks updated, {local_tags_updated} local tags saved, {unchanged} unchanged, {missing} missing, {failed} failed; Last.fm tags: considered={}, updated_entities={}, tags_saved={}, skipped_existing={}, not_found={}, failed={}; MusicBrainz tags: considered={}, updated_entities={}, tags_saved={}, skipped_existing={}, not_found={}, failed={}",
|
||||||
lastfm_stats.considered,
|
lastfm_stats.considered,
|
||||||
lastfm_stats.updated_entities,
|
lastfm_stats.updated_entities,
|
||||||
lastfm_stats.tags_saved,
|
lastfm_stats.tags_saved,
|
||||||
lastfm_stats.skipped_existing,
|
lastfm_stats.skipped_existing,
|
||||||
lastfm_stats.not_found,
|
lastfm_stats.not_found,
|
||||||
lastfm_stats.failed,
|
lastfm_stats.failed,
|
||||||
|
musicbrainz_stats.considered,
|
||||||
|
musicbrainz_stats.updated_entities,
|
||||||
|
musicbrainz_stats.tags_saved,
|
||||||
|
musicbrainz_stats.skipped_existing,
|
||||||
|
musicbrainz_stats.not_found,
|
||||||
|
musicbrainz_stats.failed,
|
||||||
));
|
));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -345,12 +375,277 @@ async fn backfill_lastfm_tags(
|
|||||||
.build()?;
|
.build()?;
|
||||||
|
|
||||||
let mut stats = LastfmTagStats::default();
|
let mut stats = LastfmTagStats::default();
|
||||||
backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
|
if backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
|
||||||
backfill_lastfm_release_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
|
== LastfmTagPassResult::RateLimited
|
||||||
backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
|
{
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
if backfill_lastfm_release_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
|
||||||
|
== LastfmTagPassResult::RateLimited
|
||||||
|
{
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
|
if backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
|
||||||
|
== LastfmTagPassResult::RateLimited
|
||||||
|
{
|
||||||
|
return Ok(stats);
|
||||||
|
}
|
||||||
Ok(stats)
|
Ok(stats)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn backfill_musicbrainz_tags(
|
||||||
|
ctx: &JobContext,
|
||||||
|
log: &mut JobLog,
|
||||||
|
overwrite: bool,
|
||||||
|
) -> anyhow::Result<MusicBrainzTagStats> {
|
||||||
|
log.info("MusicBrainz tag backfill started");
|
||||||
|
let client = crate::jobs::musicbrainz::MusicBrainzClient::new("furumusic-metadata-backfill")?;
|
||||||
|
let mut stats = MusicBrainzTagStats::default();
|
||||||
|
backfill_musicbrainz_artist_tags(ctx, log, &client, overwrite, &mut stats).await?;
|
||||||
|
backfill_musicbrainz_release_tags(ctx, log, &client, overwrite, &mut stats).await?;
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn backfill_musicbrainz_artist_tags(
|
||||||
|
ctx: &JobContext,
|
||||||
|
log: &mut JobLog,
|
||||||
|
client: &crate::jobs::musicbrainz::MusicBrainzClient,
|
||||||
|
overwrite: bool,
|
||||||
|
stats: &mut MusicBrainzTagStats,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let rows = sqlx::query_as::<_, LastfmArtistTagRow>(
|
||||||
|
r#"SELECT DISTINCT a.id, a.name::text AS name
|
||||||
|
FROM furumusic__artist a
|
||||||
|
JOIN furumusic__track_artist ta ON ta.artist_id = a.id
|
||||||
|
JOIN furumusic__track t ON t.id = ta.track_id
|
||||||
|
WHERE a.is_hidden = false AND t.is_hidden = false
|
||||||
|
ORDER BY a.id"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&ctx.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"MusicBrainz artist tag pass: checking {} artist(s)",
|
||||||
|
rows.len()
|
||||||
|
));
|
||||||
|
let total = rows.len();
|
||||||
|
for (index, row) in rows.into_iter().enumerate() {
|
||||||
|
if should_log_lastfm_item(index + 1, total, 25) {
|
||||||
|
log.info(&format!(
|
||||||
|
"MusicBrainz artist tags {}/{}: artist {} \"{}\"",
|
||||||
|
index + 1,
|
||||||
|
total,
|
||||||
|
row.id,
|
||||||
|
row.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if should_skip_source_entity(&ctx.pool, "artist", row.id, "musicbrainz", overwrite).await? {
|
||||||
|
stats.skipped_existing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
stats.considered += 1;
|
||||||
|
|
||||||
|
let mbid =
|
||||||
|
match crate::jobs::musicbrainz::load_external_id(&ctx.pool, "artist", row.id, "artist")
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(mbid) => Some(mbid),
|
||||||
|
None => match client.search_artist(&row.name).await {
|
||||||
|
Ok(Some(found)) => {
|
||||||
|
crate::jobs::musicbrainz::save_external_id(
|
||||||
|
&ctx.pool,
|
||||||
|
"artist",
|
||||||
|
row.id,
|
||||||
|
"artist",
|
||||||
|
&found.mbid,
|
||||||
|
found.score as f64 / 100.0,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Some(found.mbid)
|
||||||
|
}
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"MusicBrainz artist search failed for artist {} \"{}\": {err}",
|
||||||
|
row.id, row.name
|
||||||
|
));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mbid) = mbid else {
|
||||||
|
stats.not_found += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match client.lookup_artist_tags(&mbid).await {
|
||||||
|
Ok(tags) if !tags.is_empty() => {
|
||||||
|
let tags = musicbrainz_tags_to_candidates(&tags);
|
||||||
|
match replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "musicbrainz", false)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(saved) => {
|
||||||
|
stats.tags_saved += saved;
|
||||||
|
stats.updated_entities += 1;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"MusicBrainz artist tags save failed for artist {} \"{}\": {err}",
|
||||||
|
row.id, row.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
stats.not_found += 1;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"MusicBrainz artist tags failed for artist {} \"{}\" mbid={}: {err}",
|
||||||
|
row.id, row.name, mbid
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn backfill_musicbrainz_release_tags(
|
||||||
|
ctx: &JobContext,
|
||||||
|
log: &mut JobLog,
|
||||||
|
client: &crate::jobs::musicbrainz::MusicBrainzClient,
|
||||||
|
overwrite: bool,
|
||||||
|
stats: &mut MusicBrainzTagStats,
|
||||||
|
) -> anyhow::Result<()> {
|
||||||
|
let rows = sqlx::query_as::<_, LastfmReleaseTagRow>(
|
||||||
|
r#"SELECT r.id,
|
||||||
|
r.title::text AS title,
|
||||||
|
(
|
||||||
|
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
|
||||||
|
) 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.is_hidden = false
|
||||||
|
ORDER BY r.id"#,
|
||||||
|
)
|
||||||
|
.fetch_all(&ctx.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
log.info(&format!(
|
||||||
|
"MusicBrainz release tag pass: checking {} release(s)",
|
||||||
|
rows.len()
|
||||||
|
));
|
||||||
|
let total = rows.len();
|
||||||
|
for (index, row) in rows.into_iter().enumerate() {
|
||||||
|
if should_log_lastfm_item(index + 1, total, 25) {
|
||||||
|
log.info(&format!(
|
||||||
|
"MusicBrainz release tags {}/{}: release {} \"{}\"",
|
||||||
|
index + 1,
|
||||||
|
total,
|
||||||
|
row.id,
|
||||||
|
row.title
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if should_skip_source_entity(&ctx.pool, "release", row.id, "musicbrainz", overwrite).await?
|
||||||
|
{
|
||||||
|
stats.skipped_existing += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let Some(artist) = row
|
||||||
|
.artist_name
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
|
else {
|
||||||
|
stats.not_found += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
stats.considered += 1;
|
||||||
|
|
||||||
|
let mbid = match crate::jobs::musicbrainz::load_or_search_release_mbid(
|
||||||
|
&ctx.pool,
|
||||||
|
client,
|
||||||
|
row.id,
|
||||||
|
artist,
|
||||||
|
&row.title,
|
||||||
|
row.track_title.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok((release_mbid, _release_group_mbid)) => release_mbid,
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"MusicBrainz release search failed for release {} \"{}\" / \"{}\": {err}",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mbid) = mbid else {
|
||||||
|
stats.not_found += 1;
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
match client.lookup_release_tags(&mbid).await {
|
||||||
|
Ok(result) if !result.tags.is_empty() => {
|
||||||
|
if let Some(group_mbid) = result.release_group_mbid.as_deref() {
|
||||||
|
crate::jobs::musicbrainz::save_external_id(
|
||||||
|
&ctx.pool,
|
||||||
|
"release",
|
||||||
|
row.id,
|
||||||
|
"release_group",
|
||||||
|
group_mbid,
|
||||||
|
1.0,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
let tags = musicbrainz_tags_to_candidates(&result.tags);
|
||||||
|
match replace_entity_tags(&ctx.pool, "release", row.id, &tags, "musicbrainz", false)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(saved) => {
|
||||||
|
stats.tags_saved += saved;
|
||||||
|
stats.updated_entities += 1;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"MusicBrainz release tags save failed for release {} \"{}\" / \"{}\": {err}",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(_) => {
|
||||||
|
stats.not_found += 1;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"MusicBrainz release tags failed for release {} \"{}\" mbid={}: {err}",
|
||||||
|
row.id, row.title, mbid
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
async fn backfill_lastfm_artist_tags(
|
async fn backfill_lastfm_artist_tags(
|
||||||
ctx: &JobContext,
|
ctx: &JobContext,
|
||||||
log: &mut JobLog,
|
log: &mut JobLog,
|
||||||
@@ -358,7 +653,7 @@ async fn backfill_lastfm_artist_tags(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
stats: &mut LastfmTagStats,
|
stats: &mut LastfmTagStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<LastfmTagPassResult> {
|
||||||
let rows = sqlx::query_as::<_, LastfmArtistTagRow>(
|
let rows = sqlx::query_as::<_, LastfmArtistTagRow>(
|
||||||
r#"SELECT DISTINCT a.id, a.name::text AS name
|
r#"SELECT DISTINCT a.id, a.name::text AS name
|
||||||
FROM furumusic__artist a
|
FROM furumusic__artist a
|
||||||
@@ -376,31 +671,65 @@ async fn backfill_lastfm_artist_tags(
|
|||||||
));
|
));
|
||||||
let total = rows.len();
|
let total = rows.len();
|
||||||
for (index, row) in rows.into_iter().enumerate() {
|
for (index, row) in rows.into_iter().enumerate() {
|
||||||
if should_skip_lastfm_entity(&ctx.pool, "artist", row.id, overwrite).await? {
|
if should_log_lastfm_item(index + 1, total, 25) {
|
||||||
stats.skipped_existing += 1;
|
log.info(&format!(
|
||||||
if should_log_lastfm_progress(index + 1, total, 25) {
|
"Last.fm artist tags {}/{}: artist {} \"{}\"",
|
||||||
log.info(&format!(
|
index + 1,
|
||||||
"Last.fm artist tags progress: {}/{}",
|
total,
|
||||||
index + 1,
|
row.id,
|
||||||
total
|
row.name
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
match should_skip_lastfm_entity(&ctx.pool, "artist", row.id, overwrite).await {
|
||||||
|
Ok(true) => {
|
||||||
|
stats.skipped_existing += 1;
|
||||||
|
if should_log_lastfm_progress(index + 1, total, 25) {
|
||||||
|
log.info(&format!(
|
||||||
|
"Last.fm artist tags progress: {}/{}",
|
||||||
|
index + 1,
|
||||||
|
total
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm artist tags skip check failed for artist {} \"{}\": {err}",
|
||||||
|
row.id, row.name
|
||||||
|
));
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
stats.considered += 1;
|
stats.considered += 1;
|
||||||
match fetch_lastfm_artist_tags(client, api_key, &row.name).await {
|
match fetch_lastfm_artist_tags(client, api_key, &row.name).await {
|
||||||
Ok(tags) if !tags.is_empty() => {
|
Ok(tags) if !tags.is_empty() => {
|
||||||
let saved =
|
match replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false).await
|
||||||
replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false)
|
{
|
||||||
.await?;
|
Ok(saved) => {
|
||||||
stats.tags_saved += saved;
|
stats.tags_saved += saved;
|
||||||
stats.updated_entities += 1;
|
stats.updated_entities += 1;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm artist tags save failed for artist {} \"{}\": {err}",
|
||||||
|
row.id, row.name
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
}
|
}
|
||||||
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
|
Err(err) if is_lastfm_rate_limit_error(&err) => {
|
||||||
return Err(err);
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm rate limit reached while fetching artist tags for artist {} \"{}\"; stopping Last.fm tag backfill for this run",
|
||||||
|
row.id, row.name
|
||||||
|
));
|
||||||
|
return Ok(LastfmTagPassResult::RateLimited);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
stats.failed += 1;
|
stats.failed += 1;
|
||||||
@@ -419,7 +748,7 @@ async fn backfill_lastfm_artist_tags(
|
|||||||
}
|
}
|
||||||
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(LastfmTagPassResult::Completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn backfill_lastfm_release_tags(
|
async fn backfill_lastfm_release_tags(
|
||||||
@@ -429,7 +758,7 @@ async fn backfill_lastfm_release_tags(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
stats: &mut LastfmTagStats,
|
stats: &mut LastfmTagStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<LastfmTagPassResult> {
|
||||||
let rows = sqlx::query_as::<_, LastfmReleaseTagRow>(
|
let rows = sqlx::query_as::<_, LastfmReleaseTagRow>(
|
||||||
r#"SELECT r.id,
|
r#"SELECT r.id,
|
||||||
r.title::text AS title,
|
r.title::text AS title,
|
||||||
@@ -440,7 +769,15 @@ async fn backfill_lastfm_release_tags(
|
|||||||
WHERE ra.release_id = r.id
|
WHERE ra.release_id = r.id
|
||||||
ORDER BY ra.position
|
ORDER BY ra.position
|
||||||
LIMIT 1
|
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
|
FROM furumusic__release r
|
||||||
WHERE r.is_hidden = false
|
WHERE r.is_hidden = false
|
||||||
ORDER BY r.id"#,
|
ORDER BY r.id"#,
|
||||||
@@ -454,18 +791,41 @@ async fn backfill_lastfm_release_tags(
|
|||||||
));
|
));
|
||||||
let total = rows.len();
|
let total = rows.len();
|
||||||
for (index, row) in rows.into_iter().enumerate() {
|
for (index, row) in rows.into_iter().enumerate() {
|
||||||
if should_skip_lastfm_entity(&ctx.pool, "release", row.id, overwrite).await? {
|
if should_log_lastfm_item(index + 1, total, 25) {
|
||||||
stats.skipped_existing += 1;
|
log.info(&format!(
|
||||||
if should_log_lastfm_progress(index + 1, total, 25) {
|
"Last.fm release tags {}/{}: release {} \"{}\"",
|
||||||
log.info(&format!(
|
index + 1,
|
||||||
"Last.fm release tags progress: {}/{}",
|
total,
|
||||||
index + 1,
|
row.id,
|
||||||
total
|
row.title
|
||||||
));
|
));
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
|
match should_skip_lastfm_entity(&ctx.pool, "release", row.id, overwrite).await {
|
||||||
|
Ok(true) => {
|
||||||
|
stats.skipped_existing += 1;
|
||||||
|
if should_log_lastfm_progress(index + 1, total, 25) {
|
||||||
|
log.info(&format!(
|
||||||
|
"Last.fm release tags progress: {}/{}",
|
||||||
|
index + 1,
|
||||||
|
total
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm release tags skip check failed for release {} \"{}\": {err}",
|
||||||
|
row.id, row.title
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(artist) = row
|
||||||
|
.artist_name
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
else {
|
else {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
if should_log_lastfm_progress(index + 1, total, 25) {
|
if should_log_lastfm_progress(index + 1, total, 25) {
|
||||||
@@ -480,17 +840,32 @@ async fn backfill_lastfm_release_tags(
|
|||||||
stats.considered += 1;
|
stats.considered += 1;
|
||||||
match fetch_lastfm_album_tags(client, api_key, artist, &row.title).await {
|
match fetch_lastfm_album_tags(client, api_key, artist, &row.title).await {
|
||||||
Ok(tags) if !tags.is_empty() => {
|
Ok(tags) if !tags.is_empty() => {
|
||||||
let saved =
|
match replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false)
|
||||||
replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false)
|
.await
|
||||||
.await?;
|
{
|
||||||
stats.tags_saved += saved;
|
Ok(saved) => {
|
||||||
stats.updated_entities += 1;
|
stats.tags_saved += saved;
|
||||||
|
stats.updated_entities += 1;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm release tags save failed for release {} \"{}\" / \"{}\": {err}",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
}
|
}
|
||||||
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
|
Err(err) if is_lastfm_rate_limit_error(&err) => {
|
||||||
return Err(err);
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm rate limit reached while fetching release tags for release {} \"{}\" / \"{}\"; stopping Last.fm tag backfill for this run",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
return Ok(LastfmTagPassResult::RateLimited);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
stats.failed += 1;
|
stats.failed += 1;
|
||||||
@@ -509,7 +884,7 @@ async fn backfill_lastfm_release_tags(
|
|||||||
}
|
}
|
||||||
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(LastfmTagPassResult::Completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn backfill_lastfm_track_tags(
|
async fn backfill_lastfm_track_tags(
|
||||||
@@ -519,7 +894,7 @@ async fn backfill_lastfm_track_tags(
|
|||||||
api_key: &str,
|
api_key: &str,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
stats: &mut LastfmTagStats,
|
stats: &mut LastfmTagStats,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<LastfmTagPassResult> {
|
||||||
let rows = sqlx::query_as::<_, LastfmTrackTagRow>(
|
let rows = sqlx::query_as::<_, LastfmTrackTagRow>(
|
||||||
r#"SELECT t.id,
|
r#"SELECT t.id,
|
||||||
t.title::text AS title,
|
t.title::text AS title,
|
||||||
@@ -544,18 +919,41 @@ async fn backfill_lastfm_track_tags(
|
|||||||
));
|
));
|
||||||
let total = rows.len();
|
let total = rows.len();
|
||||||
for (index, row) in rows.into_iter().enumerate() {
|
for (index, row) in rows.into_iter().enumerate() {
|
||||||
if should_skip_lastfm_entity(&ctx.pool, "track", row.id, overwrite).await? {
|
if should_log_lastfm_item(index + 1, total, 50) {
|
||||||
stats.skipped_existing += 1;
|
log.info(&format!(
|
||||||
if should_log_lastfm_progress(index + 1, total, 50) {
|
"Last.fm track tags {}/{}: track {} \"{}\"",
|
||||||
log.info(&format!(
|
index + 1,
|
||||||
"Last.fm track tags progress: {}/{}",
|
total,
|
||||||
index + 1,
|
row.id,
|
||||||
total
|
row.title
|
||||||
));
|
));
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
|
match should_skip_lastfm_entity(&ctx.pool, "track", row.id, overwrite).await {
|
||||||
|
Ok(true) => {
|
||||||
|
stats.skipped_existing += 1;
|
||||||
|
if should_log_lastfm_progress(index + 1, total, 50) {
|
||||||
|
log.info(&format!(
|
||||||
|
"Last.fm track tags progress: {}/{}",
|
||||||
|
index + 1,
|
||||||
|
total
|
||||||
|
));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm track tags skip check failed for track {} \"{}\": {err}",
|
||||||
|
row.id, row.title
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(artist) = row
|
||||||
|
.artist_name
|
||||||
|
.as_deref()
|
||||||
|
.filter(|value| !value.trim().is_empty())
|
||||||
else {
|
else {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
if should_log_lastfm_progress(index + 1, total, 50) {
|
if should_log_lastfm_progress(index + 1, total, 50) {
|
||||||
@@ -570,16 +968,30 @@ async fn backfill_lastfm_track_tags(
|
|||||||
stats.considered += 1;
|
stats.considered += 1;
|
||||||
match fetch_lastfm_track_tags(client, api_key, artist, &row.title).await {
|
match fetch_lastfm_track_tags(client, api_key, artist, &row.title).await {
|
||||||
Ok(tags) if !tags.is_empty() => {
|
Ok(tags) if !tags.is_empty() => {
|
||||||
let saved =
|
match replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await {
|
||||||
replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await?;
|
Ok(saved) => {
|
||||||
stats.tags_saved += saved;
|
stats.tags_saved += saved;
|
||||||
stats.updated_entities += 1;
|
stats.updated_entities += 1;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm track tags save failed for track {} \"{}\" / \"{}\": {err}",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
stats.not_found += 1;
|
stats.not_found += 1;
|
||||||
}
|
}
|
||||||
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
|
Err(err) if is_lastfm_rate_limit_error(&err) => {
|
||||||
return Err(err);
|
stats.failed += 1;
|
||||||
|
log.warn(&format!(
|
||||||
|
"Last.fm rate limit reached while fetching track tags for track {} \"{}\" / \"{}\"; stopping Last.fm tag backfill for this run",
|
||||||
|
row.id, artist, row.title
|
||||||
|
));
|
||||||
|
return Ok(LastfmTagPassResult::RateLimited);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
stats.failed += 1;
|
stats.failed += 1;
|
||||||
@@ -598,18 +1010,36 @@ async fn backfill_lastfm_track_tags(
|
|||||||
}
|
}
|
||||||
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(LastfmTagPassResult::Completed)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn should_log_lastfm_progress(done: usize, total: usize, every: usize) -> bool {
|
fn should_log_lastfm_progress(done: usize, total: usize, every: usize) -> bool {
|
||||||
total > 0 && (done == total || done % every == 0)
|
total > 0 && (done == total || done % every == 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn should_log_lastfm_item(done: usize, total: usize, every: usize) -> bool {
|
||||||
|
total > 0 && (done == 1 || done == total || done % every == 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_lastfm_rate_limit_error(err: &anyhow::Error) -> bool {
|
||||||
|
err.to_string().contains("Last.fm rate limit exceeded")
|
||||||
|
}
|
||||||
|
|
||||||
async fn should_skip_lastfm_entity(
|
async fn should_skip_lastfm_entity(
|
||||||
pool: &sqlx::PgPool,
|
pool: &sqlx::PgPool,
|
||||||
entity_kind: &str,
|
entity_kind: &str,
|
||||||
entity_id: i64,
|
entity_id: i64,
|
||||||
overwrite: bool,
|
overwrite: bool,
|
||||||
|
) -> anyhow::Result<bool> {
|
||||||
|
should_skip_source_entity(pool, entity_kind, entity_id, "lastfm", overwrite).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn should_skip_source_entity(
|
||||||
|
pool: &sqlx::PgPool,
|
||||||
|
entity_kind: &str,
|
||||||
|
entity_id: i64,
|
||||||
|
source: &str,
|
||||||
|
overwrite: bool,
|
||||||
) -> anyhow::Result<bool> {
|
) -> anyhow::Result<bool> {
|
||||||
if overwrite {
|
if overwrite {
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
@@ -617,16 +1047,28 @@ async fn should_skip_lastfm_entity(
|
|||||||
let exists: Option<i64> = sqlx::query_scalar(
|
let exists: Option<i64> = sqlx::query_scalar(
|
||||||
r#"SELECT 1
|
r#"SELECT 1
|
||||||
FROM furumusic__entity_genre_tag
|
FROM furumusic__entity_genre_tag
|
||||||
WHERE entity_kind = $1 AND entity_id = $2 AND source = 'lastfm'
|
WHERE entity_kind = $1 AND entity_id = $2 AND source = $3
|
||||||
LIMIT 1"#,
|
LIMIT 1"#,
|
||||||
)
|
)
|
||||||
.bind(entity_kind)
|
.bind(entity_kind)
|
||||||
.bind(entity_id)
|
.bind(entity_id)
|
||||||
|
.bind(source)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await?;
|
.await?;
|
||||||
Ok(exists.is_some())
|
Ok(exists.is_some())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn musicbrainz_tags_to_candidates(
|
||||||
|
tags: &[crate::jobs::musicbrainz::MusicBrainzTag],
|
||||||
|
) -> Vec<TagCandidate> {
|
||||||
|
tags.iter()
|
||||||
|
.map(|tag| TagCandidate {
|
||||||
|
name: tag.name.clone(),
|
||||||
|
weight: tag.weight,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
async fn fetch_lastfm_artist_tags(
|
async fn fetch_lastfm_artist_tags(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
@@ -723,7 +1165,11 @@ async fn fetch_lastfm_top_tags(
|
|||||||
Value::Object(_) => tag_from_value(tag_value).into_iter().collect::<Vec<_>>(),
|
Value::Object(_) => tag_from_value(tag_value).into_iter().collect::<Vec<_>>(),
|
||||||
_ => Vec::new(),
|
_ => Vec::new(),
|
||||||
};
|
};
|
||||||
tags.sort_by(|a, b| b.weight.total_cmp(&a.weight).then_with(|| a.name.cmp(&b.name)));
|
tags.sort_by(|a, b| {
|
||||||
|
b.weight
|
||||||
|
.total_cmp(&a.weight)
|
||||||
|
.then_with(|| a.name.cmp(&b.name))
|
||||||
|
});
|
||||||
tags.truncate(LASTFM_TAG_LIMIT);
|
tags.truncate(LASTFM_TAG_LIMIT);
|
||||||
Ok(tags)
|
Ok(tags)
|
||||||
}
|
}
|
||||||
@@ -896,9 +1342,9 @@ fn tags_from_text(value: &str) -> Vec<TagCandidate> {
|
|||||||
for raw in normalized_separators.split([';', ',']) {
|
for raw in normalized_separators.split([';', ',']) {
|
||||||
if let Some(name) = clean_tag_name(raw) {
|
if let Some(name) = clean_tag_name(raw) {
|
||||||
if !is_ignored_tag(&normalize_tag_name(&name))
|
if !is_ignored_tag(&normalize_tag_name(&name))
|
||||||
&& !tags
|
&& !tags.iter().any(|tag: &TagCandidate| {
|
||||||
.iter()
|
normalize_tag_name(&tag.name) == normalize_tag_name(&name)
|
||||||
.any(|tag: &TagCandidate| normalize_tag_name(&tag.name) == normalize_tag_name(&name))
|
})
|
||||||
{
|
{
|
||||||
tags.push(TagCandidate { name, weight: 1.0 });
|
tags.push(TagCandidate { name, weight: 1.0 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
pub mod archive_cleanup;
|
||||||
pub mod artwork_backfill;
|
pub mod artwork_backfill;
|
||||||
pub mod inbox_discover;
|
pub mod inbox_discover;
|
||||||
pub mod inbox_process;
|
pub mod inbox_process;
|
||||||
pub mod lastfm_popularity;
|
pub mod lastfm_popularity;
|
||||||
pub mod lastfm_scrobble;
|
pub mod lastfm_scrobble;
|
||||||
pub mod metadata_backfill;
|
pub mod metadata_backfill;
|
||||||
|
pub mod musicbrainz;
|
||||||
|
|
||||||
use std::path::{Component, Path, PathBuf};
|
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())
|
||||||
|
}
|
||||||
+157
-13
@@ -29,7 +29,7 @@ use cot::form::{Form, FormResult};
|
|||||||
use cot::html::Html;
|
use cot::html::Html;
|
||||||
use cot::middleware::SessionMiddleware;
|
use cot::middleware::SessionMiddleware;
|
||||||
use cot::project::RegisterAppsContext;
|
use cot::project::RegisterAppsContext;
|
||||||
use cot::request::extractors::{RequestForm, UrlQuery};
|
use cot::request::extractors::{Path, RequestForm, UrlQuery};
|
||||||
use cot::response::IntoResponse;
|
use cot::response::IntoResponse;
|
||||||
use cot::router::method::get;
|
use cot::router::method::get;
|
||||||
use cot::router::{Route, Router};
|
use cot::router::{Route, Router};
|
||||||
@@ -52,6 +52,7 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
registry.register(jobs::inbox_discover::InboxDiscoverJob);
|
registry.register(jobs::inbox_discover::InboxDiscoverJob);
|
||||||
registry.register(jobs::inbox_process::InboxProcessJob);
|
registry.register(jobs::inbox_process::InboxProcessJob);
|
||||||
registry.register(jobs::inbox_process::FileProcessJob);
|
registry.register(jobs::inbox_process::FileProcessJob);
|
||||||
|
registry.register(jobs::archive_cleanup::ArchiveCleanupJob);
|
||||||
registry.register(jobs::artwork_backfill::ArtworkBackfillJob);
|
registry.register(jobs::artwork_backfill::ArtworkBackfillJob);
|
||||||
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
registry.register(jobs::metadata_backfill::MetadataBackfillJob);
|
||||||
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
|
registry.register(jobs::lastfm_popularity::LastfmPopularityJob);
|
||||||
@@ -63,15 +64,55 @@ fn build_registry() -> Arc<JobRegistry> {
|
|||||||
// Handlers
|
// 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 {
|
let _user = match auth::get_session_user(&session, &db).await {
|
||||||
Some(u) => u,
|
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 };
|
let template = player::PlayerPageTemplate { t: i18n.t };
|
||||||
Html::new(template.render()?).into_response()
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct SetLangQuery {
|
struct SetLangQuery {
|
||||||
lang: String,
|
lang: String,
|
||||||
@@ -131,6 +172,21 @@ struct LoginForm {
|
|||||||
password: String,
|
password: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct LoginQuery {
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SharePathId {
|
||||||
|
id: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct SharePathToken {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Logout
|
// Logout
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -168,6 +224,58 @@ async fn metrics_handler(
|
|||||||
.expect("valid response"))
|
.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
|
// App
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -196,11 +304,26 @@ impl App for FuruApp {
|
|||||||
),
|
),
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/",
|
"/",
|
||||||
|session: Session, db: Database, i18n: I18n| async move {
|
|session: Session, db: Database, i18n: I18n, query: UrlQuery<IndexQuery>| async move {
|
||||||
index(session, db, i18n).await
|
index(session, db, i18n, query).await
|
||||||
},
|
},
|
||||||
"index",
|
"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(
|
Route::with_handler_and_name(
|
||||||
"/metrics",
|
"/metrics",
|
||||||
get({
|
get({
|
||||||
@@ -218,14 +341,15 @@ impl App for FuruApp {
|
|||||||
"/login",
|
"/login",
|
||||||
get({
|
get({
|
||||||
let config = Arc::clone(&self.config);
|
let config = Arc::clone(&self.config);
|
||||||
move |i18n: I18n, db: Database| {
|
move |i18n: I18n, db: Database, query: UrlQuery<LoginQuery>| {
|
||||||
let config = Arc::clone(&config);
|
let config = Arc::clone(&config);
|
||||||
async move {
|
async move {
|
||||||
// No users at all → redirect to first-run setup
|
// No users at all → redirect to first-run setup
|
||||||
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
if User::count_all(&db).await.unwrap_or(0) == 0 {
|
||||||
return Ok(auth::redirect("/admin/setup"));
|
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?
|
.await?
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
@@ -255,6 +379,15 @@ impl App for FuruApp {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !live_config.auth_password_enabled {
|
||||||
|
metrics::record_auth_attempt("password", "failure", "disabled");
|
||||||
|
let msg = i18n.t.login_disabled.to_owned();
|
||||||
|
return login_page_handler(i18n, &config, db, msg)
|
||||||
|
.await?
|
||||||
|
.into_response();
|
||||||
|
}
|
||||||
|
|
||||||
// Try to authenticate
|
// Try to authenticate
|
||||||
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
if let Ok(Some(user)) = User::get_by_username(&db, &data.username).await
|
||||||
{
|
{
|
||||||
@@ -263,23 +396,24 @@ impl App for FuruApp {
|
|||||||
match hash.verify(&password) {
|
match hash.verify(&password) {
|
||||||
PasswordVerificationResult::Ok
|
PasswordVerificationResult::Ok
|
||||||
| PasswordVerificationResult::OkObsolete(_) => {
|
| PasswordVerificationResult::OkObsolete(_) => {
|
||||||
|
let redirect_to =
|
||||||
|
auth::get_post_login_redirect(&session)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
auth::login(&session, user.id_val()).await?;
|
auth::login(&session, user.id_val()).await?;
|
||||||
|
auth::clear_post_login_redirect(&session).await?;
|
||||||
metrics::record_auth_attempt(
|
metrics::record_auth_attempt(
|
||||||
"password", "success", "ok",
|
"password", "success", "ok",
|
||||||
);
|
);
|
||||||
metrics::record_session_created("password");
|
metrics::record_session_created("password");
|
||||||
return Ok(auth::redirect("/"));
|
return Ok(auth::redirect(&redirect_to));
|
||||||
}
|
}
|
||||||
PasswordVerificationResult::Invalid => {}
|
PasswordVerificationResult::Invalid => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
metrics::record_auth_attempt(
|
metrics::record_auth_attempt("password", "failure", "bad_credentials");
|
||||||
"password",
|
|
||||||
"failure",
|
|
||||||
"bad_credentials",
|
|
||||||
);
|
|
||||||
let msg = i18n.t.login_invalid.to_owned();
|
let msg = i18n.t.login_invalid.to_owned();
|
||||||
login_page_handler(i18n, &config, db, msg)
|
login_page_handler(i18n, &config, db, msg)
|
||||||
.await?
|
.await?
|
||||||
@@ -301,6 +435,16 @@ impl App for FuruApp {
|
|||||||
get(oidc::oidc_callback_handler),
|
get(oidc::oidc_callback_handler),
|
||||||
"oidc_callback",
|
"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",
|
||||||
|
),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+327
-60
@@ -6,11 +6,11 @@ use std::sync::{LazyLock, Mutex};
|
|||||||
use std::task::{Context, Poll};
|
use std::task::{Context, Poll};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use cot::http::header::CONTENT_LENGTH;
|
use cot::Error;
|
||||||
use cot::http::Method;
|
use cot::http::Method;
|
||||||
|
use cot::http::header::CONTENT_LENGTH;
|
||||||
use cot::request::Request;
|
use cot::request::Request;
|
||||||
use cot::response::Response;
|
use cot::response::Response;
|
||||||
use cot::Error;
|
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use tower::{Layer, Service};
|
use tower::{Layer, Service};
|
||||||
|
|
||||||
@@ -80,28 +80,33 @@ where
|
|||||||
|
|
||||||
fn call(&mut self, request: Request) -> Self::Future {
|
fn call(&mut self, request: Request) -> Self::Future {
|
||||||
let method = request.method().clone();
|
let method = request.method().clone();
|
||||||
let route = normalize_route(request.uri().path());
|
let route = known_http_route(request.uri().path()).map(str::to_owned);
|
||||||
let request_bytes = request
|
let request_bytes = request
|
||||||
.headers()
|
.headers()
|
||||||
.get(CONTENT_LENGTH)
|
.get(CONTENT_LENGTH)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
.and_then(|value| value.parse::<f64>().ok())
|
.and_then(|value| value.parse::<f64>().ok())
|
||||||
.unwrap_or(0.0);
|
.unwrap_or(0.0);
|
||||||
let labels = http_labels(&method, &route, "in_flight");
|
if let Some(route) = &route {
|
||||||
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
|
let labels = http_labels(&method, route, "in_flight");
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_gauge("furumusic_http_in_flight_requests", labels, 1.0);
|
||||||
"furumusic_http_request_body_bytes_total",
|
REGISTRY.inc_counter(
|
||||||
vec![
|
"furumusic_http_request_body_bytes_total",
|
||||||
("method", method.as_str().to_owned()),
|
vec![
|
||||||
("route", route.clone()),
|
("method", method.as_str().to_owned()),
|
||||||
],
|
("route", route.clone()),
|
||||||
request_bytes,
|
],
|
||||||
);
|
request_bytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let start = Instant::now();
|
let start = Instant::now();
|
||||||
let fut = self.inner.call(request);
|
let fut = self.inner.call(request);
|
||||||
Box::pin(async move {
|
Box::pin(async move {
|
||||||
let result = fut.await;
|
let result = fut.await;
|
||||||
|
let Some(route) = route else {
|
||||||
|
return result;
|
||||||
|
};
|
||||||
let elapsed = start.elapsed().as_secs_f64();
|
let elapsed = start.elapsed().as_secs_f64();
|
||||||
REGISTRY.inc_gauge(
|
REGISTRY.inc_gauge(
|
||||||
"furumusic_http_in_flight_requests",
|
"furumusic_http_in_flight_requests",
|
||||||
@@ -159,6 +164,14 @@ pub fn record_active_user(user_id: i64) {
|
|||||||
users.insert(user_id, Instant::now());
|
users.insert(user_id, Instant::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn active_user_last_seen_ms() -> HashMap<i64, i64> {
|
||||||
|
let users = ACTIVE_USERS.lock().expect("active user lock");
|
||||||
|
users
|
||||||
|
.iter()
|
||||||
|
.map(|(user_id, last_seen)| (*user_id, last_seen.elapsed().as_millis() as i64))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn record_auth_attempt(method: &'static str, outcome: &'static str, reason: &'static str) {
|
pub fn record_auth_attempt(method: &'static str, outcome: &'static str, reason: &'static str) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_auth_login_attempts_total",
|
"furumusic_auth_login_attempts_total",
|
||||||
@@ -228,8 +241,17 @@ pub fn record_agent_discover_run(outcome: &'static str, duration: Duration) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_agent_discover_files(seen: u64, queued: u64, skipped_hash: u64, skipped_existing: u64) {
|
pub fn record_agent_discover_files(
|
||||||
REGISTRY.inc_counter("furumusic_agent_discover_files_seen_total", Vec::new(), seen as f64);
|
seen: u64,
|
||||||
|
queued: u64,
|
||||||
|
skipped_hash: u64,
|
||||||
|
skipped_existing: u64,
|
||||||
|
) {
|
||||||
|
REGISTRY.inc_counter(
|
||||||
|
"furumusic_agent_discover_files_seen_total",
|
||||||
|
Vec::new(),
|
||||||
|
seen as f64,
|
||||||
|
);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_discover_files_queued_total",
|
"furumusic_agent_discover_files_queued_total",
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
@@ -332,18 +354,12 @@ pub fn record_agent_llm(
|
|||||||
let model = normalize_model_label(model);
|
let model = normalize_model_label(model);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_llm_requests_total",
|
"furumusic_agent_llm_requests_total",
|
||||||
vec![
|
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
|
||||||
("model", model.clone()),
|
|
||||||
("outcome", outcome.to_owned()),
|
|
||||||
],
|
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
REGISTRY.observe_histogram(
|
REGISTRY.observe_histogram(
|
||||||
"furumusic_agent_llm_duration_seconds",
|
"furumusic_agent_llm_duration_seconds",
|
||||||
vec![
|
vec![("model", model.clone()), ("outcome", outcome.to_owned())],
|
||||||
("model", model.clone()),
|
|
||||||
("outcome", outcome.to_owned()),
|
|
||||||
],
|
|
||||||
duration.as_secs_f64(),
|
duration.as_secs_f64(),
|
||||||
JOB_BUCKETS,
|
JOB_BUCKETS,
|
||||||
);
|
);
|
||||||
@@ -354,10 +370,7 @@ pub fn record_agent_llm(
|
|||||||
);
|
);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_llm_tokens_total",
|
"furumusic_agent_llm_tokens_total",
|
||||||
vec![
|
vec![("model", model.clone()), ("type", "completion".to_owned())],
|
||||||
("model", model.clone()),
|
|
||||||
("type", "completion".to_owned()),
|
|
||||||
],
|
|
||||||
completion_tokens as f64,
|
completion_tokens as f64,
|
||||||
);
|
);
|
||||||
REGISTRY.observe_histogram(
|
REGISTRY.observe_histogram(
|
||||||
@@ -392,7 +405,12 @@ pub fn record_agent_llm_parse_failure(model: &str) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Duration, results: usize) {
|
pub fn record_agent_rag(
|
||||||
|
kind: &'static str,
|
||||||
|
outcome: &'static str,
|
||||||
|
duration: Duration,
|
||||||
|
results: usize,
|
||||||
|
) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_rag_queries_total",
|
"furumusic_agent_rag_queries_total",
|
||||||
vec![("kind", kind.to_owned()), ("outcome", outcome.to_owned())],
|
vec![("kind", kind.to_owned()), ("outcome", outcome.to_owned())],
|
||||||
@@ -415,7 +433,10 @@ pub fn record_agent_rag(kind: &'static str, outcome: &'static str, duration: Dur
|
|||||||
pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, bytes: usize) {
|
pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, bytes: usize) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_cover_lookup_total",
|
"furumusic_agent_cover_lookup_total",
|
||||||
vec![("source", source.to_owned()), ("outcome", outcome.to_owned())],
|
vec![
|
||||||
|
("source", source.to_owned()),
|
||||||
|
("outcome", outcome.to_owned()),
|
||||||
|
],
|
||||||
1.0,
|
1.0,
|
||||||
);
|
);
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
@@ -425,7 +446,11 @@ pub fn record_agent_cover_lookup(source: &'static str, outcome: &'static str, by
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn record_agent_cover_variant(variant: &'static str, outcome: &'static str, duration: Duration) {
|
pub fn record_agent_cover_variant(
|
||||||
|
variant: &'static str,
|
||||||
|
outcome: &'static str,
|
||||||
|
duration: Duration,
|
||||||
|
) {
|
||||||
REGISTRY.inc_counter(
|
REGISTRY.inc_counter(
|
||||||
"furumusic_agent_cover_variant_generation_total",
|
"furumusic_agent_cover_variant_generation_total",
|
||||||
vec![
|
vec![
|
||||||
@@ -481,7 +506,12 @@ pub fn record_torrent_download(outcome: &'static str, selected_bytes: u64, durat
|
|||||||
|
|
||||||
pub async fn render(pool: &PgPool, config: &AppConfig) -> String {
|
pub async fn render(pool: &PgPool, config: &AppConfig) -> String {
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
emit_static_gauge(&mut out, "furumusic_build_info", &[("version", env!("CARGO_PKG_VERSION"))], 1.0);
|
emit_static_gauge(
|
||||||
|
&mut out,
|
||||||
|
"furumusic_build_info",
|
||||||
|
&[("version", env!("CARGO_PKG_VERSION"))],
|
||||||
|
1.0,
|
||||||
|
);
|
||||||
render_active_users(&mut out);
|
render_active_users(&mut out);
|
||||||
render_storage(&mut out, config);
|
render_storage(&mut out, config);
|
||||||
render_db_metrics(&mut out, pool).await;
|
render_db_metrics(&mut out, pool).await;
|
||||||
@@ -530,11 +560,42 @@ fn render_storage(out: &mut String, config: &AppConfig) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
||||||
render_group_counts(out, pool, "furumusic_users_total", "SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role", "role").await;
|
render_group_counts(
|
||||||
render_single_count(out, pool, "furumusic_library_tracks_total", "SELECT COUNT(*) FROM furumusic__track").await;
|
out,
|
||||||
render_single_count(out, pool, "furumusic_library_releases_total", "SELECT COUNT(*) FROM furumusic__release").await;
|
pool,
|
||||||
render_single_count(out, pool, "furumusic_library_artists_total", "SELECT COUNT(*) FROM furumusic__artist").await;
|
"furumusic_users_total",
|
||||||
render_single_count(out, pool, "furumusic_library_playlists_total", "SELECT COUNT(*) FROM furumusic__playlist").await;
|
"SELECT role::text AS label, COUNT(*) AS count FROM furumusic__user GROUP BY role",
|
||||||
|
"role",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_tracks_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__track",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_releases_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__release",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_artists_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__artist",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_library_playlists_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__playlist",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
render_group_counts(out, pool, "furumusic_media_files_total", "SELECT file_type::text AS label, COUNT(*) AS count FROM furumusic__media_file GROUP BY file_type", "type").await;
|
render_group_counts(out, pool, "furumusic_media_files_total", "SELECT file_type::text AS label, COUNT(*) AS count FROM furumusic__media_file GROUP BY file_type", "type").await;
|
||||||
render_group_sums(out, pool, "furumusic_media_file_bytes_total", "SELECT file_type::text AS label, COALESCE(SUM(file_size_bytes), 0)::bigint AS value FROM furumusic__media_file GROUP BY file_type", "type").await;
|
render_group_sums(out, pool, "furumusic_media_file_bytes_total", "SELECT file_type::text AS label, COALESCE(SUM(file_size_bytes), 0)::bigint AS value FROM furumusic__media_file GROUP BY file_type", "type").await;
|
||||||
render_group_counts(out, pool, "furumusic_agent_reviews_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status", "status").await;
|
render_group_counts(out, pool, "furumusic_agent_reviews_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__pending_review GROUP BY status", "status").await;
|
||||||
@@ -542,7 +603,13 @@ async fn render_db_metrics(out: &mut String, pool: &PgPool) {
|
|||||||
render_group_counts(out, pool, "furumusic_scheduler_job_running", "SELECT job_name::text AS label, COUNT(*) AS count FROM furumusic__job_run WHERE status = 'running' GROUP BY job_name", "job").await;
|
render_group_counts(out, pool, "furumusic_scheduler_job_running", "SELECT job_name::text AS label, COUNT(*) AS count FROM furumusic__job_run WHERE status = 'running' GROUP BY job_name", "job").await;
|
||||||
render_group_sums(out, pool, "furumusic_scheduler_job_enabled", "SELECT name::text AS label, (CASE WHEN enabled THEN 1 ELSE 0 END)::bigint AS value FROM furumusic__scheduled_job", "job").await;
|
render_group_sums(out, pool, "furumusic_scheduler_job_enabled", "SELECT name::text AS label, (CASE WHEN enabled THEN 1 ELSE 0 END)::bigint AS value FROM furumusic__scheduled_job", "job").await;
|
||||||
render_group_counts(out, pool, "furumusic_torrent_sessions_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__torrent_session GROUP BY status", "status").await;
|
render_group_counts(out, pool, "furumusic_torrent_sessions_total", "SELECT status::text AS label, COUNT(*) AS count FROM furumusic__torrent_session GROUP BY status", "status").await;
|
||||||
render_single_count(out, pool, "furumusic_play_history_total", "SELECT COUNT(*) FROM furumusic__play_history").await;
|
render_single_count(
|
||||||
|
out,
|
||||||
|
pool,
|
||||||
|
"furumusic_play_history_total",
|
||||||
|
"SELECT COUNT(*) FROM furumusic__play_history",
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn render_single_count(out: &mut String, pool: &PgPool, metric: &'static str, sql: &str) {
|
async fn render_single_count(out: &mut String, pool: &PgPool, metric: &'static str, sql: &str) {
|
||||||
@@ -558,7 +625,10 @@ async fn render_group_counts(
|
|||||||
sql: &str,
|
sql: &str,
|
||||||
label_name: &'static str,
|
label_name: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
|
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
for (label, count) in rows {
|
for (label, count) in rows {
|
||||||
emit_static_gauge(out, metric, &[(label_name, label.as_str())], count as f64);
|
emit_static_gauge(out, metric, &[(label_name, label.as_str())], count as f64);
|
||||||
}
|
}
|
||||||
@@ -572,7 +642,10 @@ async fn render_group_sums(
|
|||||||
sql: &str,
|
sql: &str,
|
||||||
label_name: &'static str,
|
label_name: &'static str,
|
||||||
) {
|
) {
|
||||||
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql).fetch_all(pool).await {
|
if let Ok(rows) = sqlx::query_as::<_, (String, i64)>(sql)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
for (label, value) in rows {
|
for (label, value) in rows {
|
||||||
emit_static_gauge(out, metric, &[(label_name, label.as_str())], value as f64);
|
emit_static_gauge(out, metric, &[(label_name, label.as_str())], value as f64);
|
||||||
}
|
}
|
||||||
@@ -633,7 +706,12 @@ impl Registry {
|
|||||||
for (bucket, count) in state.buckets.iter().zip(state.counts.iter()) {
|
for (bucket, count) in state.buckets.iter().zip(state.counts.iter()) {
|
||||||
let mut labels = key.labels.clone();
|
let mut labels = key.labels.clone();
|
||||||
labels.push(("le", bucket.to_string()));
|
labels.push(("le", bucket.to_string()));
|
||||||
emit_metric(&mut out, &format!("{}_bucket", key.name), &labels, *count as f64);
|
emit_metric(
|
||||||
|
&mut out,
|
||||||
|
&format!("{}_bucket", key.name),
|
||||||
|
&labels,
|
||||||
|
*count as f64,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
let mut inf_labels = key.labels.clone();
|
let mut inf_labels = key.labels.clone();
|
||||||
inf_labels.push(("le", "+Inf".to_owned()));
|
inf_labels.push(("le", "+Inf".to_owned()));
|
||||||
@@ -675,31 +753,220 @@ fn http_labels(method: &Method, route: &str, status: &str) -> Vec<(&'static str,
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_route(path: &str) -> String {
|
fn known_http_route(path: &str) -> Option<&'static str> {
|
||||||
let mut route = String::with_capacity(path.len());
|
let path = canonicalize_http_path(path);
|
||||||
for segment in path.split('/') {
|
KNOWN_HTTP_ROUTES
|
||||||
if segment.is_empty() {
|
.iter()
|
||||||
continue;
|
.copied()
|
||||||
}
|
.find(|pattern| route_pattern_matches(pattern, &path))
|
||||||
route.push('/');
|
}
|
||||||
if segment.parse::<i64>().is_ok() || looks_like_uuid(segment) {
|
|
||||||
route.push_str("{id}");
|
fn canonicalize_http_path(path: &str) -> String {
|
||||||
} else {
|
let without_trailing = path.trim_end_matches('/');
|
||||||
route.push_str(segment);
|
if without_trailing.is_empty() {
|
||||||
}
|
|
||||||
}
|
|
||||||
if route.is_empty() {
|
|
||||||
"/".to_owned()
|
"/".to_owned()
|
||||||
} else {
|
} else {
|
||||||
route
|
without_trailing.to_owned()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn looks_like_uuid(value: &str) -> bool {
|
fn route_pattern_matches(pattern: &str, path: &str) -> bool {
|
||||||
value.len() == 36
|
if pattern == "/" {
|
||||||
&& value
|
return path == "/";
|
||||||
.chars()
|
}
|
||||||
.all(|ch| ch.is_ascii_hexdigit() || ch == '-')
|
|
||||||
|
let mut pattern_segments = pattern.trim_start_matches('/').split('/');
|
||||||
|
let mut path_segments = path.trim_start_matches('/').split('/');
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match (pattern_segments.next(), path_segments.next()) {
|
||||||
|
(None, None) => return true,
|
||||||
|
(Some(pattern_segment), Some(path_segment)) => {
|
||||||
|
if path_segment.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if is_route_param(pattern_segment) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if pattern_segment != path_segment {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_route_param(segment: &str) -> bool {
|
||||||
|
segment.starts_with('{') && segment.ends_with('}')
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_HTTP_ROUTES: &[&str] = &[
|
||||||
|
// Keep this allowlist in sync with Cot route declarations. Unknown paths are
|
||||||
|
// intentionally skipped so bot traffic cannot create high-cardinality labels.
|
||||||
|
"/",
|
||||||
|
"/admin",
|
||||||
|
"/swagger",
|
||||||
|
"/swagger/openapi.json",
|
||||||
|
"/share/track/{id}",
|
||||||
|
"/share/release/{id}",
|
||||||
|
"/share/playlist/{token}",
|
||||||
|
"/metrics",
|
||||||
|
"/login",
|
||||||
|
"/logout",
|
||||||
|
"/set-lang",
|
||||||
|
"/auth/oidc/start",
|
||||||
|
"/auth/oidc/callback",
|
||||||
|
"/api/me",
|
||||||
|
"/admin/setup",
|
||||||
|
"/admin/v2",
|
||||||
|
"/admin/v2/api/dashboard",
|
||||||
|
"/admin/v2/api/reviews",
|
||||||
|
"/admin/v2/api/reviews/bulk",
|
||||||
|
"/admin/v2/api/users",
|
||||||
|
"/admin/v2/api/users/{id}",
|
||||||
|
"/admin/v2/api/reviews/{id}/approve",
|
||||||
|
"/admin/v2/api/jobs",
|
||||||
|
"/admin/v2/api/jobs/metadata_backfill/run-options",
|
||||||
|
"/admin/v2/api/jobs/artwork_backfill/run-options",
|
||||||
|
"/admin/v2/api/jobs/{name}/run",
|
||||||
|
"/admin/v2/api/settings",
|
||||||
|
"/admin/v2/api/settings/probe",
|
||||||
|
"/admin/v2/api/jobs/{name}/toggle",
|
||||||
|
"/admin/v2/api/jobs/{name}/runs",
|
||||||
|
"/admin/v2/api/jobs/{name}/runs/{run_id}",
|
||||||
|
"/admin/v2/api/library",
|
||||||
|
"/admin/v2/api/library/item",
|
||||||
|
"/admin/v2/api/library/item/detail",
|
||||||
|
"/admin/v2/api/library/item/image",
|
||||||
|
"/admin/v2/api/library/item/upload-image",
|
||||||
|
"/admin/v2/api/library/bulk",
|
||||||
|
"/admin/debug",
|
||||||
|
"/admin/settings",
|
||||||
|
"/admin/settings/probe",
|
||||||
|
"/admin/users",
|
||||||
|
"/admin/users/new",
|
||||||
|
"/admin/users/{id}/edit",
|
||||||
|
"/admin/users/{id}/delete",
|
||||||
|
"/admin/artists",
|
||||||
|
"/admin/artists/new",
|
||||||
|
"/admin/artists/{id}/edit",
|
||||||
|
"/admin/artists/{id}/delete",
|
||||||
|
"/admin/artists/{id}/available-covers",
|
||||||
|
"/admin/artists/{id}/set-image",
|
||||||
|
"/admin/artists/{id}/upload-image",
|
||||||
|
"/admin/releases",
|
||||||
|
"/admin/releases/new",
|
||||||
|
"/admin/releases/{id}/edit",
|
||||||
|
"/admin/releases/{id}/delete",
|
||||||
|
"/admin/media-files",
|
||||||
|
"/admin/media-files/{id}/delete",
|
||||||
|
"/admin/jobs",
|
||||||
|
"/admin/jobs/metadata_backfill/run-options",
|
||||||
|
"/admin/jobs/{name}/run",
|
||||||
|
"/admin/jobs/{name}/toggle",
|
||||||
|
"/admin/jobs/{name}/cron",
|
||||||
|
"/admin/jobs/{name}/runs/{run_id}",
|
||||||
|
"/admin/jobs/{name}",
|
||||||
|
"/admin/reviews/clear",
|
||||||
|
"/admin/reviews/bulk",
|
||||||
|
"/admin/reviews",
|
||||||
|
"/admin/reviews/{id}",
|
||||||
|
"/admin/reviews/{id}/approve",
|
||||||
|
"/admin/reviews/{id}/reject",
|
||||||
|
"/admin/reviews/{id}/requeue",
|
||||||
|
"/api/player/me",
|
||||||
|
"/api/player/lastfm/status",
|
||||||
|
"/api/player/lastfm/connect",
|
||||||
|
"/api/player/lastfm/callback",
|
||||||
|
"/api/player/lastfm/disconnect",
|
||||||
|
"/api/player/lastfm/now-playing",
|
||||||
|
"/api/player/lastfm/scrobble",
|
||||||
|
"/api/player/agent-queue",
|
||||||
|
"/api/player/torrents",
|
||||||
|
"/api/player/torrents/session/{id}",
|
||||||
|
"/api/player/torrents/preview",
|
||||||
|
"/api/player/uploads/local",
|
||||||
|
"/api/player/uploads/tracks",
|
||||||
|
"/api/player/uploads/tracks/{track_id}",
|
||||||
|
"/api/player/uploads/bulk-tracks",
|
||||||
|
"/api/player/uploads/releases/{id}",
|
||||||
|
"/api/player/uploads/reviews/{id}",
|
||||||
|
"/api/player/uploads/reviews/{id}/approve",
|
||||||
|
"/api/player/torrents/{id}/start",
|
||||||
|
"/api/player/torrents/{id}/pause",
|
||||||
|
"/api/player/torrents/{id}/status",
|
||||||
|
"/api/player/artists",
|
||||||
|
"/api/player/artists/{id}",
|
||||||
|
"/api/player/releases/{id}",
|
||||||
|
"/api/player/radio/{kind}/{id}",
|
||||||
|
"/api/player/playlists",
|
||||||
|
"/api/player/share-playlist",
|
||||||
|
"/api/player/share-playlist/{id}",
|
||||||
|
"/api/player/playlists/{id}",
|
||||||
|
"/api/player/playlists/{id}/tracks",
|
||||||
|
"/api/player/likes",
|
||||||
|
"/api/player/likes/toggle/{track_id}",
|
||||||
|
"/api/player/likes/release/{id}",
|
||||||
|
"/api/player/follows",
|
||||||
|
"/api/player/follows/toggle/{id}",
|
||||||
|
"/api/player/stream/{track_id}",
|
||||||
|
"/api/player/cover/{media_file_id}/{variant}",
|
||||||
|
"/api/player/cover/{media_file_id}",
|
||||||
|
"/api/player/devices/heartbeat",
|
||||||
|
"/api/player/devices/poll",
|
||||||
|
"/api/player/devices/active",
|
||||||
|
"/api/player/devices/command",
|
||||||
|
"/api/player/jams/users",
|
||||||
|
"/api/player/jams",
|
||||||
|
"/api/player/jams/join",
|
||||||
|
"/api/player/jams/invite",
|
||||||
|
"/api/player/jams/leave",
|
||||||
|
"/api/player/state",
|
||||||
|
"/api/player/history",
|
||||||
|
"/api/player/search",
|
||||||
|
"/api/player/tracks-by-ids",
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::known_http_route;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_http_route_matches_declared_dynamic_routes() {
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/stream/42"),
|
||||||
|
Some("/api/player/stream/{track_id}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/admin/jobs/metadata_backfill/runs/123"),
|
||||||
|
Some("/admin/jobs/{name}/runs/{run_id}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/share/playlist/abcDEF123"),
|
||||||
|
Some("/share/playlist/{token}")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/share/release/42"),
|
||||||
|
Some("/share/release/{id}")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_http_route_skips_unknown_bot_paths() {
|
||||||
|
assert_eq!(known_http_route("/wp-login.php"), None);
|
||||||
|
assert_eq!(
|
||||||
|
known_http_route("/api/player/not-a-real-endpoint/123"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
assert_eq!(known_http_route("/static/random-bot-path.js"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_http_route_uses_stable_canonical_labels() {
|
||||||
|
assert_eq!(known_http_route("/admin/"), Some("/admin"));
|
||||||
|
assert_eq!(known_http_route("/login/"), Some("/login"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_model_label(value: &str) -> String {
|
fn normalize_model_label(value: &str) -> String {
|
||||||
|
|||||||
@@ -1865,6 +1865,92 @@ pub mod db_migrations {
|
|||||||
&[Operation::custom(create_entity_genre_tags).build()];
|
&[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] = &[
|
pub const MIGRATIONS: &[&SyncDynMigration] = &[
|
||||||
&M0006CreateMediaFile,
|
&M0006CreateMediaFile,
|
||||||
&M0007CreateArtist,
|
&M0007CreateArtist,
|
||||||
@@ -1891,5 +1977,7 @@ pub mod db_migrations {
|
|||||||
&M0033CreateLastfmScrobbling,
|
&M0033CreateLastfmScrobbling,
|
||||||
&M0034CreateArtworkLookupState,
|
&M0034CreateArtworkLookupState,
|
||||||
&M0035CreateEntityGenreTags,
|
&M0035CreateEntityGenreTags,
|
||||||
|
&M0036CreateExternalMetadataIds,
|
||||||
|
&M0037CreatePlaylistShareLinks,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
+415
-4
@@ -4,6 +4,7 @@ use std::sync::LazyLock;
|
|||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
use cot::db::Database;
|
use cot::db::Database;
|
||||||
|
use cot::request::extractors::UrlQuery;
|
||||||
use cot::session::Session;
|
use cot::session::Session;
|
||||||
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
use openidconnect::core::{CoreClient, CoreProviderMetadata};
|
||||||
use openidconnect::{
|
use openidconnect::{
|
||||||
@@ -54,6 +55,13 @@ const SESSION_NONCE: &str = "oidc_nonce";
|
|||||||
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
const SESSION_PKCE_VERIFIER: &str = "oidc_pkce_verifier";
|
||||||
const SESSION_REDIRECT_URI: &str = "oidc_redirect_uri";
|
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
|
// Provider cache
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -247,13 +255,23 @@ pub struct OidcCallbackQuery {
|
|||||||
state: String,
|
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(
|
pub async fn oidc_callback_handler(
|
||||||
i18n: I18n,
|
i18n: I18n,
|
||||||
db: Database,
|
db: Database,
|
||||||
session: Session,
|
session: Session,
|
||||||
cot::request::extractors::UrlQuery(query): cot::request::extractors::UrlQuery<
|
UrlQuery(query): UrlQuery<OidcCallbackQuery>,
|
||||||
OidcCallbackQuery,
|
|
||||||
>,
|
|
||||||
) -> cot::Result<cot::response::Response> {
|
) -> cot::Result<cot::response::Response> {
|
||||||
let (config, _) = AppConfig::load_with_db(&db).await;
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
@@ -430,8 +448,13 @@ pub async fn oidc_callback_handler(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let redirect_to = auth::get_post_login_redirect(&session)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| "/".to_string());
|
||||||
|
|
||||||
// Log the user in.
|
// Log the user in.
|
||||||
auth::login(&session, user.id_val()).await?;
|
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_auth_attempt("oidc", "success", "ok");
|
||||||
crate::metrics::record_session_created("oidc");
|
crate::metrics::record_session_created("oidc");
|
||||||
|
|
||||||
@@ -453,7 +476,297 @@ pub async fn oidc_callback_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
Ok(auth::redirect("/"))
|
Ok(auth::redirect(&redirect_to))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Mobile OIDC flow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
pub async fn oidc_mobile_start_handler(
|
||||||
|
origin: RequestOrigin,
|
||||||
|
db: Database,
|
||||||
|
session: Session,
|
||||||
|
UrlQuery(query): UrlQuery<MobileOidcStartQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let Some(app_redirect_uri) = safe_mobile_redirect_uri(query.redirect_uri.as_deref()) else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "bad_redirect_uri");
|
||||||
|
return Ok(text_response(
|
||||||
|
cot::http::StatusCode::BAD_REQUEST,
|
||||||
|
"invalid mobile redirect_uri",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
|
||||||
|
if !config.auth_sso_enabled
|
||||||
|
|| config.oidc_issuer.is_empty()
|
||||||
|
|| config.oidc_client_id.is_empty()
|
||||||
|
|| config.oidc_client_secret.is_empty()
|
||||||
|
{
|
||||||
|
tracing::warn!("Mobile OIDC start requested but SSO is not configured");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"sso_not_configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = oidc_http_client();
|
||||||
|
let client = match get_or_refresh_provider(&config, &http).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC provider error: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let provider_redirect_uri = format!("{}/auth/mobile/oidc/callback", origin.0);
|
||||||
|
let redirect_url = RedirectUrl::new(provider_redirect_uri.clone())
|
||||||
|
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI: {e}")))?;
|
||||||
|
let client = client.set_redirect_uri(redirect_url);
|
||||||
|
|
||||||
|
let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
|
||||||
|
let (auth_url, csrf_state, nonce) = client
|
||||||
|
.authorize_url(
|
||||||
|
openidconnect::AuthenticationFlow::<openidconnect::core::CoreResponseType>::AuthorizationCode,
|
||||||
|
CsrfToken::new_random,
|
||||||
|
Nonce::new_random,
|
||||||
|
)
|
||||||
|
.add_scope(Scope::new("email".to_string()))
|
||||||
|
.add_scope(Scope::new("profile".to_string()))
|
||||||
|
.set_pkce_challenge(pkce_challenge)
|
||||||
|
.url();
|
||||||
|
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_CSRF_STATE, csrf_state.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_NONCE, nonce.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_PKCE_VERIFIER, pkce_verifier.secret().clone())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(
|
||||||
|
SESSION_MOBILE_PROVIDER_REDIRECT_URI,
|
||||||
|
provider_redirect_uri.clone(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
session
|
||||||
|
.insert(SESSION_MOBILE_APP_REDIRECT_URI, app_redirect_uri)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
tracing::info!(
|
||||||
|
auth_url = %auth_url,
|
||||||
|
provider_redirect_uri = %provider_redirect_uri,
|
||||||
|
"Mobile OIDC start: redirecting to provider",
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(auth::redirect(auth_url.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn oidc_mobile_callback_handler(
|
||||||
|
db: Database,
|
||||||
|
session: Session,
|
||||||
|
UrlQuery(query): UrlQuery<MobileOidcCallbackQuery>,
|
||||||
|
) -> cot::Result<cot::response::Response> {
|
||||||
|
let app_redirect_uri = mobile_app_redirect_uri_from_session(&session).await?;
|
||||||
|
|
||||||
|
if query.error.is_some() {
|
||||||
|
tracing::warn!("Mobile OIDC callback returned provider error");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_denied");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(code) = query.code else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_code");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_code"));
|
||||||
|
};
|
||||||
|
let Some(state) = query.state else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
|
||||||
|
};
|
||||||
|
|
||||||
|
let saved_csrf: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_CSRF_STATE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let saved_nonce: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_NONCE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let saved_pkce: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_PKCE_VERIFIER)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let provider_redirect_uri: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let Some(saved_csrf) = saved_csrf else {
|
||||||
|
tracing::warn!("Mobile OIDC callback: no CSRF state in session");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_state");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_state"));
|
||||||
|
};
|
||||||
|
if state != saved_csrf {
|
||||||
|
tracing::warn!("Mobile OIDC callback: CSRF state mismatch");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "csrf");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "csrf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(nonce_str) = saved_nonce else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_nonce");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_nonce"));
|
||||||
|
};
|
||||||
|
let Some(pkce_str) = saved_pkce else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_pkce");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "missing_pkce"));
|
||||||
|
};
|
||||||
|
let Some(provider_redirect_uri) = provider_redirect_uri else {
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_redirect_uri");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"missing_redirect_uri",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
let (config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
if !config.auth_sso_enabled
|
||||||
|
|| config.oidc_issuer.is_empty()
|
||||||
|
|| config.oidc_client_id.is_empty()
|
||||||
|
|| config.oidc_client_secret.is_empty()
|
||||||
|
{
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_configured");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(
|
||||||
|
&app_redirect_uri,
|
||||||
|
"sso_not_configured",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let http = oidc_http_client();
|
||||||
|
let client = match get_or_refresh_provider(&config, &http).await {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC provider error during callback: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provider_error");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "provider_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let redirect_url = RedirectUrl::new(provider_redirect_uri)
|
||||||
|
.map_err(|e| cot::Error::internal(format!("bad mobile redirect URI from session: {e}")))?;
|
||||||
|
let client = client.set_redirect_uri(redirect_url);
|
||||||
|
|
||||||
|
let token_request = match client.exchange_code(AuthorizationCode::new(code)) {
|
||||||
|
Ok(req) => req,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC token endpoint not configured: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_config");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let token_response = token_request
|
||||||
|
.set_pkce_verifier(PkceCodeVerifier::new(pkce_str))
|
||||||
|
.request_async(&http)
|
||||||
|
.await;
|
||||||
|
let token_response = match token_response {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC token exchange failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "token_exchange");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
use openidconnect::TokenResponse;
|
||||||
|
let id_token = match token_response.id_token() {
|
||||||
|
Some(t) => t,
|
||||||
|
None => {
|
||||||
|
tracing::error!("Mobile OIDC response missing ID token");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "missing_id_token");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let nonce = Nonce::new(nonce_str);
|
||||||
|
let claims = match id_token.claims(&client.id_token_verifier(), &nonce) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC ID token verification failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "id_token_verify");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let sub = claims.subject().to_string();
|
||||||
|
let issuer = claims.issuer().to_string();
|
||||||
|
let email = claims.email().map(|e| e.to_string());
|
||||||
|
let name = claims
|
||||||
|
.name()
|
||||||
|
.and_then(|n| n.get(None))
|
||||||
|
.map(|n| n.to_string());
|
||||||
|
let groups = extract_groups_from_jwt(&id_token.to_string());
|
||||||
|
|
||||||
|
if !is_allowed_by_groups(&groups, &config.oidc_user_groups, &config.oidc_admin_groups) {
|
||||||
|
tracing::warn!(
|
||||||
|
"Mobile OIDC login denied by group allowlist: sub={sub}, groups={groups:?}, user_groups={:?}, admin_groups={:?}",
|
||||||
|
config.oidc_user_groups,
|
||||||
|
config.oidc_admin_groups,
|
||||||
|
);
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "not_in_group");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "access_denied"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let user = match provision_user(
|
||||||
|
&db,
|
||||||
|
&issuer,
|
||||||
|
&sub,
|
||||||
|
email.as_deref(),
|
||||||
|
name.as_deref(),
|
||||||
|
&groups,
|
||||||
|
&config.oidc_admin_groups,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Mobile OIDC user provisioning failed: {e}");
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "failure", "provisioning");
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
return Ok(mobile_redirect_error(&app_redirect_uri, "oidc_error"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let exchange_code = auth::create_mobile_exchange_code(&db, user.id_val())
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
clear_mobile_oidc_session(&session).await?;
|
||||||
|
|
||||||
|
crate::metrics::record_auth_attempt("mobile_oidc", "success", "ok");
|
||||||
|
crate::metrics::record_session_created("mobile_oidc");
|
||||||
|
Ok(mobile_redirect_success(&app_redirect_uri, &exchange_code))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -611,6 +924,104 @@ fn redirect_login_with_error(message: &str) -> cot::Result<cot::response::Respon
|
|||||||
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
Ok(auth::redirect(&format!("/login?error={encoded}")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn text_response(status: cot::http::StatusCode, message: &str) -> cot::response::Response {
|
||||||
|
cot::http::Response::builder()
|
||||||
|
.status(status)
|
||||||
|
.body(cot::Body::fixed(message.to_owned()))
|
||||||
|
.expect("valid response")
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn mobile_app_redirect_uri_from_session(session: &Session) -> cot::Result<String> {
|
||||||
|
let saved: Option<String> = session
|
||||||
|
.get(SESSION_MOBILE_APP_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(safe_mobile_redirect_uri(saved.as_deref())
|
||||||
|
.unwrap_or_else(|| DEFAULT_MOBILE_REDIRECT_URI.to_owned()))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_mobile_oidc_session(session: &Session) -> cot::Result<()> {
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_CSRF_STATE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_NONCE)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_PKCE_VERIFIER)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_PROVIDER_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
let _: Option<String> = session
|
||||||
|
.remove(SESSION_MOBILE_APP_REDIRECT_URI)
|
||||||
|
.await
|
||||||
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn safe_mobile_redirect_uri(raw: Option<&str>) -> Option<String> {
|
||||||
|
let value = raw
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.unwrap_or(DEFAULT_MOBILE_REDIRECT_URI);
|
||||||
|
if value.len() > 2048 || value.bytes().any(|b| matches!(b, b'\r' | b'\n')) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let lower = value.to_ascii_lowercase();
|
||||||
|
if lower.starts_with("furumi://") || lower.starts_with("furumusic://") {
|
||||||
|
return Some(value.to_owned());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_redirect_success(app_redirect_uri: &str, code: &str) -> cot::response::Response {
|
||||||
|
auth::redirect(&append_query_param(app_redirect_uri, "code", code))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mobile_redirect_error(app_redirect_uri: &str, error: &str) -> cot::response::Response {
|
||||||
|
auth::redirect(&append_query_param(app_redirect_uri, "error", error))
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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.
|
/// Minimal percent-encoding for query parameter values.
|
||||||
fn urlencoded(s: &str) -> String {
|
fn urlencoded(s: &str) -> String {
|
||||||
let mut out = String::with_capacity(s.len() * 2);
|
let mut out = String::with_capacity(s.len() * 2);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub(super) struct Paginated<T: Serialize> {
|
|||||||
pub(super) total: i64,
|
pub(super) total: i64,
|
||||||
pub(super) page: i32,
|
pub(super) page: i32,
|
||||||
pub(super) per_page: i32,
|
pub(super) per_page: i32,
|
||||||
|
pub(super) has_more: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, JsonSchema)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
@@ -288,6 +289,19 @@ pub(super) struct PlaylistDetail {
|
|||||||
pub(super) tracks: Vec<TrackItem>,
|
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)]
|
#[derive(Debug, Serialize, JsonSchema)]
|
||||||
pub(super) struct SearchResults {
|
pub(super) struct SearchResults {
|
||||||
pub(super) artists: Vec<ArtistCard>,
|
pub(super) artists: Vec<ArtistCard>,
|
||||||
|
|||||||
+875
-295
File diff suppressed because it is too large
Load Diff
@@ -45,10 +45,17 @@ pub(super) struct RemoveTrackRequest {
|
|||||||
pub(super) track_id: i64,
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub(super) struct PaginationQuery {
|
pub(super) struct PaginationQuery {
|
||||||
pub(super) page: Option<i32>,
|
pub(super) page: Option<i32>,
|
||||||
pub(super) limit: Option<i32>,
|
pub(super) limit: Option<i32>,
|
||||||
|
pub(super) mine: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
|
|||||||
@@ -1372,6 +1372,7 @@ async fn run_scheduled_job(
|
|||||||
if !live_config.agent_enabled
|
if !live_config.agent_enabled
|
||||||
&& job_name != "lastfm_popularity"
|
&& job_name != "lastfm_popularity"
|
||||||
&& job_name != "lastfm_scrobble"
|
&& job_name != "lastfm_scrobble"
|
||||||
|
&& job_name != "archive_cleanup"
|
||||||
&& job_name != "artwork_backfill"
|
&& job_name != "artwork_backfill"
|
||||||
{
|
{
|
||||||
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
tracing::warn!(job = job_name, "Skipping: agent_enabled=false");
|
||||||
|
|||||||
+476
-6
@@ -307,7 +307,8 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn:disabled,
|
.btn:disabled,
|
||||||
.icon-btn:disabled {
|
.icon-btn:disabled,
|
||||||
|
.seg-btn:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
@@ -416,6 +417,15 @@ tbody tr:hover {
|
|||||||
.col-tags { width: 250px; }
|
.col-tags { width: 250px; }
|
||||||
.col-time { width: 132px; }
|
.col-time { width: 132px; }
|
||||||
|
|
||||||
|
.users-table-wrap {
|
||||||
|
max-height: calc(100vh - 300px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.users-table .user-status-column { width: 116px; }
|
||||||
|
.users-table .user-role-column { width: 110px; }
|
||||||
|
.users-table .user-active-column { width: 112px; }
|
||||||
|
.users-table .user-seen-column { width: 170px; }
|
||||||
|
|
||||||
.check {
|
.check {
|
||||||
width: 16px;
|
width: 16px;
|
||||||
height: 16px;
|
height: 16px;
|
||||||
@@ -1274,6 +1284,137 @@ tbody tr:hover {
|
|||||||
padding: 14px;
|
padding: 14px;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-modal {
|
||||||
|
width: min(860px, calc(100vw - 80px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-modal-tabs {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-summary-grid,
|
||||||
|
.user-profile-facts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-summary-card,
|
||||||
|
.user-profile-facts > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-summary-card span,
|
||||||
|
.user-profile-facts span {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-summary-card strong,
|
||||||
|
.user-profile-facts strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-stats-grid {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-row {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 52px minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-cover {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-cover svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-main {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-title,
|
||||||
|
.user-activity-meta,
|
||||||
|
.user-activity-sub {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-title {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-meta {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-sub {
|
||||||
|
margin-top: 3px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-time {
|
||||||
|
min-width: 118px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-activity-time strong {
|
||||||
|
display: block;
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
{% endblock head_extra %}
|
{% endblock head_extra %}
|
||||||
|
|
||||||
@@ -1293,7 +1434,7 @@ tbody tr:hover {
|
|||||||
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="openReviews()">
|
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="openReviews()">
|
||||||
<i data-lucide="inbox"></i>
|
<i data-lucide="inbox"></i>
|
||||||
<span>Review Queue</span>
|
<span>Review Queue</span>
|
||||||
<span class="nav-count" x-text="reviews.total || 0"></span>
|
<span class="nav-count" x-text="reviewTotalAll()"></span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="openJobs()">
|
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="openJobs()">
|
||||||
<i data-lucide="calendar-clock"></i>
|
<i data-lucide="calendar-clock"></i>
|
||||||
@@ -1309,6 +1450,11 @@ tbody tr:hover {
|
|||||||
<i data-lucide="wrench"></i>
|
<i data-lucide="wrench"></i>
|
||||||
<span>Future Tools</span>
|
<span>Future Tools</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="nav-btn" :class="{active: activeView === 'users'}" @click="openUsers()">
|
||||||
|
<i data-lucide="users"></i>
|
||||||
|
<span>Users</span>
|
||||||
|
<span class="nav-count" x-text="users.online_count ? users.online_count + ' online' : ''"></span>
|
||||||
|
</button>
|
||||||
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()">
|
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()">
|
||||||
<i data-lucide="settings"></i>
|
<i data-lucide="settings"></i>
|
||||||
<span>Settings</span>
|
<span>Settings</span>
|
||||||
@@ -1380,7 +1526,7 @@ tbody tr:hover {
|
|||||||
<template x-for="status in reviewStatuses" :key="status.value">
|
<template x-for="status in reviewStatuses" :key="status.value">
|
||||||
<button class="seg-btn" :class="{active: reviewFilter.status === status.value}" @click="setReviewStatus(status.value)">
|
<button class="seg-btn" :class="{active: reviewFilter.status === status.value}" @click="setReviewStatus(status.value)">
|
||||||
<span x-text="status.label"></span>
|
<span x-text="status.label"></span>
|
||||||
<span class="muted" x-text="status.value ? statusCount(status.value) : reviews.total"></span>
|
<span class="muted" x-text="status.value ? statusCount(status.value) : reviewTotalAll()"></span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -1569,12 +1715,19 @@ tbody tr:hover {
|
|||||||
<label><input type="checkbox" x-model="metadataBackfillOptions.duration_seconds" /> duration_seconds</label>
|
<label><input type="checkbox" x-model="metadataBackfillOptions.duration_seconds" /> duration_seconds</label>
|
||||||
<label><input type="checkbox" x-model="metadataBackfillOptions.local_genres" /> local genres from files</label>
|
<label><input type="checkbox" x-model="metadataBackfillOptions.local_genres" /> local genres from files</label>
|
||||||
<label><input type="checkbox" x-model="metadataBackfillOptions.lastfm_tags" /> Last.fm tags</label>
|
<label><input type="checkbox" x-model="metadataBackfillOptions.lastfm_tags" /> Last.fm tags</label>
|
||||||
|
<label><input type="checkbox" x-model="metadataBackfillOptions.musicbrainz_tags" /> MusicBrainz tags</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="mode-row">
|
<div class="mode-row">
|
||||||
<label><input type="radio" value="fill_missing" x-model="metadataBackfillOptions.mode" /> Fill missing only</label>
|
<label><input type="radio" value="fill_missing" x-model="metadataBackfillOptions.mode" /> Fill missing only</label>
|
||||||
<label><input type="radio" value="overwrite" x-model="metadataBackfillOptions.mode" /> Overwrite existing values</label>
|
<label><input type="radio" value="overwrite" x-model="metadataBackfillOptions.mode" /> Overwrite existing values</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="metadata-backfill-options" x-show="isArtworkBackfillJob(activeJob)">
|
||||||
|
<div class="mode-row">
|
||||||
|
<label><input type="radio" value="missing" x-model="artworkBackfillOptions.mode" /> Missing images only</label>
|
||||||
|
<label><input type="radio" value="overwrite" x-model="artworkBackfillOptions.mode" /> Search all and replace existing</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="job-param-note" x-show="activeJob && !jobHasParameterForm(activeJob)">
|
<div class="job-param-note" x-show="activeJob && !jobHasParameterForm(activeJob)">
|
||||||
This task has no manual parameters.
|
This task has no manual parameters.
|
||||||
</div>
|
</div>
|
||||||
@@ -1729,6 +1882,79 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="content" x-show="activeView === 'users'">
|
||||||
|
<section class="stats-strip">
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-value" x-text="fmt(users.total || 0)"></div>
|
||||||
|
<div class="stat-label">Users</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-value" x-text="fmt(users.online_count || 0)"></div>
|
||||||
|
<div class="stat-label">Online now</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong>Users</strong>
|
||||||
|
<span x-text="usersSubtitle()"></span>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<input class="search" placeholder="Search users" x-model="userSearch" @input.debounce.350ms="loadUsers()" />
|
||||||
|
<button class="btn" @click="loadUsers(false)">
|
||||||
|
<i data-lucide="refresh-cw"></i>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap users-table-wrap">
|
||||||
|
<table class="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th class="user-status-column">Status</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th class="user-role-column">Role</th>
|
||||||
|
<th class="user-active-column">Account</th>
|
||||||
|
<th class="user-seen-column">Last activity</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template x-for="user in users.items" :key="user.id">
|
||||||
|
<tr @click="openUser(user)">
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="userStatusClass(user)" x-text="userStatusLabel(user)"></span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="primary-line" x-text="user.display_name || user.username"></div>
|
||||||
|
<div class="secondary-line" x-text="user.email ? user.username + ' · ' + user.email : user.username"></div>
|
||||||
|
</td>
|
||||||
|
<td><span x-text="user.role"></span></td>
|
||||||
|
<td>
|
||||||
|
<span class="badge" :class="user.is_active ? 'ok' : 'disabled'" x-text="user.is_active ? 'active' : 'disabled'"></span>
|
||||||
|
</td>
|
||||||
|
<td><span x-text="userLastSeenLabel(user)"></span></td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<div class="empty" x-show="!usersLoading && users.items.length === 0">No users in this filter</div>
|
||||||
|
</div>
|
||||||
|
<div class="action-strip">
|
||||||
|
<span class="selection-summary" x-text="usersRangeText()"></span>
|
||||||
|
<div class="toolbar">
|
||||||
|
<select class="search" style="width:92px" x-model.number="users.limit" @change="users.offset = 0; loadUsers(false)">
|
||||||
|
<option value="25">25</option>
|
||||||
|
<option value="40">40</option>
|
||||||
|
<option value="80">80</option>
|
||||||
|
<option value="120">120</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn" @click="pageUsers(-1)" :disabled="users.offset === 0">Previous</button>
|
||||||
|
<button class="btn" @click="pageUsers(1)" :disabled="users.offset + users.limit >= users.total">Next</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="content" x-show="activeView === 'tools'">
|
<div class="content" x-show="activeView === 'tools'">
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
@@ -2010,6 +2236,90 @@ tbody tr:hover {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<div class="modal-backdrop" x-show="userModalOpen && activeUserDetail" x-transition @click.self="userModalOpen = false">
|
||||||
|
<section class="modal user-modal">
|
||||||
|
<div class="modal-head">
|
||||||
|
<div class="panel-title">
|
||||||
|
<strong x-text="activeUserDetail ? (activeUserDetail.user.display_name || activeUserDetail.user.username) : 'User'"></strong>
|
||||||
|
<span x-text="activeUserDetail ? userStatusLabel(activeUserDetail.user) + ' · ' + activeUserDetail.user.role : ''"></span>
|
||||||
|
</div>
|
||||||
|
<button class="icon-btn" @click="userModalOpen = false">
|
||||||
|
<i data-lucide="x"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body" x-show="activeUserDetail">
|
||||||
|
<div class="segmented user-modal-tabs">
|
||||||
|
<button class="seg-btn" :class="{active: userModalTab === 'overview'}" @click="userModalTab = 'overview'">Overview</button>
|
||||||
|
<button class="seg-btn" :class="{active: userModalTab === 'activity'}" @click="userModalTab = 'activity'">Activity</button>
|
||||||
|
<button class="seg-btn" :class="{active: userModalTab === 'library'}" @click="userModalTab = 'library'" disabled>Library</button>
|
||||||
|
</div>
|
||||||
|
<div x-show="userModalTab === 'overview'">
|
||||||
|
<section class="user-summary-grid">
|
||||||
|
<div class="user-summary-card">
|
||||||
|
<span>Status</span>
|
||||||
|
<strong x-text="userStatusLabel(activeUserDetail.user)"></strong>
|
||||||
|
</div>
|
||||||
|
<div class="user-summary-card">
|
||||||
|
<span>Last activity</span>
|
||||||
|
<strong x-text="userLastSeenLabel(activeUserDetail.user)"></strong>
|
||||||
|
</div>
|
||||||
|
<div class="user-summary-card">
|
||||||
|
<span>Account</span>
|
||||||
|
<strong x-text="activeUserDetail.user.is_active ? 'active' : 'disabled'"></strong>
|
||||||
|
</div>
|
||||||
|
<div class="user-summary-card">
|
||||||
|
<span>Last.fm</span>
|
||||||
|
<strong x-text="activeUserDetail.stats.lastfm_connected ? 'connected' : 'not connected'"></strong>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section class="stats-strip user-stats-grid">
|
||||||
|
<template x-for="cell in userStatCards()" :key="cell.label">
|
||||||
|
<div class="stat-cell">
|
||||||
|
<div class="stat-value" x-text="cell.display"></div>
|
||||||
|
<div class="stat-label" x-text="cell.label"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
<div class="field">
|
||||||
|
<label>Profile</label>
|
||||||
|
<div class="user-profile-facts">
|
||||||
|
<div><span>Username</span><strong x-text="activeUserDetail.user.username"></strong></div>
|
||||||
|
<div><span>Email</span><strong x-text="activeUserDetail.user.email || 'not set'"></strong></div>
|
||||||
|
<div><span>Display name</span><strong x-text="activeUserDetail.user.display_name || 'not set'"></strong></div>
|
||||||
|
<div><span>Role</span><strong x-text="activeUserDetail.user.role"></strong></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div x-show="userModalTab === 'activity'">
|
||||||
|
<div class="user-activity-list">
|
||||||
|
<template x-for="play in (activeUserDetail?.recent_plays || [])" :key="play.history_id">
|
||||||
|
<div class="user-activity-row">
|
||||||
|
<div class="user-activity-cover">
|
||||||
|
<template x-if="play.cover_url">
|
||||||
|
<img :src="play.cover_url" :alt="play.release_title || play.title" loading="lazy">
|
||||||
|
</template>
|
||||||
|
<template x-if="!play.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>
|
||||||
|
</div>
|
||||||
|
<div class="user-activity-main">
|
||||||
|
<div class="user-activity-title" x-text="play.title"></div>
|
||||||
|
<div class="user-activity-meta" x-text="play.artists"></div>
|
||||||
|
<div class="user-activity-sub" x-text="userPlayMeta(play)"></div>
|
||||||
|
</div>
|
||||||
|
<div class="user-activity-time">
|
||||||
|
<strong x-text="userPlayListened(play)"></strong>
|
||||||
|
<span x-text="shortDate(play.played_at)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div class="empty" x-show="activeUserDetail && (!activeUserDetail.recent_plays || activeUserDetail.recent_plays.length === 0)">No play history for this user yet</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
|
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
|
||||||
<section class="modal">
|
<section class="modal">
|
||||||
<div class="modal-head">
|
<div class="modal-head">
|
||||||
@@ -2306,7 +2616,13 @@ function adminV2() {
|
|||||||
stats: {},
|
stats: {},
|
||||||
runtime: { agent: {}, storage: [], node: {} },
|
runtime: { agent: {}, storage: [], node: {} },
|
||||||
libraryOverview: {},
|
libraryOverview: {},
|
||||||
reviews: { items: [], total: 0, limit: 80, offset: 0, status_counts: [] },
|
reviews: { items: [], total: 0, total_all: 0, limit: 80, offset: 0, status_counts: [] },
|
||||||
|
users: { items: [], total: 0, limit: 40, offset: 0, online_count: 0 },
|
||||||
|
usersLoading: false,
|
||||||
|
userSearch: '',
|
||||||
|
userModalOpen: false,
|
||||||
|
userModalTab: 'overview',
|
||||||
|
activeUserDetail: null,
|
||||||
reviewFilter: { status: null, search: '' },
|
reviewFilter: { status: null, search: '' },
|
||||||
selectedReviewIds: {},
|
selectedReviewIds: {},
|
||||||
reviewSelectionScope: 'ids',
|
reviewSelectionScope: 'ids',
|
||||||
@@ -2347,8 +2663,12 @@ function adminV2() {
|
|||||||
duration_seconds: true,
|
duration_seconds: true,
|
||||||
local_genres: true,
|
local_genres: true,
|
||||||
lastfm_tags: true,
|
lastfm_tags: true,
|
||||||
|
musicbrainz_tags: true,
|
||||||
mode: 'fill_missing'
|
mode: 'fill_missing'
|
||||||
},
|
},
|
||||||
|
artworkBackfillOptions: {
|
||||||
|
mode: 'missing'
|
||||||
|
},
|
||||||
libraryKind: 'artists',
|
libraryKind: 'artists',
|
||||||
librarySearch: '',
|
librarySearch: '',
|
||||||
library: { items: [], total: 0, limit: 40, offset: 0 },
|
library: { items: [], total: 0, limit: 40, offset: 0 },
|
||||||
@@ -2450,6 +2770,8 @@ function adminV2() {
|
|||||||
this.activeJobName ? this.loadRunsForJob(this.activeJobName, false) : Promise.resolve(),
|
this.activeJobName ? this.loadRunsForJob(this.activeJobName, false) : Promise.resolve(),
|
||||||
this.refreshActiveRunDetail()
|
this.refreshActiveRunDetail()
|
||||||
]);
|
]);
|
||||||
|
} else if (this.activeView === 'users') {
|
||||||
|
await Promise.allSettled([this.loadUsers(false)]);
|
||||||
} else {
|
} else {
|
||||||
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
|
||||||
}
|
}
|
||||||
@@ -2482,6 +2804,8 @@ function adminV2() {
|
|||||||
this.libraryKind = nextKind;
|
this.libraryKind = nextKind;
|
||||||
} else if (view === 'settings') {
|
} else if (view === 'settings') {
|
||||||
this.activeView = 'settings';
|
this.activeView = 'settings';
|
||||||
|
} else if (view === 'users') {
|
||||||
|
this.activeView = 'users';
|
||||||
} else if (view === 'tools') {
|
} else if (view === 'tools') {
|
||||||
this.activeView = 'tools';
|
this.activeView = 'tools';
|
||||||
} else {
|
} else {
|
||||||
@@ -2513,6 +2837,9 @@ function adminV2() {
|
|||||||
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
|
||||||
await this.loadSettingsProbe(false);
|
await this.loadSettingsProbe(false);
|
||||||
}
|
}
|
||||||
|
} else if (this.activeView === 'users') {
|
||||||
|
if (updateRoute) this.setRoute('#users');
|
||||||
|
await this.loadUsers(false);
|
||||||
} else if (this.activeView === 'tools' && updateRoute) {
|
} else if (this.activeView === 'tools' && updateRoute) {
|
||||||
this.setRoute('#tools');
|
this.setRoute('#tools');
|
||||||
}
|
}
|
||||||
@@ -2555,6 +2882,12 @@ function adminV2() {
|
|||||||
this.setRoute('#tools');
|
this.setRoute('#tools');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
openUsers() {
|
||||||
|
this.activeView = 'users';
|
||||||
|
this.setRoute('#users');
|
||||||
|
this.loadUsers();
|
||||||
|
},
|
||||||
|
|
||||||
async loadReviews(resetOffset = true) {
|
async loadReviews(resetOffset = true) {
|
||||||
if (resetOffset) this.reviews.offset = 0;
|
if (resetOffset) this.reviews.offset = 0;
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
@@ -2603,6 +2936,38 @@ function adminV2() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async loadUsers(resetOffset = true) {
|
||||||
|
if (this.activeView !== 'users' && resetOffset) return;
|
||||||
|
if (resetOffset) this.users.offset = 0;
|
||||||
|
this.usersLoading = true;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('limit', this.users.limit || 40);
|
||||||
|
params.set('offset', this.users.offset || 0);
|
||||||
|
if (this.userSearch) params.set('search', this.userSearch);
|
||||||
|
try {
|
||||||
|
this.users = await this.request(`${this.apiBase}/users?${params.toString()}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast(error.message);
|
||||||
|
} finally {
|
||||||
|
this.usersLoading = false;
|
||||||
|
this.icons();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async openUser(user) {
|
||||||
|
if (!user) return;
|
||||||
|
this.userModalTab = 'overview';
|
||||||
|
this.activeUserDetail = { user, stats: {}, recent_plays: [] };
|
||||||
|
this.userModalOpen = true;
|
||||||
|
try {
|
||||||
|
this.activeUserDetail = await this.request(`${this.apiBase}/users/${user.id}`);
|
||||||
|
} catch (error) {
|
||||||
|
this.showToast(error.message);
|
||||||
|
} finally {
|
||||||
|
this.icons();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async loadSettings(showErrors = true) {
|
async loadSettings(showErrors = true) {
|
||||||
try {
|
try {
|
||||||
this.settings = await this.request(`${this.apiBase}/settings`);
|
this.settings = await this.request(`${this.apiBase}/settings`);
|
||||||
@@ -2826,6 +3191,11 @@ function adminV2() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(this.metadataBackfillPayload())
|
body: JSON.stringify(this.metadataBackfillPayload())
|
||||||
})
|
})
|
||||||
|
: this.isArtworkBackfillJob(job)
|
||||||
|
? await this.request(`${this.apiBase}/jobs/artwork_backfill/run-options`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(this.artworkBackfillPayload())
|
||||||
|
})
|
||||||
: await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/run`, { method: 'POST' });
|
: await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/run`, { method: 'POST' });
|
||||||
this.showToast(`Run #${result.run_id} started`);
|
this.showToast(`Run #${result.run_id} started`);
|
||||||
this.activeJobName = job.name;
|
this.activeJobName = job.name;
|
||||||
@@ -2845,8 +3215,12 @@ function adminV2() {
|
|||||||
return job && job.name === 'metadata_backfill';
|
return job && job.name === 'metadata_backfill';
|
||||||
},
|
},
|
||||||
|
|
||||||
|
isArtworkBackfillJob(job) {
|
||||||
|
return job && job.name === 'artwork_backfill';
|
||||||
|
},
|
||||||
|
|
||||||
jobHasParameterForm(job) {
|
jobHasParameterForm(job) {
|
||||||
return this.isMetadataBackfillJob(job);
|
return this.isMetadataBackfillJob(job) || this.isArtworkBackfillJob(job);
|
||||||
},
|
},
|
||||||
|
|
||||||
metadataBackfillPayload() {
|
metadataBackfillPayload() {
|
||||||
@@ -2858,10 +3232,18 @@ function adminV2() {
|
|||||||
duration_seconds: Boolean(options.duration_seconds),
|
duration_seconds: Boolean(options.duration_seconds),
|
||||||
local_genres: Boolean(options.local_genres),
|
local_genres: Boolean(options.local_genres),
|
||||||
lastfm_tags: Boolean(options.lastfm_tags),
|
lastfm_tags: Boolean(options.lastfm_tags),
|
||||||
|
musicbrainz_tags: Boolean(options.musicbrainz_tags),
|
||||||
overwrite: options.mode === 'overwrite'
|
overwrite: options.mode === 'overwrite'
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
||||||
|
artworkBackfillPayload() {
|
||||||
|
const options = this.artworkBackfillOptions || {};
|
||||||
|
return {
|
||||||
|
overwrite_existing: options.mode === 'overwrite'
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
async toggleJob(job) {
|
async toggleJob(job) {
|
||||||
try {
|
try {
|
||||||
const result = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/toggle`, { method: 'POST' });
|
const result = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/toggle`, { method: 'POST' });
|
||||||
@@ -3413,6 +3795,13 @@ function adminV2() {
|
|||||||
this.loadLibrary(false);
|
this.loadLibrary(false);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
pageUsers(delta) {
|
||||||
|
const next = Math.max(0, (this.users.offset || 0) + delta * (this.users.limit || 40));
|
||||||
|
if (next === this.users.offset) return;
|
||||||
|
this.users.offset = next;
|
||||||
|
this.loadUsers(false);
|
||||||
|
},
|
||||||
|
|
||||||
statCells() {
|
statCells() {
|
||||||
const agent = (this.runtime && this.runtime.agent) || {};
|
const agent = (this.runtime && this.runtime.agent) || {};
|
||||||
const storage = (this.runtime && this.runtime.storage) || [];
|
const storage = (this.runtime && this.runtime.storage) || [];
|
||||||
@@ -3469,6 +3858,7 @@ function adminV2() {
|
|||||||
if (this.activeView === 'jobs') return 'Tasks';
|
if (this.activeView === 'jobs') return 'Tasks';
|
||||||
if (this.activeView === 'tools') return 'Future Tools';
|
if (this.activeView === 'tools') return 'Future Tools';
|
||||||
if (this.activeView === 'settings') return 'Settings';
|
if (this.activeView === 'settings') return 'Settings';
|
||||||
|
if (this.activeView === 'users') return 'Users';
|
||||||
return 'Review Queue';
|
return 'Review Queue';
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -3477,11 +3867,16 @@ function adminV2() {
|
|||||||
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
|
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
|
||||||
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
|
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
|
||||||
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
|
if (this.activeView === 'settings') return 'Application configuration and external API credentials';
|
||||||
|
if (this.activeView === 'users') return 'Account status, live presence, and per-user listening statistics';
|
||||||
return 'Full-screen review triage with filter-aware bulk actions';
|
return 'Full-screen review triage with filter-aware bulk actions';
|
||||||
},
|
},
|
||||||
|
|
||||||
reviewPanelSubtitle() {
|
reviewPanelSubtitle() {
|
||||||
return `${this.fmt(this.reviews.total || 0)} rows · ${this.reviewFilter.status || 'all statuses'}`;
|
const total = this.reviews.total || 0;
|
||||||
|
const totalAll = this.reviewTotalAll();
|
||||||
|
const status = this.reviewFilter.status || 'all statuses';
|
||||||
|
if (this.reviewFilter.status) return `${this.fmt(total)} ${status} · ${this.fmt(totalAll)} total`;
|
||||||
|
return `${this.fmt(totalAll)} rows · ${status}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
jobPanelSubtitle() {
|
jobPanelSubtitle() {
|
||||||
@@ -3493,6 +3888,75 @@ function adminV2() {
|
|||||||
return `${this.libraryKind} · ${this.fmt(this.library.total || 0)} rows`;
|
return `${this.libraryKind} · ${this.fmt(this.library.total || 0)} rows`;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
usersSubtitle() {
|
||||||
|
return `${this.fmt(this.users.total || 0)} users · ${this.fmt(this.users.online_count || 0)} online`;
|
||||||
|
},
|
||||||
|
|
||||||
|
userStatusClass(user) {
|
||||||
|
return user?.is_online ? 'ok' : 'disabled';
|
||||||
|
},
|
||||||
|
|
||||||
|
userStatusLabel(user) {
|
||||||
|
return user?.is_online ? 'online' : 'offline';
|
||||||
|
},
|
||||||
|
|
||||||
|
userLastSeenLabel(user) {
|
||||||
|
if (!user || user.last_seen_ms == null) return 'never in this session';
|
||||||
|
if (user.is_online) return 'online now';
|
||||||
|
return this.durationApprox(user.last_seen_ms / 1000) + ' ago';
|
||||||
|
},
|
||||||
|
|
||||||
|
usersRangeText() {
|
||||||
|
const total = this.users.total || 0;
|
||||||
|
if (!total) return 'No users';
|
||||||
|
const start = (this.users.offset || 0) + 1;
|
||||||
|
const end = Math.min(total, (this.users.offset || 0) + (this.users.limit || 40));
|
||||||
|
return `${start}-${end} of ${this.fmt(total)}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
userStatCards() {
|
||||||
|
const stats = this.activeUserDetail?.stats || {};
|
||||||
|
return [
|
||||||
|
{ label: 'Plays', display: this.fmt(stats.plays || 0) },
|
||||||
|
{ label: 'Completed plays', display: this.fmt(stats.completed_plays || 0) },
|
||||||
|
{ label: 'Listened time', display: this.durationApprox(stats.listened_seconds || 0) },
|
||||||
|
{ label: 'Liked tracks', display: this.fmt(stats.liked_tracks || 0) },
|
||||||
|
{ label: 'Followed artists', display: this.fmt(stats.followed_artists || 0) },
|
||||||
|
{ label: 'Own playlists', display: this.fmt(stats.own_playlists || 0) },
|
||||||
|
{ label: 'Saved playlists', display: this.fmt(stats.saved_playlists || 0) },
|
||||||
|
{ label: 'Uploaded tracks', display: this.fmt(stats.uploaded_tracks || 0) },
|
||||||
|
{ label: 'Torrent sessions', display: this.fmt(stats.torrent_sessions || 0) }
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
userPlayListened(play) {
|
||||||
|
const listened = play?.duration_listened;
|
||||||
|
const duration = play?.track_duration_seconds;
|
||||||
|
const listenedText = listened == null ? 'unknown' : this.durationApprox(listened);
|
||||||
|
const durationText = duration ? this.durationApprox(duration) : null;
|
||||||
|
return durationText ? `${listenedText} / ${durationText}` : listenedText;
|
||||||
|
},
|
||||||
|
|
||||||
|
userPlayMeta(play) {
|
||||||
|
const parts = [];
|
||||||
|
if (play.release_title) {
|
||||||
|
parts.push(play.release_year ? `${play.release_title} (${play.release_year})` : play.release_title);
|
||||||
|
}
|
||||||
|
if (play.audio_format) parts.push(String(play.audio_format).toUpperCase());
|
||||||
|
if (play.audio_bitrate) parts.push(`${play.audio_bitrate} kbps`);
|
||||||
|
if (play.uploader_name) parts.push(`uploaded by ${play.uploader_name}`);
|
||||||
|
parts.push(play.completed ? 'completed' : 'partial');
|
||||||
|
return parts.join(' · ');
|
||||||
|
},
|
||||||
|
|
||||||
|
durationApprox(seconds) {
|
||||||
|
const value = Math.max(0, Number(seconds || 0));
|
||||||
|
if (value < 60) return `${Math.floor(value)}s`;
|
||||||
|
if (value < 3600) return `${Math.floor(value / 60)}m`;
|
||||||
|
if (value < 86400) return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`;
|
||||||
|
return `${Math.floor(value / 86400)}d ${Math.floor((value % 86400) / 3600)}h`;
|
||||||
|
},
|
||||||
|
|
||||||
librarySelectionSummary() {
|
librarySelectionSummary() {
|
||||||
const count = this.selectedLibraryCount();
|
const count = this.selectedLibraryCount();
|
||||||
if (!count) return 'Nothing selected';
|
if (!count) return 'Nothing selected';
|
||||||
@@ -3523,6 +3987,12 @@ function adminV2() {
|
|||||||
return row ? row.count : 0;
|
return row ? row.count : 0;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
reviewTotalAll() {
|
||||||
|
const explicit = Number(this.reviews.total_all || 0);
|
||||||
|
if (explicit > 0 || Object.prototype.hasOwnProperty.call(this.reviews || {}, 'total_all')) return explicit;
|
||||||
|
return (this.reviews.status_counts || []).reduce((sum, row) => sum + Number(row.count || 0), 0);
|
||||||
|
},
|
||||||
|
|
||||||
formatConfidence(value) {
|
formatConfidence(value) {
|
||||||
return typeof value === 'number' ? `${Math.round(value * 100)}%` : '-';
|
return typeof value === 'number' ? `${Math.round(value * 100)}%` : '-';
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -722,6 +722,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
|
<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>
|
<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>
|
||||||
|
<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 }}">
|
<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>
|
<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>
|
</button>
|
||||||
|
|||||||
+508
-16
@@ -44,8 +44,13 @@ const T = {
|
|||||||
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
|
lastfmDisconnectFailed: "{{ t.player_lastfm_disconnect_failed }}",
|
||||||
startRadio: "{{ t.player_start_radio }}",
|
startRadio: "{{ t.player_start_radio }}",
|
||||||
radioFailed: "{{ t.player_radio_failed }}",
|
radioFailed: "{{ t.player_radio_failed }}",
|
||||||
|
share: "{{ t.player_share }}",
|
||||||
|
shareTrack: "{{ t.player_share_track }}",
|
||||||
|
shareQueue: "{{ t.player_share_queue }}",
|
||||||
|
sharedPlaylist: "{{ t.player_shared_playlist }}",
|
||||||
connectionLost: "{{ t.player_connection_lost }}",
|
connectionLost: "{{ t.player_connection_lost }}",
|
||||||
connectionLostDetail: "{{ t.player_connection_lost_detail }}",
|
connectionLostDetail: "{{ t.player_connection_lost_detail }}",
|
||||||
|
activeDevice: "{{ t.player_active_device }}",
|
||||||
trackWord: "{{ t.player_tracks_count }}",
|
trackWord: "{{ t.player_tracks_count }}",
|
||||||
releaseWord: "{{ t.player_releases_count }}",
|
releaseWord: "{{ t.player_releases_count }}",
|
||||||
ofWord: "{{ t.player_of }}",
|
ofWord: "{{ t.player_of }}",
|
||||||
@@ -284,6 +289,204 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Alpine.store('sharing', {
|
||||||
|
_handledInitialLink: false,
|
||||||
|
sharedTrackId: null,
|
||||||
|
|
||||||
|
absoluteUrl(path) {
|
||||||
|
return new URL(path, window.location.origin).href;
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyText(text) {
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText && window.isSecureContext) {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const textarea = document.createElement('textarea');
|
||||||
|
textarea.value = text;
|
||||||
|
textarea.setAttribute('readonly', '');
|
||||||
|
textarea.style.position = 'fixed';
|
||||||
|
textarea.style.left = '-9999px';
|
||||||
|
textarea.style.top = '0';
|
||||||
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
|
let ok = false;
|
||||||
|
try {
|
||||||
|
ok = document.execCommand('copy');
|
||||||
|
} catch {}
|
||||||
|
textarea.remove();
|
||||||
|
return ok;
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyUrl(path, trigger = null) {
|
||||||
|
const copied = await this.copyText(this.absoluteUrl(path));
|
||||||
|
this.flashTrigger(trigger, copied);
|
||||||
|
return copied;
|
||||||
|
},
|
||||||
|
|
||||||
|
copyTrack(track, trigger = null) {
|
||||||
|
const id = Number(track?.id || track);
|
||||||
|
if (!id) return;
|
||||||
|
this.copyUrl(`/share/track/${id}`, trigger);
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyQueue(trigger = null) {
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
return this.copyTracks(queue?.tracks || [], T.sharedPlaylist, trigger);
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyRelease(release, trigger = null) {
|
||||||
|
const id = Number(release?.id || release || 0);
|
||||||
|
if (!id) return;
|
||||||
|
return this.copyUrl(`/share/release/${id}`, trigger);
|
||||||
|
},
|
||||||
|
|
||||||
|
async copyTracks(tracks, title = T.sharedPlaylist, trigger = null) {
|
||||||
|
const trackIds = (tracks || []).map(track => Number(track.id)).filter(Boolean);
|
||||||
|
if (!trackIds.length) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/share-playlist', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ track_ids: trackIds, title }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('share failed');
|
||||||
|
const data = await res.json();
|
||||||
|
await this.copyUrl(data.url || `/share/playlist/${data.token}`, trigger);
|
||||||
|
} catch (err) {
|
||||||
|
this.flashTrigger(trigger, false);
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
flashTrigger(trigger, copied) {
|
||||||
|
if (!trigger) return;
|
||||||
|
const className = copied ? 'share-copy-flash' : 'share-copy-failed';
|
||||||
|
trigger.classList.remove('share-copy-flash', 'share-copy-failed');
|
||||||
|
void trigger.offsetWidth;
|
||||||
|
trigger.classList.add(className);
|
||||||
|
window.setTimeout(() => trigger.classList.remove(className), 720);
|
||||||
|
},
|
||||||
|
|
||||||
|
markSharedTrack(trackId) {
|
||||||
|
const id = Number(trackId || 0);
|
||||||
|
this.sharedTrackId = id > 0 ? id : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
isSharedTrack(track) {
|
||||||
|
const id = Number(track?.id || track || 0);
|
||||||
|
return id > 0 && id === Number(this.sharedTrackId || 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
focusSharedTrack(trackId) {
|
||||||
|
const run = () => this.scrollSharedTrackIntoView(trackId);
|
||||||
|
requestAnimationFrame(run);
|
||||||
|
setTimeout(run, 120);
|
||||||
|
setTimeout(run, 360);
|
||||||
|
setTimeout(run, 720);
|
||||||
|
},
|
||||||
|
|
||||||
|
scrollSharedTrackIntoView(trackId) {
|
||||||
|
const id = Number(trackId || 0);
|
||||||
|
if (!id) return false;
|
||||||
|
const row = document.querySelector(`#center-scroll [data-shared-track-id="${id}"]`);
|
||||||
|
const container = document.getElementById('center-scroll');
|
||||||
|
if (!row || !container) return false;
|
||||||
|
|
||||||
|
const containerRect = container.getBoundingClientRect();
|
||||||
|
const rowRect = row.getBoundingClientRect();
|
||||||
|
const top = container.scrollTop
|
||||||
|
+ rowRect.top
|
||||||
|
- containerRect.top
|
||||||
|
- ((container.clientHeight - rowRect.height) / 2);
|
||||||
|
container.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleInitialShareLinks() {
|
||||||
|
if (this._handledInitialLink) return;
|
||||||
|
this._handledInitialLink = true;
|
||||||
|
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const trackId = Number(params.get('track') || 0);
|
||||||
|
const releaseId = Number(params.get('release') || 0);
|
||||||
|
const playlistToken = (params.get('playlist_share') || '').trim();
|
||||||
|
|
||||||
|
if (trackId > 0) {
|
||||||
|
await this.openSharedTrack(trackId);
|
||||||
|
this._clearShareQuery();
|
||||||
|
} else if (releaseId > 0) {
|
||||||
|
await this.openSharedRelease(releaseId);
|
||||||
|
this._clearShareQuery();
|
||||||
|
} else if (playlistToken) {
|
||||||
|
await this.openSharedPlaylist(playlistToken);
|
||||||
|
this._clearShareQuery();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSharedTrack(trackId) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/tracks-by-ids', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids: [Number(trackId)] }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('track load failed');
|
||||||
|
const tracks = await res.json();
|
||||||
|
const track = Array.isArray(tracks) ? tracks[0] : null;
|
||||||
|
if (!track) return;
|
||||||
|
this.markSharedTrack(track.id);
|
||||||
|
if (track.release_id) {
|
||||||
|
await Alpine.store('library').openRelease(track.release_id, { focusSharedTrackId: track.id });
|
||||||
|
}
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
queue.tracks = [track];
|
||||||
|
queue.currentIndex = 0;
|
||||||
|
Alpine.store('player')._playLocal(track, { paused: true });
|
||||||
|
this.focusSharedTrack(track.id);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSharedRelease(releaseId) {
|
||||||
|
const id = Number(releaseId || 0);
|
||||||
|
if (!id) return;
|
||||||
|
this.markSharedTrack(null);
|
||||||
|
await Alpine.store('library').openRelease(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
async openSharedPlaylist(token) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/player/share-playlist/${encodeURIComponent(token)}`);
|
||||||
|
if (!res.ok) throw new Error('playlist load failed');
|
||||||
|
const data = await res.json();
|
||||||
|
const tracks = Array.isArray(data.tracks) ? data.tracks : [];
|
||||||
|
if (!tracks.length) return;
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
queue.tracks = tracks;
|
||||||
|
queue.currentIndex = 0;
|
||||||
|
Alpine.store('player')._playLocal(tracks[0], { paused: true });
|
||||||
|
Alpine.store('library').showSharedPlaylist(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_clearShareQuery() {
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
url.searchParams.delete('track');
|
||||||
|
url.searchParams.delete('release');
|
||||||
|
url.searchParams.delete('playlist_share');
|
||||||
|
const query = url.searchParams.toString();
|
||||||
|
const next = `${url.pathname}${query ? '?' + query : ''}${window.location.hash}`;
|
||||||
|
history.replaceState({}, '', next);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// User store
|
// User store
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
@@ -509,11 +712,18 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_playbackStartedAt: null,
|
_playbackStartedAt: null,
|
||||||
_listenedSeconds: 0,
|
_listenedSeconds: 0,
|
||||||
_lastAudioTime: 0,
|
_lastAudioTime: 0,
|
||||||
|
_localSourceTrackId: null,
|
||||||
_remoteExecuting: false,
|
_remoteExecuting: false,
|
||||||
_remoteStateBaseTime: 0,
|
_remoteStateBaseTime: 0,
|
||||||
_remoteStateReceivedAt: 0,
|
_remoteStateReceivedAt: 0,
|
||||||
_remoteStateTimer: null,
|
_remoteStateTimer: null,
|
||||||
|
|
||||||
|
_hasInitialShareLink() {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
return Number(params.get('track') || 0) > 0
|
||||||
|
|| (params.get('playlist_share') || '').trim().length > 0;
|
||||||
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
audio.volume = this.volume;
|
audio.volume = this.volume;
|
||||||
|
|
||||||
@@ -527,6 +737,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
audio.addEventListener('ended', () => {
|
audio.addEventListener('ended', () => {
|
||||||
this._trackListenedDelta();
|
this._trackListenedDelta();
|
||||||
this._recordHistory(true);
|
this._recordHistory(true);
|
||||||
|
if (Alpine.store('devices')?.shouldPlayJamLocally()) {
|
||||||
|
this.isPlaying = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.next();
|
this.next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -554,7 +768,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}, 250);
|
}, 250);
|
||||||
|
|
||||||
// Restore state
|
// Restore state
|
||||||
this._restoreState();
|
if (!this._hasInitialShareLink()) {
|
||||||
|
this._restoreState();
|
||||||
|
}
|
||||||
|
|
||||||
// Save state on page unload
|
// Save state on page unload
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
@@ -566,6 +782,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (!track) return;
|
if (!track) return;
|
||||||
if (this._shouldSendRemote()) {
|
if (this._shouldSendRemote()) {
|
||||||
this._mirrorRemoteTrack(track, true, 0);
|
this._mirrorRemoteTrack(track, true, 0);
|
||||||
|
if (this._shouldMirrorRemoteLocally()) {
|
||||||
|
this._playLocal(track, {
|
||||||
|
position_seconds: 0,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
this._sendRemote('play_track', this._remotePlaybackPayload(track, {
|
this._sendRemote('play_track', this._remotePlaybackPayload(track, {
|
||||||
position_seconds: 0,
|
position_seconds: 0,
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -582,6 +804,12 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const track = queue.tracks[idx];
|
const track = queue.tracks[idx];
|
||||||
if (this._shouldSendRemote()) {
|
if (this._shouldSendRemote()) {
|
||||||
this._mirrorRemoteTrack(track, true, 0);
|
this._mirrorRemoteTrack(track, true, 0);
|
||||||
|
if (this._shouldMirrorRemoteLocally()) {
|
||||||
|
this._playLocal(track, {
|
||||||
|
position_seconds: 0,
|
||||||
|
paused: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
this._sendRemote('play_from_index', this._remotePlaybackPayload(track, {
|
this._sendRemote('play_from_index', this._remotePlaybackPayload(track, {
|
||||||
index: idx,
|
index: idx,
|
||||||
position_seconds: 0,
|
position_seconds: 0,
|
||||||
@@ -594,6 +822,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
_playLocal(track, options = {}) {
|
_playLocal(track, options = {}) {
|
||||||
this.currentTrack = track;
|
this.currentTrack = track;
|
||||||
|
this._localSourceTrackId = track.id;
|
||||||
this._historyRecorded = false;
|
this._historyRecorded = false;
|
||||||
this._resetPlaybackTracking();
|
this._resetPlaybackTracking();
|
||||||
audio.src = track.stream_url;
|
audio.src = track.stream_url;
|
||||||
@@ -625,6 +854,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.isPlaying = false;
|
this.isPlaying = false;
|
||||||
this._remoteStateBaseTime = this.currentTime;
|
this._remoteStateBaseTime = this.currentTime;
|
||||||
this._remoteStateReceivedAt = Date.now();
|
this._remoteStateReceivedAt = Date.now();
|
||||||
|
if (this._shouldMirrorRemoteLocally()) {
|
||||||
|
this._pauseLocal();
|
||||||
|
}
|
||||||
this._sendRemote('pause');
|
this._sendRemote('pause');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -636,6 +868,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.isPlaying = true;
|
this.isPlaying = true;
|
||||||
this._remoteStateBaseTime = this.currentTime;
|
this._remoteStateBaseTime = this.currentTime;
|
||||||
this._remoteStateReceivedAt = Date.now();
|
this._remoteStateReceivedAt = Date.now();
|
||||||
|
if (this._shouldMirrorRemoteLocally()) {
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
}
|
||||||
this._sendRemote('resume');
|
this._sendRemote('resume');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -655,6 +890,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;
|
this.progress = this.duration > 0 ? (this.currentTime / this.duration) * 100 : 0;
|
||||||
this._remoteStateBaseTime = nextTime;
|
this._remoteStateBaseTime = nextTime;
|
||||||
this._remoteStateReceivedAt = Date.now();
|
this._remoteStateReceivedAt = Date.now();
|
||||||
|
if (this._shouldMirrorRemoteLocally() && this._localSourceTrackId === this.currentTrack?.id) {
|
||||||
|
audio.currentTime = nextTime;
|
||||||
|
this._lastAudioTime = audio.currentTime || 0;
|
||||||
|
}
|
||||||
this._sendRemote('seek', { time: nextTime });
|
this._sendRemote('seek', { time: nextTime });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -672,13 +911,35 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
seekFromClick(event) {
|
seekFromClick(event) {
|
||||||
const bar = event.currentTarget;
|
const bar = event.currentTarget;
|
||||||
|
this._setProgressFromClientX(event.clientX, bar);
|
||||||
|
},
|
||||||
|
|
||||||
|
_setProgressFromClientX(clientX, bar) {
|
||||||
const rect = bar.getBoundingClientRect();
|
const rect = bar.getBoundingClientRect();
|
||||||
const pct = (event.clientX - rect.left) / rect.width;
|
const pct = rect.width > 0 ? Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)) : 0;
|
||||||
if (this.duration > 0) {
|
if (this.duration > 0) {
|
||||||
this.seek(pct * this.duration);
|
this.seek(pct * this.duration);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
startProgressDrag(event) {
|
||||||
|
const bar = event.currentTarget;
|
||||||
|
this._setProgressFromClientX(event.clientX, bar);
|
||||||
|
bar.setPointerCapture?.(event.pointerId);
|
||||||
|
|
||||||
|
const move = (e) => {
|
||||||
|
this._setProgressFromClientX(e.clientX, bar);
|
||||||
|
};
|
||||||
|
const stop = () => {
|
||||||
|
window.removeEventListener('pointermove', move);
|
||||||
|
window.removeEventListener('pointerup', stop);
|
||||||
|
window.removeEventListener('pointercancel', stop);
|
||||||
|
};
|
||||||
|
window.addEventListener('pointermove', move);
|
||||||
|
window.addEventListener('pointerup', stop);
|
||||||
|
window.addEventListener('pointercancel', stop);
|
||||||
|
},
|
||||||
|
|
||||||
next() {
|
next() {
|
||||||
if (this._shouldSendRemote()) {
|
if (this._shouldSendRemote()) {
|
||||||
this._sendRemote('next', {
|
this._sendRemote('next', {
|
||||||
@@ -741,7 +1002,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
setVolume(v) {
|
setVolume(v) {
|
||||||
this._setVolumeLocal(v);
|
this._setVolumeLocal(v);
|
||||||
if (this._shouldSendRemote()) {
|
if (this._shouldSendRemote() && !this._shouldMirrorRemoteLocally()) {
|
||||||
this._sendRemote('set_volume', { volume: this.volume });
|
this._sendRemote('set_volume', { volume: this.volume });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -831,6 +1092,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return !!devices && !this._remoteExecuting && !devices.isActive();
|
return !!devices && !this._remoteExecuting && !devices.isActive();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_shouldMirrorRemoteLocally() {
|
||||||
|
return !!Alpine.store('devices')?.shouldPlayJamLocally();
|
||||||
|
},
|
||||||
|
|
||||||
_sendRemote(command, payload = {}) {
|
_sendRemote(command, payload = {}) {
|
||||||
const devices = Alpine.store('devices');
|
const devices = Alpine.store('devices');
|
||||||
if (!devices) return false;
|
if (!devices) return false;
|
||||||
@@ -905,8 +1170,57 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this._updateMediaSession();
|
this._updateMediaSession();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_syncLocalPlaybackState(state) {
|
||||||
|
if (!state) return;
|
||||||
|
const queue = Alpine.store('queue');
|
||||||
|
const tracks = Array.isArray(state.tracks) ? state.tracks.filter(Boolean) : [];
|
||||||
|
if (queue && tracks.length > 0) {
|
||||||
|
queue.tracks = queue._tracksWithJamDefaults(tracks);
|
||||||
|
queue.currentIndex = Math.max(0, Math.min(Number(state.index || 0), queue.tracks.length - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = state.track || queue?.tracks?.[queue.currentIndex] || null;
|
||||||
|
if (!track) return;
|
||||||
|
|
||||||
|
const desiredTime = Math.max(0, Number(state.position_seconds || 0));
|
||||||
|
const duration = Number(state.duration_seconds || track.duration_seconds || this.duration || 0);
|
||||||
|
this.currentTrack = track;
|
||||||
|
this.shuffle = !!state.shuffle;
|
||||||
|
this.repeatMode = state.repeat_mode || 'off';
|
||||||
|
this.duration = duration;
|
||||||
|
this._remoteStateBaseTime = desiredTime;
|
||||||
|
this._remoteStateReceivedAt = Date.now();
|
||||||
|
|
||||||
|
if (this._localSourceTrackId !== track.id) {
|
||||||
|
this._playLocal(track, {
|
||||||
|
position_seconds: desiredTime,
|
||||||
|
paused: !!state.paused,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const drift = Math.abs((audio.currentTime || 0) - desiredTime);
|
||||||
|
const driftLimit = state.paused ? 0.08 : 0.75;
|
||||||
|
if (drift > driftLimit) {
|
||||||
|
audio.currentTime = desiredTime;
|
||||||
|
this._lastAudioTime = audio.currentTime || 0;
|
||||||
|
}
|
||||||
|
if (state.paused) {
|
||||||
|
if (!audio.paused) audio.pause();
|
||||||
|
this.isPlaying = false;
|
||||||
|
} else if (audio.paused) {
|
||||||
|
audio.play().catch(() => {});
|
||||||
|
this.isPlaying = true;
|
||||||
|
} else {
|
||||||
|
this.isPlaying = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.currentTime = audio.currentTime || desiredTime;
|
||||||
|
this.progress = duration > 0 ? (this.currentTime / duration) * 100 : 0;
|
||||||
|
this._updateMediaSession();
|
||||||
|
},
|
||||||
|
|
||||||
_tickRemoteProgress(force = false) {
|
_tickRemoteProgress(force = false) {
|
||||||
if (this._isLocalPlaybackDevice() || !this.currentTrack) return;
|
if (this._isLocalPlaybackDevice() || this._shouldMirrorRemoteLocally() || !this.currentTrack) return;
|
||||||
if (!force && !this.isPlaying) return;
|
if (!force && !this.isPlaying) return;
|
||||||
let nextTime = Number(this._remoteStateBaseTime || 0);
|
let nextTime = Number(this._remoteStateBaseTime || 0);
|
||||||
if (this.isPlaying && this._remoteStateReceivedAt > 0) {
|
if (this.isPlaying && this._remoteStateReceivedAt > 0) {
|
||||||
@@ -1045,6 +1359,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
: tracks[idx];
|
: tracks[idx];
|
||||||
if (currentTrack) {
|
if (currentTrack) {
|
||||||
this.currentTrack = currentTrack;
|
this.currentTrack = currentTrack;
|
||||||
|
this._localSourceTrackId = currentTrack.id;
|
||||||
this._historyRecorded = false;
|
this._historyRecorded = false;
|
||||||
this._resetPlaybackTracking();
|
this._resetPlaybackTracking();
|
||||||
audio.src = currentTrack.stream_url;
|
audio.src = currentTrack.stream_url;
|
||||||
@@ -1176,9 +1491,15 @@ document.addEventListener('alpine:init', () => {
|
|||||||
jamUsers: [],
|
jamUsers: [],
|
||||||
jamSelectedUsers: [],
|
jamSelectedUsers: [],
|
||||||
jamSearching: false,
|
jamSearching: false,
|
||||||
|
jamLocalPlayback: false,
|
||||||
|
remoteHintVisible: false,
|
||||||
|
remoteHintDeviceName: '',
|
||||||
|
_remoteHintShown: false,
|
||||||
|
_remoteHintTimer: null,
|
||||||
_pollTimer: null,
|
_pollTimer: null,
|
||||||
_jamSearchTimer: null,
|
_jamSearchTimer: null,
|
||||||
_stateRefreshTick: 0,
|
_stateRefreshTick: 0,
|
||||||
|
_lastPlaybackState: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.id = this._ensureId();
|
this.id = this._ensureId();
|
||||||
@@ -1234,6 +1555,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
});
|
});
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
||||||
this._apply(data);
|
this._apply(data);
|
||||||
|
|
||||||
const player = Alpine.store('player');
|
const player = Alpine.store('player');
|
||||||
@@ -1242,7 +1564,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
if (player && !this.isActive()) {
|
if (player && !this.isActive()) {
|
||||||
if (data.playback_state) {
|
if (data.playback_state) {
|
||||||
player._applyRemotePlaybackState(data.playback_state);
|
if (this.shouldPlayJamLocally()) {
|
||||||
|
player._syncLocalPlaybackState(data.playback_state);
|
||||||
|
} else {
|
||||||
|
player._applyRemotePlaybackState(data.playback_state);
|
||||||
|
}
|
||||||
} else if (++this._stateRefreshTick % 8 === 0) {
|
} else if (++this._stateRefreshTick % 8 === 0) {
|
||||||
player._restoreState();
|
player._restoreState();
|
||||||
}
|
}
|
||||||
@@ -1252,9 +1578,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
_apply(data) {
|
_apply(data) {
|
||||||
const wasActive = this.isActive();
|
const wasActive = this.isActive();
|
||||||
|
const previousJamId = this.currentJamId;
|
||||||
this.activeDeviceId = data.active_device_id || null;
|
this.activeDeviceId = data.active_device_id || null;
|
||||||
this.devices = Array.isArray(data.devices) ? data.devices : [];
|
this.devices = Array.isArray(data.devices) ? data.devices : [];
|
||||||
this.jams = Array.isArray(data.jams) ? data.jams : [];
|
this.jams = Array.isArray(data.jams) ? data.jams : [];
|
||||||
|
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
||||||
if (data.current_jam_id) {
|
if (data.current_jam_id) {
|
||||||
this.currentJamId = data.current_jam_id;
|
this.currentJamId = data.current_jam_id;
|
||||||
sessionStorage.setItem('furu_player_jam_id', this.currentJamId);
|
sessionStorage.setItem('furu_player_jam_id', this.currentJamId);
|
||||||
@@ -1262,9 +1590,40 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.currentJamId = null;
|
this.currentJamId = null;
|
||||||
sessionStorage.removeItem('furu_player_jam_id');
|
sessionStorage.removeItem('furu_player_jam_id');
|
||||||
}
|
}
|
||||||
|
if (previousJamId !== this.currentJamId || !this.canPlayJamLocally()) {
|
||||||
|
this._setJamLocalPlayback(false, { pauseLocal: true });
|
||||||
|
}
|
||||||
if (wasActive && !this.isActive()) {
|
if (wasActive && !this.isActive()) {
|
||||||
Alpine.store('player')?._pauseLocal();
|
Alpine.store('player')?._pauseLocal();
|
||||||
}
|
}
|
||||||
|
this._maybeShowRemoteHint();
|
||||||
|
},
|
||||||
|
|
||||||
|
remoteActiveDevice() {
|
||||||
|
if (!this.activeDeviceId || this.activeDeviceId === this.id) return null;
|
||||||
|
return this.devices.find(device => device.id === this.activeDeviceId) || null;
|
||||||
|
},
|
||||||
|
|
||||||
|
_maybeShowRemoteHint() {
|
||||||
|
if (this._remoteHintShown || this.remoteHintVisible || this.open) return;
|
||||||
|
const active = this.remoteActiveDevice();
|
||||||
|
if (!active) return;
|
||||||
|
this.remoteHintDeviceName = active.name || 'Devices';
|
||||||
|
this.remoteHintVisible = true;
|
||||||
|
this._remoteHintShown = true;
|
||||||
|
clearTimeout(this._remoteHintTimer);
|
||||||
|
this._remoteHintTimer = setTimeout(() => this.dismissRemoteHint(), 60000);
|
||||||
|
},
|
||||||
|
|
||||||
|
dismissRemoteHint() {
|
||||||
|
this.remoteHintVisible = false;
|
||||||
|
this._remoteHintShown = true;
|
||||||
|
clearTimeout(this._remoteHintTimer);
|
||||||
|
this._remoteHintTimer = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
remoteHintText() {
|
||||||
|
return `${T.activeDevice}: ${this.remoteHintDeviceName || this.activeLabel()}`;
|
||||||
},
|
},
|
||||||
|
|
||||||
selectedJam() {
|
selectedJam() {
|
||||||
@@ -1317,6 +1676,41 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return !!jam && !jam.is_owner;
|
return !!jam && !jam.is_owner;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
canPlayJamLocally() {
|
||||||
|
const jam = this.selectedJam();
|
||||||
|
return !!jam && jam.is_member && !jam.is_owner && jam.host_device_online;
|
||||||
|
},
|
||||||
|
|
||||||
|
shouldPlayJamLocally() {
|
||||||
|
return this.jamLocalPlayback && this.canPlayJamLocally();
|
||||||
|
},
|
||||||
|
|
||||||
|
setJamLocalPlayback(enabled) {
|
||||||
|
this._setJamLocalPlayback(enabled, { pauseLocal: true });
|
||||||
|
if (!this.jamLocalPlayback) return;
|
||||||
|
|
||||||
|
const player = Alpine.store('player');
|
||||||
|
if (player && this._lastPlaybackState) {
|
||||||
|
player._syncLocalPlaybackState(this._lastPlaybackState);
|
||||||
|
} else if (player?.currentTrack) {
|
||||||
|
player._syncLocalPlaybackState(player._remotePlaybackPayload(player.currentTrack, {
|
||||||
|
position_seconds: player.currentTime || 0,
|
||||||
|
duration_seconds: player.duration || 0,
|
||||||
|
paused: !player.isPlaying,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
this.poll();
|
||||||
|
},
|
||||||
|
|
||||||
|
_setJamLocalPlayback(enabled, options = {}) {
|
||||||
|
const next = !!enabled && this.canPlayJamLocally();
|
||||||
|
const wasEnabled = this.jamLocalPlayback;
|
||||||
|
this.jamLocalPlayback = next;
|
||||||
|
if (wasEnabled && !next && options.pauseLocal) {
|
||||||
|
Alpine.store('player')?._pauseLocal();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
isActive() {
|
isActive() {
|
||||||
if (this.isControllingRemoteJam()) return false;
|
if (this.isControllingRemoteJam()) return false;
|
||||||
return !this.activeDeviceId || this.activeDeviceId === this.id;
|
return !this.activeDeviceId || this.activeDeviceId === this.id;
|
||||||
@@ -1330,6 +1724,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
toggle() {
|
toggle() {
|
||||||
|
this.dismissRemoteHint();
|
||||||
this.open = !this.open;
|
this.open = !this.open;
|
||||||
if (this.open) this.poll();
|
if (this.open) this.poll();
|
||||||
},
|
},
|
||||||
@@ -1521,6 +1916,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
this.currentJamId = jam.id;
|
this.currentJamId = jam.id;
|
||||||
sessionStorage.setItem('furu_player_jam_id', jam.id);
|
sessionStorage.setItem('furu_player_jam_id', jam.id);
|
||||||
|
if (data.playback_state) this._lastPlaybackState = data.playback_state;
|
||||||
this._apply(data);
|
this._apply(data);
|
||||||
Alpine.store('queue')?._ensureCurrentJamAttribution();
|
Alpine.store('queue')?._ensureCurrentJamAttribution();
|
||||||
this.open = false;
|
this.open = false;
|
||||||
@@ -1554,6 +1950,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.currentJamId = null;
|
this.currentJamId = null;
|
||||||
this.jamPanelOpen = false;
|
this.jamPanelOpen = false;
|
||||||
this.jamPanelJamId = null;
|
this.jamPanelJamId = null;
|
||||||
|
this._setJamLocalPlayback(false, { pauseLocal: true });
|
||||||
sessionStorage.removeItem('furu_player_jam_id');
|
sessionStorage.removeItem('furu_player_jam_id');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -1813,11 +2210,13 @@ document.addEventListener('alpine:init', () => {
|
|||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
Alpine.store('library', {
|
Alpine.store('library', {
|
||||||
view: 'artists',
|
view: 'artists',
|
||||||
|
artistFilter: 'all',
|
||||||
artists: [],
|
artists: [],
|
||||||
artistsPage: 0,
|
artistsPage: 0,
|
||||||
artistsTotal: 0,
|
artistsTotal: 0,
|
||||||
loading: false,
|
loading: false,
|
||||||
_allLoaded: false,
|
_allLoaded: false,
|
||||||
|
_artistsLoadToken: 0,
|
||||||
currentArtist: null,
|
currentArtist: null,
|
||||||
currentRelease: null,
|
currentRelease: null,
|
||||||
currentPlaylist: null,
|
currentPlaylist: null,
|
||||||
@@ -1832,7 +2231,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
_hashNav: false, // guard against circular hash updates
|
_hashNav: false, // guard against circular hash updates
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.loadArtists(1);
|
|
||||||
this._setupScroll();
|
this._setupScroll();
|
||||||
|
|
||||||
// Listen for browser back/forward
|
// Listen for browser back/forward
|
||||||
@@ -1846,6 +2244,9 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// Navigate to initial hash (if any)
|
// Navigate to initial hash (if any)
|
||||||
this._navigateFromHash({ fromHash: true, restoreScroll: true });
|
this._navigateFromHash({ fromHash: true, restoreScroll: true });
|
||||||
|
this.$nextTick(() => {
|
||||||
|
Alpine.store('sharing')?.handleInitialShareLinks();
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
_setHash(hash) {
|
_setHash(hash) {
|
||||||
@@ -1869,7 +2270,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
const params = match[3] || '';
|
const params = match[3] || '';
|
||||||
|
|
||||||
if (view === 'artists' && !id) {
|
if (view === 'artists' && !id) {
|
||||||
if (this.view !== 'artists') this.goArtists(options);
|
if (this.view !== 'artists' || this.artistsPage === 0) this.goArtists(options);
|
||||||
|
else if (options.restoreScroll) this._restoreScrollPosition(hash);
|
||||||
|
} else if (view === 'uploads' && !id) {
|
||||||
|
if (this.view !== 'my_uploads' || this.artistsPage === 0) this.goMyUploads(options);
|
||||||
else if (options.restoreScroll) this._restoreScrollPosition(hash);
|
else if (options.restoreScroll) this._restoreScrollPosition(hash);
|
||||||
} else if (view === 'artist' && id) {
|
} else if (view === 'artist' && id) {
|
||||||
this.openArtist(id, options);
|
this.openArtist(id, options);
|
||||||
@@ -1919,7 +2323,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_afterNavigation(options = {}) {
|
_afterNavigation(options = {}) {
|
||||||
if (options.restoreScroll) {
|
if (options.focusSharedTrackId) {
|
||||||
|
this.$nextTick(() => {
|
||||||
|
Alpine.store('sharing')?.focusSharedTrack(options.focusSharedTrackId);
|
||||||
|
});
|
||||||
|
} else if (options.restoreScroll) {
|
||||||
this._restoreScrollPosition(this._activeHash);
|
this._restoreScrollPosition(this._activeHash);
|
||||||
} else {
|
} else {
|
||||||
this.$nextTick(() => { this._scrollToTop(); });
|
this.$nextTick(() => { this._scrollToTop(); });
|
||||||
@@ -1935,8 +2343,22 @@ document.addEventListener('alpine:init', () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_resetArtistList(filter) {
|
||||||
|
this.artistFilter = filter;
|
||||||
|
this.artists = [];
|
||||||
|
this.artistsPage = 0;
|
||||||
|
this.artistsTotal = 0;
|
||||||
|
this._allLoaded = false;
|
||||||
|
this.loading = false;
|
||||||
|
this._artistsLoadToken += 1;
|
||||||
|
},
|
||||||
|
|
||||||
goArtists(options = {}) {
|
goArtists(options = {}) {
|
||||||
this._beginNavigation('#artists', options);
|
this._beginNavigation('#artists', options);
|
||||||
|
if (this.artistFilter !== 'all' || this.artistsPage === 0) {
|
||||||
|
this._resetArtistList('all');
|
||||||
|
this.loadArtists(1);
|
||||||
|
}
|
||||||
this.view = 'artists';
|
this.view = 'artists';
|
||||||
this.currentArtist = null;
|
this.currentArtist = null;
|
||||||
this.currentRelease = null;
|
this.currentRelease = null;
|
||||||
@@ -1948,25 +2370,63 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this._afterNavigation(options);
|
this._afterNavigation(options);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
goMyUploads(options = {}) {
|
||||||
|
this._beginNavigation('#uploads', options);
|
||||||
|
if (this.artistFilter !== 'uploads' || this.artistsPage === 0) {
|
||||||
|
this._resetArtistList('uploads');
|
||||||
|
this.loadArtists(1);
|
||||||
|
}
|
||||||
|
this.view = 'my_uploads';
|
||||||
|
this.currentArtist = null;
|
||||||
|
this.currentRelease = null;
|
||||||
|
this.currentPlaylist = null;
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.searchResults = null;
|
||||||
|
this._previousView = 'my_uploads';
|
||||||
|
this.$nextTick(() => { this._setupScroll(); });
|
||||||
|
this._afterNavigation(options);
|
||||||
|
},
|
||||||
|
|
||||||
async loadArtists(page) {
|
async loadArtists(page) {
|
||||||
if (this.loading || this._allLoaded) return;
|
if (this.loading || this._allLoaded) return;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
|
const filter = this.artistFilter;
|
||||||
|
const token = this._artistsLoadToken + 1;
|
||||||
|
this._artistsLoadToken = token;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/player/artists?page=${page}&limit=60`);
|
const mine = filter === 'uploads' ? '&mine=true' : '';
|
||||||
|
const res = await fetch(`/api/player/artists?page=${page}&limit=80${mine}`);
|
||||||
if (!res.ok) throw new Error('failed');
|
if (!res.ok) throw new Error('failed');
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
if (token !== this._artistsLoadToken || filter !== this.artistFilter) return;
|
||||||
|
const incoming = Array.isArray(data.items) ? data.items : [];
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
this.artists = data.items;
|
this.artists = incoming;
|
||||||
} else {
|
} else {
|
||||||
this.artists = [...this.artists, ...data.items];
|
const existing = new Set(this.artists.map(artist => artist.id));
|
||||||
|
this.artists = [...this.artists, ...incoming.filter(artist => !existing.has(artist.id))];
|
||||||
}
|
}
|
||||||
this.artistsPage = data.page;
|
this.artistsPage = data.page;
|
||||||
this.artistsTotal = data.total;
|
this.artistsTotal = data.total;
|
||||||
if (this.artists.length >= data.total) {
|
if (data.has_more === false || this.artists.length >= data.total) {
|
||||||
this._allLoaded = true;
|
this._allLoaded = true;
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
this.loading = false;
|
if (token === this._artistsLoadToken) {
|
||||||
|
this.loading = false;
|
||||||
|
this.$nextTick(() => { this._fillArtistViewport(); });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
isFeaturedOnlyArtist(artist) {
|
||||||
|
return Number(artist?.release_count || 0) <= 0 && Number(artist?.track_count || 0) > 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
shouldShowFeaturedSeparator(index) {
|
||||||
|
if (!(this.view === 'artists' || this.view === 'my_uploads') || index <= 0) return false;
|
||||||
|
const current = this.artists[index];
|
||||||
|
const previous = this.artists[index - 1];
|
||||||
|
return this.isFeaturedOnlyArtist(current) && !this.isFeaturedOnlyArtist(previous);
|
||||||
},
|
},
|
||||||
|
|
||||||
async openArtist(id, options = {}) {
|
async openArtist(id, options = {}) {
|
||||||
@@ -2241,6 +2701,28 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this._afterNavigation(options);
|
this._afterNavigation(options);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
showSharedPlaylist(share, options = {}) {
|
||||||
|
this._saveScrollPosition(this._activeHash);
|
||||||
|
this.searchQuery = '';
|
||||||
|
this.searchResults = null;
|
||||||
|
this.currentArtist = null;
|
||||||
|
this.currentRelease = null;
|
||||||
|
this.currentPlaylist = {
|
||||||
|
id: 0,
|
||||||
|
title: share?.title || T.sharedPlaylist,
|
||||||
|
description: null,
|
||||||
|
is_own: false,
|
||||||
|
owner_name: null,
|
||||||
|
is_public: false,
|
||||||
|
is_saved: false,
|
||||||
|
kind: 'shared',
|
||||||
|
tracks: Array.isArray(share?.tracks) ? share.tracks : [],
|
||||||
|
};
|
||||||
|
this.view = 'playlist_detail';
|
||||||
|
this._previousView = 'artists';
|
||||||
|
this._afterNavigation(options);
|
||||||
|
},
|
||||||
|
|
||||||
async playRelease(releaseId) {
|
async playRelease(releaseId) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/player/releases/${releaseId}`);
|
const res = await fetch(`/api/player/releases/${releaseId}`);
|
||||||
@@ -2303,8 +2785,8 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.searchLoading = false;
|
this.searchLoading = false;
|
||||||
if (this.view === 'search') {
|
if (this.view === 'search') {
|
||||||
this.view = this._previousView || 'artists';
|
this.view = this._previousView || 'artists';
|
||||||
this._setHash('#artists');
|
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
|
||||||
if (this.view === 'artists') {
|
if (this.view === 'artists' || this.view === 'my_uploads') {
|
||||||
this.$nextTick(() => { this._setupScroll(); });
|
this.$nextTick(() => { this._setupScroll(); });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2324,11 +2806,21 @@ document.addEventListener('alpine:init', () => {
|
|||||||
if (entries[0].isIntersecting && !this.loading && !this._allLoaded) {
|
if (entries[0].isIntersecting && !this.loading && !this._allLoaded) {
|
||||||
this.loadArtists(this.artistsPage + 1);
|
this.loadArtists(this.artistsPage + 1);
|
||||||
}
|
}
|
||||||
}, { root: document.getElementById('center-scroll'), threshold: 0.1 });
|
}, { root: document.getElementById('center-scroll'), rootMargin: '900px 0px', threshold: 0.01 });
|
||||||
this._observer.observe(sentinel);
|
this._observer.observe(sentinel);
|
||||||
|
this._fillArtistViewport();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_fillArtistViewport() {
|
||||||
|
if (!(this.view === 'artists' || this.view === 'my_uploads') || this.loading || this._allLoaded) return;
|
||||||
|
const el = this._scrollElement();
|
||||||
|
if (!el) return;
|
||||||
|
if (el.scrollHeight <= el.clientHeight + 900) {
|
||||||
|
this.loadArtists(this.artistsPage + 1);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
$nextTick(fn) {
|
$nextTick(fn) {
|
||||||
setTimeout(fn, 50);
|
setTimeout(fn, 50);
|
||||||
},
|
},
|
||||||
|
|||||||
+169
-77
@@ -60,7 +60,13 @@
|
|||||||
:class="{ active: $store.library.view === 'artists' }"
|
:class="{ active: $store.library.view === 'artists' }"
|
||||||
@click="$store.library.goArtists()">
|
@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>
|
<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>
|
</div>
|
||||||
<div class="sidebar-section">
|
<div class="sidebar-section">
|
||||||
@@ -168,7 +174,13 @@
|
|||||||
:class="{ active: $store.library.view === 'artists' }"
|
:class="{ active: $store.library.view === 'artists' }"
|
||||||
@click="$store.library.goArtists(); $store.mobile.closeLibrary()">
|
@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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -399,17 +411,6 @@
|
|||||||
<template x-if="!artist.image_url">
|
<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>
|
<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>
|
</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>
|
||||||
<div class="search-artist-name" x-text="artist.name"></div>
|
<div class="search-artist-name" x-text="artist.name"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -492,6 +493,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<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>
|
<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>
|
||||||
|
<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 }}">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -507,39 +511,59 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- Artists Grid -->
|
<!-- Artists Grid -->
|
||||||
<template x-if="$store.library.view === 'artists'">
|
<template x-if="$store.library.view === 'artists' || $store.library.view === 'my_uploads'">
|
||||||
<div>
|
<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">
|
<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="card" @click="$store.library.openArtist(artist.id)">
|
<div class="artist-grid-entry">
|
||||||
<div class="card-img">
|
<div class="artist-section-divider"
|
||||||
<template x-if="artist.image_url">
|
x-show="$store.library.shouldShowFeaturedSeparator(idx)"
|
||||||
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
x-cloak>
|
||||||
</template>
|
<span>{{ t.player_featured_only_artists }}</span>
|
||||||
<template x-if="!artist.image_url">
|
</div>
|
||||||
<span class="placeholder-icon"><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></span>
|
<div class="card" @click="$store.library.openArtist(artist.id)">
|
||||||
</template>
|
<div class="card-img">
|
||||||
<button class="artist-follow-card-btn"
|
<template x-if="artist.image_url">
|
||||||
:class="{ followed: $store.follows.has(artist.id) }"
|
<img :src="artist.image_url" :alt="artist.name" loading="lazy">
|
||||||
@click.stop="$store.follows.toggle(artist.id)"
|
</template>
|
||||||
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
<template x-if="!artist.image_url">
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<span class="placeholder-icon"><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></span>
|
||||||
<path d="M16 21v-2a4 4 0 00-4-4H6a4 4 0 00-4 4v2"/>
|
</template>
|
||||||
<circle cx="9" cy="7" r="4"/>
|
<button class="artist-follow-card-btn"
|
||||||
<path x-show="!$store.follows.has(artist.id)" d="M19 8v6M16 11h6"/>
|
:class="{ followed: $store.follows.has(artist.id) }"
|
||||||
<path x-show="$store.follows.has(artist.id)" d="M16 11l2 2 4-5"/>
|
@click.stop="$store.follows.toggle(artist.id)"
|
||||||
</svg>
|
:title="$store.follows.has(artist.id) ? '{{ t.player_unfollow_artist }}' : '{{ t.player_follow_artist }}'">
|
||||||
</button>
|
<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="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>
|
||||||
<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>
|
</template>
|
||||||
</div>
|
</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>
|
<div class="loading-spinner"><div class="spinner"></div></div>
|
||||||
</template>
|
</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 id="artist-sentinel" style="height:1px"></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -548,7 +572,8 @@
|
|||||||
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
|
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
|
||||||
<div>
|
<div>
|
||||||
<div class="breadcrumb">
|
<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>/</span>
|
||||||
<span x-text="$store.library.currentArtist.name"></span>
|
<span x-text="$store.library.currentArtist.name"></span>
|
||||||
</div>
|
</div>
|
||||||
@@ -639,23 +664,37 @@
|
|||||||
<span style="text-align:right">{{ t.player_duration }}</span>
|
<span style="text-align:right">{{ t.player_duration }}</span>
|
||||||
</div>
|
</div>
|
||||||
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
|
<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 }"
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
||||||
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
|
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
|
||||||
<span class="track-num" x-text="idx + 1"></span>
|
<span class="track-num" x-text="idx + 1"></span>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
<div class="track-title">
|
<button class="artist-appearance-cover"
|
||||||
<span x-text="track.title"></span>
|
type="button"
|
||||||
<span style="color:var(--text-subdued)"> · </span>
|
@click.stop="$store.library.openRelease(track.release_id)"
|
||||||
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
|
:title="track.release_title"
|
||||||
</div>
|
aria-label="{{ t.player_release }}">
|
||||||
<div class="track-artists-inline">
|
<template x-if="track.cover_url">
|
||||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
|
||||||
<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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<span></span>
|
<span></span>
|
||||||
@@ -678,6 +717,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<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>
|
<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>
|
||||||
|
<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 }}">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -694,7 +736,8 @@
|
|||||||
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
|
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
|
||||||
<div>
|
<div>
|
||||||
<div class="breadcrumb">
|
<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>/</span>
|
||||||
<template x-if="$store.library.currentRelease.artists.length > 0">
|
<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>
|
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
|
||||||
@@ -732,25 +775,36 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
<div class="release-year" x-text="$store.library.currentRelease.year || ''"></div>
|
||||||
<div class="release-actions">
|
<div class="release-actions">
|
||||||
<button class="release-action-btn secondary"
|
<div class="release-action-row release-meta-actions">
|
||||||
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
|
<button class="release-action-btn secondary"
|
||||||
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
@click.stop="$store.library.openReleaseInfo($store.library.currentRelease)"
|
||||||
aria-label="{{ t.player_release_info }}">
|
:title="$store.library.releaseInfo($store.library.currentRelease)"
|
||||||
<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>
|
aria-label="{{ t.player_release_info }}">
|
||||||
{{ t.player_info }}
|
<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>
|
||||||
</button>
|
{{ t.player_info }}
|
||||||
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
</button>
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
<button class="release-action-btn secondary release-share-btn"
|
||||||
{{ t.player_play }}
|
@click.stop="$store.sharing.copyRelease($store.library.currentRelease, $event.currentTarget)"
|
||||||
</button>
|
title="{{ t.player_share }}"
|
||||||
<button class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="{{ t.player_add_to_end_queue }}">
|
aria-label="{{ t.player_share }}">
|
||||||
<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>
|
<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_queue }}
|
{{ t.player_share }}
|
||||||
</button>
|
</button>
|
||||||
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" title="{{ t.player_play_next }}">
|
</div>
|
||||||
<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>
|
<div class="release-action-row release-playback-actions">
|
||||||
{{ t.player_next }}
|
<button class="release-action-btn primary" @click="$store.queue.playRelease($store.library.currentRelease.tracks, 0)">
|
||||||
</button>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
|
||||||
|
{{ t.player_play }}
|
||||||
|
</button>
|
||||||
|
<button class="release-action-btn secondary" @click="$store.queue.addToEnd($store.library.currentRelease.tracks)" title="{{ t.player_add_to_end_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>
|
||||||
|
{{ t.player_queue }}
|
||||||
|
</button>
|
||||||
|
<button class="release-action-btn secondary" @click="$store.queue.addNextInQueue($store.library.currentRelease.tracks)" 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>
|
||||||
|
{{ t.player_next }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -764,7 +818,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
|
<template x-for="(track, idx) in $store.library.currentRelease.tracks" :key="track.id">
|
||||||
<div class="track-row"
|
<div class="track-row"
|
||||||
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
|
:data-shared-track-id="track.id"
|
||||||
|
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id, 'shared-target': $store.sharing.isSharedTrack(track) }"
|
||||||
@dblclick="$store.queue.playRelease([track], 0)">
|
@dblclick="$store.queue.playRelease([track], 0)">
|
||||||
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
<span class="track-num" x-text="track.track_number || (idx + 1)"></span>
|
||||||
<div class="track-info">
|
<div class="track-info">
|
||||||
@@ -798,6 +853,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<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>
|
<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>
|
||||||
|
<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 }}">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -871,6 +929,9 @@
|
|||||||
<button class="track-action-btn queue-insert-btn queue-end-btn" @click.stop="$store.queue.addToEnd([track])" title="{{ t.player_add_to_queue }}">
|
<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>
|
<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>
|
||||||
|
<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 }}">
|
<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>
|
<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>
|
</button>
|
||||||
@@ -890,7 +951,12 @@
|
|||||||
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
|
<div class="queue-panel" :class="{ hidden: !$store.queue.visible }">
|
||||||
<div class="queue-header">
|
<div class="queue-header">
|
||||||
<h3>{{ t.player_queue }}</h3>
|
<h3>{{ t.player_queue }}</h3>
|
||||||
<button class="queue-clear-btn" @click="$store.queue.clear()">{{ t.player_clear }}</button>
|
<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>
|
||||||
<div class="queue-tracks">
|
<div class="queue-tracks">
|
||||||
<template x-if="$store.queue.tracks.length === 0">
|
<template x-if="$store.queue.tracks.length === 0">
|
||||||
@@ -967,7 +1033,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="player-now-playing">
|
<div class="player-now-playing">
|
||||||
<template x-if="$store.player.currentTrack">
|
<template x-if="$store.player.currentTrack">
|
||||||
<div style="display:flex;align-items:center;gap:12px;overflow:hidden">
|
<div class="player-current-track">
|
||||||
<div class="player-cover"
|
<div class="player-cover"
|
||||||
@click.stop="$store.mobile.openPlayerFullscreen()">
|
@click.stop="$store.mobile.openPlayerFullscreen()">
|
||||||
<template x-if="$store.player.currentTrack.cover_url">
|
<template x-if="$store.player.currentTrack.cover_url">
|
||||||
@@ -987,6 +1053,12 @@
|
|||||||
aria-label="{{ t.player_like }}">
|
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>
|
<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>
|
||||||
|
<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>
|
||||||
<div class="player-track-artist">
|
<div class="player-track-artist">
|
||||||
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks($store.player.currentTrack)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
|
||||||
@@ -1012,7 +1084,7 @@
|
|||||||
<button class="player-btn" :class="{ active: $store.player.shuffle }" @click="$store.player.toggleShuffle()" title="{{ t.player_shuffle }}">
|
<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>
|
<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>
|
||||||
<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>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
|
<button class="player-btn player-btn-play" @click="$store.player.toggle()">
|
||||||
@@ -1023,7 +1095,7 @@
|
|||||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 4h4v16H6zM14 4h4v16h-4z"/></svg>
|
||||||
</template>
|
</template>
|
||||||
</button>
|
</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>
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="{{ t.player_repeat }}">
|
<button class="player-btn" :class="{ active: $store.player.repeatMode !== 'off' }" @click="$store.player.cycleRepeat()" title="{{ t.player_repeat }}">
|
||||||
@@ -1037,7 +1109,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="player-timeline">
|
<div class="player-timeline">
|
||||||
<span class="player-time" x-text="formatTime($store.player.currentTime)"></span>
|
<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-fill" :style="'width:' + $store.player.progress + '%'">
|
||||||
<div class="progress-bar-thumb"></div>
|
<div class="progress-bar-thumb"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1074,6 +1148,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>
|
<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>
|
</button>
|
||||||
<div class="device-picker" @click.outside="$store.devices.open = false">
|
<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"
|
<button class="queue-toggle-btn device-toggle-btn"
|
||||||
:class="{ active: $store.devices.isActive() }"
|
:class="{ active: $store.devices.isActive() }"
|
||||||
@click="$store.devices.toggle()"
|
@click="$store.devices.toggle()"
|
||||||
@@ -1164,6 +1248,14 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="jam-create-panel" x-show="$store.devices.jamPanelOpen" x-transition x-cloak>
|
<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>
|
<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">
|
<div class="jam-selected-users" x-show="$store.devices.jamSelectedUsers.length > 0">
|
||||||
<template x-for="user in $store.devices.jamSelectedUsers" :key="user.id">
|
<template x-for="user in $store.devices.jamSelectedUsers" :key="user.id">
|
||||||
<button class="jam-user-chip" @click="$store.devices.removeJamInvitee(user.id)">
|
<button class="jam-user-chip" @click="$store.devices.removeJamInvitee(user.id)">
|
||||||
|
|||||||
+519
-55
@@ -513,6 +513,31 @@ button.user-stat:hover {
|
|||||||
gap: 20px;
|
gap: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.artist-grid-entry {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-section-divider {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 28px;
|
||||||
|
margin: 2px 0 -2px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-section-divider::before,
|
||||||
|
.artist-section-divider::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -710,6 +735,26 @@ button.user-stat:hover {
|
|||||||
.track-row:hover { background: var(--bg-hover); }
|
.track-row:hover { background: var(--bg-hover); }
|
||||||
.track-row.playing { color: var(--accent); }
|
.track-row.playing { color: var(--accent); }
|
||||||
.track-row.playing .track-num { color: var(--accent); }
|
.track-row.playing .track-num { color: var(--accent); }
|
||||||
|
.track-row.shared-target {
|
||||||
|
background: rgba(29, 185, 84, 0.12);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
animation: shared-track-pulse 1.2s ease;
|
||||||
|
}
|
||||||
|
.track-row.shared-target .track-num,
|
||||||
|
.track-row.shared-target .track-title {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shared-track-pulse {
|
||||||
|
0% {
|
||||||
|
background: rgba(29, 185, 84, 0.28);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent), 0 0 0 1px rgba(29, 185, 84, 0.18);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: rgba(29, 185, 84, 0.12);
|
||||||
|
box-shadow: inset 3px 0 0 var(--accent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.track-num {
|
.track-num {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -735,6 +780,47 @@ button.user-stat:hover {
|
|||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.artist-appearance-row .track-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-cover {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-subdued);
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 0 0 42px;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-cover img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-cover svg {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-cover:hover {
|
||||||
|
filter: brightness(1.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.artist-appearance-copy {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.track-album { font-size: 13px; color: var(--text-subdued); }
|
.track-album { font-size: 13px; color: var(--text-subdued); }
|
||||||
.track-duration { font-size: 13px; color: var(--text-subdued); text-align: right; pointer-events: none; }
|
.track-duration { font-size: 13px; color: var(--text-subdued); text-align: right; pointer-events: none; }
|
||||||
|
|
||||||
@@ -887,6 +973,14 @@ button.user-stat:hover {
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-action-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-action-btn {
|
.release-action-btn {
|
||||||
@@ -1014,6 +1108,88 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.queue-header h3 { font-size: 14px; font-weight: 600; }
|
.queue-header h3 { font-size: 14px; font-weight: 600; }
|
||||||
|
|
||||||
|
.queue-header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-share-btn {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-share-btn:hover:not(:disabled) {
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-share-btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-share-btn svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-share-btn.share-copy-flash,
|
||||||
|
.queue-share-btn.share-copy-flash,
|
||||||
|
.player-current-share.share-copy-flash,
|
||||||
|
.release-share-btn.share-copy-flash {
|
||||||
|
animation: share-copy-flash 0.72s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-share-btn.share-copy-failed,
|
||||||
|
.queue-share-btn.share-copy-failed,
|
||||||
|
.player-current-share.share-copy-failed,
|
||||||
|
.release-share-btn.share-copy-failed {
|
||||||
|
animation: share-copy-failed 0.72s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes share-copy-flash {
|
||||||
|
0% {
|
||||||
|
background: rgba(29,185,84,0.28);
|
||||||
|
border-color: rgba(29,185,84,0.52);
|
||||||
|
color: #c9ffd9;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
background: rgba(29,185,84,0.34);
|
||||||
|
border-color: rgba(29,185,84,0.72);
|
||||||
|
color: #d9ffe5;
|
||||||
|
transform: scale(1.08);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background: inherit;
|
||||||
|
border-color: inherit;
|
||||||
|
color: inherit;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes share-copy-failed {
|
||||||
|
0%, 100% {
|
||||||
|
color: inherit;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
background: rgba(229,96,96,0.2);
|
||||||
|
border-color: rgba(229,96,96,0.5);
|
||||||
|
color: #ffc7c7;
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.queue-clear-btn {
|
.queue-clear-btn {
|
||||||
background: rgba(229, 96, 96, 0.13);
|
background: rgba(229, 96, 96, 0.13);
|
||||||
border: 1px solid rgba(229, 96, 96, 0.18);
|
border: 1px solid rgba(229, 96, 96, 0.18);
|
||||||
@@ -1173,6 +1349,13 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-now-playing > div { min-width: 0; }
|
.player-now-playing > div { min-width: 0; }
|
||||||
|
|
||||||
|
.player-current-track {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
.player-cover {
|
.player-cover {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@@ -1216,6 +1399,18 @@ button.user-stat:hover {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-current-share {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
padding: 4px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-current-share svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-track-artist {
|
.player-track-artist {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-subdued);
|
color: var(--text-subdued);
|
||||||
@@ -1307,24 +1502,44 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 4px;
|
height: 28px;
|
||||||
background: var(--bg-active);
|
background: transparent;
|
||||||
border-radius: 2px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
touch-action: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover { height: 6px; }
|
.progress-bar::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-active);
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar:hover::before { height: 6px; }
|
||||||
|
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
height: 100%;
|
height: 4px;
|
||||||
background: var(--text-primary);
|
background: var(--text-primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
transition: width 0.1s linear;
|
transition: width 0.1s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-bar-fill { background: var(--accent); }
|
.progress-bar:hover .progress-bar-fill {
|
||||||
|
height: 6px;
|
||||||
|
background: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
.progress-bar-thumb {
|
.progress-bar-thumb {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
@@ -1339,7 +1554,8 @@ button.user-stat:hover {
|
|||||||
transition: opacity 0.15s;
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar:hover .progress-bar-thumb { opacity: 1; }
|
.progress-bar:hover .progress-bar-thumb,
|
||||||
|
.progress-bar:active .progress-bar-thumb { opacity: 1; }
|
||||||
|
|
||||||
.player-right {
|
.player-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1370,19 +1586,35 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.volume-slider {
|
.volume-slider {
|
||||||
width: 104px;
|
width: 104px;
|
||||||
height: 4px;
|
height: 28px;
|
||||||
background: var(--bg-active);
|
background: transparent;
|
||||||
border-radius: 2px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 50%;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--bg-active);
|
||||||
|
transform: translateY(-50%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider-fill {
|
.volume-slider-fill {
|
||||||
height: 100%;
|
height: 4px;
|
||||||
background: var(--text-primary);
|
background: var(--text-primary);
|
||||||
border-radius: 2px;
|
border-radius: 2px;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider:hover .volume-slider-fill { background: var(--accent); }
|
.volume-slider:hover .volume-slider-fill { background: var(--accent); }
|
||||||
@@ -1452,6 +1684,51 @@ button.user-stat:hover {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.remote-playback-hint {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: 38px;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(280px, calc(100vw - 24px));
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
box-shadow: 0 14px 34px rgba(0,0,0,0.46);
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 10px 12px 12px;
|
||||||
|
text-align: left;
|
||||||
|
z-index: 44;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-playback-hint-text {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remote-playback-hint-progress {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--accent);
|
||||||
|
transform-origin: left center;
|
||||||
|
animation: remote-playback-hint-progress 60s linear forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes remote-playback-hint-progress {
|
||||||
|
from { transform: scaleX(1); }
|
||||||
|
to { transform: scaleX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
.device-popover {
|
.device-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
@@ -1595,6 +1872,34 @@ button.user-stat:hover {
|
|||||||
font-weight: 750;
|
font-weight: 750;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.jam-local-playback-toggle {
|
||||||
|
min-height: 30px;
|
||||||
|
margin: -1px 0 7px;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(82,145,255,0.055);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jam-local-playback-toggle:hover {
|
||||||
|
background: rgba(82,145,255,0.085);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jam-local-playback-toggle input {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.jam-selected-users {
|
.jam-selected-users {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1712,6 +2017,26 @@ button.user-stat:hover {
|
|||||||
animation: spin 0.8s linear infinite;
|
animation: spin 0.8s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scroll-load-indicator {
|
||||||
|
min-height: 56px;
|
||||||
|
margin: 18px 0 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-load-spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid var(--bg-active);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@keyframes spin { to { transform: rotate(360deg); } }
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
/* Empty state */
|
/* Empty state */
|
||||||
@@ -3789,7 +4114,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
@media (max-width: 900px), (pointer: coarse) and (max-width: 1024px) {
|
@media (max-width: 900px), (pointer: coarse) and (max-width: 1024px) {
|
||||||
:root {
|
:root {
|
||||||
--player-height: 168px;
|
--player-height: 214px;
|
||||||
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3962,12 +4287,13 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-bar {
|
.player-bar {
|
||||||
position: relative;
|
position: relative;
|
||||||
grid-template-columns: auto minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: 62px 58px;
|
grid-template-rows: 62px 58px 44px;
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"now now"
|
"now"
|
||||||
"buttons actions";
|
"buttons"
|
||||||
gap: 4px 10px;
|
"actions";
|
||||||
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 34px 12px calc(9px + var(--safe-bottom));
|
padding: 34px 12px calc(9px + var(--safe-bottom));
|
||||||
touch-action: auto;
|
touch-action: auto;
|
||||||
@@ -3976,15 +4302,19 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-now-playing {
|
.player-now-playing {
|
||||||
grid-area: now;
|
grid-area: now;
|
||||||
justify-content: center;
|
justify-content: stretch;
|
||||||
text-align: center;
|
text-align: left;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-now-playing > div {
|
.player-bar:not(.mobile-expanded) .player-current-track {
|
||||||
flex-direction: row;
|
display: grid;
|
||||||
justify-content: center;
|
grid-template-columns: 52px minmax(0, 1fr) 30px 30px;
|
||||||
gap: 10px !important;
|
grid-template-rows: repeat(3, auto);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: stretch;
|
||||||
|
column-gap: 10px;
|
||||||
|
row-gap: 1px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 620px;
|
max-width: 620px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
@@ -3997,23 +4327,64 @@ button.user-stat:hover {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-info {
|
.player-bar:not(.mobile-expanded) .player-track-info {
|
||||||
width: min(58vw, 360px);
|
display: contents;
|
||||||
max-width: 360px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-title-row {
|
.player-bar:not(.mobile-expanded) .player-track-title-row {
|
||||||
justify-content: center;
|
display: contents;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-current-like {
|
.player-bar:not(.mobile-expanded) .player-current-like,
|
||||||
display: none;
|
.player-bar:not(.mobile-expanded) .player-current-share {
|
||||||
|
display: flex;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
flex: 0 0 30px;
|
||||||
|
padding: 5px;
|
||||||
|
align-self: center;
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-title,
|
.player-bar:not(.mobile-expanded) .player-current-like svg,
|
||||||
.player-track-artist,
|
.player-bar:not(.mobile-expanded) .player-current-share svg {
|
||||||
.player-track-release {
|
width: 17px;
|
||||||
text-align: center;
|
height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-title,
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-artist,
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-release {
|
||||||
|
grid-column: 2;
|
||||||
|
min-width: 0;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-cover {
|
||||||
|
grid-column: 1;
|
||||||
|
grid-row: 1 / span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-title {
|
||||||
|
grid-row: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-artist {
|
||||||
|
grid-row: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-track-release {
|
||||||
|
grid-row: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-current-share {
|
||||||
|
grid-column: 3;
|
||||||
|
grid-row: 1 / span 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-current-like {
|
||||||
|
grid-column: 4;
|
||||||
|
grid-row: 1 / span 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-release {
|
.player-track-release {
|
||||||
@@ -4031,7 +4402,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-buttons {
|
.player-buttons {
|
||||||
grid-area: buttons;
|
grid-area: buttons;
|
||||||
justify-self: start;
|
justify-self: center;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4046,11 +4417,31 @@ button.user-stat:hover {
|
|||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-btn-prev {
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-btn-next {
|
||||||
|
min-width: 50px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-btn svg {
|
.player-btn svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-btn-prev svg {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .player-btn-next svg {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-btn-play {
|
.player-btn-play {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
@@ -4091,7 +4482,12 @@ button.user-stat:hover {
|
|||||||
background: rgba(29, 185, 84, 0.18);
|
background: rgba(29, 185, 84, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar:not(.mobile-expanded) .progress-bar::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar:not(.mobile-expanded) .progress-bar-fill {
|
.player-bar:not(.mobile-expanded) .progress-bar-fill {
|
||||||
|
height: 100%;
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
@@ -4120,8 +4516,8 @@ button.user-stat:hover {
|
|||||||
.player-version-chip {
|
.player-version-chip {
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 8px;
|
||||||
bottom: calc(12px + var(--safe-bottom));
|
bottom: calc(2px + var(--safe-bottom));
|
||||||
width: auto;
|
width: auto;
|
||||||
max-width: none;
|
max-width: none;
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
@@ -4139,9 +4535,9 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.player-right {
|
.player-right {
|
||||||
grid-area: actions;
|
grid-area: actions;
|
||||||
justify-self: end;
|
justify-self: stretch;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(132px, 1fr) 40px 40px;
|
grid-template-columns: minmax(0, 1fr) 40px 40px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -4150,12 +4546,19 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
.volume-control {
|
.volume-control {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 30px minmax(112px, 1fr);
|
grid-template-columns: 30px minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
justify-self: center;
|
||||||
|
width: min(80vw, 360px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-right > .queue-toggle-btn,
|
||||||
|
.player-right > .device-picker {
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
.volume-btn {
|
.volume-btn {
|
||||||
min-width: 30px;
|
min-width: 30px;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -4164,13 +4567,19 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider {
|
.volume-slider {
|
||||||
display: block;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 9px;
|
height: 34px;
|
||||||
border-radius: 999px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider-fill {
|
.volume-slider-fill {
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.volume-slider::before {
|
||||||
|
height: 7px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4182,11 +4591,13 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
height: 6px;
|
height: 34px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.progress-bar::before,
|
||||||
.progress-bar-fill {
|
.progress-bar-fill {
|
||||||
|
height: 6px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4329,6 +4740,12 @@ button.user-stat:hover {
|
|||||||
height: 38px;
|
height: 38px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .player-current-share {
|
||||||
|
display: flex;
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .player-track-release {
|
.player-bar.mobile-expanded .mobile-full-player .player-track-release {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -4359,11 +4776,26 @@ button.user-stat:hover {
|
|||||||
min-height: 56px;
|
min-height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .player-btn-prev {
|
||||||
|
min-width: 52px;
|
||||||
|
min-height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .player-btn-next {
|
||||||
|
min-width: 64px;
|
||||||
|
min-height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .player-btn svg {
|
.player-bar.mobile-expanded .mobile-full-player .player-btn svg {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .player-btn-next svg {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .player-btn-play {
|
.player-bar.mobile-expanded .mobile-full-player .player-btn-play {
|
||||||
width: 72px;
|
width: 72px;
|
||||||
height: 72px;
|
height: 72px;
|
||||||
@@ -4379,10 +4811,15 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .progress-bar {
|
.player-bar.mobile-expanded .mobile-full-player .progress-bar {
|
||||||
height: 7px;
|
height: 36px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .progress-bar::before,
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .progress-bar-fill {
|
||||||
|
height: 7px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .progress-bar-thumb {
|
.player-bar.mobile-expanded .mobile-full-player .progress-bar-thumb {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@@ -4417,8 +4854,8 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .volume-slider {
|
.player-bar.mobile-expanded .mobile-full-player .volume-slider {
|
||||||
display: block;
|
display: flex;
|
||||||
height: 7px;
|
height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .device-popover {
|
.player-bar.mobile-expanded .mobile-full-player .device-popover {
|
||||||
@@ -4432,6 +4869,14 @@ button.user-stat:hover {
|
|||||||
max-height: 42dvh;
|
max-height: 42dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-bar.mobile-expanded .mobile-full-player .remote-playback-hint {
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(90px + var(--safe-bottom));
|
||||||
|
right: 18px;
|
||||||
|
max-width: calc(100vw - 36px);
|
||||||
|
}
|
||||||
|
|
||||||
.player-bar.mobile-expanded .mobile-full-player .mobile-expanded-queue {
|
.player-bar.mobile-expanded .mobile-full-player .mobile-expanded-queue {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -4537,7 +4982,7 @@ button.user-stat:hover {
|
|||||||
|
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
:root {
|
:root {
|
||||||
--player-height: 170px;
|
--player-height: 214px;
|
||||||
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
--player-bar-space: calc(var(--player-height) + var(--safe-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4649,11 +5094,19 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.release-actions {
|
.release-actions {
|
||||||
flex-wrap: wrap;
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 7px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.release-action-row {
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.release-action-btn {
|
.release-action-btn {
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-list-header {
|
.track-list-header {
|
||||||
@@ -5011,7 +5464,7 @@ button.user-stat:hover {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding-left: 10px;
|
padding-left: 10px;
|
||||||
padding-right: 10px;
|
padding-right: 10px;
|
||||||
grid-template-rows: 60px 58px;
|
grid-template-rows: 60px 56px 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-track-title { font-size: 12px; }
|
.player-track-title { font-size: 12px; }
|
||||||
@@ -5020,14 +5473,15 @@ button.user-stat:hover {
|
|||||||
.player-buttons { gap: 2px; }
|
.player-buttons { gap: 2px; }
|
||||||
|
|
||||||
.player-right {
|
.player-right {
|
||||||
grid-template-columns: minmax(68px, 1fr) 34px 34px;
|
grid-template-columns: minmax(0, 1fr) 34px 34px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
gap: 3px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.volume-control {
|
.volume-control {
|
||||||
grid-template-columns: 22px minmax(44px, 1fr);
|
grid-template-columns: 22px minmax(0, 1fr);
|
||||||
gap: 3px;
|
width: min(80vw, 320px);
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.player-version-chip {
|
.player-version-chip {
|
||||||
@@ -5053,7 +5507,7 @@ button.user-stat:hover {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.volume-slider {
|
.volume-slider {
|
||||||
display: block;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5063,6 +5517,16 @@ button.user-stat:hover {
|
|||||||
padding: 7px;
|
padding: 7px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.player-btn-prev {
|
||||||
|
min-width: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-btn-next {
|
||||||
|
min-width: 50px;
|
||||||
|
min-height: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
.player-btn svg {
|
.player-btn svg {
|
||||||
width: 22px;
|
width: 22px;
|
||||||
height: 22px;
|
height: 22px;
|
||||||
|
|||||||
Reference in New Issue
Block a user