Podcast-statistikk dashboard i admin-panelet (oppgave 30.4)
Nytt dashboard under /admin/podcast-stats som viser: - Nøkkeltall: totale nedlastinger, unike lyttere, antall episoder - Daglig trend med horisontale bar charts - Topp-episoder rangert etter nedlastinger - Klientfordeling (Apple Podcasts, Spotify, etc.) med stacked bar Backend: GET /admin/podcast/stats spør podcast_download_stats-tabellen (fylt av synops-stats CLI fra oppgave 30.3) og aggregerer per episode, per dag, og per klient via jsonb_each_text. Filtrering på tidsperiode (7/30/90/365 dager) og enkelt-episode.
This commit is contained in:
parent
e394035a5e
commit
3e57adce46
6 changed files with 609 additions and 2 deletions
|
|
@ -1585,3 +1585,55 @@ export function aiSuggestScript(
|
|||
): Promise<AiSuggestScriptResponse> {
|
||||
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<PodcastStatsResponse> {
|
||||
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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,6 +109,9 @@
|
|||
<a href="/admin/webhooks" class="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200">
|
||||
Webhooks
|
||||
</a>
|
||||
<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
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
|
|
|||
344
frontend/src/routes/admin/podcast-stats/+page.svelte
Normal file
344
frontend/src/routes/admin/podcast-stats/+page.svelte
Normal file
|
|
@ -0,0 +1,344 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* Admin — Podcast-statistikk (oppgave 30.4)
|
||||
*
|
||||
* Dashboard for nedlastingsstatistikk: per episode, trend over tid,
|
||||
* topp-episoder, klientfordeling (Apple/Spotify/andre).
|
||||
* Data fra podcast_download_stats (skrevet av synops-stats CLI).
|
||||
*/
|
||||
import { page } from '$app/stores';
|
||||
import {
|
||||
fetchPodcastStats,
|
||||
type PodcastStatsResponse,
|
||||
type EpisodeTotal,
|
||||
type DailyDownloads
|
||||
} from '$lib/api';
|
||||
|
||||
const session = $derived($page.data.session as Record<string, unknown> | undefined);
|
||||
const accessToken = $derived(session?.accessToken as string | undefined);
|
||||
|
||||
let data = $state<PodcastStatsResponse | null>(null);
|
||||
let error = $state<string | null>(null);
|
||||
let loading = $state(false);
|
||||
|
||||
// Filtre
|
||||
let days = $state(30);
|
||||
let selectedEpisode = $state<string>('');
|
||||
|
||||
$effect(() => {
|
||||
if (!accessToken) return;
|
||||
loadData();
|
||||
});
|
||||
|
||||
async function loadData() {
|
||||
if (!accessToken) return;
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
data = await fetchPodcastStats(accessToken, {
|
||||
days,
|
||||
episode_id: selectedEpisode || undefined
|
||||
});
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Formatering
|
||||
function formatNumber(n: number): string {
|
||||
return n.toLocaleString('nb-NO');
|
||||
}
|
||||
|
||||
function formatDate(d: string): string {
|
||||
return new Date(d).toLocaleDateString('nb-NO', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
// Enkel bar chart: beregn bredde relativt til maks
|
||||
function barWidth(value: number, max: number): number {
|
||||
if (max === 0) return 0;
|
||||
return Math.max(2, (value / max) * 100);
|
||||
}
|
||||
|
||||
// Maks nedlastinger for daglig tidsserie (for bar chart)
|
||||
const maxDaily = $derived((): number => {
|
||||
if (!data || data.daily.length === 0) return 0;
|
||||
return Math.max(...data.daily.map((d) => d.downloads));
|
||||
});
|
||||
|
||||
// Maks nedlastinger for episode-liste
|
||||
const maxEpisode = $derived((): number => {
|
||||
if (!data || data.episodes.length === 0) return 0;
|
||||
return Math.max(...data.episodes.map((e) => e.total_downloads));
|
||||
});
|
||||
|
||||
// Maks klient-antall
|
||||
const maxClient = $derived((): number => {
|
||||
if (!data || data.clients.length === 0) return 0;
|
||||
return Math.max(...data.clients.map((c) => c.count));
|
||||
});
|
||||
|
||||
// Totalt klienter for prosentregning
|
||||
const totalClientCount = $derived((): number => {
|
||||
if (!data) return 0;
|
||||
return data.clients.reduce((sum, c) => sum + c.count, 0);
|
||||
});
|
||||
|
||||
// Klientfarger
|
||||
function clientColor(client: string): string {
|
||||
const colors: Record<string, string> = {
|
||||
'Apple Podcasts': 'bg-purple-500',
|
||||
Spotify: 'bg-green-500',
|
||||
Overcast: 'bg-orange-500',
|
||||
'Pocket Casts': 'bg-red-500',
|
||||
'Podcast Addict': 'bg-blue-500',
|
||||
Castro: 'bg-lime-500',
|
||||
Castbox: 'bg-amber-500',
|
||||
Chrome: 'bg-sky-500',
|
||||
Safari: 'bg-cyan-500',
|
||||
Firefox: 'bg-rose-500'
|
||||
};
|
||||
return colors[client] || 'bg-neutral-500';
|
||||
}
|
||||
|
||||
const daysOptions = [
|
||||
{ value: 7, label: '7 dager' },
|
||||
{ value: 30, label: '30 dager' },
|
||||
{ value: 90, label: '90 dager' },
|
||||
{ value: 365, label: '1 år' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-neutral-950 text-neutral-100 p-4 sm:p-8">
|
||||
<div class="max-w-6xl mx-auto space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between flex-wrap gap-3">
|
||||
<h1 class="text-2xl font-bold">Podcast-statistikk</h1>
|
||||
<a href="/admin" class="text-sm text-neutral-400 hover:text-white">Tilbake til admin</a>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="bg-red-900/30 border border-red-700 rounded-lg p-4 text-red-300 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Filtre -->
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<select
|
||||
bind:value={days}
|
||||
class="bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm"
|
||||
onchange={() => loadData()}
|
||||
>
|
||||
{#each daysOptions as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
{#if data && data.episodes.length > 1}
|
||||
<select
|
||||
bind:value={selectedEpisode}
|
||||
class="bg-neutral-800 border border-neutral-700 rounded px-3 py-1.5 text-sm max-w-xs"
|
||||
onchange={() => loadData()}
|
||||
>
|
||||
<option value="">Alle episoder</option>
|
||||
{#each data.episodes as ep}
|
||||
<option value={ep.episode_id || ''}>
|
||||
{ep.episode_title || ep.episode_id?.slice(0, 8) || 'Ukjent'}
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
class="bg-neutral-800 hover:bg-neutral-700 px-3 py-1.5 rounded text-sm"
|
||||
onclick={() => loadData()}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Laster...' : 'Oppdater'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if !data && !error}
|
||||
<p class="text-neutral-500">Laster...</p>
|
||||
{:else if data}
|
||||
<!-- Nøkkeltall -->
|
||||
<section>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<div class="text-sm text-neutral-400 mb-1">Nedlastinger</div>
|
||||
<div class="text-3xl font-mono font-bold">{formatNumber(data.total_downloads)}</div>
|
||||
<div class="text-xs text-neutral-500 mt-1">siste {days} dager</div>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<div class="text-sm text-neutral-400 mb-1">Unike lyttere</div>
|
||||
<div class="text-3xl font-mono font-bold">
|
||||
{formatNumber(data.total_unique_listeners)}
|
||||
</div>
|
||||
<div class="text-xs text-neutral-500 mt-1">siste {days} dager</div>
|
||||
</div>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<div class="text-sm text-neutral-400 mb-1">Episoder</div>
|
||||
<div class="text-3xl font-mono font-bold">{data.episodes.length}</div>
|
||||
<div class="text-xs text-neutral-500 mt-1">med nedlastinger</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Daglig trend -->
|
||||
{#if data.daily.length > 0}
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Daglig trend</h2>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<div class="space-y-1">
|
||||
{#each data.daily as day}
|
||||
<div class="flex items-center gap-2 text-sm">
|
||||
<span class="w-16 text-xs text-neutral-400 font-mono shrink-0">
|
||||
{formatDate(day.date)}
|
||||
</span>
|
||||
<div class="flex-1 flex items-center gap-2">
|
||||
<div
|
||||
class="h-5 bg-blue-500/60 rounded-sm transition-all"
|
||||
style="width: {barWidth(day.downloads, maxDaily())}%"
|
||||
></div>
|
||||
<span class="text-xs text-neutral-300 shrink-0">
|
||||
{formatNumber(day.downloads)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mt-3 flex gap-4 text-xs text-neutral-500">
|
||||
<span>Nedlastinger per dag (unike IP per episode)</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Topp-episoder -->
|
||||
{#if data.episodes.length > 0}
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Topp-episoder</h2>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-neutral-800 text-left text-neutral-400">
|
||||
<th class="px-4 py-3 font-medium">#</th>
|
||||
<th class="px-4 py-3 font-medium">Episode</th>
|
||||
<th class="px-4 py-3 font-medium text-right">Nedlastinger</th>
|
||||
<th class="px-4 py-3 font-medium text-right hidden sm:table-cell"
|
||||
>Unike lyttere</th
|
||||
>
|
||||
<th class="px-4 py-3 font-medium w-1/4 hidden md:table-cell"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.episodes as ep, i}
|
||||
<tr class="border-b border-neutral-800/50 hover:bg-neutral-800/30">
|
||||
<td class="px-4 py-2.5 text-neutral-500 font-mono text-xs"
|
||||
>{i + 1}</td
|
||||
>
|
||||
<td class="px-4 py-2.5">
|
||||
<div class="font-medium text-neutral-200">
|
||||
{ep.episode_title || 'Ukjent episode'}
|
||||
</div>
|
||||
{#if ep.first_date && ep.last_date}
|
||||
<div class="text-xs text-neutral-500 mt-0.5">
|
||||
{formatDate(ep.first_date)} – {formatDate(ep.last_date)}
|
||||
({ep.days_with_data} dager)
|
||||
</div>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 text-right font-mono text-neutral-200">
|
||||
{formatNumber(ep.total_downloads)}
|
||||
</td>
|
||||
<td
|
||||
class="px-4 py-2.5 text-right font-mono text-neutral-400 hidden sm:table-cell"
|
||||
>
|
||||
{formatNumber(ep.total_unique_listeners)}
|
||||
</td>
|
||||
<td class="px-4 py-2.5 hidden md:table-cell">
|
||||
<div
|
||||
class="h-3 bg-blue-500/40 rounded-sm"
|
||||
style="width: {barWidth(ep.total_downloads, maxEpisode())}%"
|
||||
></div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Klientfordeling -->
|
||||
{#if data.clients.length > 0}
|
||||
<section>
|
||||
<h2 class="text-lg font-semibold mb-3">Klienter</h2>
|
||||
<div class="bg-neutral-900 border border-neutral-800 rounded-lg p-4">
|
||||
<!-- Stacked bar overview -->
|
||||
{#if totalClientCount() > 0}
|
||||
<div class="h-6 rounded-full overflow-hidden flex mb-4">
|
||||
{#each data.clients as client}
|
||||
{@const pct = (client.count / totalClientCount()) * 100}
|
||||
{#if pct >= 1}
|
||||
<div
|
||||
class="{clientColor(client.client)} opacity-80"
|
||||
style="width: {pct}%"
|
||||
title="{client.client}: {formatNumber(client.count)} ({pct.toFixed(1)}%)"
|
||||
></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Detaljert liste -->
|
||||
<div class="space-y-2">
|
||||
{#each data.clients as client}
|
||||
{@const pct =
|
||||
totalClientCount() > 0
|
||||
? (client.count / totalClientCount()) * 100
|
||||
: 0}
|
||||
<div class="flex items-center gap-3 text-sm">
|
||||
<div
|
||||
class="w-3 h-3 rounded-full shrink-0 {clientColor(client.client)}"
|
||||
></div>
|
||||
<span class="w-32 sm:w-40 truncate text-neutral-300">{client.client}</span>
|
||||
<div class="flex-1">
|
||||
<div
|
||||
class="h-4 {clientColor(client.client)} opacity-40 rounded-sm"
|
||||
style="width: {barWidth(client.count, maxClient())}%"
|
||||
></div>
|
||||
</div>
|
||||
<span class="font-mono text-xs text-neutral-300 w-14 text-right">
|
||||
{formatNumber(client.count)}
|
||||
</span>
|
||||
<span class="text-xs text-neutral-500 w-12 text-right">
|
||||
{pct.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- Tom tilstand -->
|
||||
{#if data.episodes.length === 0 && data.daily.length === 0}
|
||||
<div
|
||||
class="bg-neutral-900 border border-neutral-800 rounded-lg p-8 text-center text-neutral-500"
|
||||
>
|
||||
<p class="text-lg mb-2">Ingen nedlastingsdata</p>
|
||||
<p class="text-sm">
|
||||
Kjør <code class="bg-neutral-800 px-1.5 py-0.5 rounded text-neutral-400"
|
||||
>synops-stats --write</code
|
||||
> for å importere statistikk fra Caddy-loggene.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
206
maskinrommet/src/podcast_stats.rs
Normal file
206
maskinrommet/src/podcast_stats.rs
Normal file
|
|
@ -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<Uuid>,
|
||||
pub episode_title: Option<String>,
|
||||
pub total_downloads: i64,
|
||||
pub total_unique_listeners: i64,
|
||||
pub first_date: Option<NaiveDate>,
|
||||
pub last_date: Option<NaiveDate>,
|
||||
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<EpisodeTotal>,
|
||||
/// Daglig tidsserie (alle episoder aggregert)
|
||||
pub daily: Vec<DailyDownloads>,
|
||||
/// Klientfordeling (aggregert over perioden)
|
||||
pub clients: Vec<ClientBreakdown>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PodcastStatsParams {
|
||||
pub days: Option<i32>,
|
||||
pub episode_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct ErrorResponse {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: msg.to_string(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// GET /admin/podcast/stats
|
||||
// =============================================================================
|
||||
|
||||
pub async fn podcast_stats(
|
||||
State(state): State<AppState>,
|
||||
_admin: AdminUser,
|
||||
axum::extract::Query(params): axum::extract::Query<PodcastStatsParams>,
|
||||
) -> Result<Json<PodcastStatsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
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<Uuid>,
|
||||
) -> Result<Vec<EpisodeTotal>, 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<Uuid>,
|
||||
) -> Result<Vec<DailyDownloads>, 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<Uuid>,
|
||||
) -> Result<Vec<ClientBreakdown>, 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())
|
||||
}
|
||||
3
tasks.md
3
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/<slug>/<episode>/player`.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue