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> {
|
): Promise<AiSuggestScriptResponse> {
|
||||||
return post(accessToken, '/intentions/ai_suggest_script', req);
|
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">
|
<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
|
Webhooks
|
||||||
</a>
|
</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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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 tiptap;
|
||||||
pub mod transcribe;
|
pub mod transcribe;
|
||||||
pub mod tts;
|
pub mod tts;
|
||||||
|
pub mod podcast_stats;
|
||||||
pub mod usage_overview;
|
pub mod usage_overview;
|
||||||
pub mod user_usage;
|
pub mod user_usage;
|
||||||
mod workspace;
|
mod workspace;
|
||||||
|
|
@ -251,6 +252,8 @@ async fn main() {
|
||||||
.route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
|
.route("/admin/ai/delete_routing", post(ai_admin::delete_routing))
|
||||||
// Forbruksoversikt (oppgave 15.8)
|
// Forbruksoversikt (oppgave 15.8)
|
||||||
.route("/admin/usage", get(usage_overview::usage_overview))
|
.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)
|
// 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))
|
||||||
|
|
|
||||||
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
|
### 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.
|
- [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.
|
- [x] 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
|
|
||||||
|
|
||||||
### Embed-spiller
|
### 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`.
|
- [ ] 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