- Inkluder samlinger med rss-trait (ikke bare podcast-trait) i dropdown - Fiks slug-lesing fra traits.publishing.slug - Installer synops-import-podcast til /usr/local/bin - Marker oppgave 30.7 som ferdig i tasks.md
337 lines
10 KiB
Rust
337 lines
10 KiB
Rust
// Podcast-import — API-endepunkter for import-wizard (oppgave 30.7).
|
|
//
|
|
// Tre endepunkter:
|
|
// POST /admin/podcast/import-preview — dry-run: hent og vis hva som ville importeres
|
|
// POST /admin/podcast/import — start faktisk import via jobbkø
|
|
// GET /admin/podcast/import-status — hent status for en import-jobb
|
|
//
|
|
// Preview kjører synops-import-podcast --dry-run direkte (synkront).
|
|
// Import legger en jobb i køen som kjører CLI-verktøyet asynkront.
|
|
//
|
|
// Ref: docs/features/podcast_hosting.md § "Prøveimport-flyten"
|
|
|
|
use axum::{extract::State, http::StatusCode, Json};
|
|
use axum::extract::Query;
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::AuthUser;
|
|
use crate::cli_dispatch;
|
|
use crate::jobs;
|
|
use crate::AppState;
|
|
|
|
// =============================================================================
|
|
// Preview (dry-run)
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ImportPreviewRequest {
|
|
pub feed_url: String,
|
|
pub collection_id: Uuid,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ImportPreviewResponse {
|
|
pub status: String,
|
|
pub feed_url: String,
|
|
pub feed_title: Option<String>,
|
|
pub episodes_found: usize,
|
|
pub episodes_imported: usize,
|
|
pub episodes_skipped: usize,
|
|
pub dry_run: bool,
|
|
pub episodes: Vec<serde_json::Value>,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
/// POST /admin/podcast/import-preview
|
|
///
|
|
/// Kjører synops-import-podcast --dry-run og returnerer resultatet.
|
|
/// Synkront — feeden hentes og parses, men ingenting skrives.
|
|
pub async fn import_preview(
|
|
user: AuthUser,
|
|
State(state): State<AppState>,
|
|
Json(body): Json<ImportPreviewRequest>,
|
|
) -> Result<Json<serde_json::Value>, StatusCode> {
|
|
// Verifiser at samlingen eksisterer og har podcast-trait
|
|
let collection_exists: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'collection')",
|
|
)
|
|
.bind(body.collection_id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil: {e}");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
if !collection_exists {
|
|
tracing::warn!(
|
|
collection_id = %body.collection_id,
|
|
"import-preview: samling finnes ikke"
|
|
);
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
// Kjør CLI-verktøyet med --dry-run
|
|
let bin = import_podcast_bin();
|
|
let mut cmd = tokio::process::Command::new(&bin);
|
|
cmd.arg("--feed-url").arg(&body.feed_url)
|
|
.arg("--collection-id").arg(body.collection_id.to_string())
|
|
.arg("--created-by").arg(user.node_id.to_string())
|
|
.arg("--dry-run");
|
|
|
|
cli_dispatch::set_database_url(&mut cmd).map_err(|e| {
|
|
tracing::error!("DATABASE_URL mangler: {e}");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
cli_dispatch::forward_env(&mut cmd, "CAS_ROOT");
|
|
|
|
tracing::info!(
|
|
feed_url = %body.feed_url,
|
|
collection_id = %body.collection_id,
|
|
user = %user.node_id,
|
|
"Starter podcast import preview (dry-run)"
|
|
);
|
|
|
|
let result = cli_dispatch::run_cli_tool("synops-import-podcast", &mut cmd)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("import-preview feilet: {e}");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
Ok(Json(result))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Import (via jobbkø)
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ImportStartRequest {
|
|
pub feed_url: String,
|
|
pub collection_id: Uuid,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ImportStartResponse {
|
|
pub job_id: Uuid,
|
|
}
|
|
|
|
/// POST /admin/podcast/import
|
|
///
|
|
/// Legger en import-jobb i køen. Returnerer job_id umiddelbart.
|
|
pub async fn import_start(
|
|
user: AuthUser,
|
|
State(state): State<AppState>,
|
|
Json(body): Json<ImportStartRequest>,
|
|
) -> Result<Json<ImportStartResponse>, StatusCode> {
|
|
// Verifiser samling
|
|
let collection_exists: bool = sqlx::query_scalar(
|
|
"SELECT EXISTS(SELECT 1 FROM nodes WHERE id = $1 AND node_kind = 'collection')",
|
|
)
|
|
.bind(body.collection_id)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil: {e}");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
if !collection_exists {
|
|
return Err(StatusCode::NOT_FOUND);
|
|
}
|
|
|
|
let payload = serde_json::json!({
|
|
"feed_url": body.feed_url,
|
|
"collection_id": body.collection_id.to_string(),
|
|
"created_by": user.node_id.to_string(),
|
|
});
|
|
|
|
let job_id = jobs::enqueue(
|
|
&state.db,
|
|
"import_podcast",
|
|
payload,
|
|
Some(body.collection_id),
|
|
3, // medium priority
|
|
)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("Kunne ikke enqueue import_podcast: {e}");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
tracing::info!(
|
|
job_id = %job_id,
|
|
feed_url = %body.feed_url,
|
|
collection_id = %body.collection_id,
|
|
user = %user.node_id,
|
|
"Podcast-import lagt i jobbkø"
|
|
);
|
|
|
|
Ok(Json(ImportStartResponse { job_id }))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Import-status (poll)
|
|
// =============================================================================
|
|
|
|
#[derive(Deserialize)]
|
|
pub struct ImportStatusQuery {
|
|
pub job_id: Uuid,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
pub struct ImportStatusResponse {
|
|
pub job_id: Uuid,
|
|
pub status: String,
|
|
pub result: Option<serde_json::Value>,
|
|
pub error_msg: Option<String>,
|
|
pub created_at: String,
|
|
pub started_at: Option<String>,
|
|
pub completed_at: Option<String>,
|
|
}
|
|
|
|
/// GET /admin/podcast/import-status?job_id=<uuid>
|
|
pub async fn import_status(
|
|
_user: AuthUser,
|
|
State(state): State<AppState>,
|
|
Query(query): Query<ImportStatusQuery>,
|
|
) -> Result<Json<ImportStatusResponse>, StatusCode> {
|
|
let row = sqlx::query_as::<_, (String, Option<serde_json::Value>, Option<String>, String, Option<String>, Option<String>)>(
|
|
r#"
|
|
SELECT status, result, error_msg,
|
|
created_at::text, started_at::text, completed_at::text
|
|
FROM job_queue
|
|
WHERE id = $1
|
|
"#,
|
|
)
|
|
.bind(query.job_id)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil: {e}");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
match row {
|
|
Some((status, result, error_msg, created_at, started_at, completed_at)) => {
|
|
Ok(Json(ImportStatusResponse {
|
|
job_id: query.job_id,
|
|
status,
|
|
result,
|
|
error_msg,
|
|
created_at,
|
|
started_at,
|
|
completed_at,
|
|
}))
|
|
}
|
|
None => Err(StatusCode::NOT_FOUND),
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Collections med podcast-trait (for dropdown)
|
|
// =============================================================================
|
|
|
|
#[derive(Serialize)]
|
|
pub struct PodcastCollection {
|
|
pub id: Uuid,
|
|
pub title: Option<String>,
|
|
pub slug: Option<String>,
|
|
}
|
|
|
|
/// GET /admin/podcast/collections — samlinger med podcast-trait
|
|
pub async fn podcast_collections(
|
|
_user: AuthUser,
|
|
State(state): State<AppState>,
|
|
) -> Result<Json<Vec<PodcastCollection>>, StatusCode> {
|
|
let rows = sqlx::query_as::<_, (Uuid, Option<String>, serde_json::Value)>(
|
|
r#"
|
|
SELECT id, title, COALESCE(metadata, '{}'::jsonb)
|
|
FROM nodes
|
|
WHERE node_kind = 'collection'
|
|
AND (metadata->'traits'->'podcast' IS NOT NULL
|
|
OR metadata->'traits'->'rss' IS NOT NULL)
|
|
ORDER BY title
|
|
"#,
|
|
)
|
|
.fetch_all(&state.db)
|
|
.await
|
|
.map_err(|e| {
|
|
tracing::error!("PG-feil: {e}");
|
|
StatusCode::INTERNAL_SERVER_ERROR
|
|
})?;
|
|
|
|
let collections: Vec<PodcastCollection> = rows
|
|
.into_iter()
|
|
.map(|(id, title, metadata)| {
|
|
let traits = metadata.get("traits");
|
|
let slug = traits
|
|
.and_then(|t| t.get("publishing"))
|
|
.and_then(|p| p.get("slug"))
|
|
.and_then(|s| s.as_str())
|
|
.map(|s| s.to_string());
|
|
PodcastCollection { id, title, slug }
|
|
})
|
|
.collect();
|
|
|
|
Ok(Json(collections))
|
|
}
|
|
|
|
// =============================================================================
|
|
// Jobb-handler (kalles fra jobs::dispatch)
|
|
// =============================================================================
|
|
|
|
/// Handler for `import_podcast`-jobb i jobbkøen.
|
|
pub async fn handle_import_podcast(
|
|
job: &jobs::JobRow,
|
|
) -> Result<serde_json::Value, String> {
|
|
let feed_url = job.payload["feed_url"]
|
|
.as_str()
|
|
.ok_or("Mangler feed_url i payload")?;
|
|
let collection_id = job.payload["collection_id"]
|
|
.as_str()
|
|
.ok_or("Mangler collection_id i payload")?;
|
|
let created_by = job.payload["created_by"]
|
|
.as_str()
|
|
.ok_or("Mangler created_by i payload")?;
|
|
|
|
let bin = import_podcast_bin();
|
|
let mut cmd = tokio::process::Command::new(&bin);
|
|
cmd.arg("--feed-url").arg(feed_url)
|
|
.arg("--collection-id").arg(collection_id)
|
|
.arg("--created-by").arg(created_by);
|
|
|
|
cli_dispatch::set_database_url(&mut cmd)?;
|
|
cli_dispatch::forward_env(&mut cmd, "CAS_ROOT");
|
|
|
|
tracing::info!(
|
|
job_id = %job.id,
|
|
feed_url = feed_url,
|
|
collection_id = collection_id,
|
|
"Starter synops-import-podcast"
|
|
);
|
|
|
|
let result = cli_dispatch::run_cli_tool("synops-import-podcast", &mut cmd).await?;
|
|
|
|
tracing::info!(
|
|
job_id = %job.id,
|
|
status = result["status"].as_str().unwrap_or("unknown"),
|
|
imported = result["episodes_imported"].as_u64().unwrap_or(0),
|
|
skipped = result["episodes_skipped"].as_u64().unwrap_or(0),
|
|
"synops-import-podcast fullført"
|
|
);
|
|
|
|
Ok(result)
|
|
}
|
|
|
|
// =============================================================================
|
|
// Hjelpefunksjoner
|
|
// =============================================================================
|
|
|
|
/// Synops-import-podcast binary path.
|
|
fn import_podcast_bin() -> String {
|
|
std::env::var("SYNOPS_IMPORT_PODCAST_BIN")
|
|
.unwrap_or_else(|_| "synops-import-podcast".to_string())
|
|
}
|