Compare commits

...

3 Commits

Author SHA1 Message Date
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
Ultradesu c244b3d4d8 PLAYER: fixe i8n
Build and Publish / Build and Publish Docker Image (push) Successful in 3m23s
2026-06-01 18:33:39 +03:00
13 changed files with 1447 additions and 208 deletions
Generated
+1 -1
View File
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
[[package]] [[package]]
name = "furumusic" name = "furumusic"
version = "0.2.11" version = "0.2.13"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"async-trait", "async-trait",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "furumusic" name = "furumusic"
version = "0.2.12" version = "0.2.14"
edition = "2024" edition = "2024"
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
+49
View File
@@ -227,6 +227,55 @@ impl App for AdminApp {
}, },
"admin_v2_reviews_bulk", "admin_v2_reviews_bulk",
), ),
Route::with_handler_and_name(
"/v2/api/users",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database,
query: UrlQuery<v2::UsersQuery>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::users(session, db, pg_pool, query.0).await
}
})
},
"admin_v2_users",
),
Route::with_handler_and_name(
"/v2/api/users/{id}",
{
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
get(move |session: Session, db: Database, path: Path<PathId>| {
let pool = Arc::clone(&pool);
let pool_config = Arc::clone(&pool_config);
async move {
let pg_pool = pool
.get_or_init(|| async {
sqlx::postgres::PgPoolOptions::new()
.max_connections(5)
.connect(&pool_config.database_url)
.await
.expect("admin pool")
})
.await;
v2::user_detail(session, db, pg_pool, path.0.id).await
}
})
},
"admin_v2_user_detail",
),
Route::with_handler_and_name( Route::with_handler_and_name(
"/v2/api/reviews/{id}/approve", "/v2/api/reviews/{id}/approve",
{ {
+275
View File
@@ -45,6 +45,13 @@ pub(super) struct LibraryQuery {
pub(super) offset: Option<i64>, pub(super) offset: Option<i64>,
} }
#[derive(Debug, Deserialize)]
pub(super) struct UsersQuery {
pub(super) search: Option<String>,
pub(super) limit: Option<i64>,
pub(super) offset: Option<i64>,
}
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
pub(super) struct BulkReviewsRequest { pub(super) struct BulkReviewsRequest {
action: String, action: String,
@@ -158,6 +165,48 @@ struct AdminDashboardDto {
library: LibraryOverviewDto, library: LibraryOverviewDto,
} }
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUsersPageDto {
items: Vec<AdminUserRowDto>,
total: i64,
limit: i64,
offset: i64,
search: Option<String>,
online_count: i64,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUserRowDto {
id: i64,
username: String,
display_name: Option<String>,
email: Option<String>,
role: String,
is_active: bool,
is_online: bool,
last_seen_ms: Option<i64>,
}
#[derive(Debug, Serialize, JsonSchema)]
struct AdminUserDetailDto {
user: AdminUserRowDto,
stats: AdminUserStatsDto,
}
#[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)] #[derive(Debug, Serialize, JsonSchema)]
struct OverviewStatsDto { struct OverviewStatsDto {
tracks: i64, tracks: i64,
@@ -645,6 +694,41 @@ pub async fn reviews(
Json(page).into_response() Json(page).into_response()
} }
pub async fn users(
session: Session,
db: Database,
pool: &PgPool,
query: UsersQuery,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let page = load_admin_users_page(pool, query)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
Json(page).into_response()
}
pub async fn user_detail(
session: Session,
db: Database,
pool: &PgPool,
user_id: i64,
) -> cot::Result<cot::response::Response> {
if let Err(response) = require_admin_json(&session, &db).await {
return Ok(response);
}
let detail = load_admin_user_detail(pool, user_id)
.await
.map_err(|e| cot::Error::internal(e.to_string()))?;
match detail {
Some(detail) => Json(detail).into_response(),
None => Ok(json_error(StatusCode::NOT_FOUND, "user not found")),
}
}
pub async fn bulk_reviews( pub async fn bulk_reviews(
session: Session, session: Session,
db: Database, db: Database,
@@ -1554,6 +1638,197 @@ 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.media_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),
)?;
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,
},
}))
}
fn admin_user_row(
row: AdminUserSqlRow,
active: &HashMap<i64, i64>,
online_cutoff_ms: i64,
) -> AdminUserRowDto {
let last_seen_ms = active.get(&row.id).copied();
AdminUserRowDto {
id: row.id,
username: row.username,
display_name: row.display_name,
email: row.email,
role: row.role,
is_active: row.is_active,
is_online: last_seen_ms.is_some_and(|value| value <= online_cutoff_ms),
last_seen_ms,
}
}
fn load_runtime_overview(config: &AppConfig) -> RuntimeOverviewDto { fn load_runtime_overview(config: &AppConfig) -> RuntimeOverviewDto {
let llm_configured = !config.agent_llm_url.trim().is_empty(); let llm_configured = !config.agent_llm_url.trim().is_empty();
let agent_status = if !config.agent_enabled { let agent_status = if !config.agent_enabled {
+65
View File
@@ -298,6 +298,7 @@ translations! {
player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков..."; player_search_placeholder: "Search artists, releases, tracks..." , "Поиск артистов, релизов, треков...";
player_connection_lost: "Server connection lost" , "Нет соединения с сервером"; player_connection_lost: "Server connection lost" , "Нет соединения с сервером";
player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю..."; player_connection_lost_detail: "Player cannot reach the server. Retrying..." , "Плеер не может связаться с сервером. Повторяю...";
player_active_device: "Active device" , "Активный девайс";
player_no_results: "No results found" , "Ничего не найдено"; player_no_results: "No results found" , "Ничего не найдено";
player_new_playlist: "New Playlist" , "Новый плейлист"; player_new_playlist: "New Playlist" , "Новый плейлист";
player_rename_playlist: "Rename Playlist" , "Переименовать плейлист"; player_rename_playlist: "Rename Playlist" , "Переименовать плейлист";
@@ -386,7 +387,71 @@ translations! {
player_saved_torrents: "Saved torrents" , "Сохранённые торренты"; player_saved_torrents: "Saved torrents" , "Сохранённые торренты";
player_refresh: "Refresh" , "Обновить"; player_refresh: "Refresh" , "Обновить";
player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет"; player_no_saved_torrents: "No saved torrents" , "Сохранённых торрентов нет";
player_import: "Import" , "Импорт";
player_upload: "Upload" , "Загрузить"; player_upload: "Upload" , "Загрузить";
player_my_uploads: "My uploads" , "Мои загрузки";
player_my_uploaded_tracks: "My uploaded tracks" , "Мои загруженные треки";
player_no_uploaded_tracks: "No uploaded tracks yet" , "Загруженных треков пока нет";
player_needs_approval: "Needs approval" , "Нужно подтверждение";
player_pending_or_failed: "pending or failed" , "ожидают или с ошибкой";
player_no_tracks_need_approval: "No tracks need approval" , "Нет треков для подтверждения";
player_queued_processing: "Queued / processing" , "В очереди / обработке";
player_showing: "Showing" , "Показано";
player_status: "Status" , "Статус";
player_file: "File" , "Файл";
player_created: "Created" , "Создано";
player_updated: "Updated" , "Обновлено";
player_error: "Error" , "Ошибка";
player_pending: "Pending" , "Ожидает";
player_artist: "Artist" , "Артист";
player_album: "Album" , "Альбом";
player_album_artists: "Album artists" , "Артисты альбома";
player_featured: "Featured" , "При участии";
player_featured_short: "feat." , "уч.";
player_track_number: "Track #" , "Трек #";
player_disc_number: "Disc #" , "Диск #";
player_genre: "Genre" , "Жанр";
player_notes: "Notes" , "Заметки";
player_type_unchanged: "Type unchanged" , "Тип без изменений";
player_visibility_unchanged: "Visibility unchanged" , "Видимость без изменений";
player_visible: "Visible" , "Видимый";
player_hidden: "Hidden" , "Скрыт";
player_no_year: "no year" , "год неизвестен";
player_apply: "Apply" , "Применить";
player_edit: "Edit" , "Редактировать";
player_edit_release: "Edit release" , "Редактировать релиз";
player_edit_track: "Edit track" , "Редактировать трек";
player_edit_metadata: "Edit metadata" , "Редактировать метаданные";
player_metadata: "Metadata" , "Метаданные";
player_release_metadata: "Release metadata" , "Метаданные релиза";
player_track_metadata: "Track metadata" , "Метаданные трека";
player_approve_metadata: "Approve metadata" , "Подтвердить метаданные";
player_delete_review: "Delete review" , "Удалить проверку";
player_approve: "Approve" , "Подтвердить";
player_save_track: "Save track" , "Сохранить трек";
player_save_release: "Save release" , "Сохранить релиз";
player_artists_placeholder: "Artist, Artist" , "Артист, артист";
player_artist_featured_placeholder: "Artist, Featured Artist" , "Артист, приглашённый артист";
player_release_type_album: "Album" , "Альбом";
player_release_type_single: "Single" , "Сингл";
player_release_type_ep: "EP" , "EP";
player_release_type_compilation: "Compilation" , "Сборник";
player_release_type_mixtape: "Mixtape" , "Микстейп";
player_release_type_live: "Live" , "Концерт";
player_release_type_soundtrack: "Soundtrack" , "Саундтрек";
player_release_type_remix: "Remix" , "Ремикс";
player_release_type_demo: "Demo" , "Демо";
player_failed_load_uploaded_tracks: "Failed to load uploaded tracks" , "Не удалось загрузить загруженные треки";
player_failed_save_track: "Failed to save track" , "Не удалось сохранить трек";
player_track_metadata_saved: "Track metadata saved" , "Метаданные трека сохранены";
player_failed_save_release: "Failed to save release" , "Не удалось сохранить релиз";
player_release_metadata_saved: "Release metadata saved" , "Метаданные релиза сохранены";
player_failed_delete_review: "Failed to delete review" , "Не удалось удалить проверку";
player_review_deleted: "Review deleted" , "Проверка удалена";
player_failed_approve_review: "Failed to approve review" , "Не удалось подтвердить проверку";
player_track_approved_imported: "Track approved and imported" , "Трек подтверждён и импортирован";
player_failed_update_selected_tracks: "Failed to update selected tracks" , "Не удалось обновить выбранные треки";
player_selected_tracks_updated: "Selected tracks updated" , "Выбранные треки обновлены";
player_choose_saved_or_add_torrent: "Choose a saved item or upload new files." , "Выберите сохранённый элемент или загрузите новые файлы."; player_choose_saved_or_add_torrent: "Choose a saved item or upload new files." , "Выберите сохранённый элемент или загрузите новые файлы.";
player_local_files: "Local audio files" , "Локальные аудиофайлы"; player_local_files: "Local audio files" , "Локальные аудиофайлы";
player_torrent_file: "Torrent file" , "Torrent-файл"; player_torrent_file: "Torrent file" , "Torrent-файл";
+187 -56
View File
@@ -84,6 +84,12 @@ struct LastfmTagStats {
failed: u64, failed: u64,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LastfmTagPassResult {
Completed,
RateLimited,
}
pub struct MetadataBackfillJob; pub struct MetadataBackfillJob;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -345,9 +351,21 @@ async fn backfill_lastfm_tags(
.build()?; .build()?;
let mut stats = LastfmTagStats::default(); let mut stats = LastfmTagStats::default();
backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?; if backfill_lastfm_artist_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
backfill_lastfm_release_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?; == LastfmTagPassResult::RateLimited
backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?; {
return Ok(stats);
}
if backfill_lastfm_release_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
== LastfmTagPassResult::RateLimited
{
return Ok(stats);
}
if backfill_lastfm_track_tags(ctx, log, &client, api_key, overwrite, &mut stats).await?
== LastfmTagPassResult::RateLimited
{
return Ok(stats);
}
Ok(stats) Ok(stats)
} }
@@ -358,7 +376,7 @@ async fn backfill_lastfm_artist_tags(
api_key: &str, api_key: &str,
overwrite: bool, overwrite: bool,
stats: &mut LastfmTagStats, stats: &mut LastfmTagStats,
) -> anyhow::Result<()> { ) -> anyhow::Result<LastfmTagPassResult> {
let rows = sqlx::query_as::<_, LastfmArtistTagRow>( let rows = sqlx::query_as::<_, LastfmArtistTagRow>(
r#"SELECT DISTINCT a.id, a.name::text AS name r#"SELECT DISTINCT a.id, a.name::text AS name
FROM furumusic__artist a FROM furumusic__artist a
@@ -376,31 +394,66 @@ async fn backfill_lastfm_artist_tags(
)); ));
let total = rows.len(); let total = rows.len();
for (index, row) in rows.into_iter().enumerate() { for (index, row) in rows.into_iter().enumerate() {
if should_skip_lastfm_entity(&ctx.pool, "artist", row.id, overwrite).await? { if should_log_lastfm_item(index + 1, total, 25) {
stats.skipped_existing += 1; log.info(&format!(
if should_log_lastfm_progress(index + 1, total, 25) { "Last.fm artist tags {}/{}: artist {} \"{}\"",
log.info(&format!( index + 1,
"Last.fm artist tags progress: {}/{}", total,
index + 1, row.id,
total row.name
)); ));
}
match should_skip_lastfm_entity(&ctx.pool, "artist", row.id, overwrite).await {
Ok(true) => {
stats.skipped_existing += 1;
if should_log_lastfm_progress(index + 1, total, 25) {
log.info(&format!(
"Last.fm artist tags progress: {}/{}",
index + 1,
total
));
}
continue;
}
Ok(false) => {}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm artist tags skip check failed for artist {} \"{}\": {err}",
row.id, row.name
));
continue;
} }
continue;
} }
stats.considered += 1; stats.considered += 1;
match fetch_lastfm_artist_tags(client, api_key, &row.name).await { match fetch_lastfm_artist_tags(client, api_key, &row.name).await {
Ok(tags) if !tags.is_empty() => { Ok(tags) if !tags.is_empty() => {
let saved = match replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false)
replace_entity_tags(&ctx.pool, "artist", row.id, &tags, "lastfm", false) .await
.await?; {
stats.tags_saved += saved; Ok(saved) => {
stats.updated_entities += 1; stats.tags_saved += saved;
stats.updated_entities += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm artist tags save failed for artist {} \"{}\": {err}",
row.id, row.name
));
}
}
} }
Ok(_) => { Ok(_) => {
stats.not_found += 1; stats.not_found += 1;
} }
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => { Err(err) if is_lastfm_rate_limit_error(&err) => {
return Err(err); stats.failed += 1;
log.warn(&format!(
"Last.fm rate limit reached while fetching artist tags for artist {} \"{}\"; stopping Last.fm tag backfill for this run",
row.id, row.name
));
return Ok(LastfmTagPassResult::RateLimited);
} }
Err(err) => { Err(err) => {
stats.failed += 1; stats.failed += 1;
@@ -419,7 +472,7 @@ async fn backfill_lastfm_artist_tags(
} }
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await; tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
} }
Ok(()) Ok(LastfmTagPassResult::Completed)
} }
async fn backfill_lastfm_release_tags( async fn backfill_lastfm_release_tags(
@@ -429,7 +482,7 @@ async fn backfill_lastfm_release_tags(
api_key: &str, api_key: &str,
overwrite: bool, overwrite: bool,
stats: &mut LastfmTagStats, stats: &mut LastfmTagStats,
) -> anyhow::Result<()> { ) -> anyhow::Result<LastfmTagPassResult> {
let rows = sqlx::query_as::<_, LastfmReleaseTagRow>( let rows = sqlx::query_as::<_, LastfmReleaseTagRow>(
r#"SELECT r.id, r#"SELECT r.id,
r.title::text AS title, r.title::text AS title,
@@ -454,16 +507,36 @@ async fn backfill_lastfm_release_tags(
)); ));
let total = rows.len(); let total = rows.len();
for (index, row) in rows.into_iter().enumerate() { for (index, row) in rows.into_iter().enumerate() {
if should_skip_lastfm_entity(&ctx.pool, "release", row.id, overwrite).await? { if should_log_lastfm_item(index + 1, total, 25) {
stats.skipped_existing += 1; log.info(&format!(
if should_log_lastfm_progress(index + 1, total, 25) { "Last.fm release tags {}/{}: release {} \"{}\"",
log.info(&format!( index + 1,
"Last.fm release tags progress: {}/{}", total,
index + 1, row.id,
total row.title
)); ));
}
match should_skip_lastfm_entity(&ctx.pool, "release", row.id, overwrite).await {
Ok(true) => {
stats.skipped_existing += 1;
if should_log_lastfm_progress(index + 1, total, 25) {
log.info(&format!(
"Last.fm release tags progress: {}/{}",
index + 1,
total
));
}
continue;
}
Ok(false) => {}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm release tags skip check failed for release {} \"{}\": {err}",
row.id, row.title
));
continue;
} }
continue;
} }
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty()) let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
else { else {
@@ -480,17 +553,32 @@ async fn backfill_lastfm_release_tags(
stats.considered += 1; stats.considered += 1;
match fetch_lastfm_album_tags(client, api_key, artist, &row.title).await { match fetch_lastfm_album_tags(client, api_key, artist, &row.title).await {
Ok(tags) if !tags.is_empty() => { Ok(tags) if !tags.is_empty() => {
let saved = match replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false)
replace_entity_tags(&ctx.pool, "release", row.id, &tags, "lastfm", false) .await
.await?; {
stats.tags_saved += saved; Ok(saved) => {
stats.updated_entities += 1; stats.tags_saved += saved;
stats.updated_entities += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm release tags save failed for release {} \"{}\" / \"{}\": {err}",
row.id, artist, row.title
));
}
}
} }
Ok(_) => { Ok(_) => {
stats.not_found += 1; stats.not_found += 1;
} }
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => { Err(err) if is_lastfm_rate_limit_error(&err) => {
return Err(err); stats.failed += 1;
log.warn(&format!(
"Last.fm rate limit reached while fetching release tags for release {} \"{}\" / \"{}\"; stopping Last.fm tag backfill for this run",
row.id, artist, row.title
));
return Ok(LastfmTagPassResult::RateLimited);
} }
Err(err) => { Err(err) => {
stats.failed += 1; stats.failed += 1;
@@ -509,7 +597,7 @@ async fn backfill_lastfm_release_tags(
} }
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await; tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
} }
Ok(()) Ok(LastfmTagPassResult::Completed)
} }
async fn backfill_lastfm_track_tags( async fn backfill_lastfm_track_tags(
@@ -519,7 +607,7 @@ async fn backfill_lastfm_track_tags(
api_key: &str, api_key: &str,
overwrite: bool, overwrite: bool,
stats: &mut LastfmTagStats, stats: &mut LastfmTagStats,
) -> anyhow::Result<()> { ) -> anyhow::Result<LastfmTagPassResult> {
let rows = sqlx::query_as::<_, LastfmTrackTagRow>( let rows = sqlx::query_as::<_, LastfmTrackTagRow>(
r#"SELECT t.id, r#"SELECT t.id,
t.title::text AS title, t.title::text AS title,
@@ -544,16 +632,36 @@ async fn backfill_lastfm_track_tags(
)); ));
let total = rows.len(); let total = rows.len();
for (index, row) in rows.into_iter().enumerate() { for (index, row) in rows.into_iter().enumerate() {
if should_skip_lastfm_entity(&ctx.pool, "track", row.id, overwrite).await? { if should_log_lastfm_item(index + 1, total, 50) {
stats.skipped_existing += 1; log.info(&format!(
if should_log_lastfm_progress(index + 1, total, 50) { "Last.fm track tags {}/{}: track {} \"{}\"",
log.info(&format!( index + 1,
"Last.fm track tags progress: {}/{}", total,
index + 1, row.id,
total row.title
)); ));
}
match should_skip_lastfm_entity(&ctx.pool, "track", row.id, overwrite).await {
Ok(true) => {
stats.skipped_existing += 1;
if should_log_lastfm_progress(index + 1, total, 50) {
log.info(&format!(
"Last.fm track tags progress: {}/{}",
index + 1,
total
));
}
continue;
}
Ok(false) => {}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm track tags skip check failed for track {} \"{}\": {err}",
row.id, row.title
));
continue;
} }
continue;
} }
let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty()) let Some(artist) = row.artist_name.as_deref().filter(|value| !value.trim().is_empty())
else { else {
@@ -570,16 +678,31 @@ async fn backfill_lastfm_track_tags(
stats.considered += 1; stats.considered += 1;
match fetch_lastfm_track_tags(client, api_key, artist, &row.title).await { match fetch_lastfm_track_tags(client, api_key, artist, &row.title).await {
Ok(tags) if !tags.is_empty() => { Ok(tags) if !tags.is_empty() => {
let saved = match replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await
replace_entity_tags(&ctx.pool, "track", row.id, &tags, "lastfm", true).await?; {
stats.tags_saved += saved; Ok(saved) => {
stats.updated_entities += 1; stats.tags_saved += saved;
stats.updated_entities += 1;
}
Err(err) => {
stats.failed += 1;
log.warn(&format!(
"Last.fm track tags save failed for track {} \"{}\" / \"{}\": {err}",
row.id, artist, row.title
));
}
}
} }
Ok(_) => { Ok(_) => {
stats.not_found += 1; stats.not_found += 1;
} }
Err(err) if err.to_string().contains("Last.fm rate limit exceeded") => { Err(err) if is_lastfm_rate_limit_error(&err) => {
return Err(err); stats.failed += 1;
log.warn(&format!(
"Last.fm rate limit reached while fetching track tags for track {} \"{}\" / \"{}\"; stopping Last.fm tag backfill for this run",
row.id, artist, row.title
));
return Ok(LastfmTagPassResult::RateLimited);
} }
Err(err) => { Err(err) => {
stats.failed += 1; stats.failed += 1;
@@ -598,13 +721,21 @@ async fn backfill_lastfm_track_tags(
} }
tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await; tokio::time::sleep(LASTFM_TAG_REQUEST_DELAY).await;
} }
Ok(()) Ok(LastfmTagPassResult::Completed)
} }
fn should_log_lastfm_progress(done: usize, total: usize, every: usize) -> bool { fn should_log_lastfm_progress(done: usize, total: usize, every: usize) -> bool {
total > 0 && (done == total || done % every == 0) total > 0 && (done == total || done % every == 0)
} }
fn should_log_lastfm_item(done: usize, total: usize, every: usize) -> bool {
total > 0 && (done == 1 || done == total || done % every == 0)
}
fn is_lastfm_rate_limit_error(err: &anyhow::Error) -> bool {
err.to_string().contains("Last.fm rate limit exceeded")
}
async fn should_skip_lastfm_entity( async fn should_skip_lastfm_entity(
pool: &sqlx::PgPool, pool: &sqlx::PgPool,
entity_kind: &str, entity_kind: &str,
+8
View File
@@ -159,6 +159,14 @@ pub fn record_active_user(user_id: i64) {
users.insert(user_id, Instant::now()); users.insert(user_id, Instant::now());
} }
pub fn active_user_last_seen_ms() -> HashMap<i64, i64> {
let users = ACTIVE_USERS.lock().expect("active user lock");
users
.iter()
.map(|(user_id, last_seen)| (*user_id, last_seen.elapsed().as_millis() as i64))
.collect()
}
pub fn record_auth_attempt(method: &'static str, outcome: &'static str, reason: &'static str) { pub fn record_auth_attempt(method: &'static str, outcome: &'static str, reason: &'static str) {
REGISTRY.inc_counter( REGISTRY.inc_counter(
"furumusic_auth_login_attempts_total", "furumusic_auth_login_attempts_total",
+1 -1
View File
@@ -2360,7 +2360,7 @@ async fn load_user_upload_queue(
ORDER BY ORDER BY
CASE status WHEN 'processing' THEN 0 ELSE 1 END, CASE status WHEN 'processing' THEN 0 ELSE 1 END,
created_at DESC created_at DESC
LIMIT 20"#, LIMIT 100"#,
) )
.bind(uploaded_by_pattern) .bind(uploaded_by_pattern)
.fetch_all(pool) .fetch_all(pool)
+304 -1
View File
@@ -307,7 +307,8 @@ button {
} }
.btn:disabled, .btn:disabled,
.icon-btn:disabled { .icon-btn:disabled,
.seg-btn:disabled {
opacity: 0.45; opacity: 0.45;
cursor: not-allowed; cursor: not-allowed;
} }
@@ -416,6 +417,15 @@ tbody tr:hover {
.col-tags { width: 250px; } .col-tags { width: 250px; }
.col-time { width: 132px; } .col-time { width: 132px; }
.users-table-wrap {
max-height: calc(100vh - 300px);
}
.users-table .user-status-column { width: 116px; }
.users-table .user-role-column { width: 110px; }
.users-table .user-active-column { width: 112px; }
.users-table .user-seen-column { width: 170px; }
.check { .check {
width: 16px; width: 16px;
height: 16px; height: 16px;
@@ -1274,6 +1284,54 @@ tbody tr:hover {
padding: 14px; padding: 14px;
overflow: auto; overflow: auto;
} }
.user-modal {
width: min(860px, calc(100vw - 80px));
}
.user-modal-tabs {
margin-bottom: 14px;
}
.user-summary-grid,
.user-profile-facts {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin-bottom: 14px;
}
.user-summary-card,
.user-profile-facts > div {
min-width: 0;
padding: 10px 12px;
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-primary);
}
.user-summary-card span,
.user-profile-facts span {
display: block;
color: var(--text-subdued);
font-size: 11px;
margin-bottom: 4px;
}
.user-summary-card strong,
.user-profile-facts strong {
display: block;
overflow: hidden;
color: var(--text-primary);
font-size: 13px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.user-stats-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
</style> </style>
{% endblock head_extra %} {% endblock head_extra %}
@@ -1309,6 +1367,11 @@ tbody tr:hover {
<i data-lucide="wrench"></i> <i data-lucide="wrench"></i>
<span>Future Tools</span> <span>Future Tools</span>
</button> </button>
<button class="nav-btn" :class="{active: activeView === 'users'}" @click="openUsers()">
<i data-lucide="users"></i>
<span>Users</span>
<span class="nav-count" x-text="users.online_count ? users.online_count + ' online' : ''"></span>
</button>
<button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()"> <button class="nav-btn" :class="{active: activeView === 'settings'}" @click="openSettings()">
<i data-lucide="settings"></i> <i data-lucide="settings"></i>
<span>Settings</span> <span>Settings</span>
@@ -1729,6 +1792,79 @@ tbody tr:hover {
</div> </div>
</div> </div>
<div class="content" x-show="activeView === 'users'">
<section class="stats-strip">
<div class="stat-cell">
<div class="stat-value" x-text="fmt(users.total || 0)"></div>
<div class="stat-label">Users</div>
</div>
<div class="stat-cell">
<div class="stat-value" x-text="fmt(users.online_count || 0)"></div>
<div class="stat-label">Online now</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<div class="panel-title">
<strong>Users</strong>
<span x-text="usersSubtitle()"></span>
</div>
<div class="toolbar">
<input class="search" placeholder="Search users" x-model="userSearch" @input.debounce.350ms="loadUsers()" />
<button class="btn" @click="loadUsers(false)">
<i data-lucide="refresh-cw"></i>
Refresh
</button>
</div>
</div>
<div class="table-wrap users-table-wrap">
<table class="users-table">
<thead>
<tr>
<th class="user-status-column">Status</th>
<th>User</th>
<th class="user-role-column">Role</th>
<th class="user-active-column">Account</th>
<th class="user-seen-column">Last activity</th>
</tr>
</thead>
<tbody>
<template x-for="user in users.items" :key="user.id">
<tr @click="openUser(user)">
<td>
<span class="badge" :class="userStatusClass(user)" x-text="userStatusLabel(user)"></span>
</td>
<td>
<div class="primary-line" x-text="user.display_name || user.username"></div>
<div class="secondary-line" x-text="user.email ? user.username + ' · ' + user.email : user.username"></div>
</td>
<td><span x-text="user.role"></span></td>
<td>
<span class="badge" :class="user.is_active ? 'ok' : 'disabled'" x-text="user.is_active ? 'active' : 'disabled'"></span>
</td>
<td><span x-text="userLastSeenLabel(user)"></span></td>
</tr>
</template>
</tbody>
</table>
<div class="empty" x-show="!usersLoading && users.items.length === 0">No users in this filter</div>
</div>
<div class="action-strip">
<span class="selection-summary" x-text="usersRangeText()"></span>
<div class="toolbar">
<select class="search" style="width:92px" x-model.number="users.limit" @change="users.offset = 0; loadUsers(false)">
<option value="25">25</option>
<option value="40">40</option>
<option value="80">80</option>
<option value="120">120</option>
</select>
<button class="btn" @click="pageUsers(-1)" :disabled="users.offset === 0">Previous</button>
<button class="btn" @click="pageUsers(1)" :disabled="users.offset + users.limit >= users.total">Next</button>
</div>
</div>
</section>
</div>
<div class="content" x-show="activeView === 'tools'"> <div class="content" x-show="activeView === 'tools'">
<section class="panel"> <section class="panel">
<div class="panel-head"> <div class="panel-head">
@@ -2010,6 +2146,64 @@ tbody tr:hover {
</div> </div>
</main> </main>
<div class="modal-backdrop" x-show="userModalOpen && activeUserDetail" x-transition @click.self="userModalOpen = false">
<section class="modal user-modal">
<div class="modal-head">
<div class="panel-title">
<strong x-text="activeUserDetail ? (activeUserDetail.user.display_name || activeUserDetail.user.username) : 'User'"></strong>
<span x-text="activeUserDetail ? userStatusLabel(activeUserDetail.user) + ' · ' + activeUserDetail.user.role : ''"></span>
</div>
<button class="icon-btn" @click="userModalOpen = false">
<i data-lucide="x"></i>
</button>
</div>
<div class="modal-body" x-show="activeUserDetail">
<div class="segmented user-modal-tabs">
<button class="seg-btn" :class="{active: userModalTab === 'overview'}" @click="userModalTab = 'overview'">Overview</button>
<button class="seg-btn" :class="{active: userModalTab === 'activity'}" @click="userModalTab = 'activity'" disabled>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>
</section>
</div>
<div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false"> <div class="modal-backdrop" x-show="reviewModalOpen && activeReview" x-transition @click.self="reviewModalOpen = false">
<section class="modal"> <section class="modal">
<div class="modal-head"> <div class="modal-head">
@@ -2307,6 +2501,12 @@ function adminV2() {
runtime: { agent: {}, storage: [], node: {} }, runtime: { agent: {}, storage: [], node: {} },
libraryOverview: {}, libraryOverview: {},
reviews: { items: [], total: 0, limit: 80, offset: 0, status_counts: [] }, reviews: { items: [], total: 0, limit: 80, offset: 0, status_counts: [] },
users: { items: [], total: 0, limit: 40, offset: 0, online_count: 0 },
usersLoading: false,
userSearch: '',
userModalOpen: false,
userModalTab: 'overview',
activeUserDetail: null,
reviewFilter: { status: null, search: '' }, reviewFilter: { status: null, search: '' },
selectedReviewIds: {}, selectedReviewIds: {},
reviewSelectionScope: 'ids', reviewSelectionScope: 'ids',
@@ -2450,6 +2650,8 @@ function adminV2() {
this.activeJobName ? this.loadRunsForJob(this.activeJobName, false) : Promise.resolve(), this.activeJobName ? this.loadRunsForJob(this.activeJobName, false) : Promise.resolve(),
this.refreshActiveRunDetail() this.refreshActiveRunDetail()
]); ]);
} else if (this.activeView === 'users') {
await Promise.allSettled([this.loadUsers(false)]);
} else { } else {
await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]); await Promise.allSettled([this.loadJobs(false), this.loadReviews(false)]);
} }
@@ -2482,6 +2684,8 @@ function adminV2() {
this.libraryKind = nextKind; this.libraryKind = nextKind;
} else if (view === 'settings') { } else if (view === 'settings') {
this.activeView = 'settings'; this.activeView = 'settings';
} else if (view === 'users') {
this.activeView = 'users';
} else if (view === 'tools') { } else if (view === 'tools') {
this.activeView = 'tools'; this.activeView = 'tools';
} else { } else {
@@ -2513,6 +2717,9 @@ function adminV2() {
if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') { if (!this.settingsProbe.status || this.settingsProbe.status === 'idle') {
await this.loadSettingsProbe(false); await this.loadSettingsProbe(false);
} }
} else if (this.activeView === 'users') {
if (updateRoute) this.setRoute('#users');
await this.loadUsers(false);
} else if (this.activeView === 'tools' && updateRoute) { } else if (this.activeView === 'tools' && updateRoute) {
this.setRoute('#tools'); this.setRoute('#tools');
} }
@@ -2555,6 +2762,12 @@ function adminV2() {
this.setRoute('#tools'); this.setRoute('#tools');
}, },
openUsers() {
this.activeView = 'users';
this.setRoute('#users');
this.loadUsers();
},
async loadReviews(resetOffset = true) { async loadReviews(resetOffset = true) {
if (resetOffset) this.reviews.offset = 0; if (resetOffset) this.reviews.offset = 0;
const params = new URLSearchParams(); const params = new URLSearchParams();
@@ -2603,6 +2816,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: {} };
this.userModalOpen = true;
try {
this.activeUserDetail = await this.request(`${this.apiBase}/users/${user.id}`);
} catch (error) {
this.showToast(error.message);
} finally {
this.icons();
}
},
async loadSettings(showErrors = true) { async loadSettings(showErrors = true) {
try { try {
this.settings = await this.request(`${this.apiBase}/settings`); this.settings = await this.request(`${this.apiBase}/settings`);
@@ -3413,6 +3658,13 @@ function adminV2() {
this.loadLibrary(false); this.loadLibrary(false);
}, },
pageUsers(delta) {
const next = Math.max(0, (this.users.offset || 0) + delta * (this.users.limit || 40));
if (next === this.users.offset) return;
this.users.offset = next;
this.loadUsers(false);
},
statCells() { statCells() {
const agent = (this.runtime && this.runtime.agent) || {}; const agent = (this.runtime && this.runtime.agent) || {};
const storage = (this.runtime && this.runtime.storage) || []; const storage = (this.runtime && this.runtime.storage) || [];
@@ -3469,6 +3721,7 @@ function adminV2() {
if (this.activeView === 'jobs') return 'Tasks'; if (this.activeView === 'jobs') return 'Tasks';
if (this.activeView === 'tools') return 'Future Tools'; if (this.activeView === 'tools') return 'Future Tools';
if (this.activeView === 'settings') return 'Settings'; if (this.activeView === 'settings') return 'Settings';
if (this.activeView === 'users') return 'Users';
return 'Review Queue'; return 'Review Queue';
}, },
@@ -3477,6 +3730,7 @@ function adminV2() {
if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place'; if (this.activeView === 'jobs') return 'Scheduler state, recent runs, and manual controls in one place';
if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows'; if (this.activeView === 'tools') return 'Reserved space for merge, split, enrichment, and destructive workflows';
if (this.activeView === 'settings') return 'Application configuration and external API credentials'; if (this.activeView === 'settings') return 'Application configuration and external API credentials';
if (this.activeView === 'users') return 'Account status, live presence, and per-user listening statistics';
return 'Full-screen review triage with filter-aware bulk actions'; return 'Full-screen review triage with filter-aware bulk actions';
}, },
@@ -3493,6 +3747,55 @@ function adminV2() {
return `${this.libraryKind} · ${this.fmt(this.library.total || 0)} rows`; return `${this.libraryKind} · ${this.fmt(this.library.total || 0)} rows`;
}, },
usersSubtitle() {
return `${this.fmt(this.users.total || 0)} users · ${this.fmt(this.users.online_count || 0)} online`;
},
userStatusClass(user) {
return user?.is_online ? 'ok' : 'disabled';
},
userStatusLabel(user) {
return user?.is_online ? 'online' : 'offline';
},
userLastSeenLabel(user) {
if (!user || user.last_seen_ms == null) return 'never in this session';
if (user.is_online) return 'online now';
return this.durationApprox(user.last_seen_ms / 1000) + ' ago';
},
usersRangeText() {
const total = this.users.total || 0;
if (!total) return 'No users';
const start = (this.users.offset || 0) + 1;
const end = Math.min(total, (this.users.offset || 0) + (this.users.limit || 40));
return `${start}-${end} of ${this.fmt(total)}`;
},
userStatCards() {
const stats = this.activeUserDetail?.stats || {};
return [
{ label: 'Plays', display: this.fmt(stats.plays || 0) },
{ label: 'Completed plays', display: this.fmt(stats.completed_plays || 0) },
{ label: 'Listened time', display: this.durationApprox(stats.listened_seconds || 0) },
{ label: 'Liked tracks', display: this.fmt(stats.liked_tracks || 0) },
{ label: 'Followed artists', display: this.fmt(stats.followed_artists || 0) },
{ label: 'Own playlists', display: this.fmt(stats.own_playlists || 0) },
{ label: 'Saved playlists', display: this.fmt(stats.saved_playlists || 0) },
{ label: 'Uploaded tracks', display: this.fmt(stats.uploaded_tracks || 0) },
{ label: 'Torrent sessions', display: this.fmt(stats.torrent_sessions || 0) }
];
},
durationApprox(seconds) {
const value = Math.max(0, Number(seconds || 0));
if (value < 60) return `${Math.floor(value)}s`;
if (value < 3600) return `${Math.floor(value / 60)}m`;
if (value < 86400) return `${Math.floor(value / 3600)}h ${Math.floor((value % 3600) / 60)}m`;
return `${Math.floor(value / 86400)}d ${Math.floor((value % 86400) / 3600)}h`;
},
librarySelectionSummary() { librarySelectionSummary() {
const count = this.selectedLibraryCount(); const count = this.selectedLibraryCount();
if (!count) return 'Nothing selected'; if (!count) return 'Nothing selected';
+128 -101
View File
@@ -122,11 +122,11 @@
<div class="torrent-tabs"> <div class="torrent-tabs">
<button class="torrent-tab-btn" <button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'import' }" :class="{ active: $store.torrents.activeTab === 'import' }"
@click="$store.torrents.showImportTab()">Import</button> @click="$store.torrents.showImportTab()">{{ t.player_import }}</button>
<button class="torrent-tab-btn" <button class="torrent-tab-btn"
:class="{ active: $store.torrents.activeTab === 'uploads' }" :class="{ active: $store.torrents.activeTab === 'uploads' }"
@click="$store.torrents.showUploadsTab()"> @click="$store.torrents.showUploadsTab()">
<span>My uploads</span> <span>{{ t.player_my_uploads }}</span>
<span class="torrent-tab-count" <span class="torrent-tab-count"
x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0" x-show="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal > 0"
x-text="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal"></span> x-text="$store.torrents.uploadPendingTotal + $store.torrents.uploadQueuedTotal"></span>
@@ -347,7 +347,7 @@
<section class="upload-manager-panel"> <section class="upload-manager-panel">
<div class="upload-manager-head"> <div class="upload-manager-head">
<div> <div>
<h4>My uploaded tracks</h4> <h4>{{ t.player_my_uploaded_tracks }}</h4>
<p x-text="$store.torrents.uploadSummary()"></p> <p x-text="$store.torrents.uploadSummary()"></p>
</div> </div>
<button class="modal-btn modal-btn-ghost" <button class="modal-btn modal-btn-ghost"
@@ -357,22 +357,22 @@
<template x-if="$store.torrents.uploadLoaded && $store.torrents.uploadTracks.length === 0 && $store.torrents.uploadPending.length === 0 && $store.torrents.uploadQueued.length === 0"> <template x-if="$store.torrents.uploadLoaded && $store.torrents.uploadTracks.length === 0 && $store.torrents.uploadPending.length === 0 && $store.torrents.uploadQueued.length === 0">
<div class="empty-state torrent-workspace-empty"> <div class="empty-state torrent-workspace-empty">
<p>No uploaded tracks yet</p> <p>{{ t.player_no_uploaded_tracks }}</p>
</div> </div>
</template> </template>
<div class="upload-manager-grid"> <div class="upload-manager-grid">
<aside class="upload-review-column"> <aside class="upload-review-column">
<div class="upload-panel-card"> <div class="upload-panel-card">
<div class="upload-panel-title">Needs approval</div> <div class="upload-panel-title">{{ t.player_needs_approval }}</div>
<p class="upload-panel-subtitle" x-text="$store.torrents.uploadPendingTotal + ' pending or failed'"></p> <p class="upload-panel-subtitle" x-text="$store.torrents.uploadPendingTotal + ' {{ t.player_pending_or_failed }}'"></p>
<template x-if="$store.torrents.uploadPending.length === 0"> <template x-if="$store.torrents.uploadPending.length === 0">
<div class="upload-mini-empty">No tracks need approval</div> <div class="upload-mini-empty">{{ t.player_no_tracks_need_approval }}</div>
</template> </template>
<div class="upload-review-list"> <div class="upload-review-list">
<template x-for="item in $store.torrents.uploadPending" :key="item.id"> <template x-for="item in $store.torrents.uploadPending" :key="item.id">
<button class="upload-review-row" :class="{ active: $store.torrents.uploadReviewEditId === item.id, failed: item.status === 'failed' }" @click="$store.torrents.editUploadReview(item)"> <button class="upload-review-row" :class="{ active: $store.torrents.uploadReviewEditId === item.id, failed: item.status === 'failed' }" @click="$store.torrents.editUploadReview(item)">
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="item.status"></span> <span class="torrent-status-badge" :class="'status-' + item.status" x-text="$store.torrents.uploadStatusLabel(item.status)"></span>
<span class="upload-review-name" x-text="item.filename"></span> <span class="upload-review-name" x-text="item.filename"></span>
<span class="upload-review-error" x-show="item.error_message" x-text="item.error_message"></span> <span class="upload-review-error" x-show="item.error_message" x-text="item.error_message"></span>
</button> </button>
@@ -381,67 +381,94 @@
</div> </div>
<template x-if="$store.torrents.uploadQueuedTotal > 0"> <template x-if="$store.torrents.uploadQueuedTotal > 0">
<div class="upload-panel-card upload-queue-panel"> <div class="upload-panel-card upload-queue-panel">
<div class="upload-panel-title">Queued / processing</div> <div class="upload-panel-title upload-panel-title-row">
<span>{{ t.player_queued_processing }}</span>
<div class="upload-queue-nav" x-show="$store.torrents.uploadQueued.length > $store.torrents.uploadQueuePageSize">
<span class="upload-queue-range" x-text="$store.torrents.uploadQueueRangeText()"></span>
<button type="button"
class="upload-queue-nav-btn"
@click="$store.torrents.uploadQueuePrev()"
:disabled="!$store.torrents.uploadQueueCanPrev()"
title="{{ t.player_previous }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button type="button"
class="upload-queue-nav-btn"
@click="$store.torrents.uploadQueueNext()"
:disabled="!$store.torrents.uploadQueueCanNext()"
title="{{ t.player_next }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
</div>
<template x-for="item in $store.torrents.compactQueuedUploads()" :key="item.id"> <template x-for="item in $store.torrents.compactQueuedUploads()" :key="item.id">
<div class="upload-queue-row"> <div class="upload-queue-row" :title="$store.torrents.uploadQueueTooltip(item)">
<span class="torrent-status-badge" :class="'status-' + item.status" x-text="item.status"></span> <span class="torrent-status-badge" :class="'status-' + item.status" x-text="$store.torrents.uploadStatusLabel(item.status)"></span>
<span class="upload-queue-name" x-text="item.filename"></span> <span class="upload-queue-name" x-text="item.filename"></span>
</div> </div>
</template> </template>
<div class="upload-mini-empty" x-show="$store.torrents.uploadQueuedTotal > $store.torrents.uploadQueued.length" x-text="'Showing ' + $store.torrents.uploadQueued.length + ' of ' + $store.torrents.uploadQueuedTotal"></div> <div class="upload-mini-empty" x-show="$store.torrents.uploadQueuedTotal > $store.torrents.uploadQueued.length" x-text="$store.torrents.uploadQueueOverflowText()"></div>
</div> </div>
</template> </template>
</aside> </aside>
<section class="upload-library-column"> <section class="upload-library-column">
<div class="upload-bulk-bar" x-show="$store.torrents.uploadSelectedCount() > 0"> <div class="upload-bulk-bar" x-show="$store.torrents.uploadSelectedCount() > 0">
<div class="upload-bulk-title" x-text="$store.torrents.uploadSelectedCount() + ' selected'"></div> <div class="upload-bulk-title" x-text="$store.torrents.uploadSelectedCount() + ' {{ t.player_selected }}'"></div>
<input type="text" placeholder="Artists" x-model="$store.torrents.uploadBulkDraft.artists"> <input type="text" placeholder="{{ t.player_artists }}" x-model="$store.torrents.uploadBulkDraft.artists">
<input type="text" placeholder="Featured" x-model="$store.torrents.uploadBulkDraft.featured_artists"> <input type="text" placeholder="{{ t.player_featured }}" x-model="$store.torrents.uploadBulkDraft.featured_artists">
<input type="text" placeholder="Album" x-model="$store.torrents.uploadBulkDraft.release_title"> <input type="text" placeholder="{{ t.player_album }}" x-model="$store.torrents.uploadBulkDraft.release_title">
<input type="number" placeholder="Year" x-model="$store.torrents.uploadBulkDraft.release_year"> <input type="number" placeholder="{{ t.player_year }}" x-model="$store.torrents.uploadBulkDraft.release_year">
<select x-model="$store.torrents.uploadBulkDraft.release_type"> <select x-model="$store.torrents.uploadBulkDraft.release_type">
<option value="">Type unchanged</option><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option> <option value="">{{ t.player_type_unchanged }}</option><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option>
</select> </select>
<select x-model="$store.torrents.uploadBulkDraft.hidden"> <select x-model="$store.torrents.uploadBulkDraft.hidden">
<option value="">Visibility unchanged</option><option value="false">Visible</option><option value="true">Hidden</option> <option value="">{{ t.player_visibility_unchanged }}</option><option value="false">{{ t.player_visible }}</option><option value="true">{{ t.player_hidden }}</option>
</select> </select>
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadBulkEdit()" :disabled="$store.torrents.uploadBulkSaving">Apply</button> <button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadBulkEdit()" :disabled="$store.torrents.uploadBulkSaving">{{ t.player_apply }}</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearUploadSelection()">Clear</button> <button class="modal-btn modal-btn-ghost" @click="$store.torrents.clearUploadSelection()">{{ t.player_clear }}</button>
</div> </div>
<div class="upload-release-tree"> <div class="upload-release-tree">
<template x-for="release in $store.torrents.uploadReleases" :key="release.id"> <template x-for="group in $store.torrents.uploadArtistGroups()" :key="group.key">
<div class="upload-release-node" :class="{ hidden: release.is_hidden }"> <section class="upload-artist-group">
<div class="upload-release-row"> <div class="upload-artist-row">
<button class="torrent-tree-check" :class="$store.torrents.uploadReleaseSelectionState(release)" @click="$store.torrents.toggleUploadReleaseSelection(release)"> <div class="upload-artist-name" x-text="group.name"></div>
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'checked'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template> <div class="upload-artist-meta" x-text="group.releases.length + ' {{ t.player_releases_count }} - ' + group.trackCount + ' {{ t.player_tracks_count }}'"></div>
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'partial'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="5" y1="12" x2="19" y2="12"/></svg></template>
</button>
<button class="torrent-tree-toggle" :class="{ expanded: $store.torrents.uploadReleaseExpanded(release.id) }" @click="$store.torrents.toggleUploadRelease(release.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
<div class="upload-release-main">
<div class="upload-release-title"><span x-text="release.title"></span><span class="upload-hidden-pill" x-show="release.is_hidden">hidden</span></div>
<div class="upload-track-meta"><span x-text="$store.torrents.uploadReleaseArtistsText(release)"></span><span>-</span><span x-text="release.year || 'no year'"></span><span>-</span><span x-text="release.tracks.length + ' tracks'"></span></div>
</div>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUploadRelease(release)">Edit release</button>
</div> </div>
<div class="upload-track-children" x-show="$store.torrents.uploadReleaseExpanded(release.id)"> <template x-for="release in group.releases" :key="release.id">
<template x-for="item in release.tracks" :key="item.track.id"> <div class="upload-release-node" :class="{ hidden: release.is_hidden }">
<div class="upload-tree-track" :class="{ hidden: item.is_hidden, selected: $store.torrents.selectedUploadTracks.has(item.track.id) }"> <div class="upload-release-row">
<button class="torrent-tree-check" :class="{ checked: $store.torrents.selectedUploadTracks.has(item.track.id) }" @click="$store.torrents.toggleUploadTrackSelection(item.track.id)"> <button class="torrent-tree-check" :class="$store.torrents.uploadReleaseSelectionState(release)" @click="$store.torrents.toggleUploadReleaseSelection(release)">
<template x-if="$store.torrents.selectedUploadTracks.has(item.track.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template> <template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'checked'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template>
<template x-if="$store.torrents.uploadReleaseSelectionState(release) === 'partial'"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><line x1="5" y1="12" x2="19" y2="12"/></svg></template>
</button> </button>
<div class="upload-track-main"> <button class="torrent-tree-toggle" :class="{ expanded: $store.torrents.uploadReleaseExpanded(release.id) }" @click="$store.torrents.toggleUploadRelease(release.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg></button>
<div class="upload-track-title"><span x-text="item.track.track_number ? item.track.track_number + '. ' + item.track.title : item.track.title"></span><span class="upload-hidden-pill" x-show="item.is_hidden">hidden</span></div> <div class="upload-release-main">
<div class="upload-track-meta"><span x-text="$store.torrents.uploadArtistsText(item)"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)">feat.</span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="$store.torrents.uploadFeaturedArtistsText(item)"></span></div> <div class="upload-release-title"><span x-text="release.title"></span><span class="upload-hidden-pill" x-show="release.is_hidden">{{ t.player_hidden }}</span></div>
</div> <div class="upload-track-meta"><span x-text="$store.torrents.uploadReleaseArtistsText(release)"></span><span>-</span><span x-text="release.year || T.noYear"></span><span>-</span><span x-text="release.tracks.length + ' {{ t.player_tracks_count }}'"></span></div>
<div class="upload-track-actions">
<button class="track-action-btn queue-insert-btn queue-next-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">Edit</button>
</div> </div>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUploadRelease(release)">{{ t.player_edit_release }}</button>
</div> </div>
</template> <div class="upload-track-children" x-show="$store.torrents.uploadReleaseExpanded(release.id)">
</div> <template x-for="item in release.tracks" :key="item.track.id">
</div> <div class="upload-tree-track" :class="{ hidden: item.is_hidden, selected: $store.torrents.selectedUploadTracks.has(item.track.id) }">
<button class="torrent-tree-check" :class="{ checked: $store.torrents.selectedUploadTracks.has(item.track.id) }" @click="$store.torrents.toggleUploadTrackSelection(item.track.id)">
<template x-if="$store.torrents.selectedUploadTracks.has(item.track.id)"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><polyline points="20 6 9 17 4 12"/></svg></template>
</button>
<div class="upload-track-main">
<div class="upload-track-title"><span x-text="item.track.track_number ? item.track.track_number + '. ' + item.track.title : item.track.title"></span><span class="upload-hidden-pill" x-show="item.is_hidden">{{ t.player_hidden }}</span></div>
<div class="upload-track-meta"><span x-text="$store.torrents.uploadArtistsText(item)"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="T.featuredShort"></span><span x-show="$store.torrents.uploadFeaturedArtistsText(item)" x-text="$store.torrents.uploadFeaturedArtistsText(item)"></span></div>
</div>
<div class="upload-track-actions">
<button class="track-action-btn queue-insert-btn queue-next-btn" @click="$store.queue.addNextInQueue([item.track])" title="{{ t.player_play_next }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h10M5 12h7M5 18h10"/><path d="M17 9l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg></button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">{{ t.player_edit }}</button>
</div>
</div>
</template>
</div>
</div>
</template>
</section>
</template> </template>
</div> </div>
</section> </section>
@@ -455,7 +482,7 @@
<div class="upload-panel-title" x-text="$store.torrents.uploadEditorKicker()"></div> <div class="upload-panel-title" x-text="$store.torrents.uploadEditorKicker()"></div>
<h4 x-text="$store.torrents.uploadEditorTitle()"></h4> <h4 x-text="$store.torrents.uploadEditorTitle()"></h4>
</div> </div>
<button class="track-action-btn" @click="$store.torrents.closeUploadEditor()" title="Close"> <button class="track-action-btn" @click="$store.torrents.closeUploadEditor()" title="{{ t.player_close }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4">
<path d="M18 6L6 18M6 6l12 12"/> <path d="M18 6L6 18M6 6l12 12"/>
</svg> </svg>
@@ -464,53 +491,53 @@
<template x-if="$store.torrents.uploadReviewDraft"> <template x-if="$store.torrents.uploadReviewDraft">
<div class="upload-editor-form"> <div class="upload-editor-form">
<label class="upload-field upload-field-half"><span>Title</span><input type="text" x-model="$store.torrents.uploadReviewDraft.title"></label> <label class="upload-field upload-field-half"><span>{{ t.player_title }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.title"></label>
<label class="upload-field upload-field-half"><span>Artist</span><input type="text" x-model="$store.torrents.uploadReviewDraft.artist"></label> <label class="upload-field upload-field-half"><span>{{ t.player_artist }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.artist"></label>
<label class="upload-field upload-field-half"><span>Album</span><input type="text" x-model="$store.torrents.uploadReviewDraft.album"></label> <label class="upload-field upload-field-half"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.album"></label>
<label class="upload-field upload-field-half"><span>Featured</span><input type="text" x-model="$store.torrents.uploadReviewDraft.featured_artists" placeholder="Artist, Artist"></label> <label class="upload-field upload-field-half"><span>{{ t.player_featured }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.featured_artists" placeholder="{{ t.player_artists_placeholder }}"></label>
<label class="upload-field upload-field-compact"><span>Year</span><input type="number" x-model="$store.torrents.uploadReviewDraft.year"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadReviewDraft.year"></label>
<label class="upload-field upload-field-compact"><span>Track #</span><input type="number" min="1" x-model="$store.torrents.uploadReviewDraft.track_number"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_track_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadReviewDraft.track_number"></label>
<label class="upload-field upload-field-compact"><span>Genre</span><input type="text" x-model="$store.torrents.uploadReviewDraft.genre"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_genre }}</span><input type="text" x-model="$store.torrents.uploadReviewDraft.genre"></label>
<label class="upload-field upload-field-compact"> <label class="upload-field upload-field-compact">
<span>Type</span> <span>{{ t.player_type }}</span>
<select x-model="$store.torrents.uploadReviewDraft.release_type"> <select x-model="$store.torrents.uploadReviewDraft.release_type">
<option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option> <option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option>
</select> </select>
</label> </label>
<label class="upload-field upload-field-wide"><span>Notes</span><textarea rows="4" x-model="$store.torrents.uploadReviewDraft.notes"></textarea></label> <label class="upload-field upload-field-wide"><span>{{ t.player_notes }}</span><textarea rows="4" x-model="$store.torrents.uploadReviewDraft.notes"></textarea></label>
<div class="upload-editor-actions"> <div class="upload-editor-actions">
<button class="modal-btn modal-btn-danger" @click="$store.torrents.deleteUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Delete review</button> <button class="modal-btn modal-btn-danger" @click="$store.torrents.deleteUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">{{ t.player_delete_review }}</button>
<button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">Approve</button> <button class="modal-btn modal-btn-primary" @click="$store.torrents.approveUploadReview()" :disabled="$store.torrents.uploadReviewSavingId === $store.torrents.uploadReviewEditId">{{ t.player_approve }}</button>
</div> </div>
</div> </div>
</template> </template>
<template x-if="$store.torrents.uploadReleaseDraft"> <template x-if="$store.torrents.uploadReleaseDraft">
<div class="upload-editor-form"> <div class="upload-editor-form">
<label class="upload-field upload-field-wide"><span>Album</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.title"></label> <label class="upload-field upload-field-wide"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.title"></label>
<label class="upload-field upload-field-wide"><span>Album artists</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.artists"></label> <label class="upload-field upload-field-wide"><span>{{ t.player_album_artists }}</span><input type="text" x-model="$store.torrents.uploadReleaseDraft.artists"></label>
<label class="upload-field upload-field-half"><span>Year</span><input type="number" x-model="$store.torrents.uploadReleaseDraft.year"></label> <label class="upload-field upload-field-half"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadReleaseDraft.year"></label>
<label class="upload-field upload-field-half"><span>Type</span><select x-model="$store.torrents.uploadReleaseDraft.release_type"><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option></select></label> <label class="upload-field upload-field-half"><span>{{ t.player_type }}</span><select x-model="$store.torrents.uploadReleaseDraft.release_type"><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option></select></label>
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadReleaseDraft.is_hidden"><span>Hidden</span></label> <label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadReleaseDraft.is_hidden"><span>{{ t.player_hidden }}</span></label>
<div class="upload-editor-actions"> <div class="upload-editor-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadReleaseEdit()" :disabled="$store.torrents.uploadReleaseSavingId === $store.torrents.uploadReleaseEditId">Save release</button> <button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadReleaseEdit()" :disabled="$store.torrents.uploadReleaseSavingId === $store.torrents.uploadReleaseEditId">{{ t.player_save_release }}</button>
</div> </div>
</div> </div>
</template> </template>
<template x-if="$store.torrents.uploadDraft"> <template x-if="$store.torrents.uploadDraft">
<div class="upload-editor-form"> <div class="upload-editor-form">
<label class="upload-field upload-field-wide"><span>Title</span><input type="text" x-model="$store.torrents.uploadDraft.title"></label> <label class="upload-field upload-field-wide"><span>{{ t.player_title }}</span><input type="text" x-model="$store.torrents.uploadDraft.title"></label>
<label class="upload-field upload-field-half"><span>Artists</span><input type="text" x-model="$store.torrents.uploadDraft.artists"></label> <label class="upload-field upload-field-half"><span>{{ t.player_artists }}</span><input type="text" x-model="$store.torrents.uploadDraft.artists"></label>
<label class="upload-field upload-field-half"><span>Featured</span><input type="text" x-model="$store.torrents.uploadDraft.featured_artists"></label> <label class="upload-field upload-field-half"><span>{{ t.player_featured }}</span><input type="text" x-model="$store.torrents.uploadDraft.featured_artists"></label>
<label class="upload-field upload-field-half"><span>Album</span><input type="text" x-model="$store.torrents.uploadDraft.release_title"></label> <label class="upload-field upload-field-half"><span>{{ t.player_album }}</span><input type="text" x-model="$store.torrents.uploadDraft.release_title"></label>
<label class="upload-field upload-field-half"><span>Type</span><select x-model="$store.torrents.uploadDraft.release_type"><option value="album">Album</option><option value="single">Single</option><option value="ep">EP</option><option value="compilation">Compilation</option><option value="mixtape">Mixtape</option><option value="live">Live</option><option value="soundtrack">Soundtrack</option><option value="remix">Remix</option><option value="demo">Demo</option></select></label> <label class="upload-field upload-field-half"><span>{{ t.player_type }}</span><select x-model="$store.torrents.uploadDraft.release_type"><option value="album">{{ t.player_release_type_album }}</option><option value="single">{{ t.player_release_type_single }}</option><option value="ep">{{ t.player_release_type_ep }}</option><option value="compilation">{{ t.player_release_type_compilation }}</option><option value="mixtape">{{ t.player_release_type_mixtape }}</option><option value="live">{{ t.player_release_type_live }}</option><option value="soundtrack">{{ t.player_release_type_soundtrack }}</option><option value="remix">{{ t.player_release_type_remix }}</option><option value="demo">{{ t.player_release_type_demo }}</option></select></label>
<label class="upload-field upload-field-compact"><span>Year</span><input type="number" x-model="$store.torrents.uploadDraft.release_year"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_year }}</span><input type="number" x-model="$store.torrents.uploadDraft.release_year"></label>
<label class="upload-field upload-field-compact"><span>Track #</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_track_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"></label>
<label class="upload-field upload-field-compact"><span>Disc #</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number"></label> <label class="upload-field upload-field-compact"><span>{{ t.player_disc_number }}</span><input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number"></label>
<label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden"><span>Hidden</span></label> <label class="upload-field upload-field-toggle"><input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden"><span>{{ t.player_hidden }}</span></label>
<div class="upload-editor-actions"> <div class="upload-editor-actions">
<button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadEdit()" :disabled="$store.torrents.uploadSavingId === $store.torrents.uploadEditId">Save track</button> <button class="modal-btn modal-btn-primary" @click="$store.torrents.saveUploadEdit()" :disabled="$store.torrents.uploadSavingId === $store.torrents.uploadEditId">{{ t.player_save_track }}</button>
</div> </div>
</div> </div>
</template> </template>
@@ -526,7 +553,7 @@
<div class="upload-track-main"> <div class="upload-track-main">
<div class="upload-track-title"> <div class="upload-track-title">
<span x-text="item.track.title"></span> <span x-text="item.track.title"></span>
<span class="upload-hidden-pill" x-show="item.is_hidden">hidden</span> <span class="upload-hidden-pill" x-show="item.is_hidden">{{ t.player_hidden }}</span>
</div> </div>
<div class="upload-track-meta"> <div class="upload-track-meta">
<span x-text="$store.torrents.uploadArtistsText(item)"></span> <span x-text="$store.torrents.uploadArtistsText(item)"></span>
@@ -543,7 +570,7 @@
<button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}"> <button class="track-action-btn queue-insert-btn queue-end-btn" @click="$store.queue.addToEnd([item.track])" title="{{ t.player_add_to_queue }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M5 6h14M5 12h14M5 18h7"/><path d="M17 15l4 3-4 3" fill="currentColor" stroke="none"/></svg>
</button> </button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">Edit</button> <button class="modal-btn modal-btn-ghost" @click="$store.torrents.editUpload(item)">{{ t.player_edit }}</button>
</div> </div>
</div> </div>
</template> </template>
@@ -551,52 +578,52 @@
<template x-if="$store.torrents.uploadEditId === item.track.id"> <template x-if="$store.torrents.uploadEditId === item.track.id">
<div class="upload-edit-form"> <div class="upload-edit-form">
<label> <label>
<span>Title</span> <span>{{ t.player_title }}</span>
<input type="text" x-model="$store.torrents.uploadDraft.title"> <input type="text" x-model="$store.torrents.uploadDraft.title">
</label> </label>
<label> <label>
<span>Artists</span> <span>{{ t.player_artists }}</span>
<input type="text" x-model="$store.torrents.uploadDraft.artists" placeholder="Artist, Featured Artist"> <input type="text" x-model="$store.torrents.uploadDraft.artists" placeholder="{{ t.player_artist_featured_placeholder }}">
</label> </label>
<label> <label>
<span>Release</span> <span>{{ t.player_release }}</span>
<input type="text" x-model="$store.torrents.uploadDraft.release_title"> <input type="text" x-model="$store.torrents.uploadDraft.release_title">
</label> </label>
<label> <label>
<span>Type</span> <span>{{ t.player_type }}</span>
<select x-model="$store.torrents.uploadDraft.release_type"> <select x-model="$store.torrents.uploadDraft.release_type">
<option value="album">Album</option> <option value="album">{{ t.player_release_type_album }}</option>
<option value="single">Single</option> <option value="single">{{ t.player_release_type_single }}</option>
<option value="ep">EP</option> <option value="ep">{{ t.player_release_type_ep }}</option>
<option value="compilation">Compilation</option> <option value="compilation">{{ t.player_release_type_compilation }}</option>
<option value="mixtape">Mixtape</option> <option value="mixtape">{{ t.player_release_type_mixtape }}</option>
<option value="live">Live</option> <option value="live">{{ t.player_release_type_live }}</option>
<option value="soundtrack">Soundtrack</option> <option value="soundtrack">{{ t.player_release_type_soundtrack }}</option>
<option value="remix">Remix</option> <option value="remix">{{ t.player_release_type_remix }}</option>
<option value="demo">Demo</option> <option value="demo">{{ t.player_release_type_demo }}</option>
</select> </select>
</label> </label>
<label> <label>
<span>Year</span> <span>{{ t.player_year }}</span>
<input type="number" x-model="$store.torrents.uploadDraft.release_year"> <input type="number" x-model="$store.torrents.uploadDraft.release_year">
</label> </label>
<label> <label>
<span>Track #</span> <span>{{ t.player_track_number }}</span>
<input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number"> <input type="number" min="1" x-model="$store.torrents.uploadDraft.track_number">
</label> </label>
<label> <label>
<span>Disc #</span> <span>{{ t.player_disc_number }}</span>
<input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number"> <input type="number" min="1" x-model="$store.torrents.uploadDraft.disc_number">
</label> </label>
<label class="upload-hidden-toggle"> <label class="upload-hidden-toggle">
<input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden"> <input type="checkbox" x-model="$store.torrents.uploadDraft.is_hidden">
<span>Hidden</span> <span>{{ t.player_hidden }}</span>
</label> </label>
<div class="upload-edit-actions"> <div class="upload-edit-actions">
<button class="modal-btn modal-btn-primary" <button class="modal-btn modal-btn-primary"
@click="$store.torrents.saveUploadEdit()" @click="$store.torrents.saveUploadEdit()"
:disabled="$store.torrents.uploadSavingId === item.track.id">Save</button> :disabled="$store.torrents.uploadSavingId === item.track.id">{{ t.player_save }}</button>
<button class="modal-btn modal-btn-ghost" @click="$store.torrents.cancelUploadEdit()">Cancel</button> <button class="modal-btn modal-btn-ghost" @click="$store.torrents.cancelUploadEdit()">{{ t.player_cancel }}</button>
</div> </div>
</div> </div>
</template> </template>
+178 -24
View File
@@ -46,7 +46,38 @@ const T = {
radioFailed: "{{ t.player_radio_failed }}", radioFailed: "{{ t.player_radio_failed }}",
connectionLost: "{{ t.player_connection_lost }}", connectionLost: "{{ t.player_connection_lost }}",
connectionLostDetail: "{{ t.player_connection_lost_detail }}", connectionLostDetail: "{{ t.player_connection_lost_detail }}",
activeDevice: "{{ t.player_active_device }}",
trackWord: "{{ t.player_tracks_count }}", trackWord: "{{ t.player_tracks_count }}",
releaseWord: "{{ t.player_releases_count }}",
ofWord: "{{ t.player_of }}",
needsApproval: "{{ t.player_needs_approval }}",
showing: "{{ t.player_showing }}",
statusLabelText: "{{ t.player_status }}",
fileLabel: "{{ t.player_file }}",
createdLabel: "{{ t.player_created }}",
updatedLabel: "{{ t.player_updated }}",
errorLabel: "{{ t.player_error }}",
pending: "{{ t.player_pending }}",
featuredShort: "{{ t.player_featured_short }}",
noYear: "{{ t.player_no_year }}",
loadUploadsFailed: "{{ t.player_failed_load_uploaded_tracks }}",
releaseMetadata: "{{ t.player_release_metadata }}",
trackMetadata: "{{ t.player_track_metadata }}",
metadata: "{{ t.player_metadata }}",
approveMetadata: "{{ t.player_approve_metadata }}",
editRelease: "{{ t.player_edit_release }}",
editTrack: "{{ t.player_edit_track }}",
editMetadata: "{{ t.player_edit_metadata }}",
failedSaveTrack: "{{ t.player_failed_save_track }}",
trackMetadataSaved: "{{ t.player_track_metadata_saved }}",
failedSaveRelease: "{{ t.player_failed_save_release }}",
releaseMetadataSaved: "{{ t.player_release_metadata_saved }}",
failedDeleteReview: "{{ t.player_failed_delete_review }}",
reviewDeleted: "{{ t.player_review_deleted }}",
failedApproveReview: "{{ t.player_failed_approve_review }}",
trackApprovedImported: "{{ t.player_track_approved_imported }}",
failedUpdateSelectedTracks: "{{ t.player_failed_update_selected_tracks }}",
selectedTracksUpdated: "{{ t.player_selected_tracks_updated }}",
clientIdle: "{{ t.player_client_idle }}", clientIdle: "{{ t.player_client_idle }}",
active: "{{ t.player_active }}", active: "{{ t.player_active }}",
aiIdle: "{{ t.player_ai_idle }}", aiIdle: "{{ t.player_ai_idle }}",
@@ -1146,6 +1177,10 @@ document.addEventListener('alpine:init', () => {
jamUsers: [], jamUsers: [],
jamSelectedUsers: [], jamSelectedUsers: [],
jamSearching: false, jamSearching: false,
remoteHintVisible: false,
remoteHintDeviceName: '',
_remoteHintShown: false,
_remoteHintTimer: null,
_pollTimer: null, _pollTimer: null,
_jamSearchTimer: null, _jamSearchTimer: null,
_stateRefreshTick: 0, _stateRefreshTick: 0,
@@ -1235,6 +1270,34 @@ document.addEventListener('alpine:init', () => {
if (wasActive && !this.isActive()) { if (wasActive && !this.isActive()) {
Alpine.store('player')?._pauseLocal(); Alpine.store('player')?._pauseLocal();
} }
this._maybeShowRemoteHint();
},
remoteActiveDevice() {
if (!this.activeDeviceId || this.activeDeviceId === this.id) return null;
return this.devices.find(device => device.id === this.activeDeviceId) || null;
},
_maybeShowRemoteHint() {
if (this._remoteHintShown || this.remoteHintVisible || this.open) return;
const active = this.remoteActiveDevice();
if (!active) return;
this.remoteHintDeviceName = active.name || 'Devices';
this.remoteHintVisible = true;
this._remoteHintShown = true;
clearTimeout(this._remoteHintTimer);
this._remoteHintTimer = setTimeout(() => this.dismissRemoteHint(), 60000);
},
dismissRemoteHint() {
this.remoteHintVisible = false;
this._remoteHintShown = true;
clearTimeout(this._remoteHintTimer);
this._remoteHintTimer = null;
},
remoteHintText() {
return `${T.activeDevice}: ${this.remoteHintDeviceName || this.activeLabel()}`;
}, },
selectedJam() { selectedJam() {
@@ -1300,6 +1363,7 @@ document.addEventListener('alpine:init', () => {
}, },
toggle() { toggle() {
this.dismissRemoteHint();
this.open = !this.open; this.open = !this.open;
if (this.open) this.poll(); if (this.open) this.poll();
}, },
@@ -2475,6 +2539,8 @@ document.addEventListener('alpine:init', () => {
uploadQueued: [], uploadQueued: [],
uploadPendingTotal: 0, uploadPendingTotal: 0,
uploadQueuedTotal: 0, uploadQueuedTotal: 0,
uploadQueueOffset: 0,
uploadQueuePageSize: 6,
uploadLoaded: false, uploadLoaded: false,
uploadLoading: false, uploadLoading: false,
uploadEditId: null, uploadEditId: null,
@@ -2745,7 +2811,7 @@ document.addEventListener('alpine:init', () => {
try { try {
const res = await fetch('/api/player/uploads/tracks'); const res = await fetch('/api/player/uploads/tracks');
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to load uploaded tracks'); if (!res.ok) throw new Error(data.error || T.loadUploadsFailed);
this.applyUploadPage(data); this.applyUploadPage(data);
this.uploadLoaded = true; this.uploadLoaded = true;
if (this.uploadEditId && !this.uploadTracks.some(item => item.track.id === this.uploadEditId)) { if (this.uploadEditId && !this.uploadTracks.some(item => item.track.id === this.uploadEditId)) {
@@ -2769,9 +2835,9 @@ document.addEventListener('alpine:init', () => {
uploadSummary() { uploadSummary() {
const trackCount = this.uploadTracks.length; const trackCount = this.uploadTracks.length;
const releaseCount = this.uploadReleases.length; const releaseCount = this.uploadReleases.length;
const parts = [trackCount + ' tracks', releaseCount + ' releases']; const parts = [trackCount + ' ' + T.trackWord, releaseCount + ' ' + T.releaseWord];
if (this.uploadPendingTotal > 0) parts.push(this.uploadPendingTotal + ' need approval'); if (this.uploadPendingTotal > 0) parts.push(this.uploadPendingTotal + ' ' + T.needsApproval);
if (this.uploadQueuedTotal > 0) parts.push(this.uploadQueuedTotal + ' queued'); if (this.uploadQueuedTotal > 0) parts.push(this.uploadQueuedTotal + ' ' + T.queued);
return parts.join(' / '); return parts.join(' / ');
}, },
@@ -2779,7 +2845,7 @@ document.addEventListener('alpine:init', () => {
const track = item?.track || item; const track = item?.track || item;
const names = [ const names = [
...((track?.artists || []).map(artist => artist.name)), ...((track?.artists || []).map(artist => artist.name)),
...((track?.featured_artists || []).map(artist => 'ft. ' + artist.name)), ...((track?.featured_artists || []).map(artist => T.featuredShort + ' ' + artist.name)),
]; ];
return names.join(', ') || T.unknown; return names.join(', ') || T.unknown;
}, },
@@ -2789,13 +2855,99 @@ document.addEventListener('alpine:init', () => {
return names.join(', ') || T.unknown; return names.join(', ') || T.unknown;
}, },
uploadArtistGroups() {
const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true });
const groups = new Map();
for (const release of this.uploadReleases) {
const artists = Array.isArray(release?.artists) ? release.artists : [];
const artistKey = artists
.map(artist => artist?.id != null ? 'id:' + artist.id : 'name:' + String(artist?.name || '').trim().toLowerCase())
.filter(Boolean)
.join('|') || 'unknown';
if (!groups.has(artistKey)) {
groups.set(artistKey, {
key: artistKey,
name: this.uploadReleaseArtistsText(release),
releases: [],
trackCount: 0,
});
}
const group = groups.get(artistKey);
group.releases.push(release);
group.trackCount += Array.isArray(release?.tracks) ? release.tracks.length : 0;
}
return [...groups.values()]
.map(group => ({
...group,
releases: group.releases.slice().sort((a, b) => {
const aDate = String(a?.tracks?.[0]?.uploaded_at || '');
const bDate = String(b?.tracks?.[0]?.uploaded_at || '');
return bDate.localeCompare(aDate) || collator.compare(a?.title || '', b?.title || '');
}),
}))
.sort((a, b) => collator.compare(a.name, b.name));
},
uploadFeaturedArtistsText(item) { uploadFeaturedArtistsText(item) {
const track = item?.track || item; const track = item?.track || item;
return (track?.featured_artists || []).map(artist => artist.name).join(', '); return (track?.featured_artists || []).map(artist => artist.name).join(', ');
}, },
compactQueuedUploads() { compactQueuedUploads() {
return this.uploadQueued.slice(0, 6); const maxOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
this.uploadQueueOffset = Math.max(0, Math.min(this.uploadQueueOffset, maxOffset));
return this.uploadQueued.slice(this.uploadQueueOffset, this.uploadQueueOffset + this.uploadQueuePageSize);
},
uploadQueueCanPrev() {
return this.uploadQueueOffset > 0;
},
uploadQueueCanNext() {
return this.uploadQueueOffset + this.uploadQueuePageSize < this.uploadQueued.length;
},
uploadQueuePrev() {
this.uploadQueueOffset = Math.max(0, this.uploadQueueOffset - this.uploadQueuePageSize);
},
uploadQueueNext() {
const maxOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
this.uploadQueueOffset = Math.min(maxOffset, this.uploadQueueOffset + this.uploadQueuePageSize);
},
uploadQueueRangeText() {
if (this.uploadQueued.length === 0) return '';
const start = this.uploadQueueOffset + 1;
const end = Math.min(this.uploadQueueOffset + this.uploadQueuePageSize, this.uploadQueued.length);
const total = Math.max(this.uploadQueuedTotal || 0, this.uploadQueued.length);
return start + '-' + end + ' / ' + total;
},
uploadQueueOverflowText() {
const total = Math.max(this.uploadQueuedTotal || 0, this.uploadQueued.length);
return T.showing + ' ' + this.uploadQueued.length + ' ' + T.ofWord + ' ' + total;
},
uploadQueueTooltip(item) {
if (!item) return '';
const lines = [];
if (item.filename) lines.push(T.fileLabel + ': ' + item.filename);
if (item.status) lines.push(T.statusLabelText + ': ' + item.status);
if (item.created_at) lines.push(T.createdLabel + ': ' + item.created_at);
if (item.updated_at) lines.push(T.updatedLabel + ': ' + item.updated_at);
if (item.error_message) lines.push(T.errorLabel + ': ' + item.error_message);
return lines.join('\n');
},
uploadStatusLabel(status) {
const labels = {
pending: T.pending,
queued: T.queued,
processing: T.processing,
failed: T.failed,
};
return labels[String(status || '').toLowerCase()] || status || T.unknown;
}, },
uploadHasEditorOpen() { uploadHasEditorOpen() {
@@ -2803,17 +2955,17 @@ document.addEventListener('alpine:init', () => {
}, },
uploadEditorKicker() { uploadEditorKicker() {
if (this.uploadReviewDraft) return 'Needs approval'; if (this.uploadReviewDraft) return T.needsApproval;
if (this.uploadReleaseDraft) return 'Release metadata'; if (this.uploadReleaseDraft) return T.releaseMetadata;
if (this.uploadDraft) return 'Track metadata'; if (this.uploadDraft) return T.trackMetadata;
return 'Metadata'; return T.metadata;
}, },
uploadEditorTitle() { uploadEditorTitle() {
if (this.uploadReviewDraft) return 'Approve metadata'; if (this.uploadReviewDraft) return T.approveMetadata;
if (this.uploadReleaseDraft) return this.uploadReleaseDraft.title || 'Edit release'; if (this.uploadReleaseDraft) return this.uploadReleaseDraft.title || T.editRelease;
if (this.uploadDraft) return this.uploadDraft.title || 'Edit track'; if (this.uploadDraft) return this.uploadDraft.title || T.editTrack;
return 'Edit metadata'; return T.editMetadata;
}, },
closeUploadEditor() { closeUploadEditor() {
@@ -2927,11 +3079,11 @@ document.addEventListener('alpine:init', () => {
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save track'); if (!res.ok) throw new Error(data.error || T.failedSaveTrack);
this.uploadTracks = this.uploadTracks.map(item => item.track.id === id ? data : item); this.uploadTracks = this.uploadTracks.map(item => item.track.id === id ? data : item);
this.cancelUploadEdit(); this.cancelUploadEdit();
this.loadUploads({ silent: true, preserveEditor: false }); this.loadUploads({ silent: true, preserveEditor: false });
this._setMessage('Track metadata saved'); this._setMessage(T.trackMetadataSaved);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -2977,10 +3129,10 @@ document.addEventListener('alpine:init', () => {
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to save release'); if (!res.ok) throw new Error(data.error || T.failedSaveRelease);
this.applyUploadPage(data); this.applyUploadPage(data);
this.cancelUploadReleaseEdit(); this.cancelUploadReleaseEdit();
this._setMessage('Release metadata saved'); this._setMessage(T.releaseMetadataSaved);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -3036,10 +3188,10 @@ document.addEventListener('alpine:init', () => {
method: 'DELETE', method: 'DELETE',
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to delete review'); if (!res.ok) throw new Error(data.error || T.failedDeleteReview);
this.applyUploadPage(data); this.applyUploadPage(data);
this.cancelUploadReviewEdit(); this.cancelUploadReviewEdit();
this._setMessage('Review deleted'); this._setMessage(T.reviewDeleted);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -3058,10 +3210,10 @@ document.addEventListener('alpine:init', () => {
body: JSON.stringify(this.uploadReviewPayload()), body: JSON.stringify(this.uploadReviewPayload()),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to approve review'); if (!res.ok) throw new Error(data.error || T.failedApproveReview);
this.applyUploadPage(data); this.applyUploadPage(data);
this.cancelUploadReviewEdit(); this.cancelUploadReviewEdit();
this._setMessage('Track approved and imported'); this._setMessage(T.trackApprovedImported);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -3092,10 +3244,10 @@ document.addEventListener('alpine:init', () => {
}), }),
}); });
const data = await res.json(); const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to update selected tracks'); if (!res.ok) throw new Error(data.error || T.failedUpdateSelectedTracks);
this.applyUploadPage(data); this.applyUploadPage(data);
this.clearUploadSelection(); this.clearUploadSelection();
this._setMessage('Selected tracks updated'); this._setMessage(T.selectedTracksUpdated);
} catch (err) { } catch (err) {
this._setMessage(err.message || String(err), true); this._setMessage(err.message || String(err), true);
} finally { } finally {
@@ -3110,6 +3262,8 @@ document.addEventListener('alpine:init', () => {
this.uploadQueued = Array.isArray(data.queued) ? data.queued : []; this.uploadQueued = Array.isArray(data.queued) ? data.queued : [];
this.uploadPendingTotal = Number(data.pending_total || this.uploadPending.length || 0); this.uploadPendingTotal = Number(data.pending_total || this.uploadPending.length || 0);
this.uploadQueuedTotal = Number(data.queued_total || this.uploadQueued.length || 0); this.uploadQueuedTotal = Number(data.queued_total || this.uploadQueued.length || 0);
const maxQueueOffset = Math.max(0, this.uploadQueued.length - this.uploadQueuePageSize);
this.uploadQueueOffset = Math.max(0, Math.min(this.uploadQueueOffset, maxQueueOffset));
this.pruneUploadSelection(); this.pruneUploadSelection();
}, },
+36 -12
View File
@@ -639,23 +639,37 @@
<span style="text-align:right">{{ t.player_duration }}</span> <span style="text-align:right">{{ t.player_duration }}</span>
</div> </div>
<template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id"> <template x-for="(track, idx) in $store.library.currentArtist.featured_tracks" :key="track.id">
<div class="track-row" <div class="track-row artist-appearance-row"
:class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }" :class="{ playing: $store.player.currentTrack && $store.player.currentTrack.id === track.id }"
@dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)"> @dblclick="$store.queue.playRelease($store.library.currentArtist.featured_tracks, idx)">
<span class="track-num" x-text="idx + 1"></span> <span class="track-num" x-text="idx + 1"></span>
<div class="track-info"> <div class="track-info">
<div class="track-title"> <button class="artist-appearance-cover"
<span x-text="track.title"></span> type="button"
<span style="color:var(--text-subdued)"> · </span> @click.stop="$store.library.openRelease(track.release_id)"
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a> :title="track.release_title"
</div> aria-label="{{ t.player_release }}">
<div class="track-artists-inline"> <template x-if="track.cover_url">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx"> <img :src="track.cover_url" :alt="track.release_title" loading="lazy">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template> </template>
<template x-if="!track.cover_url">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="4"/></svg>
</template>
</button>
<div class="artist-appearance-copy">
<div class="track-title">
<span x-text="track.title"></span>
<span style="color:var(--text-subdued)"> · </span>
<a class="artist-link" @click.stop="$store.library.openRelease(track.release_id)" x-text="track.release_title"></a>
</div>
<div class="track-artists-inline">
<template x-for="(artist, artistIdx) in $store.library.trackArtistLinks(track)" :key="artist.label + '-' + artist.id + '-' + artistIdx">
<span>
<template x-if="artistIdx > 0"><span>, </span></template>
<a class="artist-link" @click.stop="$store.library.openArtist(artist.id)" x-text="artist.label"></a>
</span>
</template>
</div>
</div> </div>
</div> </div>
<span></span> <span></span>
@@ -1074,6 +1088,16 @@
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
</button> </button>
<div class="device-picker" @click.outside="$store.devices.open = false"> <div class="device-picker" @click.outside="$store.devices.open = false">
<template x-if="$store.devices.remoteHintVisible">
<button class="remote-playback-hint"
type="button"
@click="$store.devices.dismissRemoteHint()"
x-transition
x-cloak>
<span class="remote-playback-hint-text" x-text="$store.devices.remoteHintText()"></span>
<span class="remote-playback-hint-progress"></span>
</button>
</template>
<button class="queue-toggle-btn device-toggle-btn" <button class="queue-toggle-btn device-toggle-btn"
:class="{ active: $store.devices.isActive() }" :class="{ active: $store.devices.isActive() }"
@click="$store.devices.toggle()" @click="$store.devices.toggle()"
+214 -11
View File
@@ -735,6 +735,47 @@ button.user-stat:hover {
margin-top: 2px; margin-top: 2px;
} }
.artist-appearance-row .track-info {
display: flex;
align-items: center;
gap: 12px;
}
.artist-appearance-cover {
width: 42px;
height: 42px;
border: 0;
border-radius: 4px;
background: var(--bg-elevated);
color: var(--text-subdued);
cursor: pointer;
flex: 0 0 42px;
overflow: hidden;
padding: 0;
display: flex;
align-items: center;
justify-content: center;
}
.artist-appearance-cover img {
width: 100%;
height: 100%;
object-fit: cover;
}
.artist-appearance-cover svg {
width: 22px;
height: 22px;
}
.artist-appearance-cover:hover {
filter: brightness(1.14);
}
.artist-appearance-copy {
min-width: 0;
}
.track-album { font-size: 13px; color: var(--text-subdued); } .track-album { font-size: 13px; color: var(--text-subdued); }
.track-duration { font-size: 13px; color: var(--text-subdued); text-align: right; pointer-events: none; } .track-duration { font-size: 13px; color: var(--text-subdued); text-align: right; pointer-events: none; }
@@ -1370,19 +1411,35 @@ button.user-stat:hover {
.volume-slider { .volume-slider {
width: 104px; width: 104px;
height: 4px; height: 28px;
background: var(--bg-active); background: transparent;
border-radius: 2px; border-radius: 6px;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
touch-action: none; touch-action: none;
display: flex;
align-items: center;
}
.volume-slider::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 4px;
border-radius: 2px;
background: var(--bg-active);
transform: translateY(-50%);
} }
.volume-slider-fill { .volume-slider-fill {
height: 100%; height: 4px;
background: var(--text-primary); background: var(--text-primary);
border-radius: 2px; border-radius: 2px;
position: relative; position: relative;
z-index: 1;
pointer-events: none;
} }
.volume-slider:hover .volume-slider-fill { background: var(--accent); } .volume-slider:hover .volume-slider-fill { background: var(--accent); }
@@ -1452,6 +1509,51 @@ button.user-stat:hover {
justify-content: center; justify-content: center;
} }
.remote-playback-hint {
position: absolute;
right: 0;
bottom: 38px;
width: max-content;
max-width: min(280px, calc(100vw - 24px));
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-elevated);
color: var(--text-primary);
box-shadow: 0 14px 34px rgba(0,0,0,0.46);
cursor: pointer;
font: inherit;
overflow: hidden;
padding: 10px 12px 12px;
text-align: left;
z-index: 44;
}
.remote-playback-hint-text {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
line-height: 1.35;
}
.remote-playback-hint-progress {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 2px;
background: var(--accent);
transform-origin: left center;
animation: remote-playback-hint-progress 60s linear forwards;
}
@keyframes remote-playback-hint-progress {
from { transform: scaleX(1); }
to { transform: scaleX(0); }
}
.device-popover { .device-popover {
position: absolute; position: absolute;
right: 0; right: 0;
@@ -3239,7 +3341,40 @@ button.user-stat:hover {
.upload-release-tree { .upload-release-tree {
display: grid; display: grid;
gap: 8px; gap: 12px;
}
.upload-artist-group {
display: grid;
gap: 7px;
min-width: 0;
}
.upload-artist-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
min-width: 0;
padding: 2px 2px 0;
}
.upload-artist-name {
min-width: 0;
overflow: hidden;
color: var(--text-primary);
font-size: 13px;
font-weight: 950;
text-overflow: ellipsis;
white-space: nowrap;
}
.upload-artist-meta {
flex: 0 0 auto;
color: var(--text-subdued);
font-size: 11px;
font-weight: 750;
white-space: nowrap;
} }
.upload-release-node { .upload-release-node {
@@ -3333,6 +3468,60 @@ button.user-stat:hover {
text-transform: uppercase; text-transform: uppercase;
} }
.upload-panel-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.upload-queue-nav {
display: inline-flex;
align-items: center;
gap: 5px;
margin-left: auto;
}
.upload-queue-range {
color: var(--text-subdued);
font-size: 11px;
font-weight: 850;
text-transform: none;
white-space: nowrap;
}
.upload-queue-nav-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-secondary);
cursor: pointer;
}
.upload-queue-nav-btn:hover:not(:disabled) {
border-color: var(--text-subdued);
background: var(--bg-elevated);
color: var(--text-primary);
}
.upload-queue-nav-btn:disabled {
cursor: default;
opacity: 0.42;
}
.upload-queue-nav-btn svg {
width: 14px;
height: 14px;
}
.upload-queue-row { .upload-queue-row {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
@@ -4077,13 +4266,19 @@ button.user-stat:hover {
} }
.volume-slider { .volume-slider {
display: block; display: flex;
width: 100%; width: 100%;
height: 9px; height: 34px;
border-radius: 999px; border-radius: 6px;
} }
.volume-slider-fill { .volume-slider-fill {
height: 7px;
border-radius: 999px;
}
.volume-slider::before {
height: 7px;
border-radius: 999px; border-radius: 999px;
} }
@@ -4330,8 +4525,8 @@ button.user-stat:hover {
} }
.player-bar.mobile-expanded .mobile-full-player .volume-slider { .player-bar.mobile-expanded .mobile-full-player .volume-slider {
display: block; display: flex;
height: 7px; height: 44px;
} }
.player-bar.mobile-expanded .mobile-full-player .device-popover { .player-bar.mobile-expanded .mobile-full-player .device-popover {
@@ -4345,6 +4540,14 @@ button.user-stat:hover {
max-height: 42dvh; max-height: 42dvh;
} }
.player-bar.mobile-expanded .mobile-full-player .remote-playback-hint {
position: fixed;
top: auto;
bottom: calc(90px + var(--safe-bottom));
right: 18px;
max-width: calc(100vw - 36px);
}
.player-bar.mobile-expanded .mobile-full-player .mobile-expanded-queue { .player-bar.mobile-expanded .mobile-full-player .mobile-expanded-queue {
display: block; display: block;
width: 100%; width: 100%;
@@ -4966,7 +5169,7 @@ button.user-stat:hover {
} }
.volume-slider { .volume-slider {
display: block; display: flex;
width: 100%; width: 100%;
} }