From 62b1ecd0b673b66a8d864c2130d5b9a0152700b2 Mon Sep 17 00:00:00 2001 From: vegard Date: Thu, 19 Mar 2026 00:19:24 +0000 Subject: [PATCH] Podcast import wizard: backend + frontend (oppgave 30.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend (maskinrommet): - Nytt modul podcast_import.rs med 4 endepunkter: POST /admin/podcast/import-preview (dry-run via CLI) POST /admin/podcast/import (starter jobb i køen) GET /admin/podcast/import-status (poll jobbstatus) GET /admin/podcast/collections (samlinger med podcast-trait) - Ny jobbtype import_podcast i jobs.rs dispatcher Frontend: - Ny wizard-side /admin/podcast-import med 5 steg: 1. RSS-URL + samling → forhåndsvisning 2. Import (spinner med jobbstatus-polling) 3. Resultat med sammenligning av feeds 4. Re-import for nye episoder 5. 301-redirect-info - API-funksjoner i api.ts - Navigasjonslenke i admin-panelet --- frontend/src/lib/api.ts | 102 +++ frontend/src/routes/admin/+page.svelte | 3 + .../routes/admin/podcast-import/+page.svelte | 726 ++++++++++++++++++ maskinrommet/src/jobs.rs | 4 + maskinrommet/src/main.rs | 6 + maskinrommet/src/podcast_import.rs | 335 ++++++++ 6 files changed, 1176 insertions(+) create mode 100644 frontend/src/routes/admin/podcast-import/+page.svelte create mode 100644 maskinrommet/src/podcast_import.rs diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 39bdaa4..0c5f9f6 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1637,3 +1637,105 @@ export async function fetchPodcastStats( } return res.json(); } + +// ============================================================================= +// Podcast-import wizard (oppgave 30.7) +// ============================================================================= + +export interface EpisodePreview { + guid: string; + title: string; + published_at: string | null; + duration: string | null; + episode_number: number | null; + season_number: number | null; + action: string; + node_id: string | null; + error: string | null; +} + +export interface ImportPreviewResponse { + status: string; + feed_url: string; + feed_title: string | null; + episodes_found: number; + episodes_imported: number; + episodes_skipped: number; + dry_run: boolean; + episodes: EpisodePreview[]; + errors: string[]; +} + +export interface ImportStartResponse { + job_id: string; +} + +export interface ImportStatusResponse { + job_id: string; + status: string; + result: ImportPreviewResponse | null; + error_msg: string | null; + created_at: string; + started_at: string | null; + completed_at: string | null; +} + +export interface PodcastCollection { + id: string; + title: string | null; + slug: string | null; +} + +/** Forhåndsvisning av podcast-import (dry-run). */ +export function podcastImportPreview( + accessToken: string, + feedUrl: string, + collectionId: string +): Promise { + return post(accessToken, '/admin/podcast/import-preview', { + feed_url: feedUrl, + collection_id: collectionId + }); +} + +/** Start podcast-import via jobbkø. Returnerer job_id. */ +export function podcastImportStart( + accessToken: string, + feedUrl: string, + collectionId: string +): Promise { + return post(accessToken, '/admin/podcast/import', { + feed_url: feedUrl, + collection_id: collectionId + }); +} + +/** Hent status for en podcast-import-jobb. */ +export async function podcastImportStatus( + accessToken: string, + jobId: string +): Promise { + const res = await fetch( + `${BASE_URL}/admin/podcast/import-status?job_id=${encodeURIComponent(jobId)}`, + { headers: { Authorization: `Bearer ${accessToken}` } } + ); + if (!res.ok) { + const body = await res.text(); + throw new Error(`import status failed (${res.status}): ${body}`); + } + return res.json(); +} + +/** Hent samlinger med podcast-trait (for import-wizard dropdown). */ +export async function fetchPodcastCollections( + accessToken: string +): Promise { + const res = await fetch(`${BASE_URL}/admin/podcast/collections`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`podcast collections failed (${res.status}): ${body}`); + } + return res.json(); +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 0de6914..e7c1bd9 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -112,6 +112,9 @@ Podcast + + Import + diff --git a/frontend/src/routes/admin/podcast-import/+page.svelte b/frontend/src/routes/admin/podcast-import/+page.svelte new file mode 100644 index 0000000..18dc5ef --- /dev/null +++ b/frontend/src/routes/admin/podcast-import/+page.svelte @@ -0,0 +1,726 @@ + + +
+
+ +
+

