diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 2d2f313..39bdaa4 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -1585,3 +1585,55 @@ export function aiSuggestScript( ): Promise { return post(accessToken, '/intentions/ai_suggest_script', req); } + +// ============================================================================= +// Podcast-statistikk (oppgave 30.4) +// ============================================================================= + +export interface EpisodeTotal { + episode_id: string | null; + episode_title: string | null; + total_downloads: number; + total_unique_listeners: number; + first_date: string | null; + last_date: string | null; + days_with_data: number; +} + +export interface DailyDownloads { + date: string; + downloads: number; + unique_listeners: number; +} + +export interface ClientBreakdown { + client: string; + count: number; +} + +export interface PodcastStatsResponse { + total_downloads: number; + total_unique_listeners: number; + episodes: EpisodeTotal[]; + daily: DailyDownloads[]; + clients: ClientBreakdown[]; +} + +/** Hent podcast-nedlastingsstatistikk for admin. */ +export async function fetchPodcastStats( + accessToken: string, + params: { days?: number; episode_id?: string } = {} +): Promise { + const sp = new URLSearchParams(); + if (params.days) sp.set('days', String(params.days)); + if (params.episode_id) sp.set('episode_id', params.episode_id); + const qs = sp.toString(); + const res = await fetch(`${BASE_URL}/admin/podcast/stats${qs ? `?${qs}` : ''}`, { + headers: { Authorization: `Bearer ${accessToken}` } + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`podcast stats failed (${res.status}): ${body}`); + } + return res.json(); +} diff --git a/frontend/src/routes/admin/+page.svelte b/frontend/src/routes/admin/+page.svelte index 7d0bcd4..0de6914 100644 --- a/frontend/src/routes/admin/+page.svelte +++ b/frontend/src/routes/admin/+page.svelte @@ -109,6 +109,9 @@ Webhooks + + Podcast + diff --git a/frontend/src/routes/admin/podcast-stats/+page.svelte b/frontend/src/routes/admin/podcast-stats/+page.svelte new file mode 100644 index 0000000..3e3ccb8 --- /dev/null +++ b/frontend/src/routes/admin/podcast-stats/+page.svelte @@ -0,0 +1,344 @@ + + +
+
+ +
+

Podcast-statistikk

