From 1c70349df8999c7180163d8985050d7149303891 Mon Sep 17 00:00:00 2001 From: Ultradesu Date: Wed, 27 May 2026 15:56:57 +0300 Subject: [PATCH] ADMIN: added releases and artists management form --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/admin/mod.rs | 82 ++++++ src/admin/v2.rs | 334 +++++++++++++++++++++- src/i18n/phrases.rs | 1 + src/player/dto.rs | 2 + src/player/mod.rs | 30 +- src/player/rows.rs | 6 + templates/admin/v2.html | 522 +++++++++++++++++++++++++++++++--- templates/player/modals.html | 27 +- templates/player/scripts.html | 83 +++++- templates/player/shell.html | 17 +- templates/player/styles.html | 96 ++++++- 13 files changed, 1151 insertions(+), 53 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b956316..3cd178c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "furumusic" -version = "0.1.18" +version = "0.1.20" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 43a315e..6b0f7bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "furumusic" -version = "0.1.19" +version = "0.1.20" edition = "2024" description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL" diff --git a/src/admin/mod.rs b/src/admin/mod.rs index e71d3a4..efa5862 100644 --- a/src/admin/mod.rs +++ b/src/admin/mod.rs @@ -428,6 +428,88 @@ impl App for AdminApp { }, "admin_v2_library_item", ), + Route::with_handler_and_name( + "/v2/api/library/item/detail", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + get(move |session: Session, + db: Database, + query: UrlQuery| { + 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::library_item_detail(session, db, pg_pool, query.0).await + } + }) + }, + "admin_v2_library_item_detail", + ), + Route::with_handler_and_name( + "/v2/api/library/item/image", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + cot::router::method::post( + move |session: Session, + db: Database, + json: Json| { + 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::set_library_item_image(session, db, pg_pool, json).await + } + }, + ) + }, + "admin_v2_library_item_image", + ), + Route::with_handler_and_name( + "/v2/api/library/item/upload-image", + { + let pool = Arc::clone(&pool); + let pool_config = Arc::clone(&pool_config); + cot::router::method::post( + move |session: Session, + db: Database, + json: Json| { + 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::upload_library_item_image(session, db, pg_pool, json).await + } + }, + ) + }, + "admin_v2_library_item_upload_image", + ), Route::with_handler_and_name( "/v2/api/library/bulk", { diff --git a/src/admin/v2.rs b/src/admin/v2.rs index 1ae7af4..9c571b5 100644 --- a/src/admin/v2.rs +++ b/src/admin/v2.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use cot::db::{Database, Model}; use cot::html::Html; @@ -67,6 +67,31 @@ pub(super) struct UpdateLibraryItemRequest { id: i64, title: String, hidden: bool, + release_type: Option, + year: Option, + artist_ids: Option>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct LibraryItemDetailQuery { + kind: String, + id: i64, +} + +#[derive(Debug, Deserialize)] +pub(super) struct SetLibraryImageRequest { + kind: String, + id: i64, + media_file_id: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct UploadLibraryImageRequest { + kind: String, + id: i64, + data: String, + filename: String, + mime_type: String, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema)] @@ -348,6 +373,32 @@ struct LibraryItemDto { updated_at: Option, } +#[derive(Debug, Serialize, JsonSchema)] +struct LibraryItemDetailDto { + item: LibraryItemDto, + title: String, + hidden: bool, + release_type: Option, + year: Option, + current_image_url: Option, + selected_artist_ids: Vec, + artists: Vec, + available_covers: Vec, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct ArtistOptionDto { + id: i64, + name: String, +} + +#[derive(Debug, Serialize, JsonSchema)] +struct AvailableCoverDto { + media_file_id: i64, + release_title: String, + cover_url: String, +} + #[derive(Debug, sqlx::FromRow)] struct IdRow { id: i64, @@ -903,6 +954,28 @@ pub async fn library( Json(page).into_response() } +pub async fn library_item_detail( + session: Session, + db: Database, + pool: &PgPool, + query: LibraryItemDetailQuery, +) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + let kind = normalize_library_kind(Some(query.kind.as_str())); + let Some(item) = fetch_library_item(pool, &kind, query.id) + .await + .map_err(|e| cot::Error::internal(e.to_string()))? + else { + return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); + }; + let detail = load_library_item_detail(pool, &kind, item) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + Json(detail).into_response() +} + pub async fn update_library_item( session: Session, db: Database, @@ -936,13 +1009,27 @@ pub async fn update_library_item( .await } "releases" => { + let release_type = body + .release_type + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("album"); + let year = body + .year + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(|value| value.parse::().ok()); sqlx::query( "UPDATE furumusic__release \ - SET title = $1, title_sort = $2, is_hidden = $3, updated_at = $4 \ - WHERE id = $5", + SET title = $1, title_sort = $2, release_type = $3, year = $4, is_hidden = $5, updated_at = $6 \ + WHERE id = $7", ) .bind(title) .bind(normalize_name(title)) + .bind(release_type) + .bind(year) .bind(body.hidden) .bind(&now) .bind(body.id) @@ -970,6 +1057,28 @@ pub async fn update_library_item( if affected == 0 { return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); } + if kind == "releases" { + if let Some(mut artist_ids) = body.artist_ids { + let mut seen_artist_ids = HashSet::new(); + artist_ids.retain(|id| *id > 0 && seen_artist_ids.insert(*id)); + sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1") + .bind(body.id) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + for (position, artist_id) in artist_ids.iter().enumerate() { + sqlx::query( + "INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)", + ) + .bind(body.id) + .bind(*artist_id) + .bind(position as i32) + .execute(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + } + } + } let Some(item) = fetch_library_item(pool, &kind, body.id) .await @@ -981,6 +1090,128 @@ pub async fn update_library_item( Json(item).into_response() } +pub async fn set_library_item_image( + session: Session, + db: Database, + pool: &PgPool, + Json(body): Json, +) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + let kind = normalize_library_kind(Some(body.kind.as_str())); + if kind != "artists" && kind != "releases" { + return Ok(json_error(StatusCode::BAD_REQUEST, "unsupported kind")); + } + if let Some(fid) = body.media_file_id { + let exists: Option = sqlx::query_scalar( + "SELECT id FROM furumusic__media_file WHERE id = $1 AND file_type = 'cover_art'", + ) + .bind(fid) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + if exists.is_none() { + return Ok(json_error(StatusCode::NOT_FOUND, "image not found")); + } + } + let now = now_string(); + let result = if kind == "releases" { + sqlx::query( + "UPDATE furumusic__release SET cover_file_id = $1, updated_at = $2 WHERE id = $3", + ) + .bind(body.media_file_id) + .bind(&now) + .bind(body.id) + .execute(pool) + .await + } else { + sqlx::query( + "UPDATE furumusic__artist SET image_file_id = $1, updated_at = $2 WHERE id = $3", + ) + .bind(body.media_file_id) + .bind(&now) + .bind(body.id) + .execute(pool) + .await + } + .map_err(|e| cot::Error::internal(e.to_string()))?; + if result.rows_affected() == 0 { + return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); + } + Json(serde_json::json!({ "ok": true })).into_response() +} + +pub async fn upload_library_item_image( + session: Session, + db: Database, + pool: &PgPool, + Json(body): Json, +) -> cot::Result { + if let Err(response) = require_admin_json(&session, &db).await { + return Ok(response); + } + let kind = normalize_library_kind(Some(body.kind.as_str())); + if kind != "artists" && kind != "releases" { + return Ok(json_error(StatusCode::BAD_REQUEST, "unsupported kind")); + } + let storage_dir = AppConfig::load_with_db(&db).await.0.agent_storage_dir; + if storage_dir.trim().is_empty() { + return Err(cot::Error::internal("agent_storage_dir is not configured")); + } + use base64::Engine; + let image_data = base64::engine::general_purpose::STANDARD + .decode(body.data.trim()) + .map_err(|e| cot::Error::internal(format!("invalid base64: {e}")))?; + if image_data.is_empty() { + return Ok(json_error(StatusCode::BAD_REQUEST, "image is empty")); + } + let title: Option = if kind == "releases" { + sqlx::query_scalar("SELECT title::text FROM furumusic__release WHERE id = $1") + } else { + sqlx::query_scalar("SELECT name::text FROM furumusic__artist WHERE id = $1") + } + .bind(body.id) + .fetch_optional(pool) + .await + .map_err(|e| cot::Error::internal(e.to_string()))?; + let Some(title) = title else { + return Ok(json_error(StatusCode::NOT_FOUND, "library item not found")); + }; + let cover = crate::agent::cover_art::CoverImage { + data: image_data, + mime_type: body.mime_type, + source: crate::agent::cover_art::CoverSource::FolderFile(std::path::PathBuf::from( + body.filename, + )), + }; + let media_file_id = crate::agent::cover_art::save_cover_to_storage( + &db, + pool, + &storage_dir, + &title, + if kind == "artists" { + "__artist_image__" + } else { + "__release_cover__" + }, + &cover, + ) + .await + .map_err(|e| cot::Error::internal(format!("failed to save image: {e}")))?; + set_library_item_image( + session, + db, + pool, + Json(SetLibraryImageRequest { + kind, + id: body.id, + media_file_id: Some(media_file_id), + }), + ) + .await +} + pub async fn bulk_library( session: Session, db: Database, @@ -1619,6 +1850,103 @@ async fn fetch_library_item( Ok(row.map(|row| library_item_dto(kind, row))) } +async fn load_library_item_detail( + pool: &PgPool, + kind: &str, + item: LibraryItemDto, +) -> anyhow::Result { + let mut detail = LibraryItemDetailDto { + title: item.title.clone(), + hidden: item.is_hidden.unwrap_or(false), + release_type: None, + year: None, + current_image_url: None, + selected_artist_ids: Vec::new(), + artists: Vec::new(), + available_covers: Vec::new(), + item, + }; + + match kind { + "artists" => { + let image_file_id: Option = + sqlx::query_scalar("SELECT image_file_id FROM furumusic__artist WHERE id = $1") + .bind(detail.item.id) + .fetch_optional(pool) + .await? + .flatten(); + detail.current_image_url = + image_file_id.map(|id| format!("/api/player/cover/{id}/large")); + detail.available_covers = artist_available_covers(pool, detail.item.id).await?; + } + "releases" => { + let row: Option<(Option, Option, Option)> = sqlx::query_as( + "SELECT release_type::text, year, cover_file_id FROM furumusic__release WHERE id = $1", + ) + .bind(detail.item.id) + .fetch_optional(pool) + .await?; + if let Some((release_type, year, cover_file_id)) = row { + detail.release_type = release_type; + detail.year = year; + detail.current_image_url = + cover_file_id.map(|id| format!("/api/player/cover/{id}/large")); + } + detail.selected_artist_ids = sqlx::query_as::<_, IdRow>( + "SELECT artist_id AS id FROM furumusic__release_artist WHERE release_id = $1 ORDER BY position, artist_id", + ) + .bind(detail.item.id) + .fetch_all(pool) + .await? + .into_iter() + .map(|row| row.id) + .collect(); + detail.artists = load_artist_options(pool).await?; + } + _ => {} + } + + Ok(detail) +} + +async fn load_artist_options(pool: &PgPool) -> anyhow::Result> { + let rows = sqlx::query_as::<_, (i64, String)>( + "SELECT id, name::text FROM furumusic__artist ORDER BY name ASC", + ) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(id, name)| ArtistOptionDto { id, name }) + .collect()) +} + +async fn artist_available_covers( + pool: &PgPool, + artist_id: i64, +) -> anyhow::Result> { + let rows = sqlx::query_as::<_, (i64, String)>( + "SELECT DISTINCT r.cover_file_id AS media_file_id, r.title::text AS release_title \ + FROM furumusic__release r \ + LEFT JOIN furumusic__release_artist ra ON ra.release_id = r.id \ + LEFT JOIN furumusic__track t ON t.release_id = r.id \ + LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \ + WHERE r.cover_file_id IS NOT NULL AND (ra.artist_id = $1 OR ta.artist_id = $1) \ + ORDER BY r.title::text ASC", + ) + .bind(artist_id) + .fetch_all(pool) + .await?; + Ok(rows + .into_iter() + .map(|(media_file_id, release_title)| AvailableCoverDto { + media_file_id, + release_title, + cover_url: format!("/api/player/cover/{media_file_id}/medium"), + }) + .collect()) +} + async fn library_ids_by_filter( pool: &PgPool, kind: &str, diff --git a/src/i18n/phrases.rs b/src/i18n/phrases.rs index 26a3733..7b1f72d 100644 --- a/src/i18n/phrases.rs +++ b/src/i18n/phrases.rs @@ -268,6 +268,7 @@ translations! { // Player UI player_library: "Library" , "Библиотека"; player_artists: "Artists" , "Артисты"; + player_release: "Release" , "Релиз"; player_releases: "Releases" , "Релизы"; player_tracks: "Tracks" , "Треки"; player_title: "Title" , "Название"; diff --git a/src/player/dto.rs b/src/player/dto.rs index 8eff855..906d7ad 100644 --- a/src/player/dto.rs +++ b/src/player/dto.rs @@ -55,6 +55,8 @@ pub(super) struct TrackItem { pub(super) duration_seconds: f64, pub(super) artists: Vec, pub(super) featured_artists: Vec, + pub(super) release_id: i64, + pub(super) release_title: String, pub(super) release_year: Option, pub(super) cover_url: Option, pub(super) stream_url: String, diff --git a/src/player/mod.rs b/src/player/mod.rs index f8e1330..22237d1 100644 --- a/src/player/mod.rs +++ b/src/player/mod.rs @@ -465,6 +465,8 @@ async fn release_detail_handler( r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, r.cover_file_id as release_cover_file_id, + r.id as release_id, + r.title::text as release_title, r.year as release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, @@ -542,6 +544,8 @@ async fn release_detail_handler( duration_seconds: t.duration_seconds, artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), + release_id: t.release_id, + release_title: t.release_title, release_year: t.release_year, cover_url: track_cover_variant_url( t.cover_file_id, @@ -707,6 +711,8 @@ async fn playlist_detail_handler( r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, r.cover_file_id as release_cover_file_id, + r.id as release_id, + r.title::text as release_title, r.year as release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, @@ -804,6 +810,8 @@ async fn build_track_items( duration_seconds: t.duration_seconds, artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), + release_id: t.release_id, + release_title: t.release_title, release_year: t.release_year, cover_url: track_cover_variant_url( t.cover_file_id, @@ -835,13 +843,19 @@ async fn likes_playlist_handler( r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, r.cover_file_id as release_cover_file_id, + r.id as release_id, + r.title::text as release_title, r.year as release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, - mf.file_size_bytes + mf.file_size_bytes, + t.lastfm_listeners, + t.lastfm_playcount, + t.lastfm_rating, + t.lastfm_updated_at FROM furumusic__user_liked_track ult JOIN furumusic__track t ON t.id = ult.track_id JOIN furumusic__release r ON r.id = t.release_id @@ -1476,6 +1490,8 @@ async fn search_handler( r#"SELECT t.id, t.title::text AS title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id AS release_cover_file_id, + rel.id AS release_id, + rel.title::text AS release_title, rel.year AS release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, @@ -1550,11 +1566,13 @@ async fn search_handler( let t = sqlx::query_as::<_, SearchTrackRow>( r#"SELECT id, title, track_number, disc_number, duration_seconds, cover_file_id, - release_cover_file_id, release_year, uploader_name, audio_format, audio_bitrate, + release_cover_file_id, release_id, release_title, release_year, uploader_name, audio_format, audio_bitrate, audio_sample_rate, audio_bit_depth, file_size_bytes, lastfm_listeners, lastfm_playcount, lastfm_rating, lastfm_updated_at FROM ( SELECT t.id, t.title::text AS title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id AS release_cover_file_id, + rel.id AS release_id, + rel.title::text AS release_title, rel.year AS release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, @@ -1580,7 +1598,7 @@ async fn search_handler( ) t JOIN furumusic__release rel ON rel.id = t.release_id LEFT JOIN furumusic__media_file mf ON mf.id = t.audio_file_id - GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, rel.year, + GROUP BY t.id, t.title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, rel.cover_file_id, rel.id, rel.title, rel.year, mf.uploader_name, mf.audio_format, mf.audio_bitrate, mf.audio_sample_rate, mf.audio_bit_depth, mf.file_size_bytes, t.lastfm_listeners, t.lastfm_playcount, t.lastfm_rating, t.lastfm_updated_at ORDER BY similarity DESC @@ -1677,6 +1695,8 @@ async fn search_handler( duration_seconds: t.duration_seconds, artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), + release_id: t.release_id, + release_title: t.release_title, release_year: t.release_year, cover_url: track_cover_variant_url( t.cover_file_id, @@ -2252,6 +2272,8 @@ async fn tracks_by_ids_handler( r#"SELECT t.id, t.title::text as title, t.track_number, t.disc_number, t.duration_seconds, t.cover_file_id, r.cover_file_id as release_cover_file_id, + r.id as release_id, + r.title::text as release_title, r.year as release_year, COALESCE(mf.uploader_name, 'UFO')::text AS uploader_name, mf.audio_format, @@ -2328,6 +2350,8 @@ async fn tracks_by_ids_handler( duration_seconds: t.duration_seconds, artists: track_main_artists.remove(&tid).unwrap_or_default(), featured_artists: track_feat_artists.remove(&tid).unwrap_or_default(), + release_id: t.release_id, + release_title: t.release_title, release_year: t.release_year, cover_url: track_cover_variant_url( t.cover_file_id, diff --git a/src/player/rows.rs b/src/player/rows.rs index df59e9b..f9221cf 100644 --- a/src/player/rows.rs +++ b/src/player/rows.rs @@ -37,6 +37,8 @@ pub(super) struct TrackRow { pub(super) duration_seconds: f64, pub(super) cover_file_id: Option, pub(super) release_cover_file_id: Option, + pub(super) release_id: i64, + pub(super) release_title: String, pub(super) release_year: Option, pub(super) uploader_name: String, pub(super) audio_format: Option, @@ -107,6 +109,8 @@ pub(super) struct PlaylistTrackRow { pub(super) duration_seconds: f64, pub(super) cover_file_id: Option, pub(super) release_cover_file_id: Option, + pub(super) release_id: i64, + pub(super) release_title: String, pub(super) release_year: Option, pub(super) uploader_name: String, pub(super) audio_format: Option, @@ -170,6 +174,8 @@ pub(super) struct SearchTrackRow { pub(super) duration_seconds: f64, pub(super) cover_file_id: Option, pub(super) release_cover_file_id: Option, + pub(super) release_id: i64, + pub(super) release_title: String, pub(super) release_year: Option, pub(super) uploader_name: String, pub(super) audio_format: Option, diff --git a/templates/admin/v2.html b/templates/admin/v2.html index 04dbd93..f5ba9d0 100644 --- a/templates/admin/v2.html +++ b/templates/admin/v2.html @@ -870,6 +870,165 @@ tbody tr:hover { font-size: 11px; } +.image-editor { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + gap: 14px; + margin-bottom: 12px; +} + +.image-preview { + width: 150px; + aspect-ratio: 1; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + display: grid; + place-items: center; + overflow: hidden; + color: var(--text-subdued); + font-size: 12px; +} + +.image-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.cover-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(86px, 1fr)); + gap: 8px; +} + +.cover-option { + min-width: 0; + padding: 6px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + cursor: pointer; +} + +.cover-option:hover { + border-color: rgba(29, 185, 84, 0.55); + color: var(--text-primary); +} + +.cover-option img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + border-radius: 5px; + display: block; +} + +.cover-option span { + display: block; + margin-top: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 10px; +} + +.artist-tags { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 8px; +} + +.artist-picker { + position: relative; +} + +.artist-picker input { + width: 100%; +} + +.artist-results { + position: absolute; + left: 0; + right: 0; + top: calc(100% + 5px); + z-index: 5; + max-height: 242px; + overflow: auto; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-elevated); + box-shadow: 0 14px 34px rgba(0, 0, 0, 0.38); +} + +.artist-result { + width: 100%; + min-height: 34px; + padding: 8px 10px; + border: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + background: transparent; + color: var(--text-secondary); + text-align: left; + cursor: pointer; +} + +.artist-result:hover, +.artist-result:focus { + background: var(--bg-hover); + color: var(--text-primary); +} + +.artist-result:last-child { + border-bottom: 0; +} + +.tag button { + width: 16px; + height: 16px; + margin-left: 5px; + border: 0; + border-radius: 999px; + background: rgba(255, 255, 255, 0.14); + color: inherit; + cursor: pointer; + line-height: 1; +} + +.tag button:hover { + background: rgba(255, 255, 255, 0.24); +} + +.editor-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.image-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.file-name { + max-width: 280px; + color: var(--text-subdued); + font-size: 11px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cover-option.active { + border-color: rgba(29, 185, 84, 0.8); + box-shadow: 0 0 0 1px rgba(29, 185, 84, 0.32); +} + .muted { color: var(--text-subdued); } @@ -1753,30 +1912,120 @@ tbody tr:hover { -

+                
+ +

+                
diff --git a/templates/player/scripts.html b/templates/player/scripts.html index e5e095d..39f004a 100644 --- a/templates/player/scripts.html +++ b/templates/player/scripts.html @@ -4,11 +4,14 @@ const T = { info: "{{ t.player_info }}", noDetails: "{{ t.player_no_details }}", trackInfoTitle: "{{ t.player_track_info }}", + releaseInfoTitle: "{{ t.player_release_info }}", loadingHistory: "{{ t.player_loading_history }}", failedLoadHistory: "{{ t.player_failed_load_history }}", totalPlays: "{{ t.player_total_plays }}", unknown: "{{ t.player_unknown }}", unknownSize: "{{ t.player_unknown_size }}", + title: "{{ t.player_title }}", + release: "{{ t.player_release }}", unknownRelease: "{{ t.player_unknown_release }}", unknownTrack: "{{ t.player_unknown_track }}", unknownAudio: "{{ t.player_unknown_audio }}", @@ -124,14 +127,33 @@ document.addEventListener('alpine:init', () => { Alpine.store('info', { modal: null, open(title, body) { + if (Array.isArray(body)) { + this.openRows(title, body); + return; + } this.modal = { title: title || T.info, body: body || T.noDetails, + rows: null, + }; + }, + openRows(title, rows) { + this.modal = { + title: title || T.info, + body: '', + rows: (rows || []).filter(row => row && ((row.value !== undefined && row.value !== null && row.value !== '') || (row.links && row.links.length))), }; }, close() { this.modal = null; }, + navigate(link) { + if (!link || !link.id) return; + this.close(); + const library = Alpine.store('library'); + if (link.type === 'release') library.openRelease(link.id); + if (link.type === 'artist') library.openArtist(link.id); + }, }); // ----------------------------------------------------------------------- @@ -892,7 +914,7 @@ document.addEventListener('alpine:init', () => { }, openTrackInfo(track) { - Alpine.store('info').open(T.trackInfoTitle, this.trackInfo(track)); + Alpine.store('info').openRows(T.trackInfoTitle, this.trackInfoRows(track)); }, uploadersInfo(uploaders) { @@ -915,6 +937,31 @@ document.addEventListener('alpine:init', () => { return lines.join('\n'); }, + openReleaseInfo(release) { + Alpine.store('info').openRows(T.releaseInfoTitle, this.releaseInfoRows(release)); + }, + + infoLinks(items, type) { + const seen = new Set(); + return (items || []) + .filter(item => item && item.id && !seen.has(Number(item.id)) && seen.add(Number(item.id))) + .map(item => ({ type, id: item.id, label: item.label || item.name || item.title || String(item.id) })); + }, + + releaseInfoRows(release) { + if (!release) return []; + const rows = [ + { label: T.release, value: release.title || T.unknownRelease }, + { label: T.type, value: release.release_type || T.unknown }, + { label: T.year, value: release.year || T.unknown }, + { label: T.tracks, value: release.track_count || release.tracks?.length || 0 }, + { label: T.uploaders, value: this.uploadersInfo(release.uploaders || []) }, + ]; + const artistLinks = this.infoLinks(release.artists || [], 'artist'); + if (artistLinks.length) rows.splice(1, 0, { label: T.artists, links: artistLinks }); + return rows; + }, + trackInfo(track) { if (!track) return ''; const artists = this.trackArtistLinks(track).map(artist => artist.label).join(', ') || T.unknown; @@ -945,6 +992,40 @@ document.addEventListener('alpine:init', () => { return lines.join('\n'); }, + trackInfoRows(track) { + if (!track) return []; + const artistLinks = this.infoLinks(this.trackArtistLinks(track), 'artist'); + const releaseLinks = track.release_id + ? [{ type: 'release', id: track.release_id, label: track.release_title || T.unknownRelease }] + : []; + const audio = [ + track.audio_format || null, + track.audio_bitrate ? `${track.audio_bitrate} kbps` : null, + track.audio_sample_rate ? `${track.audio_sample_rate} Hz` : null, + track.audio_bit_depth ? `${track.audio_bit_depth}-bit` : null, + ].filter(Boolean).join(' · ') || T.unknownAudio; + const rows = [ + { label: T.title, value: track.title || T.unknownTrack }, + { label: T.release, links: releaseLinks }, + { label: T.artists, links: artistLinks }, + { label: T.releaseYear, value: track.release_year || T.unknown }, + { label: T.duration, value: formatTime(track.duration_seconds) }, + { label: T.audio, value: audio }, + { label: T.size, value: this.bytes(track.file_size_bytes) }, + { label: T.uploader, value: track.uploader_name || 'UFO' }, + ]; + if (track.lastfm_rating != null || track.lastfm_listeners != null || track.lastfm_playcount != null) { + const rating = Number(track.lastfm_rating || 0); + rows.push({ label: T.lastfmRating, value: Number.isFinite(rating) ? Math.round(rating) : T.unknown }); + rows.push({ label: T.lastfmListeners, value: new Intl.NumberFormat().format(track.lastfm_listeners || 0) }); + rows.push({ label: T.lastfmPlaycount, value: new Intl.NumberFormat().format(track.lastfm_playcount || 0) }); + if (track.lastfm_updated_at) rows.push({ label: T.lastfmUpdated, value: track.lastfm_updated_at }); + } else { + rows.push({ label: T.lastfmRating, value: T.lastfmNotLoaded }); + } + return rows; + }, + async openRelease(id, options = {}) { this._beginNavigation('#release/' + id, options); this.searchQuery = ''; diff --git a/templates/player/shell.html b/templates/player/shell.html index dc4b283..1029572 100644 --- a/templates/player/shell.html +++ b/templates/player/shell.html @@ -391,7 +391,7 @@ - @@ -562,7 +562,7 @@ - +