diff --git a/bruno/VoD-System/Markers/Delete Marker.bru b/bruno/VoD-System/Markers/Delete Marker.bru
new file mode 100644
index 0000000..b2e3459
--- /dev/null
+++ b/bruno/VoD-System/Markers/Delete Marker.bru
@@ -0,0 +1,16 @@
+meta {
+ name: Delete Marker
+ type: http
+ seq: 4
+}
+
+delete {
+ url: {{base_url}}/markers/5
+ body: none
+ auth: inherit
+}
+
+settings {
+ encodeUrl: true
+ timeout: 0
+}
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 5ffc1d3..34ae6cc 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -12,6 +12,7 @@ import VodPlayer from "./pages/VodPlayer.tsx";
import EditClip from "./pages/EditClip.tsx";
import EditVod from "./pages/EditVod.tsx";
import Profile from "./pages/Profile.tsx";
+import StreamControl from "./pages/StreamControl.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google';
@@ -35,6 +36,7 @@ function App() {
} />
} />
} />
+ } />
diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx
index bd3ecd2..bb33d56 100644
--- a/frontend/src/components/Sidebar.tsx
+++ b/frontend/src/components/Sidebar.tsx
@@ -1,6 +1,6 @@
import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx";
-import { Plus, Film, Home, User as UserIcon, Video } from 'lucide-react';
+import { Plus, Film, Home, User as UserIcon, Video, Radio } from 'lucide-react';
import type {User} from "../utils/types.ts";
type props = {
@@ -30,6 +30,7 @@ const Sidebar = ({user, className}: props) => {
<>
+ } label="Stream" />
} label="Clips" />
} label="VoDs" />
diff --git a/frontend/src/components/Topbar.tsx b/frontend/src/components/Topbar.tsx
index d3bd7dd..ccf897f 100644
--- a/frontend/src/components/Topbar.tsx
+++ b/frontend/src/components/Topbar.tsx
@@ -1,5 +1,5 @@
import { Menu, X } from 'lucide-react';
-import { login, logout } from "../utils/endpoints.ts";
+import { login, logout } from "../utils/api/users.ts";
import { Dropdown, DropdownItem } from "./Dropdown.tsx";
import { GoogleLogin } from '@react-oauth/google';
diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx
index 1561ceb..5653e7b 100644
--- a/frontend/src/components/video/VideoCard.tsx
+++ b/frontend/src/components/video/VideoCard.tsx
@@ -2,7 +2,7 @@ import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import { useEffect, useRef, useState } from "react";
-import { isThumbnailAvailable } from "../../utils/endpoints.ts";
+import { isThumbnailAvailable } from "../../utils/api/client.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
type VideoCardProps = {
diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx
index d21a82e..6ba6821 100644
--- a/frontend/src/layouts/MainLayout.tsx
+++ b/frontend/src/layouts/MainLayout.tsx
@@ -4,7 +4,8 @@ import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react";
import type {User} from "../utils/types";
-import { getUser, getStreamStatus } from "../utils/endpoints";
+import { getUser } from "../utils/api/users";
+import { getStreamStatus } from "../utils/api/stream";
const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false);
diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx
index be878ca..cc0f4e4 100644
--- a/frontend/src/pages/ClipEdit.tsx
+++ b/frontend/src/pages/ClipEdit.tsx
@@ -5,7 +5,8 @@ import PlaybackSlider from "./../components/video/PlaybackSlider";
import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx";
-import { compress, getJob } from "../utils/endpoints"
+import { compress } from "../utils/api/media"
+import { getJob } from "../utils/api/jobs"
import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx";
diff --git a/frontend/src/pages/EditClip.tsx b/frontend/src/pages/EditClip.tsx
index 58d86b1..dc5f8d2 100644
--- a/frontend/src/pages/EditClip.tsx
+++ b/frontend/src/pages/EditClip.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react";
-import { getClipById, patchClip } from "../utils/endpoints.ts";
+import { getClipById, patchClip } from "../utils/api/clips.ts";
import Box from "../components/Box.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
diff --git a/frontend/src/pages/EditVod.tsx b/frontend/src/pages/EditVod.tsx
index 45b895d..89c1cd9 100644
--- a/frontend/src/pages/EditVod.tsx
+++ b/frontend/src/pages/EditVod.tsx
@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react";
-import { getVodById, patchVod } from "../utils/endpoints.ts";
+import { getVodById, patchVod } from "../utils/api/vods.ts";
import Box from "../components/Box.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
diff --git a/frontend/src/pages/MyClips.tsx b/frontend/src/pages/MyClips.tsx
index bf4f502..5b95f37 100644
--- a/frontend/src/pages/MyClips.tsx
+++ b/frontend/src/pages/MyClips.tsx
@@ -1,7 +1,7 @@
import VideoCard from "../components/video/VideoCard";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { getClips, deleteClip } from "../utils/endpoints";
+import { getClips, deleteClip } from "../utils/api/clips";
import type { Clip } from "../utils/types";
const MyClips = () => {
diff --git a/frontend/src/pages/MyVods.tsx b/frontend/src/pages/MyVods.tsx
index 8fe1325..54a0a70 100644
--- a/frontend/src/pages/MyVods.tsx
+++ b/frontend/src/pages/MyVods.tsx
@@ -1,7 +1,7 @@
import VideoCard from "../components/video/VideoCard";
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
-import { getVods, deleteVod } from "../utils/endpoints";
+import { getVods, deleteVod } from "../utils/api/vods";
import type { Vod } from "../utils/types";
const MyVods = () => {
diff --git a/frontend/src/pages/Profile.tsx b/frontend/src/pages/Profile.tsx
index 1bb946b..156a560 100644
--- a/frontend/src/pages/Profile.tsx
+++ b/frontend/src/pages/Profile.tsx
@@ -1,7 +1,7 @@
import { useState, useEffect } from "react";
import { Eye, EyeOff, Copy, Check } from "lucide-react";
import Box from "../components/Box.tsx";
-import { getUser } from "../utils/endpoints.ts";
+import { getUser } from "../utils/api/users.ts";
import type { User } from "../utils/types.ts";
import { formatLocalDate } from "../utils/utils.ts";
diff --git a/frontend/src/pages/StreamControl.tsx b/frontend/src/pages/StreamControl.tsx
new file mode 100644
index 0000000..480ee20
--- /dev/null
+++ b/frontend/src/pages/StreamControl.tsx
@@ -0,0 +1,399 @@
+import { useEffect, useState } from "react";
+import { Radio, Check, Bookmark, Scissors, Film, Activity, Loader2, X } from "lucide-react";
+import clsx from "clsx";
+import Box from "../components/Box.tsx";
+import BlueButton from "../components/buttons/BlueButton.tsx";
+import { getUser } from "../utils/api/users.ts";
+import { getCurrentStream, getStreamHistory } from "../utils/api/stream.ts";
+import { getMarkers, createMarker, deleteMarker } from "../utils/api/markers.ts";
+import { saveSectionByMarkers, clipSection } from "../utils/api/media.ts";
+import { getJob } from "../utils/api/jobs.ts";
+import type { User, StreamStatus, StreamHistoryItem, Marker, JobResponse } from "../utils/types.ts";
+import { formatTime, formatLocalDate, stringToDate } from "../utils/utils.ts";
+
+const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
+const labelClass = "text-xs font-medium text-gray-500 uppercase tracking-wide";
+
+type TrackedJob = {
+ uuid: string;
+ label: string;
+ progress: number;
+ state: JobResponse['state'];
+ errorOutput: string | null;
+};
+
+const CardHeader = ({ icon: Icon, title, accent = "primary" }: { icon: React.ElementType; title: string; accent?: "primary" | "accent" }) => (
+
+);
+
+const JobStatus = ({ job }: { job: TrackedJob }) => {
+ if (job.state === 'FAILED') {
+ return (
+
+
+
{job.label} failed: {job.errorOutput ?? "Unknown error"}
+
+ );
+ }
+ if (job.state === 'SUCCEEDED') {
+ return (
+
+ );
+ }
+ return (
+
+ );
+};
+
+const StreamControl = () => {
+ const [user, setUser] = useState(null);
+ const [streamStatus, setStreamStatus] = useState(null);
+ const [streamDetails, setStreamDetails] = useState(null);
+ const [now, setNow] = useState(Date.now());
+
+ const [markers, setMarkers] = useState([]);
+ const [markerMessage, setMarkerMessage] = useState("");
+ const [markerBusy, setMarkerBusy] = useState(false);
+ const [markerError, setMarkerError] = useState(null);
+
+ const [clipDuration, setClipDuration] = useState(30);
+ const [clipTitle, setClipTitle] = useState("");
+ const [clipDescription, setClipDescription] = useState("");
+ const [clipError, setClipError] = useState(null);
+
+ const [startMarkerId, setStartMarkerId] = useState("");
+ const [endMarkerId, setEndMarkerId] = useState("");
+ const [markerSaveTitle, setMarkerSaveTitle] = useState("");
+ const [markerSaveDescription, setMarkerSaveDescription] = useState("");
+ const [markerSaveError, setMarkerSaveError] = useState(null);
+
+ const [jobs, setJobs] = useState([]);
+
+ const isStreaming = streamStatus?.isStreaming ?? false;
+
+ useEffect(() => {
+ getUser().then(setUser).catch(() => setUser(null));
+ }, []);
+
+ useEffect(() => {
+ if (!user) return;
+ const poll = () => getCurrentStream().then(setStreamStatus).catch(() => {});
+ poll();
+ const interval = setInterval(poll, 5000);
+ return () => clearInterval(interval);
+ }, [user]);
+
+ useEffect(() => {
+ if (!user || !streamStatus?.id) {
+ setStreamDetails(null);
+ return;
+ }
+ getStreamHistory(user.id)
+ .then((history) => setStreamDetails(history.find((s) => s.id === streamStatus.id) ?? null))
+ .catch(() => {});
+ }, [user, streamStatus?.id]);
+
+ useEffect(() => {
+ if (!streamDetails?.startDate) return;
+ const interval = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(interval);
+ }, [streamDetails?.startDate]);
+
+ const refreshMarkers = () => {
+ getMarkers()
+ .then((data) => setMarkers(data
+ .sort((a, b) => stringToDate(a.timestamp).getTime() - stringToDate(b.timestamp).getTime())))
+ .catch(() => {});
+ };
+
+ useEffect(() => {
+ if (!isStreaming) {
+ setMarkers([]);
+ return;
+ }
+ refreshMarkers();
+ const interval = setInterval(refreshMarkers, 5000);
+ return () => clearInterval(interval);
+ }, [isStreaming, streamStatus?.id]);
+
+ useEffect(() => {
+ const pending = jobs.filter((j) => j.state !== 'SUCCEEDED' && j.state !== 'FAILED');
+ if (pending.length === 0) return;
+
+ const interval = setInterval(() => {
+ pending.forEach((job) => {
+ getJob(job.uuid)
+ .then((result) => {
+ setJobs((prev) => prev.map((j) => j.uuid === job.uuid
+ ? { ...j, progress: result.progress, state: result.state, errorOutput: result.errorOutput }
+ : j));
+ })
+ .catch(() => {});
+ });
+ }, 1000);
+
+ return () => clearInterval(interval);
+ }, [jobs]);
+
+ const addJob = (uuid: string, label: string) => {
+ setJobs((prev) => [{ uuid, label, progress: 0, state: 'READY', errorOutput: null }, ...prev]);
+ };
+
+ const handleAddMarker = async () => {
+ if (!markerMessage.trim() || markerBusy) return;
+ setMarkerBusy(true);
+ setMarkerError(null);
+ try {
+ await createMarker(markerMessage.trim());
+ setMarkerMessage("");
+ refreshMarkers();
+ } catch (err) {
+ setMarkerError(err instanceof Error ? err.message : "Failed to add marker");
+ } finally {
+ setMarkerBusy(false);
+ }
+ };
+
+ const handleClip = async () => {
+ setClipError(null);
+ try {
+ const uuid = await clipSection(clipDuration, clipTitle || undefined, clipDescription || undefined);
+ addJob(uuid, `Clip (last ${clipDuration}s)`);
+ setClipTitle("");
+ setClipDescription("");
+ } catch (err) {
+ setClipError(err instanceof Error ? err.message : "Failed to clip section");
+ }
+ };
+
+ const handleSaveByMarkers = async () => {
+ setMarkerSaveError(null);
+ if (!startMarkerId || !endMarkerId) {
+ setMarkerSaveError("Tap two markers above to pick a start and end");
+ return;
+ }
+ try {
+ const uuid = await saveSectionByMarkers(
+ Number(startMarkerId),
+ Number(endMarkerId),
+ markerSaveTitle || undefined,
+ markerSaveDescription || undefined
+ );
+ addJob(uuid, "Save (by markers)");
+ setStartMarkerId("");
+ setEndMarkerId("");
+ setMarkerSaveTitle("");
+ setMarkerSaveDescription("");
+ } catch (err) {
+ setMarkerSaveError(err instanceof Error ? err.message : "Failed to save section");
+ }
+ };
+
+ const handleDeleteMarker = async (id: number) => {
+ try {
+ await deleteMarker(id);
+ if (startMarkerId === String(id)) setStartMarkerId("");
+ if (endMarkerId === String(id)) setEndMarkerId("");
+ refreshMarkers();
+ } catch (err) {
+ setMarkerError(err instanceof Error ? err.message : "Failed to delete marker");
+ }
+ };
+
+ const handleChipClick = (id: number) => {
+ const idStr = String(id);
+ setMarkerSaveError(null);
+ if (startMarkerId === idStr) { setStartMarkerId(""); return; }
+ if (endMarkerId === idStr) { setEndMarkerId(""); return; }
+ if (!startMarkerId) { setStartMarkerId(idStr); return; }
+ if (!endMarkerId) { setEndMarkerId(idStr); return; }
+ setEndMarkerId(idStr);
+ };
+
+ if (!user) {
+ return (
+
+ Please log in to control your stream.
+
+ );
+ }
+
+ const elapsed = streamDetails?.startDate
+ ? formatTime((now - stringToDate(streamDetails.startDate).getTime()) / 1000)
+ : null;
+
+ const startMarker = markers.find((m) => String(m.id) === startMarkerId);
+ const endMarker = markers.find((m) => String(m.id) === endMarkerId);
+
+ return (
+
+
Stream Control
+
+ {/* Hero status bar */}
+
+
+
+ {isStreaming && }
+
+
+
+
{isStreaming ? "Live" : "Offline"}
+ {isStreaming && streamDetails?.startDate ? (
+
Started {formatLocalDate(streamDetails.startDate)}
+ ) : !isStreaming ? (
+
Point OBS at your stream key (see Profile) to go live.
+ ) : null}
+
+
+
+ {isStreaming && elapsed ? (
+
+ ) : (
+
+ )}
+
+
+ {isStreaming && (
+ <>
+ {/* Quick mark toolbar */}
+
+
+
+ setMarkerMessage(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleAddMarker()}
+ placeholder="What just happened?"
+ className={inputClass}
+ />
+
+ Add Marker
+
+
+ {markerError && {markerError}
}
+
+ {markers.length > 0 && (
+
+ {markers.map((m) => {
+ const isStart = startMarkerId === String(m.id);
+ const isEnd = endMarkerId === String(m.id);
+ return (
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ {/* Forefront actions */}
+
+
+
+
+ {jobs.length === 0 ? (
+ Clip and save actions will show up here.
+ ) : (
+
+ {jobs.map((job) => )}
+
+ )}
+
+ >
+ )}
+
+ );
+};
+
+export default StreamControl;
diff --git a/frontend/src/pages/VideoPlayer.tsx b/frontend/src/pages/VideoPlayer.tsx
index d91f3eb..436edb4 100644
--- a/frontend/src/pages/VideoPlayer.tsx
+++ b/frontend/src/pages/VideoPlayer.tsx
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import type { Clip } from "../utils/types";
-import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
+import { AuthError } from "../utils/api/client.ts";
+import { getClipById, getVideoBlob } from "../utils/api/clips.ts";
import Box from "../components/Box.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
diff --git a/frontend/src/pages/VodPlayer.tsx b/frontend/src/pages/VodPlayer.tsx
index 409542d..22c703b 100644
--- a/frontend/src/pages/VodPlayer.tsx
+++ b/frontend/src/pages/VodPlayer.tsx
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import type { Vod } from "../utils/types";
-import { AuthError, getVodById, getVodBlob } from "../utils/endpoints.ts";
+import { AuthError } from "../utils/api/client.ts";
+import { getVodById, getVodBlob } from "../utils/api/vods.ts";
import Box from "../components/Box.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
diff --git a/frontend/src/utils/api/client.ts b/frontend/src/utils/api/client.ts
new file mode 100644
index 0000000..c4361d8
--- /dev/null
+++ b/frontend/src/utils/api/client.ts
@@ -0,0 +1,12 @@
+const API_URL = import.meta.env.VITE_API_URL;
+
+class AuthError extends Error {
+ constructor() { super("Not authenticated"); this.name = "AuthError"; }
+}
+
+const isThumbnailAvailable = async (thumbnailUrl: string): Promise => {
+ const response = await fetch(thumbnailUrl, { credentials: 'include' });
+ return response.ok;
+};
+
+export { API_URL, AuthError, isThumbnailAvailable };
diff --git a/frontend/src/utils/api/clips.ts b/frontend/src/utils/api/clips.ts
new file mode 100644
index 0000000..2259659
--- /dev/null
+++ b/frontend/src/utils/api/clips.ts
@@ -0,0 +1,67 @@
+import type { APIResponse, Clip } from "../types.ts";
+import { API_URL, AuthError } from "./client.ts";
+
+const getClips = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
+
+ if (!response.ok) {
+ const errorResult: APIResponse = await response.json();
+ throw new Error(`Failed to fetch clips: ${errorResult.message}`);
+ }
+
+ const result: APIResponse = await response.json();
+ return result.data;
+};
+
+const getClipById = async (id: string): Promise => {
+ const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
+
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) throw new AuthError();
+ throw new Error(`Failed to fetch clip: ${response.status}`);
+ }
+
+ const result: APIResponse = await response.json();
+ return result.data;
+};
+
+const getVideoBlob = async (id: string): Promise => {
+ const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
+
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) throw new AuthError();
+ throw new Error(`Failed to fetch video ${id}: ${response.status}`);
+ }
+
+ return response.blob();
+};
+
+const patchClip = async (id: number, data: { title?: string; description?: string }): Promise => {
+ const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
+
+ return result.data;
+};
+
+const deleteClip = async (id: number): Promise => {
+ const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ });
+
+ if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
+};
+
+export { getClips, getClipById, getVideoBlob, patchClip, deleteClip };
diff --git a/frontend/src/utils/api/jobs.ts b/frontend/src/utils/api/jobs.ts
new file mode 100644
index 0000000..5f3bfe4
--- /dev/null
+++ b/frontend/src/utils/api/jobs.ts
@@ -0,0 +1,15 @@
+import type { APIResponse, JobResponse } from "../types.ts";
+import { API_URL } from "./client.ts";
+
+const getJob = async (uuid: string): Promise => {
+ const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
+
+ if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
+
+ return result.data;
+};
+
+export { getJob };
diff --git a/frontend/src/utils/api/markers.ts b/frontend/src/utils/api/markers.ts
new file mode 100644
index 0000000..8eadd80
--- /dev/null
+++ b/frontend/src/utils/api/markers.ts
@@ -0,0 +1,40 @@
+import type { APIResponse, Marker } from "../types.ts";
+import { API_URL } from "./client.ts";
+
+const getMarkers = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/markers', { credentials: 'include' });
+ if (!response.ok) throw new Error(`Failed to fetch markers: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to fetch markers: ${result.message}`);
+
+ return result.data;
+};
+
+const createMarker = async (message: string): Promise => {
+ const response = await fetch(API_URL + '/api/v1/markers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ message }),
+ });
+
+ const result: APIResponse = await response.json();
+ if (!response.ok || result.status === 'error') throw new Error(`Failed to add marker: ${result.message}`);
+
+ return result.data;
+};
+
+const deleteMarker = async (id: number): Promise => {
+ const response = await fetch(API_URL + `/api/v1/markers/${id}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ });
+
+ if (!response.ok) throw new Error(`Failed to delete marker: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to delete marker: ${result.message}`);
+};
+
+export { getMarkers, createMarker, deleteMarker };
diff --git a/frontend/src/utils/api/media.ts b/frontend/src/utils/api/media.ts
new file mode 100644
index 0000000..5d8626c
--- /dev/null
+++ b/frontend/src/utils/api/media.ts
@@ -0,0 +1,72 @@
+import type { APIResponse, VideoMetadata } from "../types.ts";
+import { API_URL } from "./client.ts";
+
+const compress = async (file: File, options: VideoMetadata): Promise => {
+ const formData = new FormData();
+ formData.append('file', file);
+ formData.append('title', options.title);
+ if (options.description) formData.append('description', options.description);
+ formData.append('startPoint', options.startPoint.toString());
+ formData.append('duration', options.duration.toString());
+ if (options.fps) formData.append('fps', options.fps.toString());
+ if (options.width) formData.append('width', options.width.toString());
+ if (options.height) formData.append('height', options.height.toString());
+ if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
+
+ const response = await fetch(API_URL + '/api/v1/media/compress', {
+ method: 'POST',
+ body: formData,
+ credentials: 'include',
+ });
+
+ if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
+
+ return result.data.uuid;
+};
+
+const saveSection = async (startTime: string, endTime: string, title?: string, description?: string): Promise => {
+ const response = await fetch(API_URL + '/api/v1/media/save', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ startTime, endTime, title, description }),
+ });
+
+ const result: APIResponse = await response.json();
+ if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
+
+ return result.data.uuid;
+};
+
+const saveSectionByMarkers = async (startMarkerId: number, endMarkerId: number, title?: string, description?: string): Promise => {
+ const response = await fetch(API_URL + '/api/v1/media/save/markers', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ startMarkerId, endMarkerId, title, description }),
+ });
+
+ const result: APIResponse = await response.json();
+ if (!response.ok || result.status === 'error') throw new Error(`Failed to save section: ${result.message}`);
+
+ return result.data.uuid;
+};
+
+const clipSection = async (duration: number, title?: string, description?: string): Promise => {
+ const response = await fetch(API_URL + '/api/v1/media/clip', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify({ duration, title, description }),
+ });
+
+ const result: APIResponse = await response.json();
+ if (!response.ok || result.status === 'error') throw new Error(`Failed to clip section: ${result.message}`);
+
+ return result.data.uuid;
+};
+
+export { compress, saveSection, saveSectionByMarkers, clipSection };
diff --git a/frontend/src/utils/api/stream.ts b/frontend/src/utils/api/stream.ts
new file mode 100644
index 0000000..7cf5fc0
--- /dev/null
+++ b/frontend/src/utils/api/stream.ts
@@ -0,0 +1,31 @@
+import type { APIResponse, StreamHistoryItem, StreamStatus } from "../types.ts";
+import { API_URL } from "./client.ts";
+
+const getStreamStatus = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
+ if (!response.ok) return false;
+ const result: APIResponse = await response.json();
+ return result.status === 'success' && result.data?.isStreaming === true;
+};
+
+const getCurrentStream = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
+ if (!response.ok) throw new Error(`Failed to fetch stream status: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to fetch stream status: ${result.message}`);
+
+ return result.data;
+};
+
+const getStreamHistory = async (userId: number): Promise => {
+ const response = await fetch(API_URL + `/api/v1/stream/history/${userId}`, { credentials: 'include' });
+ if (!response.ok) throw new Error(`Failed to fetch stream history: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to fetch stream history: ${result.message}`);
+
+ return result.data;
+};
+
+export { getStreamStatus, getCurrentStream, getStreamHistory };
diff --git a/frontend/src/utils/api/users.ts b/frontend/src/utils/api/users.ts
new file mode 100644
index 0000000..73f4af1
--- /dev/null
+++ b/frontend/src/utils/api/users.ts
@@ -0,0 +1,42 @@
+import type { APIResponse, User } from "../types.ts";
+import { API_URL } from "./client.ts";
+
+const login = async (googleToken: string): Promise => {
+ const response = await fetch(API_URL + '/api/v1/users/login', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ token: googleToken }),
+ credentials: 'include',
+ });
+
+ if (!response.ok) throw new Error(`Login failed: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
+
+ return result.data.token;
+};
+
+const logout = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/users/logout', {
+ method: 'POST',
+ credentials: 'include',
+ });
+
+ if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
+};
+
+const getUser = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
+ if (!response.ok) return null;
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') return null;
+
+ return result.data;
+};
+
+export { login, logout, getUser };
diff --git a/frontend/src/utils/api/vods.ts b/frontend/src/utils/api/vods.ts
new file mode 100644
index 0000000..4f45de3
--- /dev/null
+++ b/frontend/src/utils/api/vods.ts
@@ -0,0 +1,67 @@
+import type { APIResponse, Vod } from "../types.ts";
+import { API_URL, AuthError } from "./client.ts";
+
+const getVods = async (): Promise => {
+ const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
+
+ if (!response.ok) {
+ const errorResult: APIResponse = await response.json();
+ throw new Error(`Failed to fetch vods: ${errorResult.message}`);
+ }
+
+ const result: APIResponse = await response.json();
+ return result.data;
+};
+
+const getVodById = async (id: string): Promise => {
+ const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
+
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) throw new AuthError();
+ throw new Error(`Failed to fetch vod: ${response.status}`);
+ }
+
+ const result: APIResponse = await response.json();
+ return result.data;
+};
+
+const getVodBlob = async (id: string): Promise => {
+ const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' });
+
+ if (!response.ok) {
+ if (response.status === 401 || response.status === 403) throw new AuthError();
+ throw new Error(`Failed to fetch vod ${id}: ${response.status}`);
+ }
+
+ return response.blob();
+};
+
+const patchVod = async (id: number, data: { title?: string; description?: string }): Promise => {
+ const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'include',
+ body: JSON.stringify(data),
+ });
+
+ if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
+
+ return result.data;
+};
+
+const deleteVod = async (id: number): Promise => {
+ const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
+ method: 'DELETE',
+ credentials: 'include',
+ });
+
+ if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
+
+ const result: APIResponse = await response.json();
+ if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
+};
+
+export { getVods, getVodById, getVodBlob, patchVod, deleteVod };
diff --git a/frontend/src/utils/endpoints.ts b/frontend/src/utils/endpoints.ts
deleted file mode 100644
index fc6fd67..0000000
--- a/frontend/src/utils/endpoints.ts
+++ /dev/null
@@ -1,240 +0,0 @@
-import type { VideoMetadata, APIResponse, User, Clip, Vod, JobResponse } from "./types.ts";
-
-const API_URL = import.meta.env.VITE_API_URL;
-
-export class AuthError extends Error {
- constructor() { super("Not authenticated"); this.name = "AuthError"; }
-}
-
-const login = async (googleToken: string): Promise => {
- const response = await fetch(API_URL + '/api/v1/users/login', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ token: googleToken }),
- credentials: 'include',
- });
-
- if (!response.ok) throw new Error(`Login failed: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
-
- return result.data.token;
-};
-
-const logout = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/users/logout', {
- method: 'POST',
- credentials: 'include',
- });
-
- if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
-};
-
-const compress = async (file: File, options: VideoMetadata): Promise => {
- const formData = new FormData();
- formData.append('file', file);
- formData.append('title', options.title);
- if (options.description) formData.append('description', options.description);
- formData.append('startPoint', options.startPoint.toString());
- formData.append('duration', options.duration.toString());
- if (options.fps) formData.append('fps', options.fps.toString());
- if (options.width) formData.append('width', options.width.toString());
- if (options.height) formData.append('height', options.height.toString());
- if (options.fileSize) formData.append('fileSize', options.fileSize.toString());
-
- const response = await fetch(API_URL + '/api/v1/media/compress', {
- method: 'POST',
- body: formData,
- credentials: 'include',
- });
-
- if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
-
- return result.data.uuid;
-};
-
-const getJob = async (uuid: string): Promise => {
- const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
-
- if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
-
- return result.data;
-};
-
-const getUser = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
- if (!response.ok) return null;
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') return null;
-
- return result.data;
-};
-
-const getClips = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
-
- if (!response.ok) {
- const errorResult: APIResponse = await response.json();
- throw new Error(`Failed to fetch clips: ${errorResult.message}`);
- }
-
- const result: APIResponse = await response.json();
- return result.data;
-};
-
-const getClipById = async (id: string): Promise => {
- const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
-
- if (!response.ok) {
- if (response.status === 401 || response.status === 403) throw new AuthError();
- throw new Error(`Failed to fetch clip: ${response.status}`);
- }
-
- const result: APIResponse = await response.json();
- return result.data;
-};
-
-const getVideoBlob = async (id: string): Promise => {
- const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
-
- if (!response.ok) {
- if (response.status === 401 || response.status === 403) throw new AuthError();
- throw new Error(`Failed to fetch video ${id}: ${response.status}`);
- }
-
- return response.blob();
-};
-
-const patchClip = async (id: number, data: { title?: string; description?: string }): Promise => {
- const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify(data),
- });
-
- if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
-
- return result.data;
-};
-
-const deleteClip = async (id: number): Promise => {
- const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
- method: 'DELETE',
- credentials: 'include',
- });
-
- if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
-};
-
-const getVods = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/vods', { credentials: 'include' });
-
- if (!response.ok) {
- const errorResult: APIResponse = await response.json();
- throw new Error(`Failed to fetch vods: ${errorResult.message}`);
- }
-
- const result: APIResponse = await response.json();
- return result.data;
-};
-
-const getVodById = async (id: string): Promise => {
- const response = await fetch(API_URL + `/api/v1/vods/${id}`, { credentials: 'include' });
-
- if (!response.ok) {
- if (response.status === 401 || response.status === 403) throw new AuthError();
- throw new Error(`Failed to fetch vod: ${response.status}`);
- }
-
- const result: APIResponse = await response.json();
- return result.data;
-};
-
-const getVodBlob = async (id: string): Promise => {
- const response = await fetch(API_URL + `/api/v1/vods/${id}/media`, { credentials: 'include' });
-
- if (!response.ok) {
- if (response.status === 401 || response.status === 403) throw new AuthError();
- throw new Error(`Failed to fetch vod ${id}: ${response.status}`);
- }
-
- return response.blob();
-};
-
-const patchVod = async (id: number, data: { title?: string; description?: string }): Promise => {
- const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
- method: 'PATCH',
- headers: { 'Content-Type': 'application/json' },
- credentials: 'include',
- body: JSON.stringify(data),
- });
-
- if (!response.ok) throw new Error(`Failed to update vod: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Failed to update vod: ${result.message}`);
-
- return result.data;
-};
-
-const deleteVod = async (id: number): Promise => {
- const response = await fetch(API_URL + `/api/v1/vods/${id}`, {
- method: 'DELETE',
- credentials: 'include',
- });
-
- if (!response.ok) throw new Error(`Failed to delete vod: ${response.status}`);
-
- const result: APIResponse = await response.json();
- if (result.status === 'error') throw new Error(`Failed to delete vod: ${result.message}`);
-};
-
-const getStreamStatus = async (): Promise => {
- const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
- if (!response.ok) return false;
- const result: APIResponse = await response.json();
- return result.status === 'success' && result.data?.isStreaming === true;
-};
-
-const isThumbnailAvailable = async (thumbnailUrl: string): Promise => {
- const response = await fetch(thumbnailUrl, { credentials: 'include' });
- return response.ok;
-};
-
-export {
- login,
- logout,
- compress,
- getJob,
- getUser,
- getClips,
- getClipById,
- getVideoBlob,
- patchClip,
- deleteClip,
- getVods,
- getVodById,
- getVodBlob,
- patchVod,
- deleteVod,
- getStreamStatus,
- isThumbnailAvailable,
-};
diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts
index cfba334..07287e4 100644
--- a/frontend/src/utils/types.ts
+++ b/frontend/src/utils/types.ts
@@ -16,6 +16,7 @@ type APIResponse = {
}
type User = {
+ id: number,
name: string,
username: string,
email: string,
@@ -51,11 +52,34 @@ type JobResponse = {
createdAt: string,
};
+type StreamStatus = {
+ isStreaming: boolean,
+ id: number | null,
+}
+
+type StreamHistoryItem = {
+ id: number,
+ startDate: string | null,
+ endDate: string | null,
+ lastSeen: string | null,
+}
+
+type Marker = {
+ id: number,
+ userId: number,
+ streamId: number,
+ message: string,
+ timestamp: string,
+}
+
export type {
APIResponse,
VideoMetadata,
User,
Clip,
Vod,
- JobResponse
+ JobResponse,
+ StreamStatus,
+ StreamHistoryItem,
+ Marker
}
\ No newline at end of file
diff --git a/src/main/java/com/ddf/vodsystem/controllers/MarkerController.java b/src/main/java/com/ddf/vodsystem/controllers/MarkerController.java
index b10d276..9ab63c1 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/MarkerController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/MarkerController.java
@@ -51,6 +51,15 @@ public class MarkerController {
);
}
+ @DeleteMapping("/{id}")
+ public ResponseEntity> deleteMarker(@PathVariable Long id) {
+ markerService.deleteMarker(id);
+
+ return ResponseEntity.ok(
+ new APIResponse<>(SUCCESS, "Marker deleted successfully", "Marker " + id + " deleted.")
+ );
+ }
+
private MarkerResponse convertToDTO(Marker marker) {
return new MarkerResponse(
marker.getId(),
diff --git a/src/main/java/com/ddf/vodsystem/services/MarkerService.java b/src/main/java/com/ddf/vodsystem/services/MarkerService.java
index b747f6a..77059fc 100644
--- a/src/main/java/com/ddf/vodsystem/services/MarkerService.java
+++ b/src/main/java/com/ddf/vodsystem/services/MarkerService.java
@@ -72,6 +72,11 @@ public class MarkerService {
return markerRepository.saveAndFlush(marker);
}
+ public void deleteMarker(Long id) {
+ Marker marker = getMarkerById(id);
+ markerRepository.delete(marker);
+ }
+
@Scheduled(fixedDelay = 360_000)
@Transactional
public void deleteOldMarkers() {