+ Tilbake til admin +
+ + {#if error} +
+ {error} +
+ {/if} + + +
+ + + {#if data && data.episodes.length > 1} + + {/if} + + +
+ + {#if !data && !error} +

Laster...

+ {:else if data} + +
+
+
+
Nedlastinger
+
{formatNumber(data.total_downloads)}
+
siste {days} dager
+
+
+
Unike lyttere
+
+ {formatNumber(data.total_unique_listeners)} +
+
siste {days} dager
+
+
+
Episoder
+
{data.episodes.length}
+
med nedlastinger
+
+
+
+ + + {#if data.daily.length > 0} +
+

Daglig trend

+
+
+ {#each data.daily as day} +
+ + {formatDate(day.date)} + +
+
+ + {formatNumber(day.downloads)} + +
+
+ {/each} +
+
+ Nedlastinger per dag (unike IP per episode) +
+
+
+ {/if} + + + {#if data.episodes.length > 0} +
+

Topp-episoder

+
+
+ + + + + + + + + + + + {#each data.episodes as ep, i} + + + + + + + + {/each} + +
#EpisodeNedlastinger
{i + 1} +
+ {ep.episode_title || 'Ukjent episode'} +
+ {#if ep.first_date && ep.last_date} +
+ {formatDate(ep.first_date)} – {formatDate(ep.last_date)} + ({ep.days_with_data} dager) +
+ {/if} +
+ {formatNumber(ep.total_downloads)} +
+
+
+
+ {/if} + + + {#if data.clients.length > 0} +
+

Klienter

+
+ + {#if totalClientCount() > 0} +
+ {#each data.clients as client} + {@const pct = (client.count / totalClientCount()) * 100} + {#if pct >= 1} +
+ {/if} + {/each} +
+ {/if} + + +
+ {#each data.clients as client} + {@const pct = + totalClientCount() > 0 + ? (client.count / totalClientCount()) * 100 + : 0} +
+
+ {client.client} +
+
+
+ + {formatNumber(client.count)} + + + {pct.toFixed(1)}% + +
+ {/each} +
+
+
+ {/if} + + + {#if data.episodes.length === 0 && data.daily.length === 0} +
+

Ingen nedlastingsdata

+

+ Kjør synops-stats --write for å importere statistikk fra Caddy-loggene. +

+
+ {/if} + {/if} +
+
diff --git a/maskinrommet/src/main.rs b/maskinrommet/src/main.rs index b15453f..26c5f1a 100644 --- a/maskinrommet/src/main.rs +++ b/maskinrommet/src/main.rs @@ -39,6 +39,7 @@ pub mod script_executor; pub mod tiptap; pub mod transcribe; pub mod tts; +pub mod podcast_stats; pub mod usage_overview; pub mod user_usage; mod workspace; @@ -251,6 +252,8 @@ async fn main() { .route("/admin/ai/delete_routing", post(ai_admin::delete_routing)) // Forbruksoversikt (oppgave 15.8) .route("/admin/usage", get(usage_overview::usage_overview)) + // Podcast-statistikk (oppgave 30.4) + .route("/admin/podcast/stats", get(podcast_stats::podcast_stats)) // 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_stats.rs b/maskinrommet/src/podcast_stats.rs new file mode 100644 index 0000000..1388917 --- /dev/null +++ b/maskinrommet/src/podcast_stats.rs @@ -0,0 +1,206 @@ +// Podcast-statistikk dashboard API (oppgave 30.4) +// +// Leser fra podcast_download_stats-tabellen (skrevet av synops-stats CLI) +// og returnerer aggregert data for admin-dashboardet: +// - Nedlastinger per episode (totalt + trend) +// - Topp-episoder +// - Klientfordeling (Apple Podcasts, Spotify, etc.) +// - Daglig tidsserie +// +// Ref: docs/features/podcast_statistikk.md + +use axum::extract::State; +use axum::http::StatusCode; +use axum::Json; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use uuid::Uuid; + +use crate::auth::AdminUser; +use crate::AppState; + +// ============================================================================= +// Datatyper +// ============================================================================= + +#[derive(Serialize, sqlx::FromRow)] +pub struct EpisodeTotal { + pub episode_id: Option, + pub episode_title: Option, + pub total_downloads: i64, + pub total_unique_listeners: i64, + pub first_date: Option, + pub last_date: Option, + pub days_with_data: i64, +} + +#[derive(Serialize, sqlx::FromRow)] +pub struct DailyDownloads { + pub date: NaiveDate, + pub downloads: i64, + pub unique_listeners: i64, +} + +#[derive(Serialize)] +pub struct ClientBreakdown { + pub client: String, + pub count: i64, +} + +#[derive(Serialize)] +pub struct PodcastStatsResponse { + /// Totalt over hele perioden + pub total_downloads: i64, + pub total_unique_listeners: i64, + /// Per episode, sortert etter nedlastinger (topp først) + pub episodes: Vec, + /// Daglig tidsserie (alle episoder aggregert) + pub daily: Vec, + /// Klientfordeling (aggregert over perioden) + pub clients: Vec, +} + +#[derive(Deserialize)] +pub struct PodcastStatsParams { + pub days: Option, + pub episode_id: Option, +} + +#[derive(Serialize)] +pub struct ErrorResponse { + pub error: String, +} + +fn internal_error(msg: &str) -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ErrorResponse { + error: msg.to_string(), + }), + ) +} + +// ============================================================================= +// GET /admin/podcast/stats +// ============================================================================= + +pub async fn podcast_stats( + State(state): State, + _admin: AdminUser, + axum::extract::Query(params): axum::extract::Query, +) -> Result, (StatusCode, Json)> { + let days = params.days.unwrap_or(30).clamp(1, 365); + + let episodes = fetch_episode_totals(&state.db, days, params.episode_id) + .await + .map_err(|e| internal_error(&format!("Feil i episodeoversikt: {e}")))?; + + let daily = fetch_daily(&state.db, days, params.episode_id) + .await + .map_err(|e| internal_error(&format!("Feil i daglig oversikt: {e}")))?; + + let clients = fetch_clients(&state.db, days, params.episode_id) + .await + .map_err(|e| internal_error(&format!("Feil i klientoversikt: {e}")))?; + + let total_downloads = episodes.iter().map(|e| e.total_downloads).sum(); + let total_unique_listeners = episodes.iter().map(|e| e.total_unique_listeners).sum(); + + Ok(Json(PodcastStatsResponse { + total_downloads, + total_unique_listeners, + episodes, + daily, + clients, + })) +} + +// ============================================================================= +// Spørringer +// ============================================================================= + +async fn fetch_episode_totals( + db: &PgPool, + days: i32, + episode_filter: Option, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, EpisodeTotal>( + r#" + SELECT + s.episode_id, + n.title AS episode_title, + COALESCE(SUM(s.downloads), 0)::BIGINT AS total_downloads, + COALESCE(SUM(s.unique_listeners), 0)::BIGINT AS total_unique_listeners, + MIN(s.date) AS first_date, + MAX(s.date) AS last_date, + COUNT(DISTINCT s.date)::BIGINT AS days_with_data + FROM podcast_download_stats s + LEFT JOIN nodes n ON n.id = s.episode_id + WHERE s.date >= (CURRENT_DATE - make_interval(days := $1)) + AND ($2::UUID IS NULL OR s.episode_id = $2) + GROUP BY s.episode_id, n.title + ORDER BY total_downloads DESC + "#, + ) + .bind(days) + .bind(episode_filter) + .fetch_all(db) + .await +} + +async fn fetch_daily( + db: &PgPool, + days: i32, + episode_filter: Option, +) -> Result, sqlx::Error> { + sqlx::query_as::<_, DailyDownloads>( + r#" + SELECT + s.date, + COALESCE(SUM(s.downloads), 0)::BIGINT AS downloads, + COALESCE(SUM(s.unique_listeners), 0)::BIGINT AS unique_listeners + FROM podcast_download_stats s + WHERE s.date >= (CURRENT_DATE - make_interval(days := $1)) + AND ($2::UUID IS NULL OR s.episode_id = $2) + GROUP BY s.date + ORDER BY s.date ASC + "#, + ) + .bind(days) + .bind(episode_filter) + .fetch_all(db) + .await +} + +/// Aggreger klientfordeling fra JSONB clients-feltet. +async fn fetch_clients( + db: &PgPool, + days: i32, + episode_filter: Option, +) -> Result, sqlx::Error> { + // clients er JSONB med { "Apple Podcasts": 18, "Spotify": 15, ... } + // Vi bruker jsonb_each_text for å ekspandere og aggregere + let rows: Vec<(String, i64)> = sqlx::query_as( + r#" + SELECT + kv.key AS client, + COALESCE(SUM(kv.value::BIGINT), 0)::BIGINT AS count + FROM podcast_download_stats s, + jsonb_each_text(s.clients) AS kv(key, value) + WHERE s.date >= (CURRENT_DATE - make_interval(days := $1)) + AND ($2::UUID IS NULL OR s.episode_id = $2) + GROUP BY kv.key + ORDER BY count DESC + "#, + ) + .bind(days) + .bind(episode_filter) + .fetch_all(db) + .await?; + + Ok(rows + .into_iter() + .map(|(client, count)| ClientBreakdown { client, count }) + .collect()) +} diff --git a/tasks.md b/tasks.md index e3bf091..93d0378 100644 --- a/tasks.md +++ b/tasks.md @@ -427,8 +427,7 @@ prøveimport-flyt. ### Statistikk - [x] 30.3 `synops-stats` CLI: parse Caddy access-logger for /media/*-requests. Aggreger nedlastinger per episode per dag. IAB-regler: filtrer bots (user-agent), unik IP per 24t. Output: JSON med episode_id, date, downloads, unique_listeners. `--write` lagrer i PG. -- [~] 30.4 Statistikk-dashboard: vis nedlastinger per episode, trend over tid, topp-episoder, klienter (Apple/Spotify/andre), geografi. Integrert i admin-panelet. - > Påbegynt: 2026-03-18T23:35 +- [x] 30.4 Statistikk-dashboard: vis nedlastinger per episode, trend over tid, topp-episoder, klienter (Apple/Spotify/andre), geografi. Integrert i admin-panelet. ### Embed-spiller - [ ] 30.5 Podcast-spiller komponent: Svelte-komponent med artwork, tittel, play/pause, progress, waveform, kapittelmerkering. Responsiv. Serveres som iframe-embed: `synops.no/pub///player`.