Fullfør oppgave 7.8: SRT-eksport fra transkripsjons-segmenter

Nytt GET /query/segments/srt-endepunkt som genererer nedlastbar SRT-fil
fra transcription_segments-tabellen. Bruker RLS-verifisert tilgang.
Frontend har nedlastingsknapp i TranscriptionView med autentisert fetch.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-17 18:47:50 +01:00
parent f9cc1328cd
commit 35701aeb2a
5 changed files with 189 additions and 2 deletions

View file

@ -164,6 +164,25 @@ export async function fetchSegments(
return res.json();
}
/** Last ned SRT-fil for en media-node. Trigger filnedlasting i nettleseren. */
export async function downloadSrt(accessToken: string, nodeId: string): Promise<void> {
const res = await fetch(
`${BASE_URL}/query/segments/srt?node_id=${encodeURIComponent(nodeId)}`,
{ headers: { Authorization: `Bearer ${accessToken}` } }
);
if (!res.ok) {
const body = await res.text();
throw new Error(`SRT-eksport feilet (${res.status}): ${body}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'transcription.srt';
a.click();
URL.revokeObjectURL(url);
}
/** Oppdater teksten i et transkripsjons-segment. */
export function updateSegment(
accessToken: string,

View file

@ -4,6 +4,7 @@
updateSegment,
fetchTranscriptionVersions,
retranscribe,
downloadSrt,
type Segment,
type TranscriptionVersion
} from '$lib/api';
@ -168,6 +169,14 @@
polling = false;
}
async function handleDownloadSrt() {
try {
await downloadSrt(accessToken, nodeId);
} catch (e) {
console.error('SRT-nedlasting feilet:', e);
}
}
function handleComparisonDone() {
showCompare = false;
loadSegments(nodeId, accessToken);
@ -227,6 +236,20 @@
Sammenlign versjoner
</button>
{/if}
<button
onclick={handleDownloadSrt}
class="text-[10px] text-gray-400 hover:text-blue-600 flex items-center gap-0.5"
title="Last ned SRT-fil"
>
<svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"
/>
</svg>
SRT
</button>
<button
onclick={handleRetranscribe}
disabled={retranscribing || polling}

View file

@ -140,6 +140,7 @@ async fn main() {
.route("/cas/{hash}", get(serving::get_cas_file))
.route("/query/nodes", get(queries::query_nodes))
.route("/query/segments", get(queries::query_segments))
.route("/query/segments/srt", get(queries::export_srt))
.route("/intentions/update_segment", post(intentions::update_segment))
.route("/intentions/retranscribe", post(intentions::retranscribe))
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))

View file

@ -7,6 +7,7 @@
// Ref: docs/retninger/datalaget.md (tunge spørringer-seksjonen)
use axum::{extract::State, http::StatusCode, Json};
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use uuid::Uuid;
@ -290,6 +291,87 @@ pub async fn query_segments_version(
}))
}
// =============================================================================
// GET /query/segments/srt — eksporter segmenter som nedlastbar SRT-fil
// =============================================================================
/// GET /query/segments/srt?node_id=...
///
/// Genererer en SRT-fil fra nyeste transkripsjons-segmenter.
/// Returnerer filen med Content-Disposition: attachment for nedlasting.
pub async fn export_srt(
State(state): State<AppState>,
user: AuthUser,
axum::extract::Query(params): axum::extract::Query<QuerySegmentsRequest>,
) -> Result<Response, (StatusCode, Json<ErrorResponse>)> {
let resp = run_query_segments(&state.db, user.node_id, params.node_id)
.await
.map_err(|e| {
tracing::error!(error = %e, "export_srt feilet");
internal_error("Databasefeil ved henting av segmenter")
})?;
if resp.segments.is_empty() {
return Err((
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: "Ingen transkripsjons-segmenter funnet".to_string(),
}),
));
}
let srt = segments_to_srt(&resp.segments);
Ok((
StatusCode::OK,
[
(axum::http::header::CONTENT_TYPE, "application/x-subrip; charset=utf-8"),
(axum::http::header::CONTENT_DISPOSITION, "attachment; filename=\"transcription.srt\""),
],
srt,
)
.into_response())
}
/// Konverterer segmenter til SRT-format.
///
/// SRT-format:
/// ```text
/// 1
/// 00:00:00,000 --> 00:00:05,230
/// Hei og velkommen.
///
/// 2
/// 00:00:05,230 --> 00:00:10,500
/// I dag snakker vi om...
/// ```
fn segments_to_srt(segments: &[SegmentResult]) -> String {
let mut srt = String::new();
for (i, seg) in segments.iter().enumerate() {
if i > 0 {
srt.push('\n');
}
srt.push_str(&format!(
"{}\n{} --> {}\n{}\n",
seg.seq,
format_srt_timestamp(seg.start_ms),
format_srt_timestamp(seg.end_ms),
seg.content,
));
}
srt
}
/// Formaterer millisekunder til SRT-tidsstempel: HH:MM:SS,mmm
fn format_srt_timestamp(ms: i32) -> String {
let total_seconds = ms / 1000;
let millis = ms % 1000;
let hours = total_seconds / 3600;
let minutes = (total_seconds % 3600) / 60;
let seconds = total_seconds % 60;
format!("{:02}:{:02}:{:02},{:03}", hours, minutes, seconds, millis)
}
// =============================================================================
// RLS-kontekst
// =============================================================================
@ -475,3 +557,66 @@ async fn run_query_nodes(
Ok(QueryNodesResponse { nodes, total })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_srt_timestamp() {
assert_eq!(format_srt_timestamp(0), "00:00:00,000");
assert_eq!(format_srt_timestamp(5230), "00:00:05,230");
assert_eq!(format_srt_timestamp(83456), "00:01:23,456");
assert_eq!(format_srt_timestamp(3_600_000), "01:00:00,000");
assert_eq!(format_srt_timestamp(3_723_456), "01:02:03,456");
}
#[test]
fn test_segments_to_srt() {
let segments = vec![
SegmentResult {
id: 1,
seq: 1,
start_ms: 0,
end_ms: 5230,
content: "Hei og velkommen.".to_string(),
edited: false,
},
SegmentResult {
id: 2,
seq: 2,
start_ms: 5230,
end_ms: 10500,
content: "I dag snakker vi om fotball.".to_string(),
edited: true,
},
];
let srt = segments_to_srt(&segments);
let expected = "\
1
00:00:00,000 --> 00:00:05,230
Hei og velkommen.
2
00:00:05,230 --> 00:00:10,500
I dag snakker vi om fotball.
";
assert_eq!(srt, expected);
}
#[test]
fn test_segments_to_srt_single() {
let segments = vec![SegmentResult {
id: 1,
seq: 1,
start_ms: 0,
end_ms: 3000,
content: "Bare ett segment.".to_string(),
edited: false,
}];
let srt = segments_to_srt(&segments);
assert_eq!(srt, "1\n00:00:00,000 --> 00:00:03,000\nBare ett segment.\n");
}
}

View file

@ -100,8 +100,7 @@ Uavhengige faser kan fortsatt plukkes.
- [x] 7.5 Segmenttabell-migrasjon: opprett `transcription_segments`-tabell i PG. Oppdater `transcribe.rs` til SRT-format → parse → skriv segmenter. Miljøvariabler: `WHISPER_MODEL` (default "medium"), `WHISPER_INITIAL_PROMPT`. Ref: `docs/concepts/podcastfabrikken.md` § 3.
- [x] 7.6 Transkripsjonsvisning i frontend: segmenter med tidsstempler, avspillingsknapp per segment (hopper til riktig sted i lydfilen), redigerbare tekstfelt (setter `edited = true`). Universell komponent for podcast, møter, voice memos.
- [x] 7.7 Re-transkripsjonsflyt: ved ny transkripsjon, vis side-om-side med forrige versjon. Highlight manuelt redigerte segmenter fra forrige versjon. Bruker velger per segment.
- [~] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.
> Påbegynt: 2026-03-17T18:43
- [x] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.
## Fase 8: Aliaser