Importer podcast

+ +
+ + +
+ {#each [ + { n: 1, label: 'Forhåndsvisning' }, + { n: 2, label: 'Importerer' }, + { n: 3, label: 'Resultat' }, + { n: 4, label: 'Re-import' }, + { n: 5, label: 'Redirect' } + ] as s} + + {/each} +
+ + {#if !accessToken} +

Logg inn for tilgang.

+ {:else} + + + + {#if step === 1} +
+

1. Hent forhåndsvisning fra RSS-feed

+

+ Lim inn URL-en til RSS-feeden du vil importere fra. Vi viser hva som + ville blitt importert uten å gjøre endringer. +

+ +
+
+ + +
+ +
+ + {#if collections.length === 0} +

+ Ingen samlinger med podcast-trait funnet. Opprett en samling med + podcast-trait først. +

+ {:else} + + {/if} +
+ + +
+ + {#if previewError} +
+ {previewError} +
+ {/if} + + + {#if preview} +
+
+

+ {preview.feed_title || 'Ukjent feed'} +

+ + {preview.episodes_found} episoder funnet + +
+ + +
+
+
Nye (vil importeres)
+
+ {preview.episodes_imported} +
+
+
+
Allerede importert
+
+ {preview.episodes_skipped} +
+
+
+
Totalt i feed
+
{preview.episodes_found}
+
+
+ + + {#if preview.episodes.length > 0} +
+
+ + + + + + + + + + + + {#each preview.episodes as ep} + + + + + + + + {/each} + +
StatusTittel
+ {#if ep.action === 'would_import'} + + {:else if ep.action === 'skipped'} + + {/if} + + {ep.title} +
+
+
+ {/if} + + + {#if preview.errors.length > 0} +
+

Feil under parsing

+ {#each preview.errors as err} +

{err}

+ {/each} +
+ {/if} + + + {#if preview.episodes_imported > 0} +
+ + + Lydfiler og artwork lastes ned til CAS. Dette kan ta tid. + +
+ {:else} +

+ Alle episoder er allerede importert. Ingenting nytt å importere. +

+ {/if} + + {#if importError} +
+ {importError} +
+ {/if} +
+ {/if} +
+ {/if} + + + + + {#if step === 2} +
+

2. Importerer...

+ +
+
+
+

+ Importerer episoder fra {feedUrl} +

+

+ Lydfiler og artwork lastes ned. Dette kan ta flere minutter for mange episoder. +

+
+
+ + {#if importStatus} +
+
+ Status: + {importStatus.status} +
+ {#if importStatus.started_at} +
+ Startet: {new Date(importStatus.started_at).toLocaleTimeString('nb-NO')} +
+ {/if} +
+ {/if} + + {#if importJobId} +

+ Jobb-ID: {importJobId} +

+ {/if} +
+ {/if} + + + + + {#if step === 3} +
+

3. Import-resultat

+ + {#if importStatus?.status === 'failed'} +
+

Import feilet

+ {#if importStatus.error_msg} +

{importStatus.error_msg}

+ {/if} +
+ {:else if importResult} + +
+
+
Importert
+
+ {importResult.episodes_imported} +
+
+
+
Hoppet over (duplikater)
+
+ {importResult.episodes_skipped} +
+
+
+
Feil
+
+ {importResult.errors.length} +
+
+
+ + + {#if importResult.episodes.length > 0} +
+
+ + + + + + + + + + + {#each importResult.episodes as ep} + + + + + + + {/each} + +
StatusTittel
+ {#if ep.action === 'imported'} + Importert + {:else if ep.action === 'skipped'} + Duplikat + {:else if ep.action === 'error'} + Feil + {/if} + + {ep.title} +
+
+
+ {/if} + + + {#if importResult.errors.length > 0} +
+

Feil under import

+ {#each importResult.errors as err} +

{err}

+ {/each} +
+ {/if} + + + {#if selectedCollection()?.slug} +
+

Sammenlign feeds

+
+
+ Original feed + + {feedUrl} + +
+ +
+

+ Sjekk at begge feedene viser de samme episodene med riktig metadata. +

+
+ {/if} + {/if} + + +
+ + +
+
+ {/if} + + + + + {#if step === 4} +
+

4. Re-importer nye episoder

+

+ Kjør importen igjen for å fange opp nye episoder som er publisert + siden forrige import. Eksisterende episoder hoppes over (duplikatdeteksjon via guid). +

+ +
+
+ Feed: + {feedUrl} +
+
+ Samling: + + {selectedCollection()?.title || selectedCollectionId} + +
+
+ +
+ + +
+
+ {/if} + + + + + {#if step === 5} +
+

5. Aktiver 301-redirect

+

+ Når du er fornøyd med importen, kan du sette opp en 301-redirect fra + den gamle feed-URL-en til Synops sin feed. Apple Podcasts, Spotify og + andre klienter oppdaterer automatisk innen noen dager. +

+ +
+

Viktig

+
    +
  • Sett opp 301-redirect på den gamle hosten som peker til Synops sin feed-URL.
  • +
  • Noen podcast-apper bruker dager eller uker på å følge redirecten.
  • +
  • Du kan alternativt sette redirect_feed i samlingens + podcast-trait for å redirecte fra Synops til en annen host (for utflytting).
  • +
+
+ + {#if selectedCollection()?.slug} +
+

Synops feed-URL (ny)

+

+ https://synops.no/pub/{selectedCollection()?.slug}/feed.xml +

+

+ Pek den gamle hostens feed hit med en 301 Permanent Redirect. +

+
+ {/if} + + +
+ + Avansert: Sett redirect_feed (utflytting fra Synops) + +
+

+ Brukes kun hvis du vil flytte podcasten bort fra Synops. + Setter redirect_feed + i podcast-trait, og Caddy returnerer 301 for Synops-feeden. +

+
+ + +
+ {#if redirectError} +

{redirectError}

+ {/if} + {#if redirectSaved} +

Redirect satt. Feeden vil nå returnere 301.

+ {/if} +
+
+
+ {/if} + {/if} +
+
diff --git a/maskinrommet/src/jobs.rs b/maskinrommet/src/jobs.rs index 98a70e6..7d40565 100644 --- a/maskinrommet/src/jobs.rs +++ b/maskinrommet/src/jobs.rs @@ -240,6 +240,10 @@ async fn dispatch( "orchestrate" => { handle_orchestrate(job, db).await } + // Podcast-import fra RSS-feed (oppgave 30.7) + "import_podcast" => { + crate::podcast_import::handle_import_podcast(job).await + } other => Err(format!("Ukjent jobbtype: {other}")), } } diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index 3220fc8..034f4be 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -40,6 +40,7 @@ pub mod script_executor; pub mod tiptap; pub mod transcribe; pub mod tts; +pub mod podcast_import; pub mod podcast_stats; pub mod usage_overview; pub mod user_usage; @@ -255,6 +256,11 @@ async fn main() { .route("/admin/usage", get(usage_overview::usage_overview)) // Podcast-statistikk (oppgave 30.4) .route("/admin/podcast/stats", get(podcast_stats::podcast_stats)) + // Podcast-import wizard (oppgave 30.7) + .route("/admin/podcast/import-preview", post(podcast_import::import_preview)) + .route("/admin/podcast/import", post(podcast_import::import_start)) + .route("/admin/podcast/import-status", get(podcast_import::import_status)) + .route("/admin/podcast/collections", get(podcast_import::podcast_collections)) // Brukersynlig forbruk (oppgave 15.9) .route("/my/usage", get(user_usage::my_usage)) .route("/my/workspace", get(workspace::my_workspace)) diff --git a/maskinrommet/src/podcast_import.rs b/maskinrommet/src/podcast_import.rs new file mode 100644 index 0000000..90532ff --- /dev/null +++ b/maskinrommet/src/podcast_import.rs @@ -0,0 +1,335 @@ +// 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 + 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 slug = metadata + .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()) +}