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:
parent
f9cc1328cd
commit
35701aeb2a
5 changed files with 189 additions and 2 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
tasks.md
3
tasks.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue