synops/maskinrommet/src/podcast_import.rs
vegard a469614ca1 Ferdigstill oppgave 30.7: podcast import wizard
- 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
2026-03-19 00:21:25 +00:00

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())
}