Podcast import wizard: backend + frontend (oppgave 30.7)

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
This commit is contained in:
vegard 2026-03-19 00:19:24 +00:00
parent 07d783d572
commit 62b1ecd0b6
6 changed files with 1176 additions and 0 deletions

View file

@ -1637,3 +1637,105 @@ export async function fetchPodcastStats(
} }
return res.json(); 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<ImportPreviewResponse> {
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<ImportStartResponse> {
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<ImportStatusResponse> {
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<PodcastCollection[]> {
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();
}

View file

@ -112,6 +112,9 @@
<a href="/admin/podcast-stats" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200"> <a href="/admin/podcast-stats" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
Podcast Podcast
</a> </a>
<a href="/admin/podcast-import" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
Import
</a>
</nav> </nav>
</div> </div>
</header> </header>

View file

@ -0,0 +1,726 @@
<script lang="ts">
/**
* Admin — Podcast Import Wizard (oppgave 30.7)
*
* Stegvis import av podcast fra RSS-feed:
* 1. Lim inn RSS-URL → forhåndsvisning (dry-run)
* 2. Start import (via jobbkø)
* 3. Sjekk resultat
* 4. Re-importer nye episoder
* 5. Aktiver 301-redirect
*/
import { page } from '$app/stores';
import {
podcastImportPreview,
podcastImportStart,
podcastImportStatus,
fetchPodcastCollections,
updateNode,
type ImportPreviewResponse,
type ImportStatusResponse,
type PodcastCollection,
type EpisodePreview
} from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
// Wizard state
let step = $state(1);
let feedUrl = $state('');
let selectedCollectionId = $state('');
let collections = $state<PodcastCollection[]>([]);
// Steg 1: Preview
let preview = $state<ImportPreviewResponse | null>(null);
let previewLoading = $state(false);
let previewError = $state<string | null>(null);
// Steg 2: Import
let importJobId = $state<string | null>(null);
let importStatus = $state<ImportStatusResponse | null>(null);
let importLoading = $state(false);
let importError = $state<string | null>(null);
let pollInterval = $state<ReturnType<typeof setInterval> | null>(null);
// Steg 5: Redirect
let redirectUrl = $state('');
let redirectSaving = $state(false);
let redirectError = $state<string | null>(null);
let redirectSaved = $state(false);
// Last samlinger ved oppstart
$effect(() => {
if (!accessToken) return;
loadCollections();
});
async function loadCollections() {
if (!accessToken) return;
try {
collections = await fetchPodcastCollections(accessToken);
if (collections.length === 1) {
selectedCollectionId = collections[0].id;
}
} catch (e) {
previewError = String(e);
}
}
// Steg 1: Hent forhåndsvisning
async function handlePreview() {
if (!accessToken || !feedUrl.trim() || !selectedCollectionId) return;
previewLoading = true;
previewError = null;
preview = null;
try {
preview = await podcastImportPreview(accessToken, feedUrl.trim(), selectedCollectionId);
} catch (e) {
previewError = String(e);
} finally {
previewLoading = false;
}
}
// Steg 2: Start import
async function handleImport() {
if (!accessToken || !feedUrl.trim() || !selectedCollectionId) return;
importLoading = true;
importError = null;
try {
const result = await podcastImportStart(accessToken, feedUrl.trim(), selectedCollectionId);
importJobId = result.job_id;
step = 2;
startPolling();
} catch (e) {
importError = String(e);
} finally {
importLoading = false;
}
}
function startPolling() {
if (pollInterval) clearInterval(pollInterval);
pollInterval = setInterval(pollJobStatus, 3000);
pollJobStatus();
}
async function pollJobStatus() {
if (!accessToken || !importJobId) return;
try {
importStatus = await podcastImportStatus(accessToken, importJobId);
if (importStatus.status === 'completed' || importStatus.status === 'failed') {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
step = 3;
}
} catch (e) {
importError = String(e);
}
}
// Steg 4: Re-import (kjør preview igjen for å se nye episoder, deretter import)
async function handleReimport() {
// Gå tilbake til steg 1 med feedUrl beholdt
preview = null;
importStatus = null;
importJobId = null;
step = 1;
// Trigger preview automatisk
await handlePreview();
}
// Steg 5: Sett redirect_feed
async function handleSetRedirect() {
if (!accessToken || !selectedCollectionId || !redirectUrl.trim()) return;
redirectSaving = true;
redirectError = null;
redirectSaved = false;
try {
// Hent nåværende metadata og oppdater podcast-trait
// Vi bruker updateNode for å sette redirect_feed i metadata.traits.podcast
await updateNode(accessToken, {
node_id: selectedCollectionId,
metadata: {
traits: {
podcast: {
redirect_feed: redirectUrl.trim()
}
}
}
});
redirectSaved = true;
} catch (e) {
redirectError = String(e);
} finally {
redirectSaving = false;
}
}
// Cleanup
$effect(() => {
return () => {
if (pollInterval) clearInterval(pollInterval);
};
});
// Hjelpefunksjoner
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('nb-NO', {
day: 'numeric',
month: 'short',
year: 'numeric'
});
}
function selectedCollection(): PodcastCollection | undefined {
return collections.find((c) => c.id === selectedCollectionId);
}
function episodesByAction(episodes: EpisodePreview[], action: string): EpisodePreview[] {
return episodes.filter((e) => e.action === action);
}
const importResult = $derived(
importStatus?.result as ImportPreviewResponse | null
);
</script>
<div class="min-h-screen bg-neutral-950 text-neutral-100 p-4 sm:p-8">
<div class="max-w-4xl mx-auto space-y-6">
<!-- Header -->
<div class="flex items-center justify-between flex-wrap gap-3">
<h1 class="text-2xl font-bold">Importer podcast</h1>
<div class="flex gap-3">
<a href="/admin/podcast-stats" class="text-sm text-neutral-400 hover:text-white">
Statistikk
</a>
<a href="/admin" class="text-sm text-neutral-400 hover:text-white">
Tilbake til admin
</a>
</div>
</div>
<!-- Steg-indikator -->
<div class="flex items-center gap-1 text-sm">
{#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}
<button
class="px-3 py-1.5 rounded-lg transition-colors {step === s.n
? 'bg-blue-600 text-white'
: step > s.n
? 'bg-neutral-700 text-neutral-300 hover:bg-neutral-600'
: 'bg-neutral-800 text-neutral-500'}"
onclick={() => {
if (s.n <= step || (s.n === 4 && step >= 3) || (s.n === 5 && step >= 3)) step = s.n;
}}
disabled={s.n > step && !(s.n === 4 && step >= 3) && !(s.n === 5 && step >= 3)}
>
{s.n}. {s.label}
</button>
{/each}
</div>
{#if !accessToken}
<p class="text-neutral-500">Logg inn for tilgang.</p>
{:else}
<!-- ============================================================ -->
<!-- STEG 1: Forhåndsvisning -->
<!-- ============================================================ -->
{#if step === 1}
<section class="bg-neutral-900 border border-neutral-800 rounded-lg p-6 space-y-4">
<h2 class="text-lg font-semibold">1. Hent forhåndsvisning fra RSS-feed</h2>
<p class="text-sm text-neutral-400">
Lim inn URL-en til RSS-feeden du vil importere fra. Vi viser hva som
ville blitt importert uten å gjøre endringer.
</p>
<div class="space-y-3">
<div>
<label for="feed-url" class="block text-sm font-medium text-neutral-300 mb-1">
RSS-feed URL
</label>
<input
id="feed-url"
type="url"
bind:value={feedUrl}
placeholder="https://example.com/feed.xml"
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-4 py-2.5 text-sm
placeholder-neutral-500 focus:border-blue-500 focus:outline-none"
/>
</div>
<div>
<label for="collection" class="block text-sm font-medium text-neutral-300 mb-1">
Importer til samling
</label>
{#if collections.length === 0}
<p class="text-sm text-neutral-500">
Ingen samlinger med podcast-trait funnet. Opprett en samling med
podcast-trait først.
</p>
{:else}
<select
id="collection"
bind:value={selectedCollectionId}
class="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-4 py-2.5 text-sm"
>
<option value="">Velg samling...</option>
{#each collections as col}
<option value={col.id}>
{col.title || col.id} {col.slug ? `(${col.slug})` : ''}
</option>
{/each}
</select>
{/if}
</div>
<button
onclick={handlePreview}
disabled={previewLoading || !feedUrl.trim() || !selectedCollectionId}
class="bg-blue-600 hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed
px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
>
{previewLoading ? 'Henter feed...' : 'Forhåndsvis'}
</button>
</div>
{#if previewError}
<div class="bg-red-900/30 border border-red-700 rounded-lg p-4 text-red-300 text-sm">
{previewError}
</div>
{/if}
<!-- Preview-resultat -->
{#if preview}
<div class="border-t border-neutral-800 pt-4 space-y-4">
<div class="flex items-center gap-3">
<h3 class="text-base font-semibold">
{preview.feed_title || 'Ukjent feed'}
</h3>
<span class="bg-neutral-800 text-neutral-300 px-2.5 py-0.5 rounded-full text-xs font-mono">
{preview.episodes_found} episoder funnet
</span>
</div>
<!-- Oppsummering -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="bg-neutral-800 rounded-lg p-3">
<div class="text-xs text-neutral-400 mb-1">Nye (vil importeres)</div>
<div class="text-2xl font-mono font-bold text-green-400">
{preview.episodes_imported}
</div>
</div>
<div class="bg-neutral-800 rounded-lg p-3">
<div class="text-xs text-neutral-400 mb-1">Allerede importert</div>
<div class="text-2xl font-mono font-bold text-neutral-400">
{preview.episodes_skipped}
</div>
</div>
<div class="bg-neutral-800 rounded-lg p-3">
<div class="text-xs text-neutral-400 mb-1">Totalt i feed</div>
<div class="text-2xl font-mono font-bold">{preview.episodes_found}</div>
</div>
</div>
<!-- Episode-tabell -->
{#if preview.episodes.length > 0}
<div class="bg-neutral-800/50 rounded-lg overflow-hidden">
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="w-full text-sm">
<thead class="sticky top-0 bg-neutral-800">
<tr class="border-b border-neutral-700 text-left text-neutral-400">
<th class="px-3 py-2 font-medium">Status</th>
<th class="px-3 py-2 font-medium">Tittel</th>
<th class="px-3 py-2 font-medium hidden sm:table-cell">Dato</th>
<th class="px-3 py-2 font-medium hidden md:table-cell">Varighet</th>
<th class="px-3 py-2 font-medium hidden md:table-cell">S/E</th>
</tr>
</thead>
<tbody>
{#each preview.episodes as ep}
<tr class="border-b border-neutral-800/50 hover:bg-neutral-800/30">
<td class="px-3 py-2">
{#if ep.action === 'would_import'}
<span class="inline-block w-2 h-2 rounded-full bg-green-500" title="Ny"></span>
{:else if ep.action === 'skipped'}
<span class="inline-block w-2 h-2 rounded-full bg-neutral-500" title="Eksisterer"></span>
{/if}
</td>
<td class="px-3 py-2 text-neutral-200 max-w-xs truncate">
{ep.title}
</td>
<td class="px-3 py-2 text-neutral-400 hidden sm:table-cell text-xs font-mono">
{formatDate(ep.published_at)}
</td>
<td class="px-3 py-2 text-neutral-400 hidden md:table-cell text-xs font-mono">
{ep.duration || '—'}
</td>
<td class="px-3 py-2 text-neutral-400 hidden md:table-cell text-xs font-mono">
{ep.season_number != null ? `S${ep.season_number}` : ''}
{ep.episode_number != null ? `E${ep.episode_number}` : ''}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<!-- Feilmeldinger -->
{#if preview.errors.length > 0}
<div class="bg-red-900/20 border border-red-800 rounded-lg p-3">
<h4 class="text-sm font-medium text-red-300 mb-2">Feil under parsing</h4>
{#each preview.errors as err}
<p class="text-xs text-red-400">{err}</p>
{/each}
</div>
{/if}
<!-- Importer-knapp -->
{#if preview.episodes_imported > 0}
<div class="flex items-center gap-3 pt-2">
<button
onclick={handleImport}
disabled={importLoading}
class="bg-green-600 hover:bg-green-700 disabled:opacity-50
px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
>
{importLoading
? 'Starter import...'
: `Importer ${preview.episodes_imported} episoder`}
</button>
<span class="text-xs text-neutral-500">
Lydfiler og artwork lastes ned til CAS. Dette kan ta tid.
</span>
</div>
{:else}
<p class="text-sm text-neutral-400">
Alle episoder er allerede importert. Ingenting nytt å importere.
</p>
{/if}
{#if importError}
<div class="bg-red-900/30 border border-red-700 rounded-lg p-4 text-red-300 text-sm">
{importError}
</div>
{/if}
</div>
{/if}
</section>
{/if}
<!-- ============================================================ -->
<!-- STEG 2: Importerer -->
<!-- ============================================================ -->
{#if step === 2}
<section class="bg-neutral-900 border border-neutral-800 rounded-lg p-6 space-y-4">
<h2 class="text-lg font-semibold">2. Importerer...</h2>
<div class="flex items-center gap-4">
<div class="h-8 w-8 rounded-full border-2 border-blue-500 border-t-transparent animate-spin"></div>
<div>
<p class="text-sm text-neutral-300">
Importerer episoder fra <span class="font-mono text-blue-400">{feedUrl}</span>
</p>
<p class="text-xs text-neutral-500 mt-1">
Lydfiler og artwork lastes ned. Dette kan ta flere minutter for mange episoder.
</p>
</div>
</div>
{#if importStatus}
<div class="bg-neutral-800 rounded-lg p-3 text-sm">
<div class="flex items-center gap-2">
<span class="text-neutral-400">Status:</span>
<span class="font-medium text-yellow-400">{importStatus.status}</span>
</div>
{#if importStatus.started_at}
<div class="text-xs text-neutral-500 mt-1">
Startet: {new Date(importStatus.started_at).toLocaleTimeString('nb-NO')}
</div>
{/if}
</div>
{/if}
{#if importJobId}
<p class="text-xs text-neutral-600 font-mono">
Jobb-ID: {importJobId}
</p>
{/if}
</section>
{/if}
<!-- ============================================================ -->
<!-- STEG 3: Resultat -->
<!-- ============================================================ -->
{#if step === 3}
<section class="bg-neutral-900 border border-neutral-800 rounded-lg p-6 space-y-4">
<h2 class="text-lg font-semibold">3. Import-resultat</h2>
{#if importStatus?.status === 'failed'}
<div class="bg-red-900/30 border border-red-700 rounded-lg p-4 text-red-300 text-sm">
<p class="font-medium">Import feilet</p>
{#if importStatus.error_msg}
<p class="mt-1 text-xs">{importStatus.error_msg}</p>
{/if}
</div>
{:else if importResult}
<!-- Oppsummering -->
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div class="bg-neutral-800 rounded-lg p-3">
<div class="text-xs text-neutral-400 mb-1">Importert</div>
<div class="text-2xl font-mono font-bold text-green-400">
{importResult.episodes_imported}
</div>
</div>
<div class="bg-neutral-800 rounded-lg p-3">
<div class="text-xs text-neutral-400 mb-1">Hoppet over (duplikater)</div>
<div class="text-2xl font-mono font-bold text-neutral-400">
{importResult.episodes_skipped}
</div>
</div>
<div class="bg-neutral-800 rounded-lg p-3">
<div class="text-xs text-neutral-400 mb-1">Feil</div>
<div class="text-2xl font-mono font-bold {importResult.errors.length > 0 ? 'text-red-400' : 'text-neutral-400'}">
{importResult.errors.length}
</div>
</div>
</div>
<!-- Resultattabell -->
{#if importResult.episodes.length > 0}
<div class="bg-neutral-800/50 rounded-lg overflow-hidden">
<div class="overflow-x-auto max-h-96 overflow-y-auto">
<table class="w-full text-sm">
<thead class="sticky top-0 bg-neutral-800">
<tr class="border-b border-neutral-700 text-left text-neutral-400">
<th class="px-3 py-2 font-medium">Status</th>
<th class="px-3 py-2 font-medium">Tittel</th>
<th class="px-3 py-2 font-medium hidden sm:table-cell">Dato</th>
<th class="px-3 py-2 font-medium hidden md:table-cell">Detaljer</th>
</tr>
</thead>
<tbody>
{#each importResult.episodes as ep}
<tr class="border-b border-neutral-800/50 hover:bg-neutral-800/30">
<td class="px-3 py-2">
{#if ep.action === 'imported'}
<span class="text-green-400 text-xs font-medium">Importert</span>
{:else if ep.action === 'skipped'}
<span class="text-neutral-500 text-xs">Duplikat</span>
{:else if ep.action === 'error'}
<span class="text-red-400 text-xs font-medium">Feil</span>
{/if}
</td>
<td class="px-3 py-2 text-neutral-200 max-w-xs truncate">
{ep.title}
</td>
<td class="px-3 py-2 text-neutral-400 hidden sm:table-cell text-xs font-mono">
{formatDate(ep.published_at)}
</td>
<td class="px-3 py-2 text-neutral-500 hidden md:table-cell text-xs">
{#if ep.error}
<span class="text-red-400">{ep.error}</span>
{:else if ep.node_id}
<span class="font-mono">{ep.node_id.slice(0, 8)}...</span>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
<!-- Feilmeldinger -->
{#if importResult.errors.length > 0}
<div class="bg-red-900/20 border border-red-800 rounded-lg p-3">
<h4 class="text-sm font-medium text-red-300 mb-2">Feil under import</h4>
{#each importResult.errors as err}
<p class="text-xs text-red-400">{err}</p>
{/each}
</div>
{/if}
<!-- Sammenlign feeds -->
{#if selectedCollection()?.slug}
<div class="bg-neutral-800 rounded-lg p-4 space-y-2">
<h4 class="text-sm font-medium text-neutral-300">Sammenlign feeds</h4>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div>
<span class="text-xs text-neutral-400 block mb-1">Original feed</span>
<a
href={feedUrl}
target="_blank"
rel="noopener"
class="text-blue-400 hover:underline break-all text-xs font-mono"
>
{feedUrl}
</a>
</div>
<div>
<span class="text-xs text-neutral-400 block mb-1">Synops feed</span>
<a
href="/pub/{selectedCollection()?.slug}/feed.xml"
target="_blank"
rel="noopener"
class="text-blue-400 hover:underline break-all text-xs font-mono"
>
/pub/{selectedCollection()?.slug}/feed.xml
</a>
</div>
</div>
<p class="text-xs text-neutral-500">
Sjekk at begge feedene viser de samme episodene med riktig metadata.
</p>
</div>
{/if}
{/if}
<!-- Videre -->
<div class="flex gap-3 pt-2">
<button
onclick={() => (step = 4)}
class="bg-blue-600 hover:bg-blue-700 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
>
Neste: Re-import
</button>
<button
onclick={() => (step = 5)}
class="bg-neutral-700 hover:bg-neutral-600 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
>
Hopp til redirect
</button>
</div>
</section>
{/if}
<!-- ============================================================ -->
<!-- STEG 4: Re-import -->
<!-- ============================================================ -->
{#if step === 4}
<section class="bg-neutral-900 border border-neutral-800 rounded-lg p-6 space-y-4">
<h2 class="text-lg font-semibold">4. Re-importer nye episoder</h2>
<p class="text-sm text-neutral-400">
Kjør importen igjen for å fange opp nye episoder som er publisert
siden forrige import. Eksisterende episoder hoppes over (duplikatdeteksjon via guid).
</p>
<div class="bg-neutral-800 rounded-lg p-4 space-y-2">
<div class="text-sm">
<span class="text-neutral-400">Feed:</span>
<span class="font-mono text-neutral-200 text-xs ml-2">{feedUrl}</span>
</div>
<div class="text-sm">
<span class="text-neutral-400">Samling:</span>
<span class="text-neutral-200 ml-2">
{selectedCollection()?.title || selectedCollectionId}
</span>
</div>
</div>
<div class="flex gap-3">
<button
onclick={handleReimport}
disabled={previewLoading}
class="bg-blue-600 hover:bg-blue-700 disabled:opacity-50
px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
>
{previewLoading ? 'Sjekker feed...' : 'Sjekk for nye episoder'}
</button>
<button
onclick={() => (step = 5)}
class="bg-neutral-700 hover:bg-neutral-600 px-5 py-2.5 rounded-lg text-sm font-medium transition-colors"
>
Neste: Redirect
</button>
</div>
</section>
{/if}
<!-- ============================================================ -->
<!-- STEG 5: 301-redirect -->
<!-- ============================================================ -->
{#if step === 5}
<section class="bg-neutral-900 border border-neutral-800 rounded-lg p-6 space-y-4">
<h2 class="text-lg font-semibold">5. Aktiver 301-redirect</h2>
<p class="text-sm text-neutral-400">
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.
</p>
<div class="bg-amber-900/20 border border-amber-800/50 rounded-lg p-4 text-sm text-amber-300">
<p class="font-medium">Viktig</p>
<ul class="mt-2 space-y-1 text-xs text-amber-200/80">
<li>Sett opp 301-redirect <strong>på den gamle hosten</strong> som peker til Synops sin feed-URL.</li>
<li>Noen podcast-apper bruker dager eller uker på å følge redirecten.</li>
<li>Du kan alternativt sette <code class="bg-neutral-800 px-1 rounded">redirect_feed</code> i samlingens
podcast-trait for å redirecte <em>fra</em> Synops til en annen host (for utflytting).</li>
</ul>
</div>
{#if selectedCollection()?.slug}
<div class="bg-neutral-800 rounded-lg p-4 space-y-2">
<h4 class="text-sm font-medium text-neutral-300">Synops feed-URL (ny)</h4>
<p class="font-mono text-xs text-blue-400 break-all">
https://synops.no/pub/{selectedCollection()?.slug}/feed.xml
</p>
<p class="text-xs text-neutral-500">
Pek den gamle hostens feed hit med en 301 Permanent Redirect.
</p>
</div>
{/if}
<!-- Redirect fra Synops (utflytting) -->
<details class="bg-neutral-800 rounded-lg p-4">
<summary class="text-sm font-medium text-neutral-300 cursor-pointer">
Avansert: Sett redirect_feed (utflytting fra Synops)
</summary>
<div class="mt-3 space-y-3">
<p class="text-xs text-neutral-400">
Brukes kun hvis du vil flytte podcasten <em>bort</em> fra Synops.
Setter <code class="bg-neutral-900 px-1 rounded">redirect_feed</code>
i podcast-trait, og Caddy returnerer 301 for Synops-feeden.
</p>
<div class="flex gap-2">
<input
type="url"
bind:value={redirectUrl}
placeholder="https://ny-host.no/feed.xml"
class="flex-1 bg-neutral-900 border border-neutral-700 rounded px-3 py-2 text-sm
placeholder-neutral-600 focus:border-blue-500 focus:outline-none"
/>
<button
onclick={handleSetRedirect}
disabled={redirectSaving || !redirectUrl.trim()}
class="bg-red-600 hover:bg-red-700 disabled:opacity-50
px-4 py-2 rounded text-sm font-medium transition-colors"
>
{redirectSaving ? 'Lagrer...' : 'Sett redirect'}
</button>
</div>
{#if redirectError}
<p class="text-xs text-red-400">{redirectError}</p>
{/if}
{#if redirectSaved}
<p class="text-xs text-green-400">Redirect satt. Feeden vil nå returnere 301.</p>
{/if}
</div>
</details>
</section>
{/if}
{/if}
</div>
</div>

View file

@ -240,6 +240,10 @@ async fn dispatch(
"orchestrate" => { "orchestrate" => {
handle_orchestrate(job, db).await 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}")), other => Err(format!("Ukjent jobbtype: {other}")),
} }
} }

View file

@ -40,6 +40,7 @@ pub mod script_executor;
pub mod tiptap; pub mod tiptap;
pub mod transcribe; pub mod transcribe;
pub mod tts; pub mod tts;
pub mod podcast_import;
pub mod podcast_stats; pub mod podcast_stats;
pub mod usage_overview; pub mod usage_overview;
pub mod user_usage; pub mod user_usage;
@ -255,6 +256,11 @@ async fn main() {
.route("/admin/usage", get(usage_overview::usage_overview)) .route("/admin/usage", get(usage_overview::usage_overview))
// Podcast-statistikk (oppgave 30.4) // Podcast-statistikk (oppgave 30.4)
.route("/admin/podcast/stats", get(podcast_stats::podcast_stats)) .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) // Brukersynlig forbruk (oppgave 15.9)
.route("/my/usage", get(user_usage::my_usage)) .route("/my/usage", get(user_usage::my_usage))
.route("/my/workspace", get(workspace::my_workspace)) .route("/my/workspace", get(workspace::my_workspace))

View file

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