This commit is contained in:
Generated
+1
-1
@@ -1397,7 +1397,7 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.7"
|
version = "0.1.8"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "furumusic"
|
name = "furumusic"
|
||||||
version = "0.1.8"
|
version = "0.1.9"
|
||||||
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"
|
||||||
|
|
||||||
|
|||||||
+284
-7
@@ -1,3 +1,4 @@
|
|||||||
|
mod v2;
|
||||||
pub mod views;
|
pub mod views;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -131,20 +132,296 @@ impl App for AdminApp {
|
|||||||
),
|
),
|
||||||
"admin_setup",
|
"admin_setup",
|
||||||
),
|
),
|
||||||
|
// -- Admin v2 -----------------------------------------------------
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2",
|
||||||
|
|session: Session, db: Database, i18n: I18n| async move {
|
||||||
|
let count = User::count_all(&db).await.unwrap_or(0);
|
||||||
|
if count == 0 {
|
||||||
|
return Ok(auth::redirect("/admin/setup"));
|
||||||
|
}
|
||||||
|
let admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(resp) => return Ok(resp),
|
||||||
|
};
|
||||||
|
v2::page(admin, i18n).await?.into_response()
|
||||||
|
},
|
||||||
|
"admin_v2",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/dashboard",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let registry = Arc::clone(&self.registry);
|
||||||
|
get(move |session: Session, db: Database| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let registry = Arc::clone(®istry);
|
||||||
|
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::dashboard(session, db, pg_pool, ®istry).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_dashboard",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/reviews",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(move |session: Session, db: Database,
|
||||||
|
query: UrlQuery<v2::ReviewsQuery>| {
|
||||||
|
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::reviews(session, db, pg_pool, query.0).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_reviews",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/reviews/bulk",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::BulkReviewsRequest>| {
|
||||||
|
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::bulk_reviews(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_reviews_bulk",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/jobs",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let registry = Arc::clone(&self.registry);
|
||||||
|
get(move |session: Session, db: Database| {
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
let registry = Arc::clone(®istry);
|
||||||
|
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::jobs(session, db, pg_pool, ®istry).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_jobs",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/jobs/{name}/run",
|
||||||
|
cot::router::method::post({
|
||||||
|
let handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
move |session: Session, db: Database, path: Path<PathName>| {
|
||||||
|
let handle = Arc::clone(&handle);
|
||||||
|
async move { v2::run_job(session, db, &handle, &path.0.name).await }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"admin_v2_job_run",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/jobs/{name}/toggle",
|
||||||
|
cot::router::method::post({
|
||||||
|
let handle = Arc::clone(&self.scheduler_handle);
|
||||||
|
move |session: Session, db: Database, path: Path<PathName>| {
|
||||||
|
let handle = Arc::clone(&handle);
|
||||||
|
async move { v2::toggle_job(session, db, &handle, &path.0.name).await }
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
"admin_v2_job_toggle",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/jobs/{name}/runs",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(move |session: Session, db: Database, path: Path<PathName>| {
|
||||||
|
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::job_runs(session, db, pg_pool, &path.0.name).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_job_runs",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/jobs/{name}/runs/{run_id}",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(
|
||||||
|
move |session: Session, db: Database, path: Path<PathNameRunId>| {
|
||||||
|
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::job_run_detail(session, db, pg_pool, path.0.run_id).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_job_run_detail",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
get(move |session: Session, db: Database,
|
||||||
|
query: UrlQuery<v2::LibraryQuery>| {
|
||||||
|
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(session, db, pg_pool, query.0).await
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
"admin_v2_library",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library/item",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::UpdateLibraryItemRequest>| {
|
||||||
|
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::update_library_item(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_library_item",
|
||||||
|
),
|
||||||
|
Route::with_handler_and_name(
|
||||||
|
"/v2/api/library/bulk",
|
||||||
|
{
|
||||||
|
let pool = Arc::clone(&pool);
|
||||||
|
let pool_config = Arc::clone(&pool_config);
|
||||||
|
cot::router::method::post(
|
||||||
|
move |session: Session,
|
||||||
|
db: Database,
|
||||||
|
json: Json<v2::BulkLibraryRequest>| {
|
||||||
|
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::bulk_library(session, db, pg_pool, json).await
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"admin_v2_library_bulk",
|
||||||
|
),
|
||||||
// -- Dashboard ----------------------------------------------------
|
// -- Dashboard ----------------------------------------------------
|
||||||
Route::with_handler_and_name(
|
Route::with_handler_and_name(
|
||||||
"/",
|
"/",
|
||||||
|session: Session, db: Database, i18n: I18n| async move {
|
|session: Session, db: Database, i18n: I18n| async move {
|
||||||
let count = User::count_all(&db).await.unwrap_or(0);
|
let count = User::count_all(&db).await.unwrap_or(0);
|
||||||
if count == 0 {
|
if count == 0 {
|
||||||
return Ok(auth::redirect("/admin/setup"));
|
return Ok::<cot::response::Response, cot::Error>(auth::redirect(
|
||||||
|
"/admin/setup",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
let admin =
|
let _admin = match auth::require_admin_or_redirect(&session, &db).await {
|
||||||
match auth::require_admin_or_redirect(&session, &db).await {
|
Ok(u) => u,
|
||||||
Ok(u) => u,
|
Err(resp) => return Ok(resp),
|
||||||
Err(resp) => return Ok(resp),
|
};
|
||||||
};
|
let _ = i18n;
|
||||||
views::admin_index(admin, i18n).await?.into_response()
|
Ok::<cot::response::Response, cot::Error>(auth::redirect("/admin/v2"))
|
||||||
},
|
},
|
||||||
"admin_index",
|
"admin_index",
|
||||||
),
|
),
|
||||||
|
|||||||
+1766
File diff suppressed because it is too large
Load Diff
@@ -211,23 +211,6 @@ pub async fn debug_handler(
|
|||||||
Ok(Html::new(template.render()?))
|
Ok(Html::new(template.render()?))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Template)]
|
|
||||||
#[template(path = "admin/index.html")]
|
|
||||||
struct AdminIndexTemplate {
|
|
||||||
t: &'static Translations,
|
|
||||||
user_name: String,
|
|
||||||
user_role: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn admin_index(admin: AuthenticatedUser, i18n: I18n) -> cot::Result<Html> {
|
|
||||||
let template = AdminIndexTemplate {
|
|
||||||
t: i18n.t,
|
|
||||||
user_name: admin.name,
|
|
||||||
user_role: admin.role.code().to_owned(),
|
|
||||||
};
|
|
||||||
Ok(Html::new(template.render()?))
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Settings page
|
// Settings page
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -108,6 +108,9 @@ pub(super) struct PlaylistCard {
|
|||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_count: i64,
|
pub(super) track_count: i64,
|
||||||
pub(super) is_own: bool,
|
pub(super) is_own: bool,
|
||||||
|
pub(super) owner_name: Option<String>,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
pub(super) kind: String,
|
pub(super) kind: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,6 +131,9 @@ pub(super) struct PlaylistDetail {
|
|||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) description: Option<String>,
|
pub(super) description: Option<String>,
|
||||||
pub(super) is_own: bool,
|
pub(super) is_own: bool,
|
||||||
|
pub(super) owner_name: Option<String>,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
pub(super) kind: String,
|
pub(super) kind: String,
|
||||||
pub(super) tracks: Vec<TrackItem>,
|
pub(super) tracks: Vec<TrackItem>,
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-4
@@ -546,18 +546,33 @@ async fn playlists_handler(
|
|||||||
title: "Likes".to_string(),
|
title: "Likes".to_string(),
|
||||||
track_count: likes_count.0,
|
track_count: likes_count.0,
|
||||||
is_own: true,
|
is_own: true,
|
||||||
|
owner_name: None,
|
||||||
|
is_public: false,
|
||||||
|
is_saved: false,
|
||||||
kind: "likes".to_string(),
|
kind: "likes".to_string(),
|
||||||
}];
|
}];
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, PlaylistRow>(
|
let rows = sqlx::query_as::<_, PlaylistRow>(
|
||||||
r#"SELECT p.id, p.title::text as title,
|
r#"SELECT p.id, p.title::text as title,
|
||||||
COALESCE((SELECT COUNT(*) FROM furumusic__playlist_track pt WHERE pt.playlist_id = p.id), 0) as track_count,
|
COALESCE((SELECT COUNT(*) FROM furumusic__playlist_track pt WHERE pt.playlist_id = p.id), 0) as track_count,
|
||||||
(p.owner_id = $1) as is_own
|
(p.owner_id = $1) as is_own,
|
||||||
|
COALESCE(NULLIF(u.display_name, ''), u.username)::text as owner_name,
|
||||||
|
p.is_public,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM furumusic__saved_playlist sp
|
||||||
|
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
|
||||||
|
) as is_saved
|
||||||
FROM furumusic__playlist p
|
FROM furumusic__playlist p
|
||||||
|
JOIN furumusic__user u ON u.id = p.owner_id
|
||||||
WHERE p.owner_id = $1
|
WHERE p.owner_id = $1
|
||||||
OR p.id IN (SELECT sp.playlist_id FROM furumusic__saved_playlist sp WHERE sp.user_id = $1)
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM furumusic__saved_playlist sp
|
||||||
|
WHERE sp.user_id = $1 AND sp.playlist_id = p.id
|
||||||
|
)
|
||||||
OR p.is_public = true
|
OR p.is_public = true
|
||||||
ORDER BY p.title"#,
|
ORDER BY
|
||||||
|
CASE WHEN p.owner_id = $1 THEN 0 WHEN p.is_public THEN 2 ELSE 1 END,
|
||||||
|
p.title"#,
|
||||||
)
|
)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
@@ -569,6 +584,9 @@ async fn playlists_handler(
|
|||||||
title: r.title,
|
title: r.title,
|
||||||
track_count: r.track_count,
|
track_count: r.track_count,
|
||||||
is_own: r.is_own,
|
is_own: r.is_own,
|
||||||
|
owner_name: Some(r.owner_name),
|
||||||
|
is_public: r.is_public,
|
||||||
|
is_saved: r.is_saved,
|
||||||
kind: "user".to_string(),
|
kind: "user".to_string(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -597,9 +615,19 @@ async fn playlist_detail_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let info = sqlx::query_as::<_, PlaylistInfoRow>(
|
let info = sqlx::query_as::<_, PlaylistInfoRow>(
|
||||||
"SELECT id, title::text as title, description, owner_id FROM furumusic__playlist WHERE id = $1",
|
r#"SELECT p.id, p.title::text as title, p.description, p.owner_id,
|
||||||
|
COALESCE(NULLIF(u.display_name, ''), u.username)::text as owner_name,
|
||||||
|
p.is_public,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM furumusic__saved_playlist sp
|
||||||
|
WHERE sp.user_id = $2 AND sp.playlist_id = p.id
|
||||||
|
) as is_saved
|
||||||
|
FROM furumusic__playlist p
|
||||||
|
JOIN furumusic__user u ON u.id = p.owner_id
|
||||||
|
WHERE p.id = $1"#,
|
||||||
)
|
)
|
||||||
.bind(playlist_id)
|
.bind(playlist_id)
|
||||||
|
.bind(user.id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
.map_err(|e| cot::Error::internal(e.to_string()))?;
|
||||||
@@ -637,6 +665,9 @@ async fn playlist_detail_handler(
|
|||||||
title: info.title,
|
title: info.title,
|
||||||
description: info.description,
|
description: info.description,
|
||||||
is_own: info.owner_id == user.id,
|
is_own: info.owner_id == user.id,
|
||||||
|
owner_name: Some(info.owner_name),
|
||||||
|
is_public: info.is_public,
|
||||||
|
is_saved: info.is_saved,
|
||||||
kind: "user".to_string(),
|
kind: "user".to_string(),
|
||||||
tracks: track_items,
|
tracks: track_items,
|
||||||
})
|
})
|
||||||
@@ -748,6 +779,9 @@ async fn likes_playlist_handler(
|
|||||||
title: "Likes".to_string(),
|
title: "Likes".to_string(),
|
||||||
description: None,
|
description: None,
|
||||||
is_own: true,
|
is_own: true,
|
||||||
|
owner_name: None,
|
||||||
|
is_public: false,
|
||||||
|
is_saved: false,
|
||||||
kind: "likes".to_string(),
|
kind: "likes".to_string(),
|
||||||
tracks: track_items,
|
tracks: track_items,
|
||||||
})
|
})
|
||||||
@@ -1429,6 +1463,9 @@ async fn create_playlist_handler(
|
|||||||
title,
|
title,
|
||||||
track_count: 0,
|
track_count: 0,
|
||||||
is_own: true,
|
is_own: true,
|
||||||
|
owner_name: Some(user.name),
|
||||||
|
is_public: false,
|
||||||
|
is_saved: false,
|
||||||
kind: "user".to_string(),
|
kind: "user".to_string(),
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
|
|||||||
@@ -77,6 +77,9 @@ pub(super) struct PlaylistRow {
|
|||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) track_count: i64,
|
pub(super) track_count: i64,
|
||||||
pub(super) is_own: bool,
|
pub(super) is_own: bool,
|
||||||
|
pub(super) owner_name: String,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
@@ -85,6 +88,9 @@ pub(super) struct PlaylistInfoRow {
|
|||||||
pub(super) title: String,
|
pub(super) title: String,
|
||||||
pub(super) description: Option<String>,
|
pub(super) description: Option<String>,
|
||||||
pub(super) owner_id: i64,
|
pub(super) owner_id: i64,
|
||||||
|
pub(super) owner_name: String,
|
||||||
|
pub(super) is_public: bool,
|
||||||
|
pub(super) is_saved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
|
|||||||
@@ -1124,6 +1124,34 @@ pub struct SchedulerHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl SchedulerHandle {
|
impl SchedulerHandle {
|
||||||
|
/// Start a job immediately in the background and return the created run id.
|
||||||
|
pub async fn trigger_job_now_background(
|
||||||
|
self: Arc<Self>,
|
||||||
|
job_name: &str,
|
||||||
|
) -> anyhow::Result<i64> {
|
||||||
|
self.registry
|
||||||
|
.get(job_name)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("unknown job: {job_name}"))?;
|
||||||
|
|
||||||
|
let db = self.shared_db.clone();
|
||||||
|
let pool = self.shared_pool.clone();
|
||||||
|
let (live_config, _) = AppConfig::load_with_db(&db).await;
|
||||||
|
let run = JobRun::create_running(&db, job_name, "manual")
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("failed to create job run: {e}"))?;
|
||||||
|
let run_id = run.id_val();
|
||||||
|
let job_name = job_name.to_owned();
|
||||||
|
let handle = Arc::clone(&self);
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
handle
|
||||||
|
.finish_manual_run(job_name, live_config, db, pool, run)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(run_id)
|
||||||
|
}
|
||||||
|
|
||||||
/// Execute a job immediately (manual or programmatic trigger).
|
/// Execute a job immediately (manual or programmatic trigger).
|
||||||
pub async fn trigger_job_now(&self, job_name: &str) -> anyhow::Result<i64> {
|
pub async fn trigger_job_now(&self, job_name: &str) -> anyhow::Result<i64> {
|
||||||
let job_impl = self
|
let job_impl = self
|
||||||
@@ -1172,6 +1200,51 @@ impl SchedulerHandle {
|
|||||||
Ok(run.id_val())
|
Ok(run.id_val())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn finish_manual_run(
|
||||||
|
self: Arc<Self>,
|
||||||
|
job_name: String,
|
||||||
|
live_config: AppConfig,
|
||||||
|
db: Database,
|
||||||
|
pool: sqlx::PgPool,
|
||||||
|
mut run: JobRun,
|
||||||
|
) {
|
||||||
|
let Some(job_impl) = self.registry.get(&job_name) else {
|
||||||
|
let _ = run
|
||||||
|
.set_failed(&db, 0, "", &format!("unknown job: {job_name}"))
|
||||||
|
.await;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let start = std::time::Instant::now();
|
||||||
|
let ctx = JobContext {
|
||||||
|
config: Arc::new(live_config),
|
||||||
|
db: db.clone(),
|
||||||
|
pool: pool.clone(),
|
||||||
|
run_id: run.id_val(),
|
||||||
|
registry: Arc::clone(&self.registry),
|
||||||
|
};
|
||||||
|
let mut log = JobLog::with_live_flush(pool, run.id_val());
|
||||||
|
|
||||||
|
match job_impl.run(&ctx, &mut log).await {
|
||||||
|
Ok(()) => {
|
||||||
|
let duration_ms = start.elapsed().as_millis() as i64;
|
||||||
|
let _ = run.set_completed(&db, duration_ms, &log.output()).await;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
let duration_ms = start.elapsed().as_millis() as i64;
|
||||||
|
let _ = run
|
||||||
|
.set_failed(&db, duration_ms, &log.output(), &e.to_string())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(Some(mut sched_job)) = ScheduledJob::get_by_name(&db, &job_name).await {
|
||||||
|
sched_job.last_run_at = Some(now_iso().to_string());
|
||||||
|
sched_job.updated_at = now_iso();
|
||||||
|
let _ = sched_job.save(&db).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Remove a cron job from the scheduler and re-add it with a new cron
|
/// Remove a cron job from the scheduler and re-add it with a new cron
|
||||||
/// expression. Also updates the DB row.
|
/// expression. Also updates the DB row.
|
||||||
pub async fn reschedule_job(&self, job_name: &str, new_cron: &str) -> anyhow::Result<()> {
|
pub async fn reschedule_job(&self, job_name: &str, new_cron: &str) -> anyhow::Result<()> {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+109
-1
@@ -312,6 +312,61 @@ button.user-stat:hover {
|
|||||||
color: var(--text-subdued);
|
color: var(--text-subdued);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-public-section {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-subtitle {
|
||||||
|
padding-top: 2px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-title-line,
|
||||||
|
.playlist-meta-line {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-title-text {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-item-public {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-meta-line {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-owner {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-public-badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(52, 211, 153, 0.12);
|
||||||
|
color: #6ee7b7;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-bottom {
|
.sidebar-bottom {
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
border-top: 1px solid var(--border-color);
|
border-top: 1px solid var(--border-color);
|
||||||
@@ -348,6 +403,15 @@ button.user-stat:hover {
|
|||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-detail-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: -12px 0 18px;
|
||||||
|
color: var(--text-subdued);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.breadcrumb {
|
.breadcrumb {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -2339,7 +2403,7 @@ button.user-stat:hover {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="playlist-list">
|
<div class="playlist-list">
|
||||||
<template x-for="pl in $store.playlists.list" :key="pl.id">
|
<template x-for="pl in $store.playlists.regularList()" :key="pl.id">
|
||||||
<div class="playlist-item-row">
|
<div class="playlist-item-row">
|
||||||
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id)">
|
<div class="playlist-item" @click="$store.library.openPlaylist(pl.id)">
|
||||||
<template x-if="pl.kind === 'likes'">
|
<template x-if="pl.kind === 'likes'">
|
||||||
@@ -2369,6 +2433,26 @@ button.user-stat:hover {
|
|||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||||
New Playlist
|
New Playlist
|
||||||
</button>
|
</button>
|
||||||
|
<template x-if="$store.playlists.publishedList().length > 0">
|
||||||
|
<div class="playlist-public-section">
|
||||||
|
<div class="sidebar-section-title playlist-subtitle">Published Playlists</div>
|
||||||
|
<template x-for="pl in $store.playlists.publishedList()" :key="'published-' + pl.id">
|
||||||
|
<div class="playlist-item-row">
|
||||||
|
<div class="playlist-item playlist-item-public" @click="$store.library.openPlaylist(pl.id)">
|
||||||
|
<div class="playlist-title-line">
|
||||||
|
<span class="playlist-title-text" x-text="pl.title"></span>
|
||||||
|
<span class="playlist-public-badge">Public</span>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-meta-line">
|
||||||
|
<span class="playlist-owner" x-show="pl.owner_name" x-text="'by ' + pl.owner_name"></span>
|
||||||
|
<span x-show="pl.owner_name">·</span>
|
||||||
|
<span x-text="pl.track_count + ' tracks'"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="sidebar-bottom">
|
<div class="sidebar-bottom">
|
||||||
<a href="/admin/">Admin Panel</a>
|
<a href="/admin/">Admin Panel</a>
|
||||||
@@ -2869,6 +2953,14 @@ button.user-stat:hover {
|
|||||||
<span x-text="$store.library.currentPlaylist.title"></span>
|
<span x-text="$store.library.currentPlaylist.title"></span>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="section-title" x-text="$store.library.currentPlaylist.title"></h1>
|
<h1 class="section-title" x-text="$store.library.currentPlaylist.title"></h1>
|
||||||
|
<div class="playlist-detail-meta"
|
||||||
|
x-show="$store.library.currentPlaylist.owner_name || $store.library.currentPlaylist.is_public">
|
||||||
|
<span x-show="$store.library.currentPlaylist.owner_name"
|
||||||
|
x-text="'by ' + $store.library.currentPlaylist.owner_name"></span>
|
||||||
|
<span x-show="$store.library.currentPlaylist.owner_name && $store.library.currentPlaylist.is_public">·</span>
|
||||||
|
<span class="playlist-public-badge"
|
||||||
|
x-show="$store.library.currentPlaylist.is_public">Published</span>
|
||||||
|
</div>
|
||||||
<template x-if="$store.library.currentPlaylist.description">
|
<template x-if="$store.library.currentPlaylist.description">
|
||||||
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
<p style="color:var(--text-subdued);margin-bottom:16px" x-text="$store.library.currentPlaylist.description"></p>
|
||||||
</template>
|
</template>
|
||||||
@@ -4553,6 +4645,22 @@ document.addEventListener('alpine:init', () => {
|
|||||||
} catch {}
|
} catch {}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
regularList() {
|
||||||
|
return this.list.filter(pl => (
|
||||||
|
pl.kind === 'likes' ||
|
||||||
|
pl.is_own ||
|
||||||
|
!pl.is_public
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
|
publishedList() {
|
||||||
|
return this.list.filter(pl => (
|
||||||
|
pl.kind === 'user' &&
|
||||||
|
!pl.is_own &&
|
||||||
|
pl.is_public
|
||||||
|
));
|
||||||
|
},
|
||||||
|
|
||||||
showCreate() {
|
showCreate() {
|
||||||
this.modal = { mode: 'create', title: '' };
|
this.modal = { mode: 'create', title: '' };
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user