Compare commits

...

6 Commits

Author SHA1 Message Date
Ultradesu d31dce3ece Improved player
Build and Publish / Build and Publish Docker Image (push) Successful in 2m34s
2026-06-03 03:45:23 +03:00
Ultradesu 1e1453e465 Update metadata jobs and player library 2026-06-03 03:39:16 +03:00
Ultradesu d2a8f301b8 pump ver
Build and Publish / Build and Publish Docker Image (push) Successful in 2m31s
2026-06-03 02:03:29 +03:00
Ultradesu 0a4f78acfa Improve player library and admin user stats 2026-06-03 02:02:23 +03:00
Ultradesu f716c22f86 Added user page. fixed player.
Build and Publish / Build and Publish Docker Image (push) Successful in 3m32s
2026-06-02 20:45:00 +03:00
Ultradesu a1dafaa5f2 JOBS: fixed metadata_backfill.rs 2026-06-01 18:49:44 +03:00
20 changed files with 3350 additions and 301 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]]
name = "furumusic"
version = "0.2.12"
version = "0.2.15"
dependencies = [
"anyhow",
"async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "furumusic"
version = "0.2.13"
version = "0.3.0"
edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+75
View File
@@ -227,6 +227,55 @@ impl App for AdminApp {
},
"admin_v2_reviews_bulk",
),
Route::with_handler_and_name(
"/v2/api/users",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database,
query: UrlQuery<v2::UsersQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::users(session, db, pg_pool, query.0).await
}
})
},
"admin_v2_users",
),
Route::with_handler_and_name(
"/v2/api/users/{id}",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database, path: Path<PathId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::user_detail(session, db, pg_pool, path.0.id).await
}
})
},
"admin_v2_user_detail",
),
Route::with_handler_and_name(
"/v2/api/reviews/{id}/approve",
{
@@ -308,6 +357,32 @@ impl App for AdminApp {
}),
"admin_v2_metadata_backfill_run_options",
),
Route::with_handler_and_name(
"/v2/api/jobs/artwork_backfill/run-options",
cot::router::method::post({
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
move |session: Session,
db: Database,
json: Json<v2::ArtworkBackfillRunRequest>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::run_artwork_backfill(session, db, pg_pool, json).await
}
}
}),
"admin_v2_artwork_backfill_run_options",
),
Route::with_handler_and_name(
"/v2/api/jobs/{name}/run",
cot::router::method::post({
+447
View File
@@ -45,6 +45,13 @@ pub(super) struct LibraryQuery {
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)]
pub(super) struct BulkReviewsRequest {
action: String,
@@ -76,10 +83,18 @@ pub struct MetadataBackfillRunRequest {
local_genres: bool,
#[serde(default = "default_true")]
lastfm_tags: bool,
#[serde(default = "default_true")]
musicbrainz_tags: bool,
#[serde(default)]
overwrite: bool,
}
#[derive(Debug, Deserialize)]
pub struct ArtworkBackfillRunRequest {
#[serde(default)]
overwrite_existing: bool,
}
#[derive(Debug, Deserialize)]
pub(super) struct UpdateLibraryItemRequest {
kind: String,
@@ -158,6 +173,68 @@ struct AdminDashboardDto {
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)]
struct OverviewStatsDto {
tracks: i64,
@@ -227,6 +304,7 @@ struct MetadataTagDto {
struct ReviewPageDto {
items: Vec<ReviewDto>,
total: i64,
total_all: i64,
limit: i64,
offset: i64,
status: Option<String>,
@@ -645,6 +723,41 @@ pub async fn reviews(
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(
session: Session,
db: Database,
@@ -972,6 +1085,7 @@ pub async fn run_metadata_backfill(
duration_seconds: body.duration_seconds,
local_genres: body.local_genres,
lastfm_tags: body.lastfm_tags,
musicbrainz_tags: body.musicbrainz_tags,
overwrite: body.overwrite,
};
if !options.any_field() {
@@ -1019,6 +1133,56 @@ pub async fn run_metadata_backfill(
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(
session: Session,
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 {
let llm_configured = !config.agent_llm_url.trim().is_empty();
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 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 mut qb = QueryBuilder::<Postgres>::new(
@@ -1749,6 +2195,7 @@ async fn load_review_page(pool: &PgPool, query: ReviewsQuery) -> anyhow::Result<
Ok(ReviewPageDto {
items,
total,
total_all,
limit,
offset,
status,
+1
View File
@@ -1472,6 +1472,7 @@ pub async fn metadata_backfill_run(
duration_seconds: data.duration_seconds.is_some(),
local_genres: data.local_genres.is_some(),
lastfm_tags: data.lastfm_tags.is_some(),
musicbrainz_tags: true,
overwrite: data.mode.as_deref() == Some("overwrite"),
};
+4
View File
@@ -272,6 +272,8 @@ translations! {
// Player UI
player_library: "Library" , "Библиотека";
player_artists: "Artists" , "Артисты";
player_global_library: "Global" , "Global";
player_featured_only_artists: "Featured only" , "Только фиты";
player_release: "Release" , "Релиз";
player_releases: "Releases" , "Релизы";
player_tracks: "Tracks" , "Треки";
@@ -298,6 +300,7 @@ translations! {
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
player_active_device: "Active device" , "Активный девайс";
player_no_results: "No results found" , "Ничего не найдено";
player_new_playlist: "New Playlist" , "Новый плейлист";
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
@@ -478,6 +481,7 @@ translations! {
player_seen: "seen" , "видели";
player_eta: "eta" , "осталось";
player_loading_history: "Loading history..." , "Загрузка истории...";
player_loading_more: "Loading more..." , "Загружаю ещё...";
player_failed_load_history: "Failed to load history" , "Не удалось загрузить историю";
player_total_plays: "total plays" , "прослушиваний всего";
player_play_history: "Play history" , "История прослушиваний";
+525 -26
View File
@@ -15,12 +15,19 @@ pub struct ArtworkBackfillJob;
const LASTFM_REQUEST_DELAY: std::time::Duration = std::time::Duration::from_millis(1200);
const MAX_LASTFM_RELEASE_LOOKUPS: i64 = 200;
const MAX_LASTFM_ARTIST_LOOKUPS: i64 = 200;
const MAX_CAA_RELEASE_LOOKUPS: i64 = 200;
#[derive(Debug, Clone, Copy)]
pub struct ArtworkBackfillOptions {
pub overwrite_existing: bool,
}
#[derive(Debug, sqlx::FromRow)]
struct ReleaseCandidate {
id: i64,
title: String,
artist_name: Option<String>,
track_title: Option<String>,
}
#[derive(Debug, sqlx::FromRow)]
@@ -36,6 +43,13 @@ struct ArtworkRefCandidate {
file_path: Option<String>,
}
#[derive(Debug, sqlx::FromRow)]
struct ArtistImageRepairCandidate {
id: i64,
name: String,
media_file_id: i64,
}
#[derive(Debug, Deserialize)]
struct LastfmAlbumResponse {
album: Option<LastfmImageContainer>,
@@ -85,7 +99,10 @@ struct ArtworkStats {
broken_release_refs_cleared: u64,
broken_track_refs_cleared: u64,
broken_artist_refs_cleared: u64,
remote_artist_album_fallback_refs_cleared: u64,
release_local_assigned: u64,
release_caa_assigned: u64,
release_caa_not_found: u64,
release_lastfm_assigned: u64,
release_lastfm_not_found: u64,
release_skipped_no_audio: u64,
@@ -114,12 +131,37 @@ impl Job for ArtworkBackfillJob {
}
async fn run(&self, ctx: &JobContext, log: &mut JobLog) -> anyhow::Result<()> {
run_with_options(
ctx,
log,
ArtworkBackfillOptions {
overwrite_existing: false,
},
)
.await
}
}
pub async fn run_with_options(
ctx: &JobContext,
log: &mut JobLog,
options: ArtworkBackfillOptions,
) -> anyhow::Result<()> {
let storage_dir = ctx.config.agent_storage_dir.trim();
if storage_dir.is_empty() {
log.warn("agent_storage_dir is not configured, skipping artwork backfill");
return Ok(());
}
log.info(&format!(
"Artwork backfill options: mode={}",
if options.overwrite_existing {
"overwrite_existing"
} else {
"missing_only"
}
));
let client = Client::builder()
.user_agent(format!(
"furumusic-artwork-backfill/{}",
@@ -127,6 +169,8 @@ impl Job for ArtworkBackfillJob {
))
.timeout(std::time::Duration::from_secs(20))
.build()?;
let musicbrainz_client =
crate::jobs::musicbrainz::MusicBrainzClient::new("furumusic-artwork-backfill")?;
let mut stats = ArtworkStats::default();
let normalized_paths =
@@ -140,25 +184,40 @@ impl Job for ArtworkBackfillJob {
}
repair_missing_artwork_refs(ctx, log, storage_dir, &mut stats).await?;
backfill_release_local(ctx, log, storage_dir, &mut stats).await?;
repair_remote_artist_album_fallback_refs(ctx, log, &mut stats).await?;
backfill_release_local(ctx, log, storage_dir, options, &mut stats).await?;
backfill_release_coverartarchive(
ctx,
log,
storage_dir,
&musicbrainz_client,
options,
&mut stats,
)
.await?;
let api_key = ctx.config.lastfm_api_key.trim();
if api_key.is_empty() {
log.warn("lastfm_api_key is not configured; skipping Last.fm artwork fallback");
} else {
backfill_release_lastfm(ctx, log, storage_dir, api_key, &client, &mut stats).await?;
backfill_artist_lastfm(ctx, log, storage_dir, api_key, &client, &mut stats).await?;
backfill_release_lastfm(ctx, log, storage_dir, api_key, &client, options, &mut stats)
.await?;
backfill_artist_lastfm(ctx, log, storage_dir, api_key, &client, options, &mut stats)
.await?;
}
backfill_artist_album_fallbacks(ctx, log, &mut stats).await?;
backfill_artist_album_fallbacks(ctx, log, options, &mut stats).await?;
repair_cover_variants(ctx, log, storage_dir, &mut stats).await?;
log.info(&format!(
"Artwork backfill complete: broken_release_refs_cleared={}, broken_track_refs_cleared={}, broken_artist_refs_cleared={}, release_local_assigned={}, release_lastfm_assigned={}, release_lastfm_not_found={}, release_skipped_no_audio={}, artist_lastfm_assigned={}, artist_lastfm_not_found={}, artist_album_fallback_assigned={}, variants_created={}, variants_unchanged={}, variants_missing_original={}, failed={}",
"Artwork backfill complete: broken_release_refs_cleared={}, broken_track_refs_cleared={}, broken_artist_refs_cleared={}, remote_artist_album_fallback_refs_cleared={}, release_local_assigned={}, release_caa_assigned={}, release_caa_not_found={}, release_lastfm_assigned={}, release_lastfm_not_found={}, release_skipped_no_audio={}, artist_lastfm_assigned={}, artist_lastfm_not_found={}, artist_album_fallback_assigned={}, variants_created={}, variants_unchanged={}, variants_missing_original={}, failed={}",
stats.broken_release_refs_cleared,
stats.broken_track_refs_cleared,
stats.broken_artist_refs_cleared,
stats.remote_artist_album_fallback_refs_cleared,
stats.release_local_assigned,
stats.release_caa_assigned,
stats.release_caa_not_found,
stats.release_lastfm_assigned,
stats.release_lastfm_not_found,
stats.release_skipped_no_audio,
@@ -171,7 +230,6 @@ impl Job for ArtworkBackfillJob {
stats.failed
));
Ok(())
}
}
async fn repair_missing_artwork_refs(
@@ -186,6 +244,71 @@ async fn repair_missing_artwork_refs(
Ok(())
}
async fn repair_remote_artist_album_fallback_refs(
ctx: &JobContext,
log: &mut JobLog,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let rows = sqlx::query_as::<_, ArtistImageRepairCandidate>(
r#"SELECT DISTINCT a.id,
a.name::text AS name,
a.image_file_id AS media_file_id
FROM furumusic__artist a
JOIN furumusic__media_file mf ON mf.id = a.image_file_id
WHERE a.image_file_id IS NOT NULL
AND a.is_hidden = false
AND mf.file_path NOT LIKE '%/__artist_image__/%'
AND EXISTS (
SELECT 1
FROM furumusic__release r
JOIN furumusic__artwork_lookup_state s
ON s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
WHERE r.cover_file_id = a.image_file_id
AND r.is_hidden = false
)
ORDER BY a.id"#,
)
.fetch_all(&ctx.pool)
.await?;
if rows.is_empty() {
return Ok(());
}
log.info(&format!(
"Artist fallback repair pass: clearing {} remote release cover fallback(s)",
rows.len()
));
for row in rows {
let result = sqlx::query(
r#"UPDATE furumusic__artist
SET image_file_id = NULL,
updated_at = $3
WHERE id = $1
AND image_file_id = $2"#,
)
.bind(row.id)
.bind(row.media_file_id)
.bind(now_iso())
.execute(&ctx.pool)
.await?;
if result.rows_affected() > 0 {
stats.remote_artist_album_fallback_refs_cleared += 1;
log.warn(&format!(
"Artist {} \"{}\": cleared remote release cover fallback (media_file_id={})",
row.id, row.name, row.media_file_id
));
}
}
Ok(())
}
async fn repair_missing_release_cover_refs(
ctx: &JobContext,
log: &mut JobLog,
@@ -360,6 +483,7 @@ async fn backfill_release_local(
ctx: &JobContext,
log: &mut JobLog,
storage_dir: &str,
options: ArtworkBackfillOptions,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let releases = sqlx::query_as::<_, ReleaseCandidate>(
@@ -372,17 +496,26 @@ async fn backfill_release_local(
WHERE ra.release_id = r.id
ORDER BY ra.position
LIMIT 1
) AS artist_name
) AS artist_name,
(
SELECT t.title::text
FROM furumusic__track t
WHERE t.release_id = r.id
AND t.is_hidden = false
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
LIMIT 1
) AS track_title
FROM furumusic__release r
WHERE r.cover_file_id IS NULL
WHERE ($1 OR r.cover_file_id IS NULL)
AND r.is_hidden = false
ORDER BY r.id"#,
)
.bind(options.overwrite_existing)
.fetch_all(&ctx.pool)
.await?;
if releases.is_empty() {
log.info("Release local artwork pass: all visible releases already have covers");
log.info("Release local artwork pass: no eligible releases need local cover lookup");
return Ok(());
}
log.info(&format!(
@@ -451,7 +584,7 @@ async fn backfill_release_local(
.await
{
Ok(cover_file_id) => {
cover_art::assign_cover_to_release(&ctx.pool, release.id, cover_file_id).await?;
assign_cover_to_release(&ctx.pool, release.id, cover_file_id, options).await?;
stats.release_local_assigned += 1;
log.info(&format!(
"Release {} \"{}\": assigned local cover from {source_desc}",
@@ -471,12 +604,12 @@ async fn backfill_release_local(
Ok(())
}
async fn backfill_release_lastfm(
async fn backfill_release_coverartarchive(
ctx: &JobContext,
log: &mut JobLog,
storage_dir: &str,
api_key: &str,
client: &Client,
client: &crate::jobs::musicbrainz::MusicBrainzClient,
options: ArtworkBackfillOptions,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let failed_cutoff = cutoff_iso(1);
@@ -502,25 +635,285 @@ async fn backfill_release_lastfm(
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, ta.position
LIMIT 1
)
) AS artist_name
) AS artist_name,
(
SELECT t.title::text
FROM furumusic__track t
WHERE t.release_id = r.id
AND t.is_hidden = false
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
LIMIT 1
) AS track_title
FROM furumusic__release r
LEFT JOIN furumusic__artwork_lookup_state s
ON s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source = 'lastfm'
WHERE r.cover_file_id IS NULL
AND s.source = 'coverartarchive'
WHERE ($3 OR r.cover_file_id IS NULL)
AND r.is_hidden = false
AND (
s.entity_id IS NULL
$3
OR s.entity_id IS NULL
OR s.status = 'failed' AND s.last_attempt_at < $1
OR s.status = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
OR s.status = 'found' AND s.last_attempt_at < $1
)
ORDER BY s.last_attempt_at NULLS FIRST, r.id
LIMIT $3"#,
LIMIT $4"#,
)
.bind(&failed_cutoff)
.bind(&not_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(&not_found_cutoff)
.bind(options.overwrite_existing)
.bind(MAX_LASTFM_RELEASE_LOOKUPS)
.fetch_all(&ctx.pool)
.await?;
@@ -580,7 +973,7 @@ async fn backfill_release_lastfm(
.await
{
Ok(cover_file_id) => {
cover_art::assign_cover_to_release(&ctx.pool, release.id, cover_file_id)
assign_cover_to_release(&ctx.pool, release.id, cover_file_id, options)
.await?;
record_lookup_state(
&ctx.pool,
@@ -680,12 +1073,37 @@ async fn backfill_release_lastfm(
Ok(())
}
async fn assign_cover_to_release(
pool: &sqlx::PgPool,
release_id: i64,
cover_file_id: i64,
options: ArtworkBackfillOptions,
) -> anyhow::Result<()> {
if options.overwrite_existing {
sqlx::query(
r#"UPDATE furumusic__release
SET cover_file_id = $1,
updated_at = $3
WHERE id = $2"#,
)
.bind(cover_file_id)
.bind(release_id)
.bind(now_iso())
.execute(pool)
.await?;
} else {
cover_art::assign_cover_to_release(pool, release_id, cover_file_id).await?;
}
Ok(())
}
async fn backfill_artist_lastfm(
ctx: &JobContext,
log: &mut JobLog,
storage_dir: &str,
api_key: &str,
client: &Client,
options: ArtworkBackfillOptions,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let failed_cutoff = cutoff_iso(1);
@@ -699,21 +1117,25 @@ async fn backfill_artist_lastfm(
AND s.entity_id = a.id
AND s.source = 'lastfm'
WHERE (
$3
OR
a.image_file_id IS NULL
OR mf.file_path NOT LIKE '%/__artist_image__/%'
)
AND a.is_hidden = false
AND (
s.entity_id IS NULL
$3
OR s.entity_id IS NULL
OR s.status = 'failed' AND s.last_attempt_at < $1
OR s.status = 'not_found' AND (s.attempt_count < 3 OR s.last_attempt_at < $2)
OR s.status = 'found' AND s.last_attempt_at < $1
)
ORDER BY s.last_attempt_at NULLS FIRST, a.id
LIMIT $3"#,
LIMIT $4"#,
)
.bind(&failed_cutoff)
.bind(&not_found_cutoff)
.bind(options.overwrite_existing)
.bind(MAX_LASTFM_ARTIST_LOOKUPS)
.fetch_all(&ctx.pool)
.await?;
@@ -755,6 +1177,8 @@ async fn backfill_artist_lastfm(
updated_at = $3
WHERE id = $2
AND (
$4
OR
image_file_id IS NULL
OR EXISTS (
SELECT 1
@@ -767,6 +1191,7 @@ async fn backfill_artist_lastfm(
.bind(image_file_id)
.bind(artist.id)
.bind(now_iso())
.bind(options.overwrite_existing)
.execute(&ctx.pool)
.await?;
record_lookup_state(
@@ -826,7 +1251,7 @@ async fn backfill_artist_lastfm(
artist.id, artist.name
));
stats.artist_lastfm_not_found += 1;
match assign_artist_album_fallback(ctx, artist.id).await {
match assign_artist_album_fallback(ctx, artist.id, options).await {
Ok(Some(media_file_id)) => {
stats.artist_album_fallback_assigned += 1;
log.info(&format!(
@@ -892,31 +1317,53 @@ async fn backfill_artist_lastfm(
async fn backfill_artist_album_fallbacks(
ctx: &JobContext,
log: &mut JobLog,
options: ArtworkBackfillOptions,
stats: &mut ArtworkStats,
) -> anyhow::Result<()> {
let artists = sqlx::query_as::<_, ArtistCandidate>(
r#"SELECT a.id, a.name::text AS name
FROM furumusic__artist a
WHERE a.image_file_id IS NULL
WHERE ($1 OR a.image_file_id IS NULL)
AND a.is_hidden = false
AND EXISTS (
SELECT 1
FROM furumusic__release_artist ra
JOIN furumusic__release r ON r.id = ra.release_id
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
WHERE ra.artist_id = a.id
AND r.cover_file_id IS NOT NULL
AND mf.file_type = 'cover_art'
AND r.is_hidden = false
AND NOT EXISTS (
SELECT 1
FROM furumusic__artwork_lookup_state s
WHERE s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
)
UNION
SELECT 1
FROM furumusic__track_artist ta
JOIN furumusic__track t ON t.id = ta.track_id
JOIN furumusic__release r ON r.id = t.release_id
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
WHERE ta.artist_id = a.id
AND r.cover_file_id IS NOT NULL
AND mf.file_type = 'cover_art'
AND r.is_hidden = false
AND NOT EXISTS (
SELECT 1
FROM furumusic__artwork_lookup_state s
WHERE s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
)
)
ORDER BY a.id"#,
)
.bind(options.overwrite_existing)
.fetch_all(&ctx.pool)
.await?;
@@ -931,7 +1378,7 @@ async fn backfill_artist_album_fallbacks(
));
for artist in artists {
match assign_artist_album_fallback(ctx, artist.id).await {
match assign_artist_album_fallback(ctx, artist.id, options).await {
Ok(Some(media_file_id)) => {
stats.artist_album_fallback_assigned += 1;
log.info(&format!(
@@ -1016,6 +1463,7 @@ async fn repair_cover_variants(
async fn assign_artist_album_fallback(
ctx: &JobContext,
artist_id: i64,
options: ArtworkBackfillOptions,
) -> anyhow::Result<Option<i64>> {
let media_file_id: Option<i64> = sqlx::query_scalar(
r#"SELECT media_file_id
@@ -1023,17 +1471,37 @@ async fn assign_artist_album_fallback(
SELECT DISTINCT r.cover_file_id AS media_file_id
FROM furumusic__release r
JOIN furumusic__release_artist ra ON ra.release_id = r.id
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
WHERE ra.artist_id = $1
AND r.cover_file_id IS NOT NULL
AND mf.file_type = 'cover_art'
AND r.is_hidden = false
AND NOT EXISTS (
SELECT 1
FROM furumusic__artwork_lookup_state s
WHERE s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
)
UNION
SELECT DISTINCT r.cover_file_id AS media_file_id
FROM furumusic__release r
JOIN furumusic__track t ON t.release_id = r.id
JOIN furumusic__track_artist ta ON ta.track_id = t.id
JOIN furumusic__media_file mf ON mf.id = r.cover_file_id
WHERE ta.artist_id = $1
AND r.cover_file_id IS NOT NULL
AND mf.file_type = 'cover_art'
AND r.is_hidden = false
AND NOT EXISTS (
SELECT 1
FROM furumusic__artwork_lookup_state s
WHERE s.entity_kind = 'release'
AND s.entity_id = r.id
AND s.source IN ('lastfm', 'coverartarchive')
AND s.status = 'found'
)
) covers
ORDER BY random()
LIMIT 1"#,
@@ -1051,11 +1519,12 @@ async fn assign_artist_album_fallback(
SET image_file_id = $1,
updated_at = $3
WHERE id = $2
AND image_file_id IS NULL"#,
AND ($4 OR image_file_id IS NULL)"#,
)
.bind(media_file_id)
.bind(artist_id)
.bind(now_iso())
.bind(options.overwrite_existing)
.execute(&ctx.pool)
.await?;
@@ -1324,6 +1793,36 @@ async fn record_lookup_state(
Ok(())
}
async fn record_coverartarchive_lookup_state(
pool: &sqlx::PgPool,
entity_kind: &str,
entity_id: i64,
status: &str,
error: Option<&str>,
source_url: Option<&str>,
) -> anyhow::Result<()> {
sqlx::query(
r#"INSERT INTO furumusic__artwork_lookup_state
(entity_kind, entity_id, source, status, attempt_count, last_attempt_at, last_error, source_url)
VALUES ($1, $2, 'coverartarchive', $3, 1, $4, $5, $6)
ON CONFLICT (entity_kind, entity_id, source) DO UPDATE SET
status = EXCLUDED.status,
attempt_count = furumusic__artwork_lookup_state.attempt_count + 1,
last_attempt_at = EXCLUDED.last_attempt_at,
last_error = EXCLUDED.last_error,
source_url = EXCLUDED.source_url"#,
)
.bind(entity_kind)
.bind(entity_id)
.bind(status)
.bind(now_iso())
.bind(error)
.bind(source_url)
.execute(pool)
.await?;
Ok(())
}
async fn reset_lookup_state(
pool: &sqlx::PgPool,
entity_kind: &str,
@@ -1333,7 +1832,7 @@ async fn reset_lookup_state(
r#"DELETE FROM furumusic__artwork_lookup_state
WHERE entity_kind = $1
AND entity_id = $2
AND source = 'lastfm'"#,
AND source IN ('lastfm', 'coverartarchive')"#,
)
.bind(entity_kind)
.bind(entity_id)
+95 -35
View File
@@ -17,28 +17,99 @@ pub fn is_orchestrator_running() -> bool {
ORCHESTRATOR_RUNNING.load(Ordering::SeqCst)
}
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
/// Returns true if the lock was acquired (no other orchestrator is running).
async fn try_acquire_orchestrator_lock(pool: &sqlx::PgPool) -> bool {
match sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
struct OrchestratorAdvisoryGuard {
conn: Option<sqlx::pool::PoolConnection<sqlx::Postgres>>,
}
impl Drop for OrchestratorAdvisoryGuard {
fn drop(&mut self) {
let Some(mut conn) = self.conn.take() else {
return;
};
tokio::spawn(async move {
match sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.fetch_one(pool)
.fetch_one(&mut *conn)
.await
{
Ok(acquired) => acquired,
Err(e) => {
tracing::error!("Failed to acquire advisory lock: {e}");
false
Ok(true) => tracing::info!("inbox_process: advisory lock released"),
Ok(false) => tracing::warn!(
"inbox_process: advisory lock was not held by the guard connection"
),
Err(e) => tracing::error!("inbox_process: failed to release advisory lock: {e}"),
}
});
}
}
/// Release the PostgreSQL advisory lock for the orchestrator.
async fn release_orchestrator_lock(pool: &sqlx::PgPool) {
let _ = sqlx::query("SELECT pg_advisory_unlock($1)")
async fn connection_holds_orchestrator_lock(
conn: &mut sqlx::pool::PoolConnection<sqlx::Postgres>,
) -> anyhow::Result<bool> {
let holds_lock = sqlx::query_scalar::<_, bool>(
r#"
SELECT EXISTS (
SELECT 1
FROM pg_locks
WHERE locktype = 'advisory'
AND pid = pg_backend_pid()
AND mode = 'ExclusiveLock'
AND granted
AND objsubid = 1
AND classid::bigint = (($1::bigint >> 32) & 4294967295::bigint)
AND objid::bigint = ($1::bigint & 4294967295::bigint)
)
"#,
)
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.execute(pool)
.await;
.fetch_one(&mut **conn)
.await?;
Ok(holds_lock)
}
/// Try to acquire the PostgreSQL advisory lock for the orchestrator.
///
/// The guard owns the same pooled connection that acquired the session-level
/// lock. This matters because PostgreSQL session advisory locks must be
/// released on the same connection, not just through the same pool.
async fn try_acquire_orchestrator_lock(
pool: &sqlx::PgPool,
) -> anyhow::Result<Option<OrchestratorAdvisoryGuard>> {
let mut conn = pool.acquire().await?;
// Older versions acquired the lock through the pool and could return a
// locked idle connection. If we get such a connection, drain that stale
// re-entrant lock before taking the new guarded lock.
let mut cleaned_stale_locks = 0u8;
while connection_holds_orchestrator_lock(&mut conn).await? {
let unlocked = sqlx::query_scalar::<_, bool>("SELECT pg_advisory_unlock($1)")
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.fetch_one(&mut *conn)
.await?;
cleaned_stale_locks = cleaned_stale_locks.saturating_add(u8::from(unlocked));
if cleaned_stale_locks >= 8 {
break;
}
}
if cleaned_stale_locks > 0 {
tracing::warn!(
count = cleaned_stale_locks,
"inbox_process: released stale advisory lock(s) from an idle pooled connection"
);
}
let acquired = sqlx::query_scalar::<_, bool>("SELECT pg_try_advisory_lock($1)")
.bind(ORCHESTRATOR_ADVISORY_LOCK_ID)
.fetch_one(&mut *conn)
.await?;
if acquired {
Ok(Some(OrchestratorAdvisoryGuard { conn: Some(conn) }))
} else {
Ok(None)
}
}
use crate::agent::dto::{FolderContext, NormalizedFields, PathHints, RawMetadata};
@@ -83,10 +154,10 @@ impl Job for InboxProcessJob {
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
log.info(
log.warn(
"Another inbox_process orchestrator is already running (AtomicBool), skipping",
);
return Ok(());
anyhow::bail!("another inbox_process orchestrator is already running in this process");
}
struct AtomicGuard;
impl Drop for AtomicGuard {
@@ -98,27 +169,16 @@ impl Job for InboxProcessJob {
let _atomic_guard = AtomicGuard;
// --- Guard 2: PostgreSQL advisory lock (cross-process/binary safe) ---
if !try_acquire_orchestrator_lock(&ctx.pool).await {
log.info("Another inbox_process orchestrator holds the advisory lock, skipping");
return Ok(());
let _advisory_guard = match try_acquire_orchestrator_lock(&ctx.pool).await? {
Some(guard) => guard,
None => {
log.warn("Another inbox_process orchestrator holds the advisory lock");
anyhow::bail!(
"inbox_process advisory lock is held by another database session; no in-process orchestrator is running"
);
}
tracing::info!("inbox_process: advisory lock acquired");
let pool_for_unlock = ctx.pool.clone();
struct AdvisoryGuard {
pool: sqlx::PgPool,
}
impl Drop for AdvisoryGuard {
fn drop(&mut self) {
let pool = self.pool.clone();
tokio::spawn(async move {
release_orchestrator_lock(&pool).await;
tracing::info!("inbox_process: advisory lock released");
});
}
}
let _advisory_guard = AdvisoryGuard {
pool: pool_for_unlock,
};
tracing::info!("inbox_process: advisory lock acquired");
let config = Arc::clone(&ctx.config);
let mut total_ok = 0u64;
+489 -43
View File
@@ -15,6 +15,7 @@ pub struct MetadataBackfillOptions {
pub duration_seconds: bool,
pub local_genres: bool,
pub lastfm_tags: bool,
pub musicbrainz_tags: bool,
pub overwrite: bool,
}
@@ -26,6 +27,7 @@ impl MetadataBackfillOptions {
|| self.duration_seconds
|| self.local_genres
|| self.lastfm_tags
|| self.musicbrainz_tags
}
fn needs_file_scan(self) -> bool {
@@ -59,6 +61,7 @@ struct LastfmReleaseTagRow {
id: i64,
title: String,
artist_name: Option<String>,
track_title: Option<String>,
}
#[derive(sqlx::FromRow)]
@@ -84,6 +87,22 @@ struct LastfmTagStats {
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;
#[async_trait::async_trait]
@@ -111,6 +130,7 @@ impl Job for MetadataBackfillJob {
duration_seconds: true,
local_genres: true,
lastfm_tags: true,
musicbrainz_tags: true,
overwrite: false,
},
)
@@ -137,10 +157,11 @@ pub async fn run_with_options(
let mut failed = 0u64;
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.local_genres,
options.lastfm_tags,
options.musicbrainz_tags,
if options.overwrite {
"overwrite"
} else {
@@ -245,13 +266,8 @@ pub async fn run_with_options(
let mut changed_tags = false;
if options.local_genres {
if let (Some(track_id), Some(genre)) = (row.track_id, raw_meta.genre.as_deref()) {
let saved = save_track_tag_text(
&ctx.pool,
track_id,
genre,
"file",
options.overwrite,
)
let saved =
save_track_tag_text(&ctx.pool, track_id, genre, "file", options.overwrite)
.await?;
if saved > 0 {
local_tags_updated += saved;
@@ -306,14 +322,28 @@ pub async fn run_with_options(
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!(
"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.updated_entities,
lastfm_stats.tags_saved,
lastfm_stats.skipped_existing,
lastfm_stats.not_found,
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(())
}
@@ -345,12 +375,277 @@ async fn backfill_lastfm_tags(
.build()?;
let mut stats = LastfmTagStats::default();
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?;
backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?;
if backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
== LastfmTagPassResult::RateLimited
{
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)
}
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(
ctx: &JobContext,
log: &mut JobLog,
@@ -358,7 +653,7 @@ async fn backfill_lastfm_artist_tags(
api_key: &str,
overwrite: bool,
stats: &mut LastfmTagStats,
) -> anyhow::Result<()> {
) -> anyhow::Result<LastfmTagPassResult> {
let rows = sqlx::query_as::<_, LastfmArtistTagRow>(
r#"SELECT DISTINCT a.id, a.name::text AS name
FROM furumusic__artist a
@@ -376,7 +671,17 @@ async fn backfill_lastfm_artist_tags(
));
let total = rows.len();
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) {
log.info(&format!(
"Last.fm artist tags {}/{}: artist {} \"{}\"",
index + 1,
total,
row.id,
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!(
@@ -387,20 +692,44 @@ async fn backfill_lastfm_artist_tags(
}
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;
}
}
stats.considered += 1;
match fetch_lastfm_artist_tags(client, api_key, &row.name).await {
Ok(tags) if !tags.is_empty() => {
let saved =
replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false)
.await?;
match replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false).await
{
Ok(saved) => {
stats.tags_saved += saved;
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(_) => {
stats.not_found += 1;
}
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
return Err(err);
Err(err) if is_lastfm_rate_limit_error(&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) => {
stats.failed += 1;
@@ -419,7 +748,7 @@ async fn backfill_lastfm_artist_tags(
}
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
}
Ok(())
Ok(LastfmTagPassResult::Completed)
}
async fn backfill_lastfm_release_tags(
@@ -429,7 +758,7 @@ async fn backfill_lastfm_release_tags(
api_key: &str,
overwrite: bool,
stats: &mut LastfmTagStats,
) -> anyhow::Result<()> {
) -> anyhow::Result<LastfmTagPassResult> {
let rows = sqlx::query_as::<_, LastfmReleaseTagRow>(
r#"SELECT r.id,
r.title::text AS title,
@@ -440,7 +769,15 @@ async fn backfill_lastfm_release_tags(
WHERE ra.release_id = r.id
ORDER BY ra.position
LIMIT 1
) AS artist_name
) AS artist_name,
(
SELECT t.title::text
FROM furumusic__track t
WHERE t.release_id = r.id
AND t.is_hidden = false
ORDER BY t.disc_number NULLS LAST, t.track_number NULLS LAST, t.id
LIMIT 1
) AS track_title
FROM furumusic__release r
WHERE r.is_hidden = false
ORDER BY r.id"#,
@@ -454,7 +791,17 @@ async fn backfill_lastfm_release_tags(
));
let total = rows.len();
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) {
log.info(&format!(
"Last.fm release tags {}/{}: release {} \"{}\"",
index + 1,
total,
row.id,
row.title
));
}
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!(
@@ -465,7 +812,20 @@ async fn backfill_lastfm_release_tags(
}
continue;
}
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
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 {
stats.not_found += 1;
if should_log_lastfm_progress(index + 1, total, 25) {
@@ -480,17 +840,32 @@ async fn backfill_lastfm_release_tags(
stats.considered += 1;
match fetch_lastfm_album_tags(client, api_key, artist, &row.title).await {
Ok(tags) if !tags.is_empty() => {
let saved =
replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false)
.await?;
match replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false)
.await
{
Ok(saved) => {
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(_) => {
stats.not_found += 1;
}
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
return Err(err);
Err(err) if is_lastfm_rate_limit_error(&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) => {
stats.failed += 1;
@@ -509,7 +884,7 @@ async fn backfill_lastfm_release_tags(
}
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
}
Ok(())
Ok(LastfmTagPassResult::Completed)
}
async fn backfill_lastfm_track_tags(
@@ -519,7 +894,7 @@ async fn backfill_lastfm_track_tags(
api_key: &str,
overwrite: bool,
stats: &mut LastfmTagStats,
) -> anyhow::Result<()> {
) -> anyhow::Result<LastfmTagPassResult> {
let rows = sqlx::query_as::<_, LastfmTrackTagRow>(
r#"SELECT t.id,
t.title::text AS title,
@@ -544,7 +919,17 @@ async fn backfill_lastfm_track_tags(
));
let total = rows.len();
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) {
log.info(&format!(
"Last.fm track tags {}/{}: track {} \"{}\"",
index + 1,
total,
row.id,
row.title
));
}
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!(
@@ -555,7 +940,20 @@ async fn backfill_lastfm_track_tags(
}
continue;
}
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
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 {
stats.not_found += 1;
if should_log_lastfm_progress(index + 1, total, 50) {
@@ -570,16 +968,30 @@ async fn backfill_lastfm_track_tags(
stats.considered += 1;
match fetch_lastfm_track_tags(client, api_key, artist, &row.title).await {
Ok(tags) if !tags.is_empty() => {
let saved =
replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await?;
match replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await {
Ok(saved) => {
stats.tags_saved += saved;
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(_) => {
stats.not_found += 1;
}
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => {
return Err(err);
Err(err) if is_lastfm_rate_limit_error(&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) => {
stats.failed += 1;
@@ -598,18 +1010,36 @@ async fn backfill_lastfm_track_tags(
}
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
}
Ok(())
Ok(LastfmTagPassResult::Completed)
}
fn should_log_lastfm_progress(done: usize, total: usize, every: usize) -> bool {
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(
pool: &sqlx::PgPool,
entity_kind: &str,
entity_id: i64,
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> {
if overwrite {
return Ok(false);
@@ -617,16 +1047,28 @@ async fn should_skip_lastfm_entity(
let exists: Option<i64> = sqlx::query_scalar(
r#"SELECT 1
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"#,
)
.bind(entity_kind)
.bind(entity_id)
.bind(source)
.fetch_optional(pool)
.await?;
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(
client: &reqwest::Client,
api_key: &str,
@@ -723,7 +1165,11 @@ async fn fetch_lastfm_top_tags(
Value::Object(_) => tag_from_value(tag_value).into_iter().collect::<Vec<_>>(),
_ => 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);
Ok(tags)
}
@@ -896,9 +1342,9 @@ fn tags_from_text(value: &str) -> Vec<TagCandidate> {
for raw in normalized_separators.split([';', ',']) {
if let Some(name) = clean_tag_name(raw) {
if !is_ignored_tag(&normalize_tag_name(&name))
&& !tags
.iter()
.any(|tag: &TagCandidate| normalize_tag_name(&tag.name) == normalize_tag_name(&name))
&& !tags.iter().any(|tag: &TagCandidate| {
normalize_tag_name(&tag.name) == normalize_tag_name(&name)
})
{
tags.push(TagCandidate { name, weight: 1.0 });
}
+1
View File
@@ -4,6 +4,7 @@ pub mod inbox_process;
pub mod lastfm_popularity;
pub mod lastfm_scrobble;
pub mod metadata_backfill;
pub mod musicbrainz;
use std::path::{Component, Path, PathBuf};
+587
View File
@@ -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())
}
+8
View File
@@ -159,6 +159,14 @@ pub fn record_active_user(user_id: i64) {
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) {
REGISTRY.inc_counter(
"furumusic_auth_login_attempts_total",
+46
View File
@@ -1865,6 +1865,51 @@ pub mod db_migrations {
&[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()];
}
pub const MIGRATIONS: &[&SyncDynMigration] = &[
&M0006CreateMediaFile,
&M0007CreateArtist,
@@ -1891,5 +1936,6 @@ pub mod db_migrations {
&M0033CreateLastfmScrobbling,
&M0034CreateArtworkLookupState,
&M0035CreateEntityGenreTags,
&M0036CreateExternalMetadataIds,
];
}
+1
View File
@@ -16,6 +16,7 @@ pub(super) struct Paginated<T: Serialize> {
pub(super) total: i64,
pub(super) page: i32,
pub(super) per_page: i32,
pub(super) has_more: bool,
}
#[derive(Debug, Serialize, JsonSchema)]
+109 -25
View File
@@ -2774,20 +2774,86 @@ async fn artists_handler(
pool: &sqlx::PgPool,
query: cot::request::extractors::UrlQuery<PaginationQuery>,
) -> cot::Result<cot::response::Response> {
let Some(_user) = auth::get_session_user(&session, &db).await else {
let Some(user) = auth::get_session_user(&session, &db).await else {
return Ok(json_error(StatusCode::UNAUTHORIZED, "not authenticated"));
};
let page = query.0.page.unwrap_or(1).max(1);
let per_page = query.0.limit.unwrap_or(60).clamp(1, 200);
let offset = (page - 1) as i64 * per_page as i64;
let mine = query.0.mine.unwrap_or(false);
if mine {
let total_row = sqlx::query_as::<_, CountRow>(
r#"SELECT COUNT(DISTINCT a.id) AS count
FROM furumusic__artist a
JOIN furumusic__release_artist ra ON ra.artist_id = a.id
JOIN furumusic__release r ON r.id = ra.release_id
WHERE a.is_hidden = false AND r.is_hidden = false AND ra.position = 0"#,
JOIN furumusic__track_artist ta ON ta.artist_id = a.id
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
WHERE a.is_hidden = false
AND mf.uploaded_by_user_id = $1"#,
)
.bind(user.id)
.fetch_one(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let rows = sqlx::query_as::<_, ArtistRow>(
r#"SELECT a.id, a.name::text as name, a.image_file_id,
COUNT(DISTINCT r.id) FILTER (WHERE primary_release.artist_id IS NOT NULL) AS release_count,
COUNT(DISTINCT t.id) AS track_count
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 AND t.is_hidden = false
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
JOIN furumusic__media_file mf ON mf.id = t.audio_file_id
LEFT JOIN furumusic__release_artist primary_release
ON primary_release.release_id = r.id
AND primary_release.artist_id = a.id
AND primary_release.position = 0
WHERE a.is_hidden = false
AND mf.uploaded_by_user_id = $1
GROUP BY a.id, a.name, a.name_sort, a.image_file_id
ORDER BY (COUNT(DISTINCT r.id) FILTER (WHERE primary_release.artist_id IS NOT NULL) > 0) DESC,
release_count DESC,
track_count DESC,
a.name_sort
LIMIT $2 OFFSET $3"#,
)
.bind(user.id)
.bind(per_page as i64)
.bind(offset)
.fetch_all(pool)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
let items: Vec<ArtistCard> = rows
.into_iter()
.map(|r| ArtistCard {
id: r.id,
name: r.name,
image_url: cover_variant_url(r.image_file_id, "medium"),
release_count: r.release_count,
track_count: r.track_count,
})
.collect();
let has_more = offset + (items.len() as i64) < total_row.count;
return Json(Paginated {
items,
total: total_row.count,
page,
per_page,
has_more,
})
.into_response();
}
let total_row = sqlx::query_as::<_, CountRow>(
r#"SELECT COUNT(*) AS count
FROM furumusic__artist a
WHERE a.is_hidden = false"#,
)
.fetch_one(pool)
.await
@@ -2795,21 +2861,33 @@ async fn artists_handler(
let rows = sqlx::query_as::<_, ArtistRow>(
r#"SELECT a.id, a.name::text as name, a.image_file_id,
s.release_count,
s.track_count
COALESCE(s.release_count, 0) AS release_count,
COALESCE(s.track_count, 0) AS track_count
FROM furumusic__artist a
JOIN (
SELECT ra.artist_id,
COUNT(DISTINCT r.id) AS release_count,
COUNT(t.id) AS track_count
FROM furumusic__release_artist ra
JOIN furumusic__release r ON r.id = ra.release_id AND r.is_hidden = false
LEFT JOIN furumusic__track t ON t.release_id = r.id AND t.is_hidden = false
WHERE ra.position = 0
GROUP BY ra.artist_id
LEFT JOIN (
SELECT appearance.artist_id,
COUNT(DISTINCT appearance.release_id) FILTER (WHERE appearance.is_primary_release_artist) AS release_count,
COUNT(DISTINCT appearance.track_id) AS track_count
FROM (
SELECT ta.artist_id,
t.id AS track_id,
r.id AS release_id,
primary_release.artist_id IS NOT NULL AS is_primary_release_artist
FROM furumusic__track_artist ta
JOIN furumusic__track t ON t.id = ta.track_id AND t.is_hidden = false
JOIN furumusic__release r ON r.id = t.release_id AND r.is_hidden = false
LEFT JOIN furumusic__release_artist primary_release
ON primary_release.release_id = r.id
AND primary_release.artist_id = ta.artist_id
AND primary_release.position = 0
) appearance
GROUP BY appearance.artist_id
) s ON s.artist_id = a.id
WHERE a.is_hidden = false
ORDER BY s.release_count DESC, s.track_count DESC, a.name_sort
ORDER BY (COALESCE(s.release_count, 0) > 0) DESC,
COALESCE(s.release_count, 0) DESC,
COALESCE(s.track_count, 0) DESC,
a.name_sort
LIMIT $1 OFFSET $2"#,
)
.bind(per_page as i64)
@@ -2828,12 +2906,14 @@ async fn artists_handler(
track_count: r.track_count,
})
.collect();
let has_more = offset + (items.len() as i64) < total_row.count;
Json(Paginated {
items,
total: total_row.count,
page,
per_page,
has_more,
})
.into_response()
}
@@ -3421,10 +3501,7 @@ async fn build_track_items(
.collect())
}
async fn load_track_items_by_ids(
pool: &sqlx::PgPool,
ids: &[i64],
) -> cot::Result<Vec<TrackItem>> {
async fn load_track_items_by_ids(pool: &sqlx::PgPool, ids: &[i64]) -> cot::Result<Vec<TrackItem>> {
if ids.is_empty() {
return Ok(Vec::new());
}
@@ -4014,7 +4091,10 @@ async fn devices_command_handler(
}
fn stamp_jam_queue_tracks(payload: &mut serde_json::Value, user_id: i64, user_name: &str) {
let Some(tracks) = payload.get_mut("tracks").and_then(serde_json::Value::as_array_mut) else {
let Some(tracks) = payload
.get_mut("tracks")
.and_then(serde_json::Value::as_array_mut)
else {
return;
};
for track in tracks {
@@ -5733,7 +5813,8 @@ async fn build_track_radio_ids(
let artist_ids = track_primary_artist_ids(pool, track_id).await?;
let remaining = PLAYER_RADIO_TRACK_LIMIT.saturating_sub(ids.len()) as i64;
let fallback_ids = fallback_radio_track_ids(pool, user_id, &artist_ids, &ids, remaining).await?;
let fallback_ids =
fallback_radio_track_ids(pool, user_id, &artist_ids, &ids, remaining).await?;
append_unique_track_ids(&mut ids, fallback_ids, PLAYER_RADIO_TRACK_LIMIT);
Ok(Some(ids))
@@ -5789,7 +5870,8 @@ async fn build_release_radio_ids(
let artist_ids = release_primary_artist_ids(pool, release_id).await?;
let remaining = PLAYER_RADIO_TRACK_LIMIT.saturating_sub(ids.len()) as i64;
let fallback_ids = fallback_radio_track_ids(pool, user_id, &artist_ids, &ids, remaining).await?;
let fallback_ids =
fallback_radio_track_ids(pool, user_id, &artist_ids, &ids, remaining).await?;
append_unique_track_ids(&mut ids, fallback_ids, PLAYER_RADIO_TRACK_LIMIT);
Ok(Some(ids))
@@ -6692,7 +6774,8 @@ impl App for PlayerApp {
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database, path: Path<PathRadioSeed>| {
get(
move |session: Session, db: Database, path: Path<PathRadioSeed>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
@@ -6707,7 +6790,8 @@ impl App for PlayerApp {
.await;
radio_handler(session, db, pg_pool, path).await
}
})
},
)
},
"player_radio",
),
+1
View File
@@ -49,6 +49,7 @@ pub(super) struct RemoveTrackRequest {
pub(super) struct PaginationQuery {
pub(super) page: Option<i32>,
pub(super) limit: Option<i32>,
pub(super) mine: Option<bool>,
}
#[derive(Debug, Deserialize)]
+476 -6
View File
@@ -307,7 +307,8 @@ button {
}
.btn:disabled,
.icon-btn:disabled {
.icon-btn:disabled,
.seg-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@@ -416,6 +417,15 @@ tbody tr:hover {
.col-tags { width: 250px; }
.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 {
width: 16px;
height: 16px;
@@ -1274,6 +1284,137 @@ tbody tr:hover {
padding: 14px;
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>
{% endblock head_extra %}
@@ -1293,7 +1434,7 @@ tbody tr:hover {
<button class="nav-btn" :class="{active: activeView === 'reviews'}" @click="openReviews()">
<i data-lucide="inbox"></i>
<span>Review Queue</span>
<span class="nav-count" x-text="reviews.total || 0"></span>
<span class="nav-count" x-text="reviewTotalAll()"></span>
</button>
<button class="nav-btn" :class="{active: activeView === 'jobs'}" @click="openJobs()">
<i data-lucide="calendar-clock"></i>
@@ -1309,6 +1450,11 @@ tbody tr:hover {
<i data-lucide="wrench"></i>
<span>Future Tools</span>
</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()">
<i data-lucide="settings"></i>
<span>Settings</span>
@@ -1380,7 +1526,7 @@ tbody tr:hover {
<template x-for="status in reviewStatuses" :key="status.value">
<button class="seg-btn" :class="{active: reviewFilter.status === status.value}" @click="setReviewStatus(status.value)">
<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>
</template>
</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.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.musicbrainz_tags" /> MusicBrainz tags</label>
</div>
<div class="mode-row">
<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>
</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)">
This task has no manual parameters.
</div>
@@ -1729,6 +1882,79 @@ tbody tr:hover {
</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'">
<section class="panel">
<div class="panel-head">
@@ -2010,6 +2236,90 @@ tbody tr:hover {
</div>
</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">
<section class="modal">
<div class="modal-head">
@@ -2306,7 +2616,13 @@ function adminV2() {
stats: {},
runtime: { agent: {}, storage: [], node: {} },
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: '' },
selectedReviewIds: {},
reviewSelectionScope: 'ids',
@@ -2347,8 +2663,12 @@ function adminV2() {
duration_seconds: true,
local_genres: true,
lastfm_tags: true,
musicbrainz_tags: true,
mode: 'fill_missing'
},
artworkBackfillOptions: {
mode: 'missing'
},
libraryKind: 'artists',
librarySearch: '',
library: { items: [], total: 0, limit: 40, offset: 0 },
@@ -2450,6 +2770,8 @@ function adminV2() {
this.activeJobName ? this.loadRunsForJob(this.activeJobName, false) : Promise.resolve(),
this.refreshActiveRunDetail()
]);
} else if (this.activeView === 'users') {
await Promise.allSettled([this.loadUsers(false)]);
} else {
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
}
@@ -2482,6 +2804,8 @@ function adminV2() {
this.libraryKind = nextKind;
} else if (view === 'settings') {
this.activeView = 'settings';
} else if (view === 'users') {
this.activeView = 'users';
} else if (view === 'tools') {
this.activeView = 'tools';
} else {
@@ -2513,6 +2837,9 @@ function adminV2() {
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
await this.loadSettingsProbe(false);
}
} else if (this.activeView === 'users') {
if (updateRoute) this.setRoute('#users');
await this.loadUsers(false);
} else if (this.activeView === 'tools' && updateRoute) {
this.setRoute('#tools');
}
@@ -2555,6 +2882,12 @@ function adminV2() {
this.setRoute('#tools');
},
openUsers() {
this.activeView = 'users';
this.setRoute('#users');
this.loadUsers();
},
async loadReviews(resetOffset = true) {
if (resetOffset) this.reviews.offset = 0;
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) {
try {
this.settings = await this.request(`${this.apiBase}/settings`);
@@ -2826,6 +3191,11 @@ function adminV2() {
method: 'POST',
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' });
this.showToast(`Run #${result.run_id} started`);
this.activeJobName = job.name;
@@ -2845,8 +3215,12 @@ function adminV2() {
return job && job.name === 'metadata_backfill';
},
isArtworkBackfillJob(job) {
return job && job.name === 'artwork_backfill';
},
jobHasParameterForm(job) {
return this.isMetadataBackfillJob(job);
return this.isMetadataBackfillJob(job) || this.isArtworkBackfillJob(job);
},
metadataBackfillPayload() {
@@ -2858,10 +3232,18 @@ function adminV2() {
duration_seconds: Boolean(options.duration_seconds),
local_genres: Boolean(options.local_genres),
lastfm_tags: Boolean(options.lastfm_tags),
musicbrainz_tags: Boolean(options.musicbrainz_tags),
overwrite: options.mode === 'overwrite'
};
},
artworkBackfillPayload() {
const options = this.artworkBackfillOptions || {};
return {
overwrite_existing: options.mode === 'overwrite'
};
},
async toggleJob(job) {
try {
const result = await this.request(`${this.apiBase}/jobs/${encodeURIComponent(job.name)}/toggle`, { method: 'POST' });
@@ -3413,6 +3795,13 @@ function adminV2() {
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() {
const agent = (this.runtime && this.runtime.agent) || {};
const storage = (this.runtime && this.runtime.storage) || [];
@@ -3469,6 +3858,7 @@ function adminV2() {
if (this.activeView === 'jobs') return 'Tasks';
if (this.activeView === 'tools') return 'Future Tools';
if (this.activeView === 'settings') return 'Settings';
if (this.activeView === 'users') return 'Users';
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 === '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 === 'users') return 'Account status, live presence, and per-user listening statistics';
return 'Full-screen review triage with filter-aware bulk actions';
},
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() {
@@ -3493,6 +3888,75 @@ function adminV2() {
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() {
const count = this.selectedLibraryCount();
if (!count) return 'Nothing selected';
@@ -3523,6 +3987,12 @@ function adminV2() {
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) {
return typeof value === 'number' ? `${Math.round(value * 100)}%` : '-';
},
+109 -9
View File
@@ -46,6 +46,7 @@ const T = {
radioFailed: "{{ t.player_radio_failed }}",
connectionLost: "{{ t.player_connection_lost }}",
connectionLostDetail: "{{ t.player_connection_lost_detail }}",
activeDevice: "{{ t.player_active_device }}",
trackWord: "{{ t.player_tracks_count }}",
releaseWord: "{{ t.player_releases_count }}",
ofWord: "{{ t.player_of }}",
@@ -1176,6 +1177,10 @@ document.addEventListener('alpine:init', () => {
jamUsers: [],
jamSelectedUsers: [],
jamSearching: false,
remoteHintVisible: false,
remoteHintDeviceName: '',
_remoteHintShown: false,
_remoteHintTimer: null,
_pollTimer: null,
_jamSearchTimer: null,
_stateRefreshTick: 0,
@@ -1265,6 +1270,34 @@ document.addEventListener('alpine:init', () => {
if (wasActive && !this.isActive()) {
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() {
@@ -1330,6 +1363,7 @@ document.addEventListener('alpine:init', () => {
},
toggle() {
this.dismissRemoteHint();
this.open = !this.open;
if (this.open) this.poll();
},
@@ -1813,11 +1847,13 @@ document.addEventListener('alpine:init', () => {
// -----------------------------------------------------------------------
Alpine.store('library', {
view: 'artists',
artistFilter: 'all',
artists: [],
artistsPage: 0,
artistsTotal: 0,
loading: false,
_allLoaded: false,
_artistsLoadToken: 0,
currentArtist: null,
currentRelease: null,
currentPlaylist: null,
@@ -1832,7 +1868,6 @@ document.addEventListener('alpine:init', () => {
_hashNav: false, // guard against circular hash updates
init() {
this.loadArtists(1);
this._setupScroll();
// Listen for browser back/forward
@@ -1869,7 +1904,10 @@ document.addEventListener('alpine:init', () => {
const params = match[3] || '';
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 (view === 'artist' && id) {
this.openArtist(id, options);
@@ -1935,8 +1973,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 = {}) {
this._beginNavigation('#artists', options);
if (this.artistFilter !== 'all' || this.artistsPage === 0) {
this._resetArtistList('all');
this.loadArtists(1);
}
this.view = 'artists';
this.currentArtist = null;
this.currentRelease = null;
@@ -1948,25 +2000,63 @@ document.addEventListener('alpine:init', () => {
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) {
if (this.loading || this._allLoaded) return;
this.loading = true;
const filter = this.artistFilter;
const token = this._artistsLoadToken + 1;
this._artistsLoadToken = token;
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');
const data = await res.json();
if (token !== this._artistsLoadToken || filter !== this.artistFilter) return;
const incoming = Array.isArray(data.items) ? data.items : [];
if (page === 1) {
this.artists = data.items;
this.artists = incoming;
} 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.artistsTotal = data.total;
if (this.artists.length >= data.total) {
if (data.has_more === false || this.artists.length >= data.total) {
this._allLoaded = true;
}
} catch {}
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 = {}) {
@@ -2303,8 +2393,8 @@ document.addEventListener('alpine:init', () => {
this.searchLoading = false;
if (this.view === 'search') {
this.view = this._previousView || 'artists';
this._setHash('#artists');
if (this.view === 'artists') {
this._setHash(this.view === 'my_uploads' ? '#uploads' : '#artists');
if (this.view === 'artists' || this.view === 'my_uploads') {
this.$nextTick(() => { this._setupScroll(); });
}
}
@@ -2324,11 +2414,21 @@ document.addEventListener('alpine:init', () => {
if (entries[0].isIntersecting && !this.loading && !this._allLoaded) {
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._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) {
setTimeout(fn, 50);
},
+67 -9
View File
@@ -60,7 +60,13 @@
:class="{ active: $store.library.view === 'artists' }"
@click="$store.library.goArtists()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
{{ t.player_artists }}
{{ t.player_global_library }}
</div>
<div class="sidebar-nav-item"
:class="{ active: $store.library.view === 'my_uploads' }"
@click="$store.library.goMyUploads()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
{{ t.player_my_uploads }}
</div>
</div>
<div class="sidebar-section">
@@ -168,7 +174,13 @@
:class="{ active: $store.library.view === 'artists' }"
@click="$store.library.goArtists(); $store.mobile.closeLibrary()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="4"/><path d="M20 21a8 8 0 10-16 0"/></svg>
{{ t.player_artists }}
{{ t.player_global_library }}
</div>
<div class="sidebar-nav-item"
:class="{ active: $store.library.view === 'my_uploads' }"
@click="$store.library.goMyUploads(); $store.mobile.closeLibrary()">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4"/><path d="M17 8l-5-5-5 5"/><path d="M12 3v12"/></svg>
{{ t.player_my_uploads }}
</div>
</div>
@@ -507,11 +519,17 @@
</template>
<!-- Artists Grid -->
<template x-if="$store.library.view === 'artists'">
<template x-if="$store.library.view === 'artists' || $store.library.view === 'my_uploads'">
<div>
<h1 class="section-title">{{ t.player_artists }}</h1>
<h1 class="section-title" x-text="$store.library.view === 'my_uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></h1>
<div class="card-grid">
<template x-for="artist in $store.library.artists" :key="artist.id">
<template x-for="(artist, idx) in $store.library.artists" :key="artist.id">
<div class="artist-grid-entry">
<div class="artist-section-divider"
x-show="$store.library.shouldShowFeaturedSeparator(idx)"
x-cloak>
<span>{{ t.player_featured_only_artists }}</span>
</div>
<div class="card" @click="$store.library.openArtist(artist.id)">
<div class="card-img">
<template x-if="artist.image_url">
@@ -535,11 +553,25 @@
<div class="card-title" x-text="artist.name"></div>
<div class="card-subtitle" x-text="artist.release_count + ' {{ t.player_releases_count }} · ' + artist.track_count + ' {{ t.player_tracks_count }}'"></div>
</div>
</div>
</template>
</div>
<template x-if="$store.library.loading">
<template x-if="!$store.library.loading && $store.library.artists.length === 0">
<div class="empty-state">
<p x-text="$store.library.view === 'my_uploads' ? '{{ t.player_no_uploaded_tracks }}' : '{{ t.artists_empty }}'"></p>
</div>
</template>
<template x-if="$store.library.loading && $store.library.artists.length === 0">
<div class="loading-spinner"><div class="spinner"></div></div>
</template>
<div class="scroll-load-indicator"
x-show="$store.library.loading && $store.library.artists.length > 0"
x-cloak
role="status"
aria-live="polite">
<div class="scroll-load-spinner"></div>
<span>{{ t.player_loading_more }}</span>
</div>
<div id="artist-sentinel" style="height:1px"></div>
</div>
</template>
@@ -548,7 +580,8 @@
<template x-if="$store.library.view === 'artist_detail' && $store.library.currentArtist">
<div>
<div class="breadcrumb">
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
<span>/</span>
<span x-text="$store.library.currentArtist.name"></span>
</div>
@@ -639,11 +672,24 @@
<span style="text-align:right">{{ t.player_duration }}</span>
</div>
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
<div class="track-row"
<div class="track-row artist-appearance-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
<span class="track-num" x-text="idx + 1"></span>
<div class="track-info">
<button class="artist-appearance-cover"
type="button"
@click.stop="$store.library.openRelease(track.release_id)"
:title="track.release_title"
aria-label="{{ t.player_release }}">
<template x-if="track.cover_url">
<img :src="track.cover_url" :alt="track.release_title" loading="lazy">
</template>
<template x-if="!track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
</button>
<div class="artist-appearance-copy">
<div class="track-title">
<span x-text="track.title"></span>
<span style="color:var(--text-subdued)"> · </span>
@@ -658,6 +704,7 @@
</template>
</div>
</div>
</div>
<span></span>
<div class="track-actions">
<button class="track-action-btn info-btn popularity-info-btn"
@@ -694,7 +741,8 @@
<template x-if="$store.library.view === 'release_detail' && $store.library.currentRelease">
<div>
<div class="breadcrumb">
<a @click="$store.library.goArtists()">{{ t.player_artists }}</a>
<a @click="$store.library.artistFilter === 'uploads' ? $store.library.goMyUploads() : $store.library.goArtists()"
x-text="$store.library.artistFilter === 'uploads' ? '{{ t.player_my_uploads }}' : '{{ t.player_global_library }}'"></a>
<span>/</span>
<template x-if="$store.library.currentRelease.artists.length > 0">
<a @click="$store.library.openArtist($store.library.currentRelease.artists[0].id)" x-text="$store.library.currentRelease.artists[0].name"></a>
@@ -1074,6 +1122,16 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button>
<div class="device-picker" @click.outside="$store.devices.open = false">
<template x-if="$store.devices.remoteHintVisible">
<button class="remote-playback-hint"
type="button"
@click="$store.devices.dismissRemoteHint()"
x-transition
x-cloak>
<span class="remote-playback-hint-text" x-text="$store.devices.remoteHintText()"></span>
<span class="remote-playback-hint-progress"></span>
</button>
</template>
<button class="queue-toggle-btn device-toggle-btn"
:class="{ active: $store.devices.isActive() }"
@click="$store.devices.toggle()"
+171 -10
View File
@@ -513,6 +513,31 @@ button.user-stat:hover {
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 {
background: var(--bg-secondary);
border-radius: 8px;
@@ -735,6 +760,47 @@ button.user-stat:hover {
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-duration { font-size: 13px; color: var(--text-subdued); text-align: right; pointer-events: none; }
@@ -1370,19 +1436,35 @@ button.user-stat:hover {
.volume-slider {
width: 104px;
height: 4px;
background: var(--bg-active);
border-radius: 2px;
height: 28px;
background: transparent;
border-radius: 6px;
cursor: pointer;
position: relative;
touch-action: none;
display: flex;
align-items: center;
}
.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 {
height: 100%;
height: 4px;
background: var(--text-primary);
border-radius: 2px;
position: relative;
z-index: 1;
pointer-events: none;
}
.volume-slider:hover .volume-slider-fill { background: var(--accent); }
@@ -1452,6 +1534,51 @@ button.user-stat:hover {
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 {
position: absolute;
right: 0;
@@ -1712,6 +1839,26 @@ button.user-stat:hover {
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); } }
/* Empty state */
@@ -4164,13 +4311,19 @@ button.user-stat:hover {
}
.volume-slider {
display: block;
display: flex;
width: 100%;
height: 9px;
border-radius: 999px;
height: 34px;
border-radius: 6px;
}
.volume-slider-fill {
height: 7px;
border-radius: 999px;
}
.volume-slider::before {
height: 7px;
border-radius: 999px;
}
@@ -4417,8 +4570,8 @@ button.user-stat:hover {
}
.player-bar.mobile-expanded .mobile-full-player .volume-slider {
display: block;
height: 7px;
display: flex;
height: 44px;
}
.player-bar.mobile-expanded .mobile-full-player .device-popover {
@@ -4432,6 +4585,14 @@ button.user-stat:hover {
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 {
display: block;
width: 100%;
@@ -5053,7 +5214,7 @@ button.user-stat:hover {
}
.volume-slider {
display: block;
display: flex;
width: 100%;
}