Improved release editor
Build and Publish / Build and Publish Docker Image (push) Successful in 2m35s
Build and Publish / Build and Publish Docker Image (push) Successful in 2m35s
This commit is contained in:
Generated
+1
-1
@@ -1418,7 +1418,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||
|
||||
[[package]]
|
||||
name = "furumusic"
|
||||
version = "0.4.2"
|
||||
version = "0.4.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "furumusic"
|
||||
version = "0.4.3"
|
||||
version = "0.4.4"
|
||||
edition = "2024"
|
||||
description = "Reusable web-app boilerplate: auth, OIDC/SSO, admin panel, user management, i18n, PostgreSQL"
|
||||
|
||||
|
||||
@@ -555,6 +555,32 @@ impl App for AdminApp {
|
||||
},
|
||||
"admin_v2_library_item_detail",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/library/tracks/search",
|
||||
{
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
get(move |session: Session,
|
||||
db: Database,
|
||||
query: UrlQuery<v2::TrackSearchQuery>| {
|
||||
let pool = Arc::clone(&pool);
|
||||
let pool_config = Arc::clone(&pool_config);
|
||||
async move {
|
||||
let pg_pool = pool
|
||||
.get_or_init(|| async {
|
||||
sqlx::postgres::PgPoolOptions::new()
|
||||
.max_connections(5)
|
||||
.connect(&pool_config.database_url)
|
||||
.await
|
||||
.expect("admin pool")
|
||||
})
|
||||
.await;
|
||||
v2::track_search(session, db, pg_pool, query.0).await
|
||||
}
|
||||
})
|
||||
},
|
||||
"admin_v2_library_tracks_search",
|
||||
),
|
||||
Route::with_handler_and_name(
|
||||
"/v2/api/library/item/image",
|
||||
{
|
||||
|
||||
+320
-24
@@ -110,6 +110,7 @@ pub(super) struct UpdateLibraryItemRequest {
|
||||
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||
disc_number: Option<String>,
|
||||
artist_ids: Option<Vec<i64>>,
|
||||
release_tracks: Option<Vec<ReleaseTrackUpdateRequest>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -118,6 +119,21 @@ pub(super) struct LibraryItemDetailQuery {
|
||||
id: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct TrackSearchQuery {
|
||||
search: Option<String>,
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ReleaseTrackUpdateRequest {
|
||||
id: i64,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||
track_number: Option<String>,
|
||||
#[serde(default, deserialize_with = "deserialize_optional_stringish")]
|
||||
disc_number: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub(super) struct SetLibraryImageRequest {
|
||||
kind: String,
|
||||
@@ -538,6 +554,7 @@ struct LibraryItemDetailDto {
|
||||
selected_artist_ids: Vec<i64>,
|
||||
artists: Vec<ArtistOptionDto>,
|
||||
releases: Vec<ReleaseOptionDto>,
|
||||
release_tracks: Vec<ReleaseTrackDto>,
|
||||
available_covers: Vec<AvailableCoverDto>,
|
||||
metadata_tags: Vec<MetadataTagDto>,
|
||||
}
|
||||
@@ -555,6 +572,19 @@ struct ReleaseOptionDto {
|
||||
subtitle: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct ReleaseTrackDto {
|
||||
id: i64,
|
||||
title: String,
|
||||
artists: String,
|
||||
release_id: Option<i64>,
|
||||
release_title: Option<String>,
|
||||
track_number: Option<i32>,
|
||||
disc_number: Option<i32>,
|
||||
duration_seconds: f64,
|
||||
is_hidden: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
struct AvailableCoverDto {
|
||||
media_file_id: i64,
|
||||
@@ -651,6 +681,19 @@ struct LibraryItemRow {
|
||||
updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, sqlx::FromRow)]
|
||||
struct ReleaseTrackRow {
|
||||
id: i64,
|
||||
title: String,
|
||||
artists: String,
|
||||
release_id: Option<i64>,
|
||||
release_title: Option<String>,
|
||||
track_number: Option<i32>,
|
||||
disc_number: Option<i32>,
|
||||
duration_seconds: f64,
|
||||
is_hidden: bool,
|
||||
}
|
||||
|
||||
pub async fn page(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
||||
let template = AdminV2Template {
|
||||
t: i18n.t,
|
||||
@@ -1289,6 +1332,21 @@ pub async fn library_item_detail(
|
||||
return Ok(response);
|
||||
}
|
||||
let kind = normalize_library_kind(Some(query.kind.as_str()));
|
||||
if kind == "releases" && query.id == 0 {
|
||||
let item = LibraryItemDto {
|
||||
id: 0,
|
||||
kind: kind.clone(),
|
||||
title: String::new(),
|
||||
subtitle: String::new(),
|
||||
is_hidden: Some(false),
|
||||
tags: Vec::new(),
|
||||
updated_at: None,
|
||||
};
|
||||
let detail = load_library_item_detail(pool, &kind, item)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
return Json(detail).into_response();
|
||||
}
|
||||
let Some(item) = fetch_library_item(pool, &kind, query.id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
@@ -1301,6 +1359,25 @@ pub async fn library_item_detail(
|
||||
Json(detail).into_response()
|
||||
}
|
||||
|
||||
pub async fn track_search(
|
||||
session: Session,
|
||||
db: Database,
|
||||
pool: &PgPool,
|
||||
query: TrackSearchQuery,
|
||||
) -> cot::Result<cot::response::Response> {
|
||||
if let Err(response) = require_admin_json(&session, &db).await {
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
let Some(search) = clean_search(query.search.as_deref()) else {
|
||||
return Json(Vec::<ReleaseTrackDto>::new()).into_response();
|
||||
};
|
||||
let tracks = search_tracks(pool, &search, query.limit.unwrap_or(16).clamp(1, 40))
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
Json(tracks).into_response()
|
||||
}
|
||||
|
||||
pub async fn update_library_item(
|
||||
session: Session,
|
||||
db: Database,
|
||||
@@ -1318,6 +1395,19 @@ pub async fn update_library_item(
|
||||
}
|
||||
|
||||
let now = now_string();
|
||||
if kind == "releases" && body.id == 0 {
|
||||
let release_id = create_release_library_item(pool, &body, title, &now)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
let Some(item) = fetch_library_item(pool, &kind, release_id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
else {
|
||||
return Ok(json_error(StatusCode::NOT_FOUND, "library item not found"));
|
||||
};
|
||||
return Json(item).into_response();
|
||||
}
|
||||
|
||||
let affected = match kind.as_str() {
|
||||
"artists" => {
|
||||
sqlx::query(
|
||||
@@ -1421,22 +1511,9 @@ pub async fn update_library_item(
|
||||
let mut seen_artist_ids = HashSet::new();
|
||||
artist_ids.retain(|id| *id > 0 && seen_artist_ids.insert(*id));
|
||||
if kind == "releases" {
|
||||
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
|
||||
.bind(body.id)
|
||||
.execute(pool)
|
||||
set_release_artists(pool, body.id, &artist_ids)
|
||||
.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()))?;
|
||||
}
|
||||
} else {
|
||||
sqlx::query(
|
||||
"DELETE FROM furumusic__track_artist WHERE track_id = $1 AND role = 'main'",
|
||||
@@ -1460,6 +1537,14 @@ pub async fn update_library_item(
|
||||
}
|
||||
}
|
||||
|
||||
if kind == "releases" {
|
||||
if let Some(release_tracks) = body.release_tracks.as_deref() {
|
||||
update_release_tracks(pool, body.id, release_tracks, &now)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||
}
|
||||
}
|
||||
|
||||
let Some(item) = fetch_library_item(pool, &kind, body.id)
|
||||
.await
|
||||
.map_err(|e| cot::Error::internal(e.to_string()))?
|
||||
@@ -2647,13 +2732,13 @@ async fn fetch_library_item(
|
||||
"tracks" => {
|
||||
sqlx::query_as::<_, LibraryItemRow>(
|
||||
"SELECT t.id, t.title::text AS title, \
|
||||
CONCAT(r.title::text, COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
|
||||
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
|
||||
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
|
||||
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
|
||||
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
|
||||
t.updated_at::text AS updated_at \
|
||||
FROM furumusic__track t \
|
||||
JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
|
||||
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
|
||||
LEFT JOIN furumusic__playlist_track pt ON pt.track_id = t.id \
|
||||
@@ -2704,6 +2789,7 @@ async fn load_library_item_detail(
|
||||
selected_artist_ids: Vec::new(),
|
||||
artists: Vec::new(),
|
||||
releases: Vec::new(),
|
||||
release_tracks: Vec::new(),
|
||||
available_covers: Vec::new(),
|
||||
metadata_tags: load_metadata_tags(pool, kind, item.id).await?,
|
||||
item,
|
||||
@@ -2744,16 +2830,22 @@ async fn load_library_item_detail(
|
||||
.map(|row| row.id)
|
||||
.collect();
|
||||
detail.artists = load_artist_options(pool).await?;
|
||||
if detail.item.id > 0 {
|
||||
detail.release_tracks = load_release_tracks(pool, detail.item.id).await?;
|
||||
}
|
||||
}
|
||||
"tracks" => {
|
||||
let row: Option<(i64, Option<i32>, Option<i32>, Option<i32>)> = sqlx::query_as(
|
||||
"SELECT release_id, track_number, disc_number, year FROM furumusic__track WHERE id = $1",
|
||||
let row: Option<(Option<i64>, Option<i32>, Option<i32>, Option<i32>)> = sqlx::query_as(
|
||||
"SELECT r.id AS release_id, t.track_number, t.disc_number, t.year \
|
||||
FROM furumusic__track t \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
WHERE t.id = $1",
|
||||
)
|
||||
.bind(detail.item.id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
if let Some((release_id, track_number, disc_number, year)) = row {
|
||||
detail.release_id = Some(release_id);
|
||||
detail.release_id = release_id;
|
||||
detail.track_number = track_number;
|
||||
detail.disc_number = disc_number;
|
||||
detail.year = year;
|
||||
@@ -2901,6 +2993,210 @@ async fn load_release_options(pool: &PgPool) -> anyhow::Result<Vec<ReleaseOption
|
||||
.collect())
|
||||
}
|
||||
|
||||
async fn create_release_library_item(
|
||||
pool: &PgPool,
|
||||
body: &UpdateLibraryItemRequest,
|
||||
title: &str,
|
||||
now: &str,
|
||||
) -> anyhow::Result<i64> {
|
||||
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::<i32>().ok());
|
||||
let release_id: i64 = sqlx::query_scalar(
|
||||
"INSERT INTO furumusic__release \
|
||||
(title, title_sort, release_type, year, cover_file_id, total_tracks, total_discs, is_hidden, model_name, created_at, updated_at) \
|
||||
VALUES ($1, $2, $3, $4, NULL, NULL, NULL, $5, NULL, $6, $6) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(title)
|
||||
.bind(normalize_name(title))
|
||||
.bind(release_type)
|
||||
.bind(year)
|
||||
.bind(body.hidden)
|
||||
.bind(now)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
if let Some(artist_ids) = body.artist_ids.as_deref() {
|
||||
set_release_artists(pool, release_id, artist_ids).await?;
|
||||
}
|
||||
if let Some(release_tracks) = body.release_tracks.as_deref() {
|
||||
update_release_tracks(pool, release_id, release_tracks, now).await?;
|
||||
}
|
||||
|
||||
Ok(release_id)
|
||||
}
|
||||
|
||||
async fn set_release_artists(
|
||||
pool: &PgPool,
|
||||
release_id: i64,
|
||||
artist_ids: &[i64],
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query("DELETE FROM furumusic__release_artist WHERE release_id = $1")
|
||||
.bind(release_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
let mut seen_artist_ids = HashSet::new();
|
||||
let unique_artist_ids = artist_ids
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|id| *id > 0 && seen_artist_ids.insert(*id))
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for (position, artist_id) in unique_artist_ids.iter().enumerate() {
|
||||
sqlx::query(
|
||||
"INSERT INTO furumusic__release_artist (release_id, artist_id, position) VALUES ($1, $2, $3)",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(*artist_id)
|
||||
.bind(position as i32)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn load_release_tracks(
|
||||
pool: &PgPool,
|
||||
release_id: i64,
|
||||
) -> anyhow::Result<Vec<ReleaseTrackDto>> {
|
||||
let rows = sqlx::query_as::<_, ReleaseTrackRow>(
|
||||
"SELECT t.id, t.title::text AS title, \
|
||||
COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \
|
||||
NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \
|
||||
t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \
|
||||
FROM furumusic__track t \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main' \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
|
||||
WHERE t.release_id = $1 \
|
||||
GROUP BY t.id, r.id, r.title \
|
||||
ORDER BY t.disc_number NULLS FIRST, t.track_number NULLS LAST, t.title ASC, t.id ASC",
|
||||
)
|
||||
.bind(release_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(release_track_dto).collect())
|
||||
}
|
||||
|
||||
async fn search_tracks(
|
||||
pool: &PgPool,
|
||||
search: &str,
|
||||
limit: i64,
|
||||
) -> anyhow::Result<Vec<ReleaseTrackDto>> {
|
||||
let pattern = format!("%{search}%");
|
||||
let starts_with = format!("{search}%");
|
||||
let rows = sqlx::query_as::<_, ReleaseTrackRow>(
|
||||
"SELECT t.id, t.title::text AS title, \
|
||||
COALESCE(NULLIF(STRING_AGG(DISTINCT a.name::text, ', '), ''), 'Unknown artist') AS artists, \
|
||||
NULLIF(t.release_id, 0) AS release_id, r.title::text AS release_title, \
|
||||
t.track_number, t.disc_number, t.duration_seconds, t.is_hidden \
|
||||
FROM furumusic__track t \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id AND ta.role = 'main' \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
|
||||
WHERE t.title ILIKE $1 OR COALESCE(r.title::text, '') ILIKE $1 OR COALESCE(a.name::text, '') ILIKE $1 \
|
||||
GROUP BY t.id, r.id, r.title \
|
||||
ORDER BY CASE \
|
||||
WHEN LOWER(t.title::text) = LOWER($2) THEN 0 \
|
||||
WHEN t.title ILIKE $3 THEN 1 \
|
||||
ELSE 2 \
|
||||
END, \
|
||||
t.title_sort ASC, t.id ASC \
|
||||
LIMIT $4",
|
||||
)
|
||||
.bind(pattern)
|
||||
.bind(search)
|
||||
.bind(starts_with)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(release_track_dto).collect())
|
||||
}
|
||||
|
||||
async fn update_release_tracks(
|
||||
pool: &PgPool,
|
||||
release_id: i64,
|
||||
tracks: &[ReleaseTrackUpdateRequest],
|
||||
now: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut seen_ids = HashSet::new();
|
||||
let selected = tracks
|
||||
.iter()
|
||||
.filter(|track| track.id > 0 && seen_ids.insert(track.id))
|
||||
.collect::<Vec<_>>();
|
||||
let selected_ids = selected.iter().map(|track| track.id).collect::<Vec<_>>();
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
if selected_ids.is_empty() {
|
||||
sqlx::query(
|
||||
"UPDATE furumusic__track \
|
||||
SET release_id = 0, updated_at = $2 \
|
||||
WHERE release_id = $1",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(now)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
} else {
|
||||
sqlx::query(
|
||||
"UPDATE furumusic__track \
|
||||
SET release_id = 0, updated_at = $2 \
|
||||
WHERE release_id = $1 AND id <> ALL($3)",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(now)
|
||||
.bind(&selected_ids)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for track in selected {
|
||||
let track_number = parse_optional_admin_i32(track.track_number.as_deref(), 1, 9999);
|
||||
let disc_number = parse_optional_admin_i32(track.disc_number.as_deref(), 1, 999);
|
||||
sqlx::query(
|
||||
"UPDATE furumusic__track \
|
||||
SET release_id = $1, track_number = $2, disc_number = $3, updated_at = $4 \
|
||||
WHERE id = $5",
|
||||
)
|
||||
.bind(release_id)
|
||||
.bind(track_number)
|
||||
.bind(disc_number)
|
||||
.bind(now)
|
||||
.bind(track.id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn release_track_dto(row: ReleaseTrackRow) -> ReleaseTrackDto {
|
||||
ReleaseTrackDto {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
artists: row.artists,
|
||||
release_id: row.release_id,
|
||||
release_title: row.release_title,
|
||||
track_number: row.track_number,
|
||||
disc_number: row.disc_number,
|
||||
duration_seconds: row.duration_seconds,
|
||||
is_hidden: row.is_hidden,
|
||||
}
|
||||
}
|
||||
|
||||
async fn artist_available_covers(
|
||||
pool: &PgPool,
|
||||
artist_id: i64,
|
||||
@@ -2943,7 +3239,7 @@ async fn library_ids_by_filter(
|
||||
"tracks" => QueryBuilder::<Postgres>::new(
|
||||
"SELECT DISTINCT t.id \
|
||||
FROM furumusic__track t \
|
||||
JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
|
||||
),
|
||||
@@ -3182,7 +3478,7 @@ async fn count_library(
|
||||
"tracks" => QueryBuilder::<Postgres>::new(
|
||||
"SELECT COUNT(DISTINCT t.id) AS count \
|
||||
FROM furumusic__track t \
|
||||
JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id WHERE 1=1",
|
||||
),
|
||||
@@ -3319,13 +3615,13 @@ async fn load_track_items(
|
||||
) -> anyhow::Result<Vec<LibraryItemRow>> {
|
||||
let mut qb = QueryBuilder::<Postgres>::new(
|
||||
"SELECT t.id, t.title::text AS title, \
|
||||
CONCAT(r.title::text, COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
|
||||
CONCAT(COALESCE(r.title::text, 'No release'), COALESCE(' / #' || t.track_number::text, '')) AS subtitle, \
|
||||
t.is_hidden, COUNT(DISTINCT ta.artist_id)::bigint AS primary_count, \
|
||||
COUNT(DISTINCT ph.id)::bigint AS secondary_count, \
|
||||
COUNT(DISTINCT pt.playlist_id)::bigint AS tertiary_count, \
|
||||
t.updated_at::text AS updated_at \
|
||||
FROM furumusic__track t \
|
||||
JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__release r ON r.id = t.release_id \
|
||||
LEFT JOIN furumusic__track_artist ta ON ta.track_id = t.id \
|
||||
LEFT JOIN furumusic__artist a ON a.id = ta.artist_id \
|
||||
LEFT JOIN furumusic__play_history ph ON ph.track_id = t.id \
|
||||
@@ -3341,7 +3637,7 @@ async fn load_track_items(
|
||||
qb.push_bind(pattern);
|
||||
qb.push(")");
|
||||
}
|
||||
qb.push(" GROUP BY t.id, r.title ORDER BY r.title ASC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title ASC LIMIT ");
|
||||
qb.push(" GROUP BY t.id, r.title ORDER BY COALESCE(r.title::text, '') ASC, t.disc_number NULLS FIRST, t.track_number NULLS FIRST, t.title ASC LIMIT ");
|
||||
qb.push_bind(limit);
|
||||
qb.push(" OFFSET ");
|
||||
qb.push_bind(offset);
|
||||
|
||||
+323
-13
@@ -1193,6 +1193,77 @@ tbody tr:hover {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.release-track-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.release-track-list {
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-primary);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.release-track-head,
|
||||
.release-track-row {
|
||||
display: grid;
|
||||
grid-template-columns: 72px 82px minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, .9fr) 70px 36px;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.release-track-head {
|
||||
min-height: 34px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-subdued);
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.release-track-row {
|
||||
min-height: 48px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.055);
|
||||
}
|
||||
|
||||
.release-track-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.release-track-row:hover {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.release-track-row input {
|
||||
width: 100%;
|
||||
height: 30px;
|
||||
min-height: 30px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.release-track-title,
|
||||
.release-track-meta {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.release-track-title {
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.release-track-meta {
|
||||
color: var(--text-subdued);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1836,6 +1907,10 @@ tbody tr:hover {
|
||||
<i data-lucide="square-pen"></i>
|
||||
Edit
|
||||
</button>
|
||||
<button class="btn primary" x-show="libraryKind === 'releases'" @click="openReleaseCreator()">
|
||||
<i data-lucide="plus"></i>
|
||||
New release
|
||||
</button>
|
||||
<button class="btn warn" @click="mockAction('Merge wizard will open from this action slot')">
|
||||
<i data-lucide="git-merge"></i>
|
||||
Merge
|
||||
@@ -2419,8 +2494,8 @@ tbody tr:hover {
|
||||
<section class="modal">
|
||||
<div class="modal-head">
|
||||
<div class="panel-title">
|
||||
<strong x-text="activeLibraryItem?.title || 'Editor'"></strong>
|
||||
<span x-text="activeLibraryItem?.kind || 'Library entity'"></span>
|
||||
<strong x-text="editorTitle()"></strong>
|
||||
<span x-text="editorSubtitle()"></span>
|
||||
</div>
|
||||
<button class="icon-btn" @click="editorOpen = false">
|
||||
<i data-lucide="x"></i>
|
||||
@@ -2454,6 +2529,54 @@ tbody tr:hover {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field" x-show="isReleaseEditor()">
|
||||
<label>Release tracks</label>
|
||||
<div class="release-track-search-row">
|
||||
<div class="artist-picker">
|
||||
<input class="search" placeholder="Search track" x-model="releaseTrackSearch" @input.debounce.300ms="searchReleaseTracks()" @keydown.enter.prevent="addBestReleaseTrack()" @keydown.escape="clearReleaseTrackSearch()" />
|
||||
<div class="artist-results" x-show="releaseTrackSearchOpen()" x-transition>
|
||||
<template x-for="track in availableReleaseTrackResults()" :key="track.id">
|
||||
<button class="artist-result" type="button" @click="addReleaseTrack(track)">
|
||||
<span x-text="track.title"></span>
|
||||
<small x-text="releaseTrackSearchMeta(track)"></small>
|
||||
</button>
|
||||
</template>
|
||||
<div class="artist-result muted" x-show="releaseTrackSearchLoading">Searching...</div>
|
||||
<div class="artist-result muted" x-show="!releaseTrackSearchLoading && availableReleaseTrackResults().length === 0">No matching tracks</div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" type="button" @click="addBestReleaseTrack()" :disabled="!releaseTrackSearch.trim()">
|
||||
<i data-lucide="plus"></i>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<div class="release-track-list" x-show="releaseTracks().length">
|
||||
<div class="release-track-head">
|
||||
<span>Disc</span>
|
||||
<span>Track #</span>
|
||||
<span>Title</span>
|
||||
<span>Artists</span>
|
||||
<span>Current release</span>
|
||||
<span>Time</span>
|
||||
<span></span>
|
||||
</div>
|
||||
<template x-for="track in releaseTracks()" :key="track.id">
|
||||
<div class="release-track-row">
|
||||
<input type="number" min="1" max="999" x-model="track.disc_number" />
|
||||
<input type="number" min="1" max="9999" x-model="track.track_number" />
|
||||
<div class="release-track-title" x-text="track.title"></div>
|
||||
<div class="release-track-meta" x-text="track.artists || 'Unknown artist'"></div>
|
||||
<div class="release-track-meta" x-text="releaseTrackOrigin(track)"></div>
|
||||
<div class="release-track-meta" x-text="trackDuration(track.duration_seconds)"></div>
|
||||
<button class="icon-btn" type="button" @click="removeReleaseTrack(track.id)" title="Remove from release">
|
||||
<i data-lucide="x"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="empty" x-show="!releaseTracks().length">No tracks attached</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-grid" x-show="isTrackEditor()">
|
||||
<div class="field">
|
||||
<label>Track #</label>
|
||||
@@ -2585,9 +2708,9 @@ tbody tr:hover {
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" @click="saveLibraryItem()" :disabled="!editorCanSave()">
|
||||
<i :data-lucide="editorSaving ? 'loader-circle' : 'save'"></i>
|
||||
<span x-text="editorSaving ? 'Saving...' : 'Save'"></span>
|
||||
<span x-text="editorSaving ? 'Saving...' : (editorIsNewRelease() ? 'Create' : 'Save')"></span>
|
||||
</button>
|
||||
<button class="btn danger" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading">
|
||||
<button class="btn danger" x-show="!editorIsNewRelease()" @click="deleteLibraryItem(activeLibraryItem)" :disabled="editorSaving || editorImageUploading">
|
||||
<i data-lucide="trash-2"></i>
|
||||
Delete
|
||||
</button>
|
||||
@@ -2682,8 +2805,12 @@ function adminV2() {
|
||||
editorImageFile: null,
|
||||
editorArtistToAdd: '',
|
||||
editorReleaseToAdd: '',
|
||||
releaseTrackSearch: '',
|
||||
releaseTrackSearchResults: [],
|
||||
releaseTrackSearchLoading: false,
|
||||
releaseTrackSearchToken: 0,
|
||||
editorDetail: null,
|
||||
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [] },
|
||||
editorDraft: { title: '', hidden: 'false', release_type: 'album', year: '', release_id: null, track_number: '', disc_number: '', artist_ids: [], release_tracks: [] },
|
||||
settings: { values: {}, sources: {}, lastfm_api_key_configured: false, lastfm_shared_secret_configured: false, lastfm_scrobbling_configured: false },
|
||||
settingsDraft: {
|
||||
auth_password_enabled: false,
|
||||
@@ -3396,16 +3523,30 @@ function adminV2() {
|
||||
release_id: null,
|
||||
track_number: '',
|
||||
disc_number: '',
|
||||
artist_ids: []
|
||||
artist_ids: [],
|
||||
release_tracks: []
|
||||
};
|
||||
this.editorDetail = null;
|
||||
this.editorImageFile = null;
|
||||
this.editorArtistToAdd = '';
|
||||
this.editorReleaseToAdd = '';
|
||||
this.clearReleaseTrackSearch();
|
||||
this.editorOpen = true;
|
||||
this.loadEditorDetail(item);
|
||||
},
|
||||
|
||||
openReleaseCreator() {
|
||||
this.libraryKind = 'releases';
|
||||
this.openEditor({
|
||||
id: 0,
|
||||
kind: 'releases',
|
||||
title: '',
|
||||
subtitle: 'New release',
|
||||
is_hidden: false,
|
||||
tags: []
|
||||
});
|
||||
},
|
||||
|
||||
async loadEditorDetail(item) {
|
||||
const key = `${item.kind}:${item.id}`;
|
||||
this.editorLoading = true;
|
||||
@@ -3422,11 +3563,13 @@ function adminV2() {
|
||||
release_id: detail.release_id || null,
|
||||
track_number: detail.track_number || '',
|
||||
disc_number: detail.disc_number || '',
|
||||
artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : []
|
||||
artist_ids: Array.isArray(detail.selected_artist_ids) ? detail.selected_artist_ids.slice() : [],
|
||||
release_tracks: Array.isArray(detail.release_tracks) ? detail.release_tracks.map(track => this.normalizeReleaseTrack(track)) : []
|
||||
};
|
||||
this.editorImageFile = null;
|
||||
this.editorArtistToAdd = '';
|
||||
this.editorReleaseToAdd = '';
|
||||
this.clearReleaseTrackSearch();
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
} finally {
|
||||
@@ -3449,8 +3592,22 @@ function adminV2() {
|
||||
return this.activeLibraryItem && this.activeLibraryItem.kind === 'tracks';
|
||||
},
|
||||
|
||||
editorIsNewRelease() {
|
||||
return this.isReleaseEditor() && Number(this.activeLibraryItem.id || 0) === 0;
|
||||
},
|
||||
|
||||
editorTitle() {
|
||||
if (this.editorIsNewRelease()) return 'New release';
|
||||
return this.activeLibraryItem?.title || 'Editor';
|
||||
},
|
||||
|
||||
editorSubtitle() {
|
||||
if (this.editorIsNewRelease()) return 'Create release and attach tracks';
|
||||
return this.activeLibraryItem?.kind || 'Library entity';
|
||||
},
|
||||
|
||||
canEditLibraryImage() {
|
||||
return this.isArtistEditor() || this.isReleaseEditor();
|
||||
return this.isArtistEditor() || (this.isReleaseEditor() && !this.editorIsNewRelease());
|
||||
},
|
||||
|
||||
canShowMetadataTags() {
|
||||
@@ -3497,6 +3654,7 @@ function adminV2() {
|
||||
|
||||
editorCanSave() {
|
||||
if (!this.activeLibraryItem || !this.editorDetail || this.editorLoading || this.editorSaving) return false;
|
||||
if (!String(this.editorDraft.title || '').trim()) return false;
|
||||
if (this.isTrackEditor() && !this.editorDraft.release_id) return false;
|
||||
return true;
|
||||
},
|
||||
@@ -3605,6 +3763,146 @@ function adminV2() {
|
||||
return true;
|
||||
},
|
||||
|
||||
normalizeReleaseTrack(track = {}) {
|
||||
const trackNumber = track.track_number;
|
||||
const discNumber = track.disc_number;
|
||||
return {
|
||||
id: Number(track.id),
|
||||
title: track.title || `Track #${track.id}`,
|
||||
artists: track.artists || '',
|
||||
release_id: track.release_id == null ? null : Number(track.release_id),
|
||||
release_title: track.release_title || '',
|
||||
track_number: trackNumber == null ? '' : String(trackNumber),
|
||||
disc_number: discNumber == null ? '' : String(discNumber),
|
||||
duration_seconds: Number(track.duration_seconds || 0),
|
||||
is_hidden: Boolean(track.is_hidden)
|
||||
};
|
||||
},
|
||||
|
||||
releaseTracks() {
|
||||
if (!Array.isArray(this.editorDraft.release_tracks)) {
|
||||
this.editorDraft.release_tracks = [];
|
||||
}
|
||||
return this.editorDraft.release_tracks;
|
||||
},
|
||||
|
||||
releaseTrackPayload() {
|
||||
return this.releaseTracks().map(track => ({
|
||||
id: Number(track.id),
|
||||
track_number: track.track_number || '',
|
||||
disc_number: track.disc_number || ''
|
||||
}));
|
||||
},
|
||||
|
||||
releaseTrackIds() {
|
||||
return new Set(this.releaseTracks().map(track => Number(track.id)));
|
||||
},
|
||||
|
||||
releaseTrackSearchOpen() {
|
||||
return this.isReleaseEditor() && String(this.releaseTrackSearch || '').trim().length > 0;
|
||||
},
|
||||
|
||||
availableReleaseTrackResults() {
|
||||
const selected = this.releaseTrackIds();
|
||||
return (this.releaseTrackSearchResults || []).filter(track => !selected.has(Number(track.id)));
|
||||
},
|
||||
|
||||
clearReleaseTrackSearch() {
|
||||
this.releaseTrackSearch = '';
|
||||
this.releaseTrackSearchResults = [];
|
||||
this.releaseTrackSearchLoading = false;
|
||||
this.releaseTrackSearchToken += 1;
|
||||
},
|
||||
|
||||
async searchReleaseTracks() {
|
||||
const query = String(this.releaseTrackSearch || '').trim();
|
||||
if (!query) {
|
||||
this.releaseTrackSearchResults = [];
|
||||
this.releaseTrackSearchLoading = false;
|
||||
return;
|
||||
}
|
||||
const token = this.releaseTrackSearchToken + 1;
|
||||
this.releaseTrackSearchToken = token;
|
||||
this.releaseTrackSearchLoading = true;
|
||||
try {
|
||||
const params = new URLSearchParams({ search: query, limit: '16' });
|
||||
const rows = await this.request(`${this.apiBase}/library/tracks/search?${params.toString()}`);
|
||||
if (this.releaseTrackSearchToken !== token) return;
|
||||
this.releaseTrackSearchResults = Array.isArray(rows) ? rows.map(track => this.normalizeReleaseTrack(track)) : [];
|
||||
} catch (error) {
|
||||
if (this.releaseTrackSearchToken === token) this.showToast(error.message);
|
||||
} finally {
|
||||
if (this.releaseTrackSearchToken === token) {
|
||||
this.releaseTrackSearchLoading = false;
|
||||
this.icons();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async addBestReleaseTrack() {
|
||||
if (!String(this.releaseTrackSearch || '').trim()) return;
|
||||
if (!this.availableReleaseTrackResults().length && !this.releaseTrackSearchLoading) {
|
||||
await this.searchReleaseTracks();
|
||||
}
|
||||
const track = this.availableReleaseTrackResults()[0];
|
||||
if (!track) {
|
||||
this.showToast('Choose a track from search results');
|
||||
return;
|
||||
}
|
||||
this.addReleaseTrack(track);
|
||||
},
|
||||
|
||||
addReleaseTrack(track) {
|
||||
if (!track) return;
|
||||
const normalized = this.normalizeReleaseTrack(track);
|
||||
if (this.releaseTrackIds().has(Number(normalized.id))) {
|
||||
this.showToast('Track already in release');
|
||||
return;
|
||||
}
|
||||
this.editorDraft.release_tracks = this.releaseTracks().concat([normalized]);
|
||||
this.clearReleaseTrackSearch();
|
||||
this.$nextTick(() => this.icons());
|
||||
},
|
||||
|
||||
removeReleaseTrack(id) {
|
||||
this.editorDraft.release_tracks = this.releaseTracks().filter(track => Number(track.id) !== Number(id));
|
||||
},
|
||||
|
||||
releaseTrackOrigin(track) {
|
||||
const releaseId = Number(track && track.release_id ? track.release_id : 0);
|
||||
const currentId = Number(this.activeLibraryItem && this.activeLibraryItem.id ? this.activeLibraryItem.id : 0);
|
||||
if (releaseId && releaseId === currentId) return this.editorDraft.title || track.release_title || 'This release';
|
||||
if (track && track.release_title) return track.release_title;
|
||||
if (releaseId) return `Missing release #${releaseId}`;
|
||||
return 'No release';
|
||||
},
|
||||
|
||||
releaseTrackSearchMeta(track) {
|
||||
const parts = [];
|
||||
if (track.artists) parts.push(track.artists);
|
||||
parts.push(this.releaseTrackOrigin(track));
|
||||
const number = this.releaseTrackNumberLabel(track);
|
||||
if (number) parts.push(number);
|
||||
return parts.join(' / ');
|
||||
},
|
||||
|
||||
releaseTrackNumberLabel(track) {
|
||||
const disc = String((track && track.disc_number) || '').trim();
|
||||
const number = String((track && track.track_number) || '').trim();
|
||||
if (disc && number) return `D${disc} #${number}`;
|
||||
if (number) return `#${number}`;
|
||||
if (disc) return `D${disc}`;
|
||||
return '';
|
||||
},
|
||||
|
||||
trackDuration(seconds) {
|
||||
const total = Math.round(Number(seconds || 0));
|
||||
if (!total) return '-';
|
||||
const minutes = Math.floor(total / 60);
|
||||
const rest = String(total % 60).padStart(2, '0');
|
||||
return `${minutes}:${rest}`;
|
||||
},
|
||||
|
||||
setEditorImageFile(event) {
|
||||
this.editorImageFile = event.target.files && event.target.files.length ? event.target.files[0] : null;
|
||||
},
|
||||
@@ -3729,6 +4027,7 @@ function adminV2() {
|
||||
if (!this.editorCanSave()) return;
|
||||
this.editorSaving = true;
|
||||
try {
|
||||
const wasNewRelease = this.editorIsNewRelease();
|
||||
const updated = await this.request(`${this.apiBase}/library/item`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
@@ -3741,13 +4040,15 @@ function adminV2() {
|
||||
release_id: this.editorDraft.release_id ? Number(this.editorDraft.release_id) : null,
|
||||
track_number: this.editorDraft.track_number || '',
|
||||
disc_number: this.editorDraft.disc_number || '',
|
||||
artist_ids: this.editorDraft.artist_ids || []
|
||||
artist_ids: this.editorDraft.artist_ids || [],
|
||||
release_tracks: this.isReleaseEditor() ? this.releaseTrackPayload() : null
|
||||
})
|
||||
});
|
||||
this.replaceLibraryItem(updated);
|
||||
this.activeLibraryItem = updated;
|
||||
if (this.editorDetail) this.editorDetail.item = updated;
|
||||
this.showToast('Saved');
|
||||
if (this.isReleaseEditor()) await this.loadEditorDetail(updated);
|
||||
this.showToast(wasNewRelease ? 'Release created' : 'Saved');
|
||||
await this.refreshCountsOnly();
|
||||
} catch (error) {
|
||||
this.showToast(error.message);
|
||||
@@ -3768,9 +4069,18 @@ function adminV2() {
|
||||
},
|
||||
|
||||
replaceLibraryItem(updated) {
|
||||
this.library.items = this.library.items.map(item =>
|
||||
item.kind === updated.kind && item.id === updated.id ? updated : item
|
||||
);
|
||||
let replaced = false;
|
||||
this.library.items = this.library.items.map(item => {
|
||||
if (item.kind === updated.kind && Number(item.id) === Number(updated.id)) {
|
||||
replaced = true;
|
||||
return updated;
|
||||
}
|
||||
return item;
|
||||
});
|
||||
if (!replaced && updated.kind === this.libraryKind) {
|
||||
this.library.items = [updated].concat(this.library.items || []);
|
||||
this.library.total = Number(this.library.total || 0) + 1;
|
||||
}
|
||||
},
|
||||
|
||||
async refreshCountsOnly() {
|
||||
|
||||
Reference in New Issue
Block a user