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();
|
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. */
|
/** Oppdater teksten i et transkripsjons-segment. */
|
||||||
export function updateSegment(
|
export function updateSegment(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
updateSegment,
|
updateSegment,
|
||||||
fetchTranscriptionVersions,
|
fetchTranscriptionVersions,
|
||||||
retranscribe,
|
retranscribe,
|
||||||
|
downloadSrt,
|
||||||
type Segment,
|
type Segment,
|
||||||
type TranscriptionVersion
|
type TranscriptionVersion
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
|
@ -168,6 +169,14 @@
|
||||||
polling = false;
|
polling = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleDownloadSrt() {
|
||||||
|
try {
|
||||||
|
await downloadSrt(accessToken, nodeId);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('SRT-nedlasting feilet:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleComparisonDone() {
|
function handleComparisonDone() {
|
||||||
showCompare = false;
|
showCompare = false;
|
||||||
loadSegments(nodeId, accessToken);
|
loadSegments(nodeId, accessToken);
|
||||||
|
|
@ -227,6 +236,20 @@
|
||||||
Sammenlign versjoner
|
Sammenlign versjoner
|
||||||
</button>
|
</button>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
onclick={handleRetranscribe}
|
onclick={handleRetranscribe}
|
||||||
disabled={retranscribing || polling}
|
disabled={retranscribing || polling}
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@ async fn main() {
|
||||||
.route("/cas/{hash}", get(serving::get_cas_file))
|
.route("/cas/{hash}", get(serving::get_cas_file))
|
||||||
.route("/query/nodes", get(queries::query_nodes))
|
.route("/query/nodes", get(queries::query_nodes))
|
||||||
.route("/query/segments", get(queries::query_segments))
|
.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/update_segment", post(intentions::update_segment))
|
||||||
.route("/intentions/retranscribe", post(intentions::retranscribe))
|
.route("/intentions/retranscribe", post(intentions::retranscribe))
|
||||||
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
.route("/intentions/resolve_retranscription", post(intentions::resolve_retranscription))
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
// Ref: docs/retninger/datalaget.md (tunge spørringer-seksjonen)
|
// Ref: docs/retninger/datalaget.md (tunge spørringer-seksjonen)
|
||||||
|
|
||||||
use axum::{extract::State, http::StatusCode, Json};
|
use axum::{extract::State, http::StatusCode, Json};
|
||||||
|
use axum::response::{IntoResponse, Response};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use uuid::Uuid;
|
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
|
// RLS-kontekst
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -475,3 +557,66 @@ async fn run_query_nodes(
|
||||||
|
|
||||||
Ok(QueryNodesResponse { nodes, total })
|
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.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.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.
|
- [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.
|
- [x] 7.8 SRT-eksport: generer nedlastbar SRT-fil fra `transcription_segments`-tabellen.
|
||||||
> Påbegynt: 2026-03-17T18:43
|
|
||||||
|
|
||||||
## Fase 8: Aliaser
|
## Fase 8: Aliaser
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue