// 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, pub episodes_found: usize, pub episodes_imported: usize, pub episodes_skipped: usize, pub dry_run: bool, pub episodes: Vec, pub errors: Vec, } /// 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, Json(body): Json, ) -> Result, 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, Json(body): Json, ) -> Result, 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, pub error_msg: Option, pub created_at: String, pub started_at: Option, pub completed_at: Option, } /// GET /admin/podcast/import-status?job_id= pub async fn import_status( _user: AuthUser, State(state): State, Query(query): Query, ) -> Result, StatusCode> { let row = sqlx::query_as::<_, (String, Option, Option, String, Option, Option)>( 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, pub slug: Option, } /// GET /admin/podcast/collections — samlinger med podcast-trait pub async fn podcast_collections( _user: AuthUser, State(state): State, ) -> Result>, StatusCode> { let rows = sqlx::query_as::<_, (Uuid, Option, 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 = 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 { 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()) }