15 Commits

Author SHA1 Message Date
5e00965baa Merge pull request 'Refactor job into being a simple UUID tracker' (#3) from refactor/generalize-job-abstraction into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/3
2026-06-25 20:26:08 +01:00
0e13e44feb fix(tailwind): top-level gitignore cause tailwind css issues
20:22
Scoped the bare video entry in the root .gitignore to its intended path. Tailwind V4 respects all .gitignore files in the project when scanning for utility classes, so the bare entry was silently preventing components/video/* from being scanned and its classes from appearing in the computed CSS output. The bug was absent in Docker as the root .gitignore was never copied into the container.
2026-06-25 20:22:44 +01:00
42294703ae chore(backend): remove LocalDateTime usage, replaced with Instant 2026-06-25 20:21:48 +01:00
755a18fc3a chore: update gitignore 2026-06-23 21:39:53 +01:00
8b299dc4fa fix(directories): inputs absolute paths into db 2026-06-23 21:31:45 +01:00
5651aa4ce9 refactor(directories): switch from File to Path objs 2026-06-23 20:53:24 +01:00
8b49a10a8c refactor(frontend): remove dead code 2026-06-22 22:50:07 +01:00
e36913d671 fix(frontend): update frontend to match new endpoints 2026-06-22 22:42:13 +01:00
7d8a62d331 docs(bruno): missing post response script 2026-06-22 22:34:18 +01:00
c571e12099 docs(bruno): remove old endpoints 2026-06-22 22:10:33 +01:00
e9abdfb44a docs(bruno): new bruno files 2026-06-22 22:09:10 +01:00
dc5af8f868 docs(bruno): overhaul bruno to match new API 2026-06-22 22:08:56 +01:00
da6bf16d07 refactor(media): consolidate upload/edit/download into MediaService
Merge UploadService, EditService, JobOrchestrationService, and
DownloadService into a single MediaService with a unified Job lifecycle
(READY → PROCESSING → SUCCEEDED/FAILED via JobState enum).

- Add MediaController (/api/v1/media) exposing compress, save, and clip
- Add JobController (/api/v1/job) for status polling and file download
- Move clip/thumbnail download into ClipController (/{id}/media, /{id}/thumbnail)
- Move stream saveSection/clip from StreamService into MediaService
- Simplify Job DTO: remove dual ClipOptions/files, add ProgressTracker and download file
- Refactor JobRegistryService.add() → generateJob() to co-locate creation and registration
- Wire FFmpeg out_time_ms progress parsing into StreamActionsService via CommandRunner
- Rename GET /api/v1/auth/user → GET /api/v1/user/me
- Delete DownloadController, MetadataController, UploadController
2026-06-22 20:54:16 +01:00
b732c76aef Merge branch 'master' into refactor/generalize-job-abstraction 2026-06-22 18:32:45 +01:00
9185a41147 feat(frontend): remove server-side upload process and switch to browser process 2026-06-20 22:04:42 +01:00
94 changed files with 1174 additions and 1748 deletions

2
.gitignore vendored
View File

@@ -39,7 +39,7 @@ build/
.vscode/ .vscode/
### Generated Files ### ### Generated Files ###
videos/ /videos
outputs/ outputs/
generated generated

View File

@@ -0,0 +1,16 @@
meta {
name: Delete Clip
type: http
seq: 5
}
delete {
url: {{base_url}}/clips/6
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Clip Media
type: http
seq: 1
}
get {
url: {{base_url}}/clips/5/media
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,15 @@
meta {
name: Get Clip Thumbnail
type: http
seq: 2
}
get {
url: {{base_url}}/clips/5/thumbnail
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,8 @@
meta {
name: Downloads
seq: 4
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Clip
type: http
seq: 1
}
get {
url: {{base_url}}/clips/5
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Get all clips name: Get all clips
type: http type: http
seq: 1 seq: 2
} }
get { get {

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Update Clip name: Update Clip
type: http type: http
seq: 2 seq: 3
} }
patch { patch {

View File

@@ -1,5 +1,6 @@
meta { meta {
name: Clips name: Clips
seq: 1
} }
auth { auth {

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Download
type: http
seq: 2
}
get {
url: {{base_url}}/download/output/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Download
type: http
seq: 1
}
get {
url: {{base_url}}/download/input/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Download
}

View File

@@ -1,17 +0,0 @@
meta {
name: Edit
type: http
seq: 1
}
post {
url: {{base_url}}/edit/{{uuid}}
body: formUrlEncoded
auth: inherit
}
body:form-urlencoded {
startPoint: 10
duration: 40
title: best possible title
}

View File

@@ -1,11 +0,0 @@
meta {
name: Process
type: http
seq: 2
}
get {
url: {{base_url}}/process/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Progress
type: http
seq: 3
}
get {
url: {{base_url}}/progress/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Edit
}

View File

@@ -0,0 +1,15 @@
meta {
name: Download Video
type: http
seq: 2
}
get {
url: {{base_url}}/jobs/{{uuid}}/download
body: none
auth: inherit
}
settings {
encodeUrl: true
}

View File

@@ -0,0 +1,16 @@
meta {
name: Get Job
type: http
seq: 1
}
get {
url: {{base_url}}/jobs/{{uuid}}
body: none
auth: inherit
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -0,0 +1,8 @@
meta {
name: Jobs
seq: 5
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,21 @@
meta {
name: Clip Stream
type: http
seq: 3
}
post {
url: {{base_url}}/media/clip
body: json
auth: inherit
}
body:json {
{
"duration": 60.0
}
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -0,0 +1,22 @@
meta {
name: Compress
type: http
seq: 1
}
post {
url: {{base_url}}/media/compress
body: multipartForm
auth: inherit
}
body:multipart-form {
file: @file(/Users/dylan/Downloads/Is Russia at a Breaking Point_.mp4)
title: hello
startPoint: 10
duration: 30
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -0,0 +1,22 @@
meta {
name: Save Stream Section
type: http
seq: 2
}
post {
url: {{base_url}}/media/save
body: json
auth: inherit
}
body:json {
{
"startTime": "2026-06-21T17:27:00Z",
"endTime": "2026-06-21T17:28:00Z"
}
}
script:post-response {
bru.setEnvVar("uuid", res.getBody().data.uuid);
}

View File

@@ -0,0 +1,8 @@
meta {
name: Media
seq: 2
}
auth {
mode: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Metadata
type: http
seq: 2
}
get {
url: {{base_url}}/metadata/converted/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Metadata
type: http
seq: 1
}
get {
url: {{base_url}}/metadata/original/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Metadata
}

View File

@@ -1,22 +0,0 @@
meta {
name: Save Clip
type: http
seq: 3
}
post {
url: {{base_url}}/stream/clip
body: json
auth: inherit
}
body:json {
{
"duration": 60
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,23 +0,0 @@
meta {
name: Save Section
type: http
seq: 2
}
post {
url: {{base_url}}/stream/save
body: json
auth: inherit
}
body:json {
{
"startTime": "2026-06-20T15:00:00Z",
"endTime": "2026-06-20T15:01:00Z"
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,12 +1,8 @@
meta { meta {
name: Streams name: Streams
seq: 7 seq: 4
} }
auth { auth {
mode: bearer mode: inherit
}
auth:bearer {
token: {{token}}
} }

View File

@@ -1,19 +0,0 @@
meta {
name: Upload
type: http
seq: 1
}
post {
url: {{base_url}}/upload
body: multipartForm
auth: inherit
}
body:multipart-form {
file: @file(/Users/faoite/Downloads/Why Australia's Economy is Stalling.mp4)
}
script:post-response {
bru.setEnvVar("uuid", res.body.data.uuid);
}

View File

@@ -5,7 +5,7 @@ meta {
} }
get { get {
url: {{base_url}}/auth/user url: {{base_url}}/users/me
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -5,7 +5,7 @@ meta {
} }
post { post {
url: {{base_url}}/auth/login url: {{base_url}}/users/login
body: json body: json
auth: none auth: none
} }

View File

@@ -0,0 +1,8 @@
meta {
name: Users
seq: 3
}
auth {
mode: inherit
}

View File

@@ -8,8 +8,6 @@ services:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
volumes: volumes:
- ${POSTGRES_DATA}:/var/lib/postgresql/data - ${POSTGRES_DATA}:/var/lib/postgresql/data
ports:
- "5432:5432"
backend: backend:
build: build:
@@ -21,8 +19,8 @@ services:
env_file: env_file:
- .env - .env
volumes: volumes:
- ${MEDIA_DATA}:/app/data/media - ${MEDIA_DATA}:/app/media
- ${STREAM_DATA}:/stream - ${STREAM_DATA}:/app/stream
ports: ports:
- "8080:8080" - "8080:8080"
restart: unless-stopped restart: unless-stopped

View File

@@ -1,16 +1,14 @@
import clsx from "clsx"; import clsx from "clsx";
type props = { type Props = {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
} };
const Box = ({children, className}: props) => { const Box = ({ children, className }: Props) => (
return (
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}> <div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
{ children } {children}
</div> </div>
) );
}
export default Box; export default Box;

View File

@@ -1,54 +1,45 @@
import React, {useEffect, useRef} from "react"; import React, { useState, useEffect, useRef } from "react";
import clsx from "clsx"; import clsx from "clsx";
type DropdownItemProps = { type DropdownItemProps = {
item: string; item: string;
onClick: (item: string) => void; onClick: (item: string) => void;
className?: string; className?: string;
} };
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => { const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
return ( <li
<li className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)} className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
onClick={() => onClick(item)} onClick={() => onClick(item)}
> >
{item} {item}
</li> </li>
); );
}
type DropdownProps = { type DropdownProps = {
label: React.ReactNode; label: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} };
const Dropdown = ({ label, children, className }: DropdownProps) => { const Dropdown = ({ label, children, className }: DropdownProps) => {
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
useEffect(() => { useEffect(() => {
function handleClickOutside(event: { target: any }) { const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target)) { if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false); setIsOpen(false);
} }
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
}; };
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
return ( return (
<div className={clsx(className, "relative inline-block text-left")} <div className={clsx(className, "relative inline-block text-left")} ref={ref}>
ref={ref}>
<button <button
onClick={toggleDropdown} onClick={() => setIsOpen(v => !v)}
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150" className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
> >
{label} {label}
@@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
); );
}; };
export { Dropdown, DropdownItem }; export { Dropdown, DropdownItem };

View File

@@ -1,20 +1,16 @@
import React from "react"; import React from "react";
type props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
label: String; label: string;
} };
const Selector = ({children, label}: props) => { const Selector = ({ children, label }: Props) => (
return ( <div className="flex items-center gap-2">
<div className={"flex items-center gap-2"}> <label className="w-full text-sm text-gray-600">{label}</label>
<label className={"w-full text-sm text-gray-600"}> <div className="w-px h-5 bg-gray-200 mx-2" />
{ label }
</label>
<div className={"w-px h-5 bg-gray-200 mx-2"} />
{children} {children}
</div> </div>
) );
}
export default Selector; export default Selector;

View File

@@ -31,7 +31,6 @@ const Sidebar = ({user, className}: props) => {
<Divider /> <Divider />
<div className="flex flex-col gap-0.5"> <div className="flex flex-col gap-0.5">
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" /> <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
{/*<SidebarButton url="/vods" logo={<Video size={18}/>} label="VoDs" />*/}
</div> </div>
</> </>
)} )}

View File

@@ -1,13 +1,13 @@
import MenuButton from "./MenuButton.tsx"; import MenuButton from "./MenuButton.tsx";
import { Link, useLocation } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
type props = { type Props = {
url: string; url: string;
logo: React.ReactNode; logo: React.ReactNode;
label: String; label: string;
} }
const SidebarButton = ({url, logo, label}: props) => { const SidebarButton = ({url, logo, label}: Props) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url); const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);

View File

@@ -1,42 +1,32 @@
import RangeSlider from 'react-range-slider-input'; import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css'; import 'react-range-slider-input/dist/style.css';
import {useRef} from "react"; import { useRef, type Dispatch, type SetStateAction } from "react";
import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
type Props = { type Props = {
videoRef: HTMLVideoElement | null; videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata; videoMetadata: VideoMetadata;
setSliderValue: Function; setSliderValue: Dispatch<SetStateAction<number>>;
setMetadata: Function; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
}; };
export default function ClipRangeSlider({videoRef, export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
videoMetadata, const previousRange = useRef<[number, number]>([0, 0]);
setSliderValue,
setMetadata,
className}: Props) {
const previousRangeSliderInput = useRef<[number, number]>([0, 0]);
const handleRangeSliderInput = (val: [number, number]) => { const handleRangeInput = (val: [number, number]) => {
if (!videoRef) return; if (!videoRef) return;
if (previousRangeSliderInput.current[0] != val[0]) { if (previousRange.current[0] !== val[0]) {
videoRef.currentTime = val[0]; videoRef.currentTime = val[0];
setSliderValue(val[0]); setSliderValue(val[0]);
} else if (previousRangeSliderInput.current[1] != val[1]) { } else if (previousRange.current[1] !== val[1]) {
videoRef.currentTime = val[1]; videoRef.currentTime = val[1];
setSliderValue(val[1]); setSliderValue(val[1]);
} }
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
...prevState, previousRange.current = val;
startPoint: val[0],
duration: val[1] - val[0]
}
))
previousRangeSliderInput.current = val;
}; };
return ( return (
@@ -44,9 +34,9 @@ export default function ClipRangeSlider({videoRef,
min={0} min={0}
max={videoMetadata.duration} max={videoMetadata.duration}
step={0.1} step={0.1}
onInput={handleRangeSliderInput} onInput={handleRangeInput}
className={clsx(className)} className={className}
id={"range-slider"} id="range-slider"
/> />
) );
} }

View File

@@ -1,37 +1,28 @@
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx"; import Selector from "../Selector.tsx";
import clsx from "clsx"; import clsx from "clsx";
type prop = { type Props = {
setMetadata: Function; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
} };
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 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";
export default function ConfigBox({setMetadata, className}: prop) { export default function ConfigBox({ setMetadata, className }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const vals = e.target.value.split(","); const [width, height] = e.target.value.split(",").map(Number);
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, width, height }));
...prevState, };
width: parseInt(vals[0]),
height: parseInt(vals[1])
}))
}
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
...prevState, };
fps: parseInt(e.target.value)
}))
}
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => { const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
...prevState, };
fileSize: parseInt(e.target.value) * 1000
}))
}
return ( return (
<div className={clsx("flex flex-col gap-3 p-6", className)}> <div className={clsx("flex flex-col gap-3 p-6", className)}>
@@ -64,5 +55,5 @@ export default function ConfigBox({setMetadata, className}: prop) {
/> />
</Selector> </Selector>
</div> </div>
) );
} }

View File

@@ -1,26 +1,22 @@
import clsx from "clsx"; import clsx from "clsx";
import type {VideoMetadata} from "../../utils/types.ts"; import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts";
type MetadataBoxProps = { type Props = {
setMetadata: Function setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
} };
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => { const MetadataBox = ({ setMetadata, className }: Props) => (
return (
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}> <div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<p className="w-full font-semibold text-gray-800 text-sm">Title</p> <p className="w-full font-semibold text-gray-800 text-sm">Title</p>
<input <input
type="text" type="text"
placeholder="Enter title" placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({ onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
...prevState,
title: e.target.value
}))}
className="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" className="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"
/> />
</div> </div>
) );
}
export default MetadataBox; export default MetadataBox;

View File

@@ -1,52 +1,37 @@
import {useEffect} from "react"; import { useEffect, type Dispatch, type SetStateAction } from "react";
import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
type Props = { type Props = {
videoRef: HTMLVideoElement | null; videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata; videoMetadata: VideoMetadata;
sliderValue: number; sliderValue: number;
setSliderValue: Function; setSliderValue: Dispatch<SetStateAction<number>>;
className?: string; className?: string;
}; };
export default function PlaybackSlider({videoRef, export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
videoMetadata,
sliderValue,
setSliderValue,
className}: Props) {
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => { const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!videoRef) return; if (!videoRef) return;
videoRef.currentTime = e.target.valueAsNumber; videoRef.currentTime = e.target.valueAsNumber;
setSliderValue(e.target.valueAsNumber); setSliderValue(e.target.valueAsNumber);
} };
// update slider
useEffect(() => { useEffect(() => {
if (!videoRef) return; if (!videoRef) return;
const updateSlider = () => setSliderValue(videoRef.currentTime);
const updateSlider = () => {
setSliderValue(videoRef.currentTime);
};
videoRef.addEventListener("timeupdate", updateSlider); videoRef.addEventListener("timeupdate", updateSlider);
return () => videoRef.removeEventListener("timeupdate", updateSlider);
return () => {
videoRef.removeEventListener("timeupdate", updateSlider);
};
}, [videoRef]); }, [videoRef]);
return ( return (
<input <input
type={"range"} type="range"
min={0} min={0}
max={videoMetadata.duration} max={videoMetadata.duration}
value={sliderValue} value={sliderValue}
onChange={updateVideo} onChange={updateVideo}
step={0.1} step={0.1}
className={clsx(className)} className={className}
/> />
) );
} }

View File

@@ -1,10 +1,9 @@
import { useEffect, useState} from "react"; import { useEffect, useState } from "react";
import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react'; import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import { formatTime } from "../../utils/utils.ts"; import { formatTime } from "../../utils/utils.ts";
type Props = { type Props = {
video: HTMLVideoElement | null; video: HTMLVideoElement | null;
videoMetadata: VideoMetadata; videoMetadata: VideoMetadata;
@@ -15,9 +14,10 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100); const [volume, setVolume] = useState(100);
const Icon = volume === 0 ? VolumeX : volume < 50 ? Volume1 : Volume2;
const togglePlay = () => { const togglePlay = () => {
if (!video) return; if (!video) return;
if (video.paused) { if (video.paused) {
video.play(); video.play();
setIsPlaying(true); setIsPlaying(true);
@@ -27,35 +27,20 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
} }
}; };
let Icon;
// update icon
if (volume == 0) {
Icon = VolumeX;
} else if (volume < 50) {
Icon = Volume1;
} else {
Icon = Volume2;
}
const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => { const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!video) return; if (!video) return;
const newVolume = parseInt(e.target.value);
video.volume = newVolume / 100;
setVolume(newVolume);
};
let volume = parseInt(e.target.value) // Sync isPlaying when the video is controlled outside this component (e.g. keyboard shortcuts)
video.volume = volume / 100;
setVolume(volume);
}
// Sync state with video element changes (e.g., if someone presses spacebar or clicks on the video)
useEffect(() => { useEffect(() => {
if (!video) return; if (!video) return;
const handlePlay = () => setIsPlaying(true); const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false); const handlePause = () => setIsPlaying(false);
video.addEventListener("play", handlePlay); video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause); video.addEventListener("pause", handlePause);
return () => { return () => {
video.removeEventListener("play", handlePlay); video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause); video.removeEventListener("pause", handlePause);
@@ -64,28 +49,26 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
return ( return (
<div className={clsx("flex justify-between items-center mb-2", className)}> <div className={clsx("flex justify-between items-center mb-2", className)}>
<div className={"flex gap-1"}> <div className="flex gap-1">
<Icon size={24} /> <Icon size={24} />
<input <input
type='range' type="range"
min={0} min={0}
max={100} max={100}
onChange={updateVolume}
value={volume} value={volume}
className={"w-20"} onChange={updateVolume}
className="w-20"
/> />
</div> </div>
<button <button
onClick={togglePlay} onClick={togglePlay}
className={"hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"} className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
> >
{isPlaying ? <Pause size={24} /> : <Play size={24} />} {isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button> </button>
<span>
<label>
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)} {formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</label> </span>
</div> </div>
); );
} }

View File

@@ -25,9 +25,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
setTimeout(() => { useEffect(() => {
const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt))); setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000); }, 1000);
return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => { useEffect(() => {
isThumbnailAvailable(id) isThumbnailAvailable(id)
@@ -58,7 +61,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
<div className={clsx("flex flex-col group cursor-pointer", className)}> <div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className="relative overflow-hidden rounded-lg"> <div className="relative overflow-hidden rounded-lg">
<img <img
src={thumbnailAvailable ? API_URL + `/api/v1/download/thumbnail/${id}` : fallbackThumbnail} src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
alt="Video Thumbnail" alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200" className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/> />

View File

@@ -1,11 +1,11 @@
import { useParams } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar"; import Playbar from "./../components/video/Playbar";
import PlaybackSlider from "./../components/video/PlaybackSlider"; import PlaybackSlider from "./../components/video/PlaybackSlider";
import ClipRangeSlider from "./../components/video/ClipRangeSlider"; import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx"; import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx";
import {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints" import { compress, getJob } from "../utils/endpoints"
import type { VideoMetadata } from "../utils/types.ts"; import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx";
@@ -13,13 +13,16 @@ import MetadataBox from "../components/video/MetadataBox.tsx";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => { const ClipEdit = () => {
const { id } = useParams(); const location = useLocation();
const localFile: File = location.state?.file;
const videoRef = useRef<HTMLVideoElement | null>(null); const videoRef = useRef<HTMLVideoElement | null>(null);
const videoUrl = API_URL + `/api/v1/download/input/${id}` const [videoUrl, setVideoUrl] = useState<string>('');
const [uploadedId, setUploadedId] = useState<string | null>(null);
const [metadata, setMetadata] = useState<VideoMetadata | null>(null); const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0); const [playbackValue, setPlaybackValue] = useState(0);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({ const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
// default values
title: "", title: "",
description: "", description: "",
startPoint: 0, startPoint: 0,
@@ -33,41 +36,66 @@ const ClipEdit = () => {
const [downloadable, setDownloadable] = useState(false); const [downloadable, setDownloadable] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const sendData = async() => { useEffect(() => {
if (!id) return; if (!localFile) return;
const url = URL.createObjectURL(localFile);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [localFile]);
editFile(id, outputMetadata) const handleVideoMetadataLoaded = () => {
.then(() => { const video = videoRef.current;
if (!video) return;
setMetadata({
title: '',
description: '',
startPoint: 0,
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight,
fps: 30,
fileSize: localFile?.size ?? 0,
});
};
processFile(id) const sendData = async () => {
.catch((err: Error) => setError(`Failed to process file: ${err.message}`)); if (!localFile) return;
}) let jobId: string;
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`)); try {
jobId = await compress(localFile, outputMetadata);
const interval = setInterval(async() => await pollProgress(id, interval), 500); setUploadedId(jobId);
} catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
return;
} }
const pollProgress = async (id: string, intervalId: number) => { const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
getProgress(id) };
.then((progress) => {
setProgress(progress.process.progress);
if (progress.process.complete) { const pollProgress = async (jobId: string, intervalId: number) => {
getJob(jobId)
.then((job) => {
setProgress(job.progress);
if (job.state === 'FAILED') {
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
clearInterval(intervalId);
} else if (job.isComplete) {
clearInterval(intervalId); clearInterval(intervalId);
setDownloadable(true); setDownloadable(true);
} else {
setDownloadable(false)
} }
}) })
.catch((err: Error) => { .catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`); setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId); clearInterval(intervalId);
}); });
} };
const handleDownload = async () => { const handleDownload = async () => {
const response = await fetch(API_URL + `/api/v1/download/output/${id}`); if (!uploadedId) return;
const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
console.error('Download failed'); console.error('Download failed');
@@ -79,43 +107,27 @@ const ClipEdit = () => {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${id}.mp4`; a.download = `${uploadedId}.mp4`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
window.URL.revokeObjectURL(url); window.URL.revokeObjectURL(url);
}; };
useEffect(() => {
if (!id) return;
getMetadata(id)
.then((data) => setMetadata(data ?? null))
.catch((err) => console.error("Metadata fetch failed:", err));
}, [id]);
return ( return (
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}> <div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<video <video
ref={videoRef} ref={videoRef}
className={"w-full rounded-lg shadow-sm bg-black"}> src={videoUrl}
<source src={videoUrl} type="video/mp4" /> className={"w-full rounded-lg shadow-sm bg-black"}
<source src={videoUrl} type="video/webm" /> onLoadedMetadata={handleVideoMetadataLoaded}
<source src={videoUrl} type="video/ogg" /> />
Your browser does not support the video tag. Bzzzz.
</video>
<Box className={"w-4/5 h-full m-auto"}> <Box className={"w-4/5 h-full m-auto"}>
<MetadataBox <MetadataBox setMetadata={setOutputMetadata} />
setMetadata={setOutputMetadata} <ConfigBox setMetadata={setOutputMetadata} />
/>
<ConfigBox
setMetadata={setOutputMetadata}
/>
</Box> </Box>
{metadata && {metadata &&
<Box className={"mt-4 p-5"}> <Box className={"mt-4 p-5"}>
<Playbar <Playbar
@@ -158,6 +170,6 @@ const ClipEdit = () => {
</Box> </Box>
</div> </div>
); );
} };
export default ClipEdit; export default ClipEdit;

View File

@@ -1,6 +1,5 @@
import {useState} from "react"; import {useState} from "react";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
import BlueButton from "../components/buttons/BlueButton.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
@@ -8,63 +7,14 @@ const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [progress, setProgress] = useState<number>(0);
const isVideoFileSupported = (file: File): boolean => { const press = () => {
const video = document.createElement("video");
if (file.type && video.canPlayType(file.type) !== "") {
return true;
}
const extension = file.name.split(".").pop()?.toLowerCase();
const extensionToMime: Record<string, string> = {
mp4: "video/mp4",
webm: "video/webm",
ogg: "video/ogg",
mov: "video/quicktime",
};
if (extension && extensionToMime[extension]) {
return video.canPlayType(extensionToMime[extension]) !== "";
}
return false;
};
const press = (() => {
if (!file) { if (!file) {
setError("Please choose a file"); setError("Please choose a file");
return; return;
} }
navigate('/create/new', { state: { file } });
uploadFile(file) };
.then(uuid => {
if (isVideoFileSupported(file)) {
navigate(`/create/${uuid}`)
} else {
convertFile(uuid);
const interval = setInterval(async() => await pollProgress(uuid, interval), 500);
}
})
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
});
const pollProgress = async (id: string, intervalId: number) => {
getProgress(id)
.then((progress) => {
setProgress(progress.conversion.progress);
if (progress.conversion.complete) {
clearInterval(intervalId);
navigate(`/create/${id}`)
}
})
.catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId);
});
}
return ( return (
<div className="flex items-start justify-center p-8 min-h-full"> <div className="flex items-start justify-center p-8 min-h-full">
@@ -84,8 +34,6 @@ const ClipUpload = () => {
<BlueButton onClick={press}>Upload</BlueButton> <BlueButton onClick={press}>Upload</BlueButton>
{error && <p className="text-sm text-red-500 text-center">{error}</p>} {error && <p className="text-sm text-red-500 text-center">{error}</p>}
<progress value={progress} className="w-full h-1 rounded bg-gray-200" />
</Box> </Box>
</div> </div>
) )

View File

@@ -3,8 +3,8 @@ import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lu
const features = [ const features = [
{ {
icon: Upload, icon: Upload,
title: "Upload & convert", title: "Upload & compress",
description: "Drop in a video file. Non-native formats are converted automatically before editing.", description: "Drop in a video file, set your clip range and export settings, then compress.",
}, },
{ {
icon: Scissors, icon: Scissors,
@@ -71,7 +71,7 @@ const Home = () => {
{/* Format note */} {/* Format note */}
<p className="text-xs text-gray-400"> <p className="text-xs text-gray-400">
Supports MP4, MOV, WebM, and OGG. Non-native formats are converted on upload. Supports MP4, MOV, WebM, and OGG.
</p> </p>
</div> </div>
); );

View File

@@ -1,4 +1,4 @@
import type {VideoMetadata, APIResponse, User, Clip, ProgressResult } from "./types.ts"; import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
@@ -6,258 +6,115 @@ export class AuthError extends Error {
constructor() { super("Not authenticated"); this.name = "AuthError"; } constructor() { super("Not authenticated"); this.name = "AuthError"; }
} }
/** const login = async (googleToken: string): Promise<string> => {
* Login function const response = await fetch(API_URL + '/api/v1/users/login', {
* @param GoogleToken - The Google token received from the frontend.
* @return A promise that resolves to a JWT
*/
const login = async (GoogleToken: string): Promise<string> => {
const response = await fetch(API_URL + '/api/v1/auth/login', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json', body: JSON.stringify({ token: googleToken }),
}, credentials: 'include',
body: JSON.stringify({ token: GoogleToken }),
credentials: 'include'
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Login failed: ${response.status}`);
throw new Error(`Login failed: ${response.status}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Login failed: ${result.message}`);
if (result.status === "error") {
throw new Error(`Login failed: ${result.message}`);
}
return result.data.token; return result.data.token;
} };
const logout = async () => { const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/auth/logout', { const response = await fetch(API_URL + '/api/v1/users/logout', {
method: 'POST', method: 'POST',
credentials: 'include' credentials: 'include',
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
throw new Error(`Logout failed: ${response.status}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
};
if (result.status === "error") { const compress = async (file: File, options: VideoMetadata): Promise<string> => {
throw new Error(`Logout failed: ${result.message}`);
}
}
/**
* Uploads a file to the backend.
* @param file - The file to upload.
*/
const uploadFile = async (file: File): Promise<string> => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); 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/upload', { const response = await fetch(API_URL + '/api/v1/media/compress', {
method: 'POST', method: 'POST',
body: formData, body: formData,
credentials: 'include',
}); });
const result: APIResponse = await response.json(); if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
if (result.status == "error") { const result: APIResponse = await response.json();
throw new Error(`Failed to upload file: ${result.message}`); if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
}
return result.data.uuid; return result.data.uuid;
}; };
/** const getJob = async (uuid: string): Promise<JobResponse> => {
* Submits metadata changes to the backend. const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
* @param uuid - The UUID of the video file to edit.
* @param videoMetadata - The metadata to update.
*/
const editFile = async (uuid: string, videoMetadata: VideoMetadata) => {
const formData = new URLSearchParams();
for (const [key, value] of Object.entries(videoMetadata)) { if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
if (value !== undefined && value !== null) {
formData.append(key, value.toString());
}
}
const response = await fetch(API_URL + `/api/v1/edit/${uuid}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
throw new Error(`Failed to edit file: ${response.status}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
if (result.status === "error") {
throw new Error(`Failed to edit file: ${result.message}`);
}
};
/**
* Triggers file processing.
* @param uuid - The UUID of the video file to process.
*/
const processFile = async (uuid: string) => {
const response = await fetch(API_URL + `/api/v1/process/${uuid}`, {credentials: "include"});
if (!response.ok) {
throw new Error(`Failed to process file: ${response.status}`);
}
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error("Failed to process file: " + result.message);
}
};
const convertFile = async (uuid: string) => {
const response = await fetch(API_URL + `/api/v1/convert/${uuid}`);
if (!response.ok) {
throw new Error(`Failed to convert file: ${response.status}`);
}
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error("Failed to convert file: " + result.message);
}
};
/**
* Fetches the processing progress percentage.
* @param uuid - The UUID of the video file.
*/
const getProgress = async (uuid: string): Promise<ProgressResult> => {
const response = await fetch(API_URL + `/api/v1/progress/${uuid}`);
if (!response.ok) {
throw new Error(`Failed to fetch progress: ${response.status}`);
}
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch progress: ${result.message}`);
}
if (!result.data) {
throw new Error('Invalid progress data received');
}
return result.data; return result.data;
}; };
/** const getUser = async (): Promise<User | null> => {
* Fetches original metadata from the backend. const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
* @param uuid - The UUID of the video file. if (!response.ok) return null;
*/
const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
const response = await fetch(API_URL + `/api/v1/metadata/original/${uuid}`, {credentials: "include"});
if (!response.ok) { const result: APIResponse = await response.json();
throw new Error(`Failed to fetch metadata: ${response.status}`); if (result.status === 'error') return null;
}
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch metadata: ${result.message}`);
}
return result.data; return result.data;
}; };
/**
* Fetches the current user information. Returns null if not authenticated.
*/
const getUser = async (): Promise<null | User > => {
const response = await fetch(API_URL + '/api/v1/auth/user', {credentials: "include"});
if (!response.ok) {
return null;
}
const result = await response.json();
if (result.status === "error") {
return null;
}
console.log(result.data);
return result.data;
}
/**
* Fetches all clips for the current user.
*/
const getClips = async (): Promise<Clip[]> => { const getClips = async (): Promise<Clip[]> => {
const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include'}); const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
const errorResult: APIResponse = await response.json(); const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch clips: ${errorResult.message}`); throw new Error(`Failed to fetch clips: ${errorResult.message}`);
} }
try {
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
return result.data; return result.data;
} catch { };
throw new Error('Failed to parse response');
}
}
/**
* Fetches a clip by its ID.
* @param id
*/
const getClipById = async (id: string): Promise<Clip | null> => { const getClipById = async (id: string): Promise<Clip | null> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {credentials: "include",}); const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError(); if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch clip: ${response.status}`); throw new Error(`Failed to fetch clip: ${response.status}`);
} }
try{
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
return result.data; return result.data;
} catch (error: unknown) {
throw new Error(`Failed to parse clip response:
${error instanceof Error ? error.message : 'Unknown error'}`);
}
}; };
const getVideoBlob = async(id: string): Promise<Blob> => { const getVideoBlob = async (id: string): Promise<Blob> => {
const response = await fetch(API_URL + `/api/v1/download/clip/${id}`, {credentials: "include",}); const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError(); if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch video: ${id}: ${response.status}`) throw new Error(`Failed to fetch video ${id}: ${response.status}`);
} }
try {
return response.blob(); return response.blob();
} catch { };
throw new Error(`Failed to convert Clip Return Object to blob`);
}
}
const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => { const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
@@ -267,14 +124,10 @@ const patchClip = async (id: number, data: { title?: string; description?: strin
body: JSON.stringify(data), body: JSON.stringify(data),
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`);
throw new Error(`Failed to update clip: ${response.status}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') { if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`);
throw new Error(`Failed to update clip: ${result.message}`);
}
return result.data; return result.data;
}; };
@@ -285,14 +138,10 @@ const deleteClip = async (id: number): Promise<void> => {
credentials: 'include', credentials: 'include',
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`);
throw new Error(`Failed to delete clip: ${response.status}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') { if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`);
throw new Error(`Failed to delete clip: ${result.message}`);
}
}; };
const getStreamStatus = async (): Promise<boolean> => { const getStreamStatus = async (): Promise<boolean> => {
@@ -303,27 +152,21 @@ const getStreamStatus = async (): Promise<boolean> => {
}; };
const isThumbnailAvailable = async (id: number): Promise<boolean> => { const isThumbnailAvailable = async (id: number): Promise<boolean> => {
const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"}); const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
return response.ok; return response.ok;
};
}
export { export {
login, login,
logout, logout,
getStreamStatus, compress,
uploadFile, getJob,
editFile,
processFile,
convertFile,
getProgress,
getMetadata,
getUser, getUser,
getClips, getClips,
getClipById, getClipById,
getVideoBlob, getVideoBlob,
patchClip, patchClip,
deleteClip, deleteClip,
isThumbnailAvailable getStreamStatus,
isThumbnailAvailable,
}; };

View File

@@ -33,15 +33,13 @@ type Clip = {
createdAt: string, createdAt: string,
} }
type ProgressResult = { type JobResponse = {
process: { uuid: string,
progress: number, progress: number,
complete: boolean isComplete: boolean,
}; state: 'READY' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED',
conversion: { errorOutput: string | null,
progress: number, createdAt: string,
complete: boolean
};
}; };
export type { export type {
@@ -49,5 +47,5 @@ export type {
VideoMetadata, VideoMetadata,
User, User,
Clip, Clip,
ProgressResult JobResponse
} }

View File

@@ -2,76 +2,34 @@ function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
const pad = (n: number) => n.toString().padStart(2, '0');
const padded = (n: number) => n.toString().padStart(2, '0'); if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${m}:${pad(s)}`;
if (h > 0) {
return `${h}:${padded(m)}:${padded(s)}`;
} else {
return `${m}:${padded(s)}`;
}
} }
function stringToDate(dateString: string): Date { function stringToDate(dateString: string): Date {
const validIso = dateString.substring(0, 23); const date = new Date(dateString.substring(0, 23));
const date = new Date(validIso); if (isNaN(date.getTime())) throw new Error("Invalid date string");
if (isNaN(date.getTime())) {
throw new Error("Invalid date string");
}
return date; return date;
} }
function dateToTimeAgo(date: Date): string { function dateToTimeAgo(date: Date): string {
const now = new Date(); const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000); const units: [number, string][] = [
[31536000, 'year'],
if (secondsAgo < 60) { [2592000, 'month'],
return `${secondsAgo} seconds ago`; [86400, 'day'],
} else if (secondsAgo < 3600) { [3600, 'hour'],
const minutes = Math.floor(secondsAgo / 60); [60, 'minute'],
];
if (minutes === 1) { for (const [threshold, unit] of units) {
return "1 minute ago"; if (secondsAgo >= threshold) {
const count = Math.floor(secondsAgo / threshold);
return `${count} ${unit}${count !== 1 ? 's' : ''} ago`;
} }
return `${minutes} minutes ago`;
} else if (secondsAgo < 86400) {
const hours = Math.floor(secondsAgo / 3600);
if (hours === 1) {
return "1 hour ago";
}
return `${hours} hours ago`;
} else if (secondsAgo < 2592000) {
const days = Math.floor(secondsAgo / 86400);
if (days === 1) {
return "1 day ago";
}
return `${days} days ago`;
} else if (secondsAgo < 31536000) {
const months = Math.floor(secondsAgo / 2592000);
if (months === 1) {
return "1 month ago";
}
return `${months} months ago`;
} else {
const years = Math.floor(secondsAgo / 31536000);
if (years === 1) {
return "1 year ago";
}
return `${years} years ago`;
} }
const s = Math.max(0, secondsAgo);
return `${s} second${s !== 1 ? 's' : ''} ago`;
} }
export { export { formatTime, stringToDate, dateToTimeAgo };
formatTime,
stringToDate,
dateToTimeAgo
}

View File

@@ -15,12 +15,6 @@ RUN apt-get update && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
ENV POSTGRES_DATA=/app/db
ENV MEDIA_DATA=/app/data/media
ENV STREAM_DATA=/app/data/media/streams
VOLUME ["app/db", "app/data/media/streams", "/app/data/media"]
WORKDIR /app WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080 EXPOSE 8080

View File

@@ -30,11 +30,10 @@ public class SecurityConfig {
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
.requestMatchers("/api/v1/download/clip/**","/api/v1/clips/", "/api/v1/clips/**").authenticated() .requestMatchers("/api/v1/media/**").authenticated()
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll() .requestMatchers("/api/v1/jobs/**").authenticated()
.requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll() .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
.requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**", "/api/v1/convert/**").permitAll() .requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/metadata/**").permitAll()
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )

View File

@@ -5,6 +5,10 @@ import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest; import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.services.ClipService; import com.ddf.vodsystem.services.ClipService;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -16,6 +20,7 @@ import java.util.Optional;
public class ClipController { public class ClipController {
private final ClipService clipService; private final ClipService clipService;
private static final String SUCCESS = "success"; private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public ClipController(ClipService clipService) { public ClipController(ClipService clipService) {
this.clipService = clipService; this.clipService = clipService;
@@ -79,6 +84,34 @@ public class ClipController {
); );
} }
@GetMapping("/{id}/media")
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
Resource resource = clipService.downloadClip(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/{id}/thumbnail")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = clipService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
private ClipResponse convertToDTO(Clip clip) { private ClipResponse convertToDTO(Clip clip) {
return new ClipResponse( return new ClipResponse(
clip.getId(), clip.getId(),

View File

@@ -1,81 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.services.DownloadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("api/v1/download")
public class DownloadController {
private final DownloadService downloadService;
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
@Autowired
public DownloadController(DownloadService downloadService) {
this.downloadService = downloadService;
}
@GetMapping("/output/{uuid}")
public ResponseEntity<Resource> downloadFile(@PathVariable String uuid) {
Resource resource = downloadService.downloadOutput(uuid);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/input/{uuid}")
public ResponseEntity<Resource> downloadInput(@PathVariable String uuid) {
Resource resource = downloadService.downloadInput(uuid);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/clip/{id}")
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
Resource resource = downloadService.downloadClip(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
@GetMapping("/thumbnail/{id}")
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
Resource resource = downloadService.downloadThumbnail(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
}

View File

@@ -1,44 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.JobStatus;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.services.EditService;
import com.ddf.vodsystem.dto.APIResponse;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("api/v1")
public class EditController {
private final EditService editService;
private static final String SUCCESS = "success";
public EditController(EditService editService) {
this.editService = editService;
}
@PostMapping("edit/{uuid}")
public ResponseEntity<APIResponse<Void>> edit(@PathVariable("uuid") String uuid, @ModelAttribute ClipOptions clipOptions) {
editService.edit(uuid, clipOptions);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Editing started for UUID: " + uuid, null));
}
@GetMapping("/process/{uuid}")
public ResponseEntity<APIResponse<Void>> process(@PathVariable("uuid") String uuid) {
editService.process(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Processing started for UUID: " + uuid, null));
}
@GetMapping("/convert/{uuid}")
public ResponseEntity<APIResponse<Void>> convert(@PathVariable("uuid") String uuid) {
editService.convert(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Conversion started for UUID: " + uuid, null));
}
@GetMapping("/progress/{uuid}")
public ResponseEntity<APIResponse<JobStatus>> getStatus(@PathVariable("uuid") String uuid) {
JobStatus status = editService.getStatus(uuid);
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Status for UUID: " + uuid, status));
}
}

View File

@@ -0,0 +1,70 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.JobResponse;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.exceptions.ClipNotFound;
import com.ddf.vodsystem.exceptions.NotReadyException;
import com.ddf.vodsystem.services.JobRegistryService;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("api/v1/jobs")
public class JobController {
private final JobRegistryService jobRegistryService;
private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public JobController(JobRegistryService jobRegistryService){
this.jobRegistryService = jobRegistryService;
}
@GetMapping("/{uuid}")
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);
JobResponse jobResponse = new JobResponse(
job.getUuid(),
job.getProgressTracker().getProgress(),
job.getProgressTracker().isComplete(),
job.getState(),
job.getErrorOutput(),
job.getCreatedAt()
);
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Job successfully retreived",
jobResponse
));
}
@GetMapping("/{uuid}/download")
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
Job job = jobRegistryService.getJob(uuid);
if (job.getDownload() == null) {
throw new NotReadyException("Job " + uuid + " not ready for download.");
}
Resource resource = new FileSystemResource(job.getDownload());
if (!resource.exists()) {
throw new ClipNotFound("Job download file " + job.getDownload() + " does not exist.");
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
}

View File

@@ -0,0 +1,63 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
import com.ddf.vodsystem.controllers.dto.UUIDResponse;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.MediaService;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
@RestController
@RequestMapping("/api/v1/media")
public class MediaController {
private final MediaService mediaService;
private static final String SUCCESS = "success";
public MediaController(MediaService mediaService) {
this.mediaService = mediaService;
}
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<APIResponse<UUIDResponse>> compress(
@RequestParam("file")MultipartFile file,
@Valid @ModelAttribute ClipOptions options
) throws IOException, InterruptedException {
Job job = mediaService.compress(file, options);
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Compression successfully started",
new UUIDResponse(job.getUuid())
));
}
@PostMapping("/save")
public ResponseEntity<APIResponse<UUIDResponse>> save(
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime());
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Section saving successfully started",
new UUIDResponse(job.getUuid())
));
}
@PostMapping("/clip")
public ResponseEntity<APIResponse<UUIDResponse>> clip(
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
Job job = mediaService.clip(clipSectionRequest.duration());
return ResponseEntity.ok(new APIResponse<>(
SUCCESS,
"Clipping successfully started",
new UUIDResponse(job.getUuid())
));
}
}

View File

@@ -1,48 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.JobRegistryService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/metadata")
public class MetadataController {
private final JobRegistryService jobRegistryService;
public MetadataController(
JobRegistryService jobRegistryService) {
this.jobRegistryService = jobRegistryService;
}
@GetMapping("/original/{uuid}")
public ResponseEntity<APIResponse<ClipOptions>> getMetadata(@PathVariable String uuid) {
ClipOptions originalMetadata = jobRegistryService.getJob(uuid).getInputClipOptions();
if (originalMetadata == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new APIResponse<>("error", "Original metadata not found", null));
}
return ResponseEntity.ok()
.body(new APIResponse<>("success", "Original metadata retrieved", originalMetadata));
}
@GetMapping("/converted/{uuid}")
public ResponseEntity<APIResponse<ClipOptions>> getConvertedMetadata(@PathVariable String uuid) {
ClipOptions convertedMetadata = jobRegistryService.getJob(uuid).getOutputClipOptions();
if (convertedMetadata == null) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new APIResponse<>("error", "Converted metadata not found", null));
}
return ResponseEntity.ok()
.body(new APIResponse<>("success", "Converted metadata retrieved", convertedMetadata));
}
}

View File

@@ -1,9 +1,7 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
import com.ddf.vodsystem.controllers.dto.StreamResponse; import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
import com.ddf.vodsystem.entities.Stream; import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService; import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -11,7 +9,6 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -49,20 +46,6 @@ public class StreamController {
return ResponseEntity.ok(new APIResponse<>("success", "Heartbeat recorded", null)); return ResponseEntity.ok(new APIResponse<>("success", "Heartbeat recorded", null));
} }
@PostMapping("/save")
public ResponseEntity<APIResponse<Void>> saveSection(
@RequestBody SaveSectionRequest request) throws IOException, InterruptedException {
streamService.saveSection(request.startTime(), request.endTime());
return ResponseEntity.accepted().body(new APIResponse<>("success", "Section saved", null));
}
@PostMapping("/clip")
public ResponseEntity<APIResponse<Void>> saveClip(
@RequestBody ClipSectionRequest request) throws IOException, InterruptedException {
streamService.clip(request.duration());
return ResponseEntity.accepted().body(new APIResponse<>("success", "Clip saved", null));
}
@GetMapping("/history/{userId}") @GetMapping("/history/{userId}")
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) { public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
List<Stream> history = streamService.getStreamHistory(userId); List<Stream> history = streamService.getStreamHistory(userId);

View File

@@ -1,32 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.UploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("api/v1/upload")
public class UploadController {
private final UploadService uploadService;
@Autowired
public UploadController(UploadService uploadService) {
this.uploadService = uploadService;
}
@PostMapping()
public ResponseEntity<APIResponse<Object>> uploadVideo(@RequestParam("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new IllegalArgumentException("Invalid file");
}
String uuid = uploadService.upload(file);
APIResponse<Object> response = new APIResponse<>("success", "File uploaded successfully", java.util.Collections.singletonMap("uuid", uuid));
return new ResponseEntity<>(response, HttpStatus.OK);
}
}

View File

@@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*;
import java.util.Optional; import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/v1/auth/") @RequestMapping("/api/v1/users")
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
@@ -25,7 +25,7 @@ public class UserController {
this.userService = userService; this.userService = userService;
} }
@GetMapping("/user") @GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() { public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();

View File

@@ -1,6 +1,6 @@
package com.ddf.vodsystem.controllers.dto; package com.ddf.vodsystem.controllers.dto;
import java.time.LocalDateTime; import java.time.Instant;
public record ClipResponse( public record ClipResponse(
Long id, Long id,
@@ -8,5 +8,5 @@ public record ClipResponse(
String title, String title,
String description, String description,
Float duration, Float duration,
LocalDateTime createdAt Instant createdAt
) {} ) {}

View File

@@ -0,0 +1,15 @@
package com.ddf.vodsystem.controllers.dto;
import com.ddf.vodsystem.dto.JobState;
import java.time.Instant;
public record JobResponse (
String uuid,
float progress,
boolean isComplete,
JobState state,
String errorOutput,
Instant createdAt
) {
}

View File

@@ -0,0 +1,3 @@
package com.ddf.vodsystem.controllers.dto;
public record UUIDResponse(String uuid) {}

View File

@@ -1,15 +1,31 @@
package com.ddf.vodsystem.dto; package com.ddf.vodsystem.dto;
import jakarta.validation.constraints.*;
import lombok.Data; import lombok.Data;
@Data @Data
public class ClipOptions { public class ClipOptions {
@NotBlank
private String title; private String title;
private String description; private String description;
@NotNull
@Positive
private Float startPoint; private Float startPoint;
@NotNull @Positive
private Float duration; private Float duration;
@DecimalMin("1.0") @DecimalMax("120.0")
private Float fps; private Float fps;
@Positive
private Integer width; private Integer width;
@Positive
private Integer height; private Integer height;
@Positive
private Float fileSize; private Float fileSize;
} }

View File

@@ -1,28 +1,21 @@
package com.ddf.vodsystem.dto; package com.ddf.vodsystem.dto;
import java.io.File;
import lombok.Data; import lombok.Data;
import java.nio.file.Path;
import java.time.Instant;
@Data @Data
public class Job { public class Job {
private String uuid; private String uuid;
private File inputFile; private ProgressTracker progressTracker = new ProgressTracker();
private File outputFile; private JobState state = JobState.READY;
private String errorOutput;
// configs private Path download;
private ClipOptions inputClipOptions; private Instant createdAt = Instant.now();
private ClipOptions outputClipOptions = new ClipOptions();
// job status public Job(String uuid) {
private JobStatus status = new JobStatus();
public Job(String uuid,
File inputFile,
File outputFile,
ClipOptions inputClipOptions) {
this.uuid = uuid; this.uuid = uuid;
this.inputFile = inputFile;
this.outputFile = outputFile;
this.inputClipOptions = inputClipOptions;
} }
} }

View File

@@ -0,0 +1,8 @@
package com.ddf.vodsystem.dto;
public enum JobState {
READY,
PROCESSING,
SUCCEEDED,
FAILED
}

View File

@@ -1,13 +0,0 @@
package com.ddf.vodsystem.dto;
import lombok.Data;
@Data
public class JobStatus {
private ProgressTracker process = new ProgressTracker();
private ProgressTracker conversion = new ProgressTracker();
private boolean failed = false;
private String failedReason;
}

View File

@@ -5,7 +5,7 @@ import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.Instant;
@Entity @Entity
@Table(name = "clips") @Table(name = "clips")
@@ -28,7 +28,7 @@ public class Clip {
private String description; private String description;
@Column(name = "created_at") @Column(name = "created_at")
private LocalDateTime createdAt; private Instant createdAt;
@Column(name = "width", nullable = false) @Column(name = "width", nullable = false)
private Integer width; private Integer width;

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import java.time.LocalDateTime; import java.time.Instant;
@Entity @Entity
@Table(name = "streams") @Table(name = "streams")
@@ -21,11 +21,11 @@ public class Stream {
private User user; private User user;
@Column(name = "start_time") @Column(name = "start_time")
private LocalDateTime startDate; private Instant startDate;
@Column(name = "end_time") @Column(name = "end_time")
private LocalDateTime endDate; private Instant endDate;
@Column(name = "last_seen") @Column(name = "last_seen")
private LocalDateTime lastSeen; private Instant lastSeen;
} }

View File

@@ -3,7 +3,7 @@ package com.ddf.vodsystem.entities;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.Instant;
@Entity @Entity
@Table(name = "users") @Table(name = "users")
@@ -33,7 +33,7 @@ public class User {
private Integer role; // 0: user, 1: admin private Integer role; // 0: user, 1: admin
@Column(name = "created_at") @Column(name = "created_at")
private LocalDateTime createdAt; private Instant createdAt;
@Column(name = "stream_key") @Column(name = "stream_key")
private String streamKey; private String streamKey;

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.exceptions;
public class NotReadyException extends RuntimeException {
public NotReadyException(String message) {
super(message);
}
}

View File

@@ -5,7 +5,7 @@ import com.ddf.vodsystem.entities.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -13,5 +13,5 @@ import java.util.Optional;
public interface StreamRepository extends JpaRepository<Stream, Long> { public interface StreamRepository extends JpaRepository<Stream, Long> {
Optional<Stream> findByUserAndEndDateIsNull(User user); Optional<Stream> findByUserAndEndDateIsNull(User user);
List<Stream> findByUser(User user); List<Stream> findByUser(User user);
List<Stream> findByEndDateIsNullAndLastSeenBefore(LocalDateTime cutoff); List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff);
} }

View File

@@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
@@ -60,9 +61,12 @@ public class JwtFilter extends OncePerRequestFilter {
} }
Optional<Authentication> authentication = getAuthentication(jwt); Optional<Authentication> authentication = getAuthentication(jwt);
authentication.ifPresent(value -> authentication.ifPresent(auth -> {
SecurityContextHolder.getContext().setAuthentication(value) SecurityContext context = SecurityContextHolder.createEmptyContext();
); context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
});
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }

View File

@@ -7,20 +7,20 @@ import com.ddf.vodsystem.entities.*;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.time.LocalDateTime; import java.nio.file.Path;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import com.ddf.vodsystem.exceptions.ClipNotFound; import com.ddf.vodsystem.exceptions.*;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.exceptions.StorageException;
import com.ddf.vodsystem.repositories.ClipRepository; import com.ddf.vodsystem.repositories.ClipRepository;
import com.ddf.vodsystem.services.media.MetadataService; import com.ddf.vodsystem.services.media.MetadataService;
import com.ddf.vodsystem.services.media.ThumbnailService; import com.ddf.vodsystem.services.media.ThumbnailService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@@ -146,6 +146,52 @@ public class ClipService {
return user.get().getId().equals(clip.getUser().getId()); return user.get().getId().equals(clip.getUser().getId());
} }
public Resource downloadClip(Long id) {
Optional<Clip> possibleClip = getClipById(id);
if (possibleClip.isEmpty()) {
throw new ClipNotFound("Clip " + id + " doesn't exist");
}
Clip clip = possibleClip.get();
if (!isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip");
}
String path = clip.getVideoPath();
Path file = directoryService.resolvePath(path);
if (!Files.exists(file)) {
throw new JobNotFound("Clip file not found");
}
return new FileSystemResource(file);
}
public Resource downloadThumbnail(Long id) {
Optional<Clip> possibleClip = getClipById(id);
if (possibleClip.isEmpty()) {
throw new ClipNotFound("Clip " + id + " doesn't exist");
}
Clip clip = possibleClip.get();
if (!isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip thumbnail");
}
String path = clip.getThumbnailPath();
Path file = directoryService.resolvePath(path);
if (!Files.exists(file)) {
throw new JobNotFound("Thumbnail file not found");
}
return new FileSystemResource(file);
}
/** /**
* Persists a clip to the database * Persists a clip to the database
* @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description * @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description
@@ -156,36 +202,36 @@ public class ClipService {
*/ */
public Clip saveClip(ClipOptions options, public Clip saveClip(ClipOptions options,
User user, User user,
String videoPath, Path videoPath,
String thumbnailPath) { Path thumbnailPath) {
Clip clip = new Clip(); Clip clip = new Clip();
clip.setUser(user); clip.setUser(user);
clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip"); clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip");
clip.setDescription(options.getDescription() != null ? options.getDescription() : ""); clip.setDescription(options.getDescription() != null ? options.getDescription() : "");
clip.setCreatedAt(LocalDateTime.now()); clip.setCreatedAt(Instant.now());
clip.setWidth(options.getWidth()); clip.setWidth(options.getWidth());
clip.setHeight(options.getHeight()); clip.setHeight(options.getHeight());
clip.setFps(options.getFps()); clip.setFps(options.getFps());
clip.setDuration(options.getDuration() - options.getStartPoint()); clip.setDuration(options.getDuration() - options.getStartPoint());
clip.setFileSize(options.getFileSize()); clip.setFileSize(options.getFileSize());
clip.setVideoPath(videoPath); clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
clip.setThumbnailPath(thumbnailPath); clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
return clipRepository.save(clip); return clipRepository.save(clip);
} }
public void persistClip(String title, public void persistClip(String title,
String description, String description,
User user, User user,
File clipFile, Path clipFile,
String fileName) { String fileName) {
File newClipFile; Path newClipFile;
File thumbnailFile; Path thumbnailFile;
// Move temp file from temp dir to output dir // Move temp file from temp dir to output dir
try { try {
newClipFile = directoryService.getUserClipsFile(user.getId(), fileName); newClipFile = directoryService.getClipsDir(user.getId()).resolve(fileName);
thumbnailFile = directoryService.getUserThumbnailsFile(user.getId(), fileName + ".png"); thumbnailFile = directoryService.getThumbnailsDir(user.getId()).resolve(fileName + ".png");
directoryService.cutFile(clipFile, newClipFile); directoryService.copyFile(clipFile, newClipFile);
} catch (IOException e) { } catch (IOException e) {
throw new StorageException("Failed to move clip from temporary directory to output directory", e); throw new StorageException("Failed to move clip from temporary directory to output directory", e);
} }
@@ -211,7 +257,7 @@ public class ClipService {
clipMetadata.setTitle(title); clipMetadata.setTitle(title);
clipMetadata.setDescription(description); clipMetadata.setDescription(description);
Clip clip = saveClip(clipMetadata, user, newClipFile.getAbsolutePath(), thumbnailFile.getAbsolutePath()); Clip clip = saveClip(clipMetadata, user, newClipFile, thumbnailFile);
logger.info("Clip created successfully with ID: {}", clip.getId()); logger.info("Clip created successfully with ID: {}", clip.getId());
} }

View File

@@ -10,7 +10,7 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.util.stream.Stream;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -20,146 +20,120 @@ public class DirectoryService {
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(DirectoryService.class); private static final Logger logger = org.slf4j.LoggerFactory.getLogger(DirectoryService.class);
@Value("${storage.outputs}") @Value("${media.users}")
private String outputDir; private String usersDir;
@Value("${STREAM_DATA}") @Value("${media.streams}")
private String streamDir; private String streamsDir;
@Value("${storage.temp.inputs}") @Value("${media.temp.inputs}")
private String tempInputsDir; private String tempInputsDir;
@Value("${storage.temp.outputs}") @Value("${media.temp.outputs}")
private String tempOutputsDir; private String tempOutputsDir;
private static final long TEMP_DIR_TIME_LIMIT = 3 * 60 * 60 * (long) 1000; // 3 hours private static final long TEMP_DIR_TIME_LIMIT = 3 * 60 * 60 * (long) 1000; // 3 hours
private static final long TEMP_DIR_CLEANUP_RATE = 30 * 60 * (long) 1000; // 30 minutes private static final long TEMP_DIR_CLEANUP_RATE = 30 * 60 * (long) 1000; // 30 minutes
public File getTempInputFile(String filename) { private static final String VODS_DIR_NAME = "vods";
String dir = tempInputsDir + File.separator + filename; private static final String CLIPS_DIR_NAME = "clips";
return new File(dir); private static final String THUMBNAIL_DIR_NAME = "thumbnails";
public Path getTempInputDir() {
return Path.of(tempInputsDir);
} }
public File getTempOutputFile(String filename) { public Path getTempOutputDir() {
String dir = tempOutputsDir + File.separator + filename; return Path.of(tempOutputsDir);
return new File(dir);
} }
public File getUserFolder(Long userId) throws IOException { public Path getUserDir(Long userId) throws IOException {
if (userId == null) { Path userDir = Path.of(usersDir + File.separator + userId);
throw new IllegalArgumentException("User ID cannot be null or empty"); Files.createDirectories(userDir);
return userDir;
} }
String dir = outputDir + File.separator + userId; public Path getVodsDir(Long userId) throws IOException {
createDirectory(dir); Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME);
return new File(dir); Files.createDirectories(vodDir);
return vodDir;
} }
public File getUserClipsFile(Long userId, String filename) throws IOException { public Path getClipsDir(Long userId) throws IOException {
if (filename == null || filename.isEmpty()) { Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME);
throw new IllegalArgumentException("File name cannot be null or empty"); Files.createDirectories(clipsDir);
return clipsDir;
} }
File userDir = getUserFolder(userId); public Path getThumbnailsDir(Long userId) throws IOException {
String fullPath = userDir.getAbsolutePath() + File.separator + "clips" + File.separator + filename; Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME);
File file = new File(fullPath); Files.createDirectories(thumbnailsDir);
return thumbnailsDir;
createDirectory(file.getParent());
return file;
} }
public File getVodFile(Long userId, String filename) throws IOException { public Path getStreamDir(String streamKey) throws IOException {
if (filename == null || filename.isEmpty()) { Path streamFolder = Path.of(streamsDir, streamKey);
throw new IllegalArgumentException("File name cannot be null or empty"); Files.createDirectories(streamFolder);
return streamFolder;
} }
String userDir = getUserFolder(userId).getAbsolutePath(); public Path relativisePath(Path path) {
File file = new File(userDir + File.separator + "vods" + File.separator + filename); Path media = Path.of(usersDir).toAbsolutePath();
createDirectory(file.getParent()); Path newPath = path.toAbsolutePath();
return media.relativize(newPath);
return file;
} }
public File getStreamDir(String streamKey) throws IOException { public Path resolvePath(String path) {
File file = new File(streamDir + File.separator + streamKey); Path media = Path.of(usersDir).toAbsolutePath();
createDirectory(file.getAbsolutePath()); Path newPath = Path.of(path);
return file; return media.resolve(newPath);
} }
public File getUserThumbnailsFile(Long userId, String filename) throws IOException { public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException {
if (filename == null || filename.isEmpty()) { Files.createDirectories(path.getParent());
throw new IllegalArgumentException("File name cannot be null or empty"); Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
} }
String userDir = getUserFolder(userId).getAbsolutePath(); public void copyFile(Path source, Path target) throws IOException {
File file = new File(userDir + File.separator + "thumbnails" + File.separator + filename); Files.createDirectories(target.getParent());
createDirectory(file.getParent()); Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
logger.info("Copied file from {} to {}", source, target);
return file;
}
public void saveMultipartFile(File file, MultipartFile multipartFile) throws IOException {
createDirectory(file.getParent());
Path filePath = Paths.get(file.getAbsolutePath());
Files.copy(multipartFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
}
public void copyFile(File source, File target) throws IOException {
Path sourcePath = Paths.get(source.getAbsolutePath());
Path destPath = Paths.get(target.getAbsolutePath());
Files.createDirectories(destPath.getParent());
Files.copy(sourcePath, destPath, StandardCopyOption.REPLACE_EXISTING);
logger.info("Copied file from {} to {}", sourcePath, destPath);
}
public void cutFile(File source, File target) throws IOException {
copyFile(source, target);
Files.deleteIfExists(source.toPath());
logger.info("Deleted source file: {}", source.getAbsolutePath());
}
public String getFileExtension(String filePath) {
Path path = Paths.get(filePath);
String fileName = path.getFileName().toString();
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex == -1) {
return ""; // No extension
}
return fileName.substring(dotIndex + 1);
}
private void createDirectory(String dir) throws IOException {
// Create the directory if it doesn't exist
Path outputPath = Paths.get(dir);
if (Files.notExists(outputPath)) {
Files.createDirectories(outputPath);
logger.info("Created directory: {}", outputPath);
}
} }
private void cleanUpDirectory(String dir) throws IOException { private void cleanUpDirectory(String dir) throws IOException {
File file = new File(dir); Path dirPath = Path.of(dir);
File[] files = file.listFiles();
if (files == null) { if (Files.notExists(dirPath)) {
logger.warn("No files found in directory: {}", dir); logger.warn("No files found in directory: {}", dir);
return; return;
} }
for (File f : files){ try (Stream<Path> files = Files.list(dirPath)) {
if (f.isFile() && f.lastModified() < (System.currentTimeMillis() - TEMP_DIR_TIME_LIMIT)) { files.filter(Files::isRegularFile)
Files.delete(f.toPath()); .filter(p -> {
try {
return Files.getLastModifiedTime(p).toMillis()
< System.currentTimeMillis() - TEMP_DIR_TIME_LIMIT;
} catch (IOException e) {
logger.warn("Could not read last modified time for {}", p);
return false;
} }
})
.forEach(p -> {
try {
Files.delete(p);
} catch (IOException e) {
logger.warn("Could not delete file {}", p);
}
});
} }
} }
@PostConstruct @PostConstruct
public void createDirectoriesIfNotExist() throws IOException { public void createDirectoriesIfNotExist() throws IOException {
createDirectory(tempInputsDir); Files.createDirectories(Path.of(tempInputsDir));
createDirectory(tempOutputsDir); Files.createDirectories(Path.of(tempOutputsDir));
createDirectory(outputDir); Files.createDirectories(Path.of(usersDir));
} }
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE) @Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)

View File

@@ -1,86 +0,0 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.ClipRepository;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service;
import java.io.File;
@Service
public class DownloadService {
private final JobRegistryService jobRegistryService;
private final ClipRepository clipRepository;
private final ClipService clipService;
public DownloadService(JobRegistryService jobRegistryService,
ClipRepository clipRepository,
ClipService clipService) {
this.jobRegistryService = jobRegistryService;
this.clipRepository = clipRepository;
this.clipService = clipService;
}
public Resource downloadInput(String uuid) {
Job job = jobRegistryService.getJob(uuid);
File file = job.getInputFile();
return new FileSystemResource(file);
}
public Resource downloadOutput(String uuid) {
Job job = jobRegistryService.getJob(uuid);
if (!job.getStatus().getProcess().isComplete()) {
throw new JobNotFinished("Job is not finished");
}
File file = job.getOutputFile();
return new FileSystemResource(file);
}
public Resource downloadClip(Clip clip) {
if (!clipService.isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip");
}
String path = clip.getVideoPath();
File file = new File(path);
if (!file.exists()) {
throw new JobNotFound("Clip file not found");
}
return new FileSystemResource(file);
}
public Resource downloadClip(Long id) {
Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id));
return downloadClip(clip);
}
public Resource downloadThumbnail(Clip clip) {
if (!clipService.isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("Not authenticated for this clip thumbnail");
}
String path = clip.getThumbnailPath();
File file = new File(path);
if (!file.exists()) {
throw new JobNotFound("Thumbnail file not found");
}
return new FileSystemResource(file);
}
public Resource downloadThumbnail(Long id) {
Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id));
return downloadThumbnail(clip);
}
}

View File

@@ -1,51 +0,0 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.JobStatus;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.services.media.MetadataService;
import org.springframework.stereotype.Service;
@Service
public class EditService {
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(EditService.class);
private final MetadataService metadataService;
private final JobRegistryService jobRegistryService;
private final JobOrchestrationService jobOrchestrationService;
public EditService(JobOrchestrationService jobOrchestrationService,
MetadataService metadataService,
JobRegistryService jobRegistryService) {
this.metadataService = metadataService;
this.jobRegistryService = jobRegistryService;
this.jobOrchestrationService = jobOrchestrationService;
}
public void edit(String uuid, ClipOptions clipOptions) {
Job job = jobRegistryService.getJob(uuid);
metadataService.validateMetadata(job.getInputClipOptions(), clipOptions);
job.setOutputClipOptions(clipOptions);
logger.info("Job {} - Updated clip config to {}", job.getUuid(), clipOptions);
}
public void process(String uuid) {
Job job = jobRegistryService.getJob(uuid);
jobOrchestrationService.processJob(job);
logger.info("Job {} - Started processing", uuid);
}
public void convert(String uuid) {
Job job = jobRegistryService.getJob(uuid);
jobOrchestrationService.convertJob(job);
logger.info("Job {} - Started converting", uuid);
}
public JobStatus getStatus(String uuid) {
Job job = jobRegistryService.getJob(uuid);
return job.getStatus();
}
}

View File

@@ -1,156 +0,0 @@
package com.ddf.vodsystem.services;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Optional;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.StorageException;
import com.ddf.vodsystem.services.media.CompressionService;
import com.ddf.vodsystem.services.media.RemuxService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
/**
* Orchestrates the processing pipeline for a {@link Job}: converting (remuxing)
* the input file and, separately, compressing/persisting the output clip.
* <p>
* Job storage and lookup are handled by {@link JobRegistryService}; this class
* is only responsible for acting on a {@link Job} instance once it's been retrieved.
* Long-running steps (remux, compress) are kicked off asynchronously via
* {@link java.util.concurrent.CompletableFuture}-returning service calls rather
* than blocked on, so callers should not assume the job is finished when these
* methods return.
*/
@Service
public class JobOrchestrationService {
private static final Logger logger = LoggerFactory.getLogger(JobOrchestrationService.class);
private final ClipService clipService;
private final RemuxService remuxService;
private final DirectoryService directoryService;
private final CompressionService compressionService;
private final UserService userService;
/**
* Constructs the orchestration service with its collaborating services.
*
* @param clipService used to persist a completed clip's metadata once processing succeeds
* @param remuxService performs the remux step during conversion
* @param compressionService performs the compression step during processing
* @param directoryService used for filesystem operations (e.g. copying the input file to a temp location)
* @param userService used to resolve the currently logged-in user when persisting a clip
*/
public JobOrchestrationService(ClipService clipService,
RemuxService remuxService,
CompressionService compressionService,
DirectoryService directoryService,
UserService userService) {
this.clipService = clipService;
this.remuxService = remuxService;
this.directoryService = directoryService;
this.compressionService = compressionService;
this.userService = userService;
}
/**
* Converts a job's input file in place by remuxing it.
* <p>
* The original input file is first copied to a temporary {@code .temp} file so the
* remux can read from a stable source; on successful completion the temp file is
* deleted. The remux itself runs asynchronously — this method returns once the
* remux has been kicked off, not once it has finished. Progress/completion is
* tracked via {@code job.getStatus().getConversion()}, which is reset at the
* start of this call and marked complete in the remux's completion callback.
*
* @param job the job whose input file should be converted
* @throws StorageException if the temporary file cannot be created, or if it
* cannot be deleted after a successful remux
*/
public void convertJob(Job job) {
logger.info("Converting job: {}", job.getUuid());
File tempFile = new File(job.getInputFile().getAbsolutePath() + ".temp");
try {
directoryService.copyFile(job.getInputFile(), tempFile);
} catch (IOException e) {
throw new StorageException("Could not create temporary file: " + tempFile, e);
}
job.getStatus().getConversion().reset();
try {
remuxService.remux(
tempFile,
job.getInputFile(),
job.getStatus().getConversion(),
job.getInputClipOptions().getDuration())
.thenRun(() -> {
job.getStatus().getConversion().markComplete();
try {
Files.deleteIfExists(Paths.get(tempFile.getAbsolutePath()));
} catch (IOException e) {
throw new StorageException("Could not delete path at: " + tempFile.getAbsolutePath(), e);
}
}).whenComplete((ignored, throwable) -> {
if (throwable != null) {
logger.error("Remux failed for jobId={}", job.getUuid(), throwable);
} else {
logger.info("Remux completed for jobId={}", job.getUuid());
}
});
} catch (IOException | InterruptedException e) {
logger.error("Error converting job {}: {}", job.getUuid(), e.getMessage());
Thread.currentThread().interrupt();
}
}
/**
* Compresses a job's output file according to its output clip options and, on
* success, persists the resulting clip against the currently logged-in user (if any).
* <p>
* Compression runs asynchronously — this method returns once compression has been
* kicked off, not once it has finished. Progress is tracked via
* {@code job.getStatus().getProcess()}, which is reset at the start of this call.
* If compression or persistence fails, the job's status is marked failed with the
* triggering exception's message.
*
* @param job the job to process
*/
public void processJob(Job job) {
logger.info("Job ready: {}", job.getUuid());
job.getStatus().getProcess().reset();
try {
Optional<User> user = userService.getLoggedInUser();
compressionService.compress(job.getInputFile(), job.getOutputFile(), job.getOutputClipOptions(), job.getStatus().getProcess())
.thenRun(() -> user.ifPresent(userVal ->
clipService.persistClip(
job.getOutputClipOptions().getTitle(),
job.getOutputClipOptions().getDescription(),
userVal,
job.getOutputFile(),
job.getInputFile().getName()
)
)).exceptionally(
ex -> {
job.getStatus().setFailed(true);
job.getStatus().setFailedReason(ex.getMessage());
logger.error("Error processing job {}: {}", job.getUuid(), ex.getMessage());
return null;
}
);
} catch (IOException | InterruptedException e) {
job.getStatus().setFailed(true);
job.getStatus().setFailedReason(e.getMessage());
logger.error("Error processing job {}: {}", job.getUuid(), e.getMessage());
Thread.currentThread().interrupt();
}
}
}

View File

@@ -6,12 +6,12 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* In-memory store for {@link Job} instances, keyed by job UUID. * In-memory store for {@link Job} instances, keyed by job UUID.
* Provides add/lookup only — actual job processing is handled by * Provides add/lookup only
* {@link JobOrchestrationService}.
*/ */
@Service @Service
public class JobRegistryService { public class JobRegistryService {
@@ -19,13 +19,16 @@ public class JobRegistryService {
private final ConcurrentHashMap<String, Job> jobs = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Job> jobs = new ConcurrentHashMap<>();
/** /**
* Adds a new job to the job map. * Creates and returns a new job
* * @return Job
* @param job the job to add
*/ */
public void add(Job job) { public Job generateJob() {
logger.info("Added job: {}", job.getUuid()); String uuid = UUID.randomUUID().toString();
jobs.put(job.getUuid(), job); Job job = new Job(uuid);
jobs.put(uuid, job);
logger.info("New job with UUID {} created", uuid);
return job;
} }
/** /**

View File

@@ -0,0 +1,228 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.dto.JobState;
import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.media.CompressionService;
import com.ddf.vodsystem.services.media.StreamActionsService;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
public class MediaService {
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(MediaService.class);
private final JobRegistryService jobRegistryService;
private final DirectoryService directoryService;
private final CompressionService compressionService;
private final UserService userService;
private final ClipService clipService;
private static final float CLIP_MAX_LENGTH = 180;
private static final int HLS_SEGMENT_LENGTH = 3;
private final StreamActionsService streamActionsService;
public MediaService(JobRegistryService jobRegistryService,
DirectoryService directoryService,
CompressionService compressionService,
UserService userService,
ClipService clipService, StreamActionsService streamActionsService) {
this.jobRegistryService = jobRegistryService;
this.directoryService = directoryService;
this.compressionService = compressionService;
this.userService = userService;
this.clipService = clipService;
this.streamActionsService = streamActionsService;
}
public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException {
Job job = jobRegistryService.generateJob();
Optional<User> user = userService.getLoggedInUser();
String filename = job.getUuid() + ".mp4";
Path tempInputFile = directoryService.getTempInputDir().resolve(filename);
Path tempOutputFile = directoryService.getTempOutputDir().resolve(filename);
directoryService.saveMultipartFile(tempInputFile, file);
job.setState(JobState.PROCESSING);
compressionService.compress(
tempInputFile,
tempOutputFile,
clipOptions,
job.getProgressTracker()
).thenRun(() -> {
job.setDownload(tempOutputFile);
job.setState(JobState.SUCCEEDED);
logger.info("Compression job {} succeeded", job.getUuid());
// save clip if user context
user.ifPresent(userVal ->
clipService.persistClip(
clipOptions.getTitle(),
clipOptions.getDescription(),
userVal,
tempOutputFile,
filename
)
);
}).exceptionally(ex -> {
job.setState(JobState.FAILED);
job.setErrorOutput(ex.getMessage());
logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
return null;
});
return job;
}
/**
* Saves a clip of the currently authenticated user's stream between two points in time.
* <p>
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
* trim offset into the first (partially-overlapping) segment and the total clip duration,
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
*
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
* @param endTime the exclusive end of the section to save
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
* or if no stream segments exist in the given range
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public Job saveSection(Instant startTime, Instant endTime) throws IOException {
if (startTime.isAfter(endTime)) {
throw new IllegalArgumentException("Start time must be before end time");
}
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
Job job = jobRegistryService.generateJob();
Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<Path> fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
if (fileSegments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
job.setState(JobState.PROCESSING);
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(
fileSegments,
trimOffset,
duration,
outputFile,
job.getProgressTracker()
).thenRun(() -> {
job.setDownload(outputFile);
job.setState(JobState.SUCCEEDED);
logger.info("Stream save of job {} succeeded", job.getUuid());
}).exceptionally(ex -> {
job.setState(JobState.FAILED);
job.setErrorOutput(ex.getMessage());
logger.error("Stream section save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
return null;
});
return job;
}
/**
* Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
* <p>
* Computes a time window ending at the current instant and beginning {@code duration} seconds
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
*
* @param duration the length of the clip in seconds, measured back from now; must be greater
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
* are supported and resolved to the millisecond.
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
* or if no stream segments exist in the computed window
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public Job clip(float duration) throws IOException {
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
}
// minusSeconds() is possible, but only does integer seconds, not float
Instant endTime = Instant.now();
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
Job job = jobRegistryService.generateJob();
Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<Path> fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
if (fileSegments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile, job.getProgressTracker())
.thenRun(() -> {
job.setDownload(outputFile);
job.setState(JobState.SUCCEEDED);
logger.info("Stream clip of job {} succeeded", job.getUuid());
clipService.persistClip(
Instant.now().toString(),
"",
user,
outputFile,
outputFile.getFileName().toString()
);
}
).exceptionally(ex -> {
job.setState(JobState.FAILED);
job.setErrorOutput(ex.getMessage());
logger.error("Clip save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
return null;
});
return job;
}
private List<Path> getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
long startMs = startTime.toEpochMilli();
long endMs = endTime.toEpochMilli();
try (Stream<Path> files = Files.list(streamDirectory)) {
return files
.filter(p -> p.getFileName().toString().endsWith(".ts"))
.filter(p -> {
long segMs = parseTimestampMs(p);
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
})
.sorted(Comparator.comparingLong(this::parseTimestampMs))
.collect(Collectors.toList());
}
}
private long parseTimestampMs(Path path) {
String name = path.getFileName().toString().replace(".ts", "");
long value = Long.parseLong(name);
// nginx hls_fragment_naming system uses seconds; convert to ms
return value < 1_000_000_000_000L ? value * 1000L : value;
}
}

View File

@@ -6,45 +6,28 @@ import com.ddf.vodsystem.exceptions.AlreadyStreaming;
import com.ddf.vodsystem.exceptions.KeyNotFound; import com.ddf.vodsystem.exceptions.KeyNotFound;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.StreamRepository; import com.ddf.vodsystem.repositories.StreamRepository;
import com.ddf.vodsystem.services.media.StreamActionsService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.io.File;
import java.io.IOException;
import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import java.util.stream.Collectors;
@Service @Service
public class StreamService { public class StreamService {
private static final Logger logger = LoggerFactory.getLogger(StreamService.class); private static final Logger logger = LoggerFactory.getLogger(StreamService.class);
private static final int HEARTBEAT_TIMEOUT_SECONDS = 60; private static final int HEARTBEAT_TIMEOUT_SECONDS = 60;
private static final float CLIP_MAX_LENGTH = 180;
private static final int HLS_SEGMENT_LENGTH = 3;
private final StreamRepository streamRepository; private final StreamRepository streamRepository;
private final UserService userService; private final UserService userService;
private final StreamActionsService streamActionsService;
private final DirectoryService directoryService;
private final ClipService clipService;
public StreamService(StreamRepository streamRepository, public StreamService(StreamRepository streamRepository,
UserService userService, UserService userService) {
StreamActionsService streamActionsService,
DirectoryService directoryService,
ClipService clipService) {
this.streamRepository = streamRepository; this.streamRepository = streamRepository;
this.userService = userService; this.userService = userService;
this.streamActionsService = streamActionsService;
this.directoryService = directoryService;
this.clipService = clipService;
} }
public Stream startStream(String streamKey) { public Stream startStream(String streamKey) {
@@ -54,7 +37,7 @@ public class StreamService {
throw new AlreadyStreaming("User " + user.getUsername() + " is already streaming."); throw new AlreadyStreaming("User " + user.getUsername() + " is already streaming.");
}); });
LocalDateTime now = LocalDateTime.now(); Instant now = Instant.now();
Stream stream = new Stream(); Stream stream = new Stream();
stream.setUser(user); stream.setUser(user);
stream.setStartDate(now); stream.setStartDate(now);
@@ -69,7 +52,7 @@ public class StreamService {
.orElseThrow(() -> new IllegalStateException( .orElseThrow(() -> new IllegalStateException(
"No active stream found for user " + user.getUsername())); "No active stream found for user " + user.getUsername()));
stream.setEndDate(LocalDateTime.now()); stream.setEndDate(Instant.now());
streamRepository.saveAndFlush(stream); streamRepository.saveAndFlush(stream);
} }
@@ -80,7 +63,7 @@ public class StreamService {
.orElseThrow(() -> new IllegalStateException( .orElseThrow(() -> new IllegalStateException(
"No active stream found for user " + user.getUsername())); "No active stream found for user " + user.getUsername()));
stream.setLastSeen(LocalDateTime.now()); stream.setLastSeen(Instant.now());
streamRepository.saveAndFlush(stream); streamRepository.saveAndFlush(stream);
} }
@@ -111,100 +94,14 @@ public class StreamService {
return streamRepository.findByUser(streamUser); return streamRepository.findByUser(streamUser);
} }
/**
* Saves a clip of the currently authenticated user's stream between two points in time.
* <p>
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
* trim offset into the first (partially-overlapping) segment and the total clip duration,
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
*
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
* @param endTime the exclusive end of the section to save
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
* or if no stream segments exist in the given range
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public void saveSection(Instant startTime, Instant endTime) throws IOException {
if (startTime.isAfter(endTime)) {
throw new IllegalArgumentException("Start time must be before end time");
}
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
if (fileSegments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
File outputFile = directoryService.getVodFile(user.getId(), UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile);
}
/**
* Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
* <p>
* Computes a time window ending at the current instant and beginning {@code duration} seconds
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
*
* @param duration the length of the clip in seconds, measured back from now; must be greater
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
* are supported and resolved to the millisecond.
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
* or if no stream segments exist in the computed window
* @throws NotAuthenticated if no user is currently authenticated
* @throws IOException if reading the stream directory or its segments fails
*/
public void clip(float duration) throws IOException {
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
}
// minusSeconds() is possible, but only does integer seconds, not float
Instant endTime = Instant.now();
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
User user = userService.getLoggedInUser()
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
if (fileSegments.isEmpty()) {
throw new IllegalArgumentException("No stream segments found in the given time range");
}
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
File outputFile = directoryService.getTempOutputFile(UUID.randomUUID() + ".mp4");
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile)
.thenRun(() ->
clipService.persistClip(
Instant.now().toString(),
"",
user,
outputFile,
outputFile.getName()
)
);
}
@Scheduled(fixedDelay = 30_000) @Scheduled(fixedDelay = 30_000)
@Transactional @Transactional
public void endStaleStreams() { public void endStaleStreams() {
LocalDateTime cutoff = LocalDateTime.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS); Instant cutoff = Instant.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS);
List<Stream> stale = streamRepository.findByEndDateIsNullAndLastSeenBefore(cutoff); List<Stream> stale = streamRepository.findByEndDateIsNullAndLastSeenBefore(cutoff);
if (stale.isEmpty()) return; if (stale.isEmpty()) return;
LocalDateTime now = LocalDateTime.now(); Instant now = Instant.now();
for (Stream stream : stale) { for (Stream stream : stale) {
stream.setEndDate(now); stream.setEndDate(now);
logger.warn("Stream {} for user {} ended due to heartbeat timeout", stream.getId(), stream.getUser().getUsername()); logger.warn("Stream {} for user {} ended due to heartbeat timeout", stream.getId(), stream.getUser().getUsername());
@@ -216,28 +113,4 @@ public class StreamService {
return userService.getUserByStreamKey(streamKey) return userService.getUserByStreamKey(streamKey)
.orElseThrow(() -> new KeyNotFound("Stream key not found: " + streamKey)); .orElseThrow(() -> new KeyNotFound("Stream key not found: " + streamKey));
} }
private List<File> getSegmentsInRange(String streamDirectory, Instant startTime, Instant endTime) {
File dir = new File(streamDirectory);
File[] tsFiles = dir.listFiles((d, name) -> name.endsWith(".ts"));
if (tsFiles == null) return List.of();
long startMs = startTime.toEpochMilli();
long endMs = endTime.toEpochMilli();
return Arrays.stream(tsFiles)
.filter(f -> {
long segMs = parseTimestampMs(f);
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
})
.sorted(Comparator.comparingLong(this::parseTimestampMs))
.collect(Collectors.toList());
}
private long parseTimestampMs(File file) {
String name = file.getName().replace(".ts", "");
long value = Long.parseLong(name);
// nginx hls_fragment_naming system uses seconds; convert to ms
return value < 1_000_000_000_000L ? value * 1000L : value;
}
} }

View File

@@ -1,79 +0,0 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.Job;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.StorageException;
import com.ddf.vodsystem.services.media.MetadataService;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Base64;
import java.util.UUID;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@Service
public class UploadService {
private static final Logger logger = LoggerFactory.getLogger(UploadService.class);
private final JobRegistryService jobRegistryService;
private final MetadataService metadataService;
private final DirectoryService directoryService;
public UploadService(JobRegistryService jobRegistryService,
MetadataService metadataService,
DirectoryService directoryService) {
this.jobRegistryService = jobRegistryService;
this.metadataService = metadataService;
this.directoryService = directoryService;
}
public String upload(MultipartFile file) {
// generate uuid, filename
String uuid = generateShortUUID();
String extension = directoryService.getFileExtension(file.getOriginalFilename());
File inputFile = directoryService.getTempInputFile(uuid + "." + extension);
File outputFile = directoryService.getTempOutputFile(uuid + ".mp4");
try {
directoryService.saveMultipartFile(inputFile, file);
} catch (IOException e) {
throw new StorageException("Could not save Multipart file at: " + inputFile, e);
}
logger.info("Multi-part file uploaded at {}", inputFile);
ClipOptions clipOptions = getMetadataWithTimeout(inputFile);
Job job = new Job(uuid, inputFile, outputFile, clipOptions);
jobRegistryService.add(job);
return uuid;
}
private static String generateShortUUID() {
UUID uuid = UUID.randomUUID();
ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
bb.putLong(uuid.getMostSignificantBits());
bb.putLong(uuid.getLeastSignificantBits());
return Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array());
}
private ClipOptions getMetadataWithTimeout(File file) {
try {
return metadataService.getVideoMetadata(file).get(5, TimeUnit.SECONDS);
} catch (ExecutionException | TimeoutException | InterruptedException e) {
Thread.currentThread().interrupt();
throw new FFMPEGException(e.getMessage());
}
}
}

View File

@@ -17,7 +17,7 @@ import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.security.GeneralSecurityException; import java.security.GeneralSecurityException;
import java.security.SecureRandom; import java.security.SecureRandom;
import java.time.LocalDateTime; import java.time.Instant;
import java.util.Collections; import java.util.Collections;
import java.util.HexFormat; import java.util.HexFormat;
import java.util.Optional; import java.util.Optional;
@@ -77,7 +77,7 @@ public class UserService {
if (existingUser.isEmpty()) { if (existingUser.isEmpty()) {
user.setRole(0); user.setRole(0);
user.setCreatedAt(LocalDateTime.now()); user.setCreatedAt(Instant.now());
user.setStreamKey(generateStreamKey()); user.setStreamKey(generateStreamKey());
return userRepository.saveAndFlush(user); return userRepository.saveAndFlush(user);
} }

View File

@@ -1,14 +1,19 @@
package com.ddf.vodsystem.services.media; package com.ddf.vodsystem.services.media;
import com.ddf.vodsystem.dto.CommandOutput; import com.ddf.vodsystem.dto.CommandOutput;
import com.ddf.vodsystem.dto.ProgressTracker;
import java.io.BufferedReader; import java.io.BufferedReader;
import java.io.IOException; import java.io.IOException;
import java.io.InputStreamReader; import java.io.InputStreamReader;
import java.util.List; import java.util.List;
import java.util.function.Consumer; import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class CommandRunner { public class CommandRunner {
private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
public static CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException { public static CommandOutput run(List<String> command, Consumer<String> onOutput) throws IOException, InterruptedException {
ProcessBuilder processBuilder = new ProcessBuilder(command); ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true); processBuilder.redirectErrorStream(true);
@@ -40,6 +45,14 @@ public class CommandRunner {
return run(command, null); return run(command, null);
} }
public static void setProgress(String line, ProgressTracker progress, float length) {
Matcher matcher = timePattern.matcher(line);
if (matcher.find()) {
float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
progress.setProgress(timeInMs / length);
}
}
private CommandRunner() { private CommandRunner() {
// Private constructor to prevent instantiation // Private constructor to prevent instantiation
} }

View File

@@ -8,8 +8,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@@ -26,15 +26,19 @@ public class CompressionService {
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<CommandOutput> compress(File inputFile, public CompletableFuture<CommandOutput> compress(Path inputFile,
File outputFile, Path outputFile,
ClipOptions clipOptions, ClipOptions clipOptions,
ProgressTracker progress ProgressTracker progress
) throws IOException, InterruptedException { ) throws IOException, InterruptedException {
logger.info("Compressing video from {} to {}", inputFile.getAbsolutePath(), outputFile.getAbsolutePath()); logger.info("Compressing video from {} to {}", inputFile.toAbsolutePath(), outputFile.toAbsolutePath());
List<String> command = buildCommand(inputFile, outputFile, clipOptions); List<String> command = buildCommand(
CommandOutput result = CommandRunner.run(command, line -> setProgress(line, progress, clipOptions.getDuration())); inputFile,
outputFile,
clipOptions
);
CommandOutput result = CommandRunner.run(command, line -> CommandRunner.setProgress(line, progress, clipOptions.getDuration()));
progress.markComplete(); progress.markComplete();
return CompletableFuture.completedFuture(result); return CompletableFuture.completedFuture(result);
@@ -93,14 +97,14 @@ public class CompressionService {
return command; return command;
} }
private List<String> buildInputs(File inputFile, Float startPoint, Float length) { private List<String> buildInputs(Path inputFile, Float startPoint, Float length) {
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
command.add("-ss"); command.add("-ss");
command.add(startPoint.toString()); command.add(startPoint.toString());
command.add("-i"); command.add("-i");
command.add(inputFile.getAbsolutePath()); command.add(inputFile.toAbsolutePath().toString());
command.add("-t"); command.add("-t");
command.add(Float.toString(length)); command.add(Float.toString(length));
@@ -108,7 +112,7 @@ public class CompressionService {
return command; return command;
} }
private List<String> buildCommand(File inputFile, File outputFile, ClipOptions clipOptions) { private List<String> buildCommand(Path inputFile, Path outputFile, ClipOptions clipOptions) {
List<String> command = new ArrayList<>(); List<String> command = new ArrayList<>();
command.add("ffmpeg"); command.add("ffmpeg");
command.add("-progress"); command.add("-progress");
@@ -126,7 +130,7 @@ public class CompressionService {
} }
// Output file // Output file
command.add(outputFile.getAbsolutePath()); command.add(outputFile.toAbsolutePath().toString());
return command; return command;
} }
} }

View File

@@ -10,8 +10,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future; import java.util.concurrent.Future;
@@ -21,8 +21,8 @@ public class MetadataService {
private static final Logger logger = LoggerFactory.getLogger(MetadataService.class); private static final Logger logger = LoggerFactory.getLogger(MetadataService.class);
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public Future<ClipOptions> getVideoMetadata(File file) { public Future<ClipOptions> getVideoMetadata(Path file) {
logger.info("Getting metadata for file {}", file.getAbsolutePath()); logger.info("Getting metadata for file {}", file);
List<String> command = List.of( List<String> command = List.of(
"ffprobe", "ffprobe",
@@ -30,7 +30,7 @@ public class MetadataService {
"-print_format", "json", "-print_format", "json",
"-show_format", "-select_streams", "-show_format", "-select_streams",
"v:0", "-show_entries", "stream=duration,width,height,r_frame_rate:format=size,duration", "v:0", "-show_entries", "stream=duration,width,height,r_frame_rate:format=size,duration",
"-i", file.getAbsolutePath() "-i", file.toAbsolutePath().toString()
); );
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
@@ -51,48 +51,6 @@ public class MetadataService {
} }
} }
public void validateMetadata(ClipOptions inputFileMetadata, ClipOptions outputFileMetadata) {
Float start = outputFileMetadata.getStartPoint();
Float duration = outputFileMetadata.getDuration();
Float fileSize = outputFileMetadata.getFileSize();
Integer width = outputFileMetadata.getWidth();
Integer height = outputFileMetadata.getHeight();
Float fps = outputFileMetadata.getFps();
if (start == null) {
outputFileMetadata.setStartPoint(0f);
}
if (duration == null) {
outputFileMetadata.setDuration(inputFileMetadata.getDuration());
}
if (start != null && start < 0) {
throw new IllegalArgumentException("Start point cannot be negative");
}
if (duration != null && duration < 0) {
throw new IllegalArgumentException("Duration cannot be negative");
}
if (fileSize != null && fileSize < 100) {
throw new IllegalArgumentException("File size cannot be less than 100kb");
}
if (width != null && width < 1) {
throw new IllegalArgumentException("Width cannot be less than 1");
}
if (height != null && height < 1) {
throw new IllegalArgumentException("Height cannot be less than 1");
}
if (fps != null && fps < 1) {
throw new IllegalArgumentException("FPS cannot be less than 1");
}
}
private ClipOptions parseVideoMetadata(JsonNode node) { private ClipOptions parseVideoMetadata(JsonNode node) {
ClipOptions metadata = new ClipOptions(); ClipOptions metadata = new ClipOptions();
metadata.setStartPoint(0f); metadata.setStartPoint(0f);

View File

@@ -1,11 +1,12 @@
package com.ddf.vodsystem.services.media; package com.ddf.vodsystem.services.media;
import com.ddf.vodsystem.dto.ProgressTracker;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File; import java.nio.file.Path;
import java.util.List; import java.util.List;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -46,15 +47,16 @@ public class StreamActionsService {
* failure * failure
*/ */
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public CompletableFuture<File> saveSection( public CompletableFuture<Path> saveSection(
List<File> segments, List<Path> segments,
float trimOffset, float trimOffset,
float duration, float duration,
File outputFile Path outputFile,
ProgressTracker progressTracker
) { ) {
try { try {
String concatInput = segments.stream() String concatInput = segments.stream()
.map(File::getAbsolutePath) .map(p -> p.toAbsolutePath().toString())
.collect(Collectors.joining("|")); .collect(Collectors.joining("|"));
List<String> command = List.of( List<String> command = List.of(
@@ -64,16 +66,17 @@ public class StreamActionsService {
"-ss", String.valueOf(trimOffset), "-ss", String.valueOf(trimOffset),
"-t", String.valueOf(duration), "-t", String.valueOf(duration),
"-c", "copy", "-c", "copy",
outputFile.getAbsolutePath() outputFile.toAbsolutePath().toString()
); );
logger.info("Saving section with ({} segments) to '{}'", logger.info("Saving section with ({} segments) to '{}'",
segments.size(), outputFile.getAbsolutePath()); segments.size(), outputFile);
CommandRunner.run(command, line -> logger.debug("{}", line)); CommandRunner.run(command, line -> CommandRunner.setProgress(line, progressTracker, duration));
progressTracker.markComplete();
return CompletableFuture.completedFuture(outputFile); return CompletableFuture.completedFuture(outputFile);
} catch (Exception e) { } catch (Exception e) {
logger.error("Failed to save section to '{}'", outputFile.getAbsolutePath(), e); logger.error("Failed to save section to '{}'", outputFile, e);
return CompletableFuture.failedFuture(e); return CompletableFuture.failedFuture(e);
} }
} }

View File

@@ -5,8 +5,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path;
import java.util.List; import java.util.List;
@Service @Service
@@ -14,15 +14,15 @@ public class ThumbnailService {
private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class); private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class);
@Async("ffmpegTaskExecutor") @Async("ffmpegTaskExecutor")
public void createThumbnail(File inputFile, File outputFile, Float timeInVideo) throws IOException, InterruptedException { public void createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) throws IOException, InterruptedException {
logger.info("Creating thumbnail at {} seconds", timeInVideo); logger.info("Creating thumbnail at {} seconds", timeInVideo);
List<String> command = List.of( List<String> command = List.of(
"ffmpeg", "ffmpeg",
"-ss", timeInVideo.toString(), "-ss", timeInVideo.toString(),
"-i", inputFile.getAbsolutePath(), "-i", inputFile.toAbsolutePath().toString(),
"-frames:v", "1", "-frames:v", "1",
outputFile.getAbsolutePath() outputFile.toAbsolutePath().toString()
); );
CommandRunner.run(command); CommandRunner.run(command);

View File

@@ -1 +1,6 @@
spring.docker.compose.file=docker-compose.dev.yml spring.docker.compose.file=docker-compose.dev.yml
media.temp.inputs=./videos/inputs
media.temp.outputs=./videos/outputs
media.users=${MEDIA_DATA}
media.streams=${STREAM_DATA}

View File

@@ -3,9 +3,6 @@ spring.application.name=vodSystem
# VODs # VODs
spring.servlet.multipart.max-file-size=2GB spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB spring.servlet.multipart.max-request-size=2GB
storage.temp.inputs=videos/inputs/
storage.temp.outputs=videos/outputs/
storage.outputs=${MEDIA_DATA}
# Database # Database
spring.datasource.url=${POSTGRES_URL} spring.datasource.url=${POSTGRES_URL}
@@ -21,15 +18,20 @@ spring.sql.init.schema-locations=classpath:db/schema.sql
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}
spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET}
spring.security.oauth2.client.registration.google.scope=profile,email spring.security.oauth2.client.registration.google.scope=profile,email
jwt.secret.key=${JWT_SECRET_KEY} jwt.secret.key=${JWT_SECRET_KEY}
jwt.expiration=3600000 jwt.expiration=3600000
# Frontend # Frontend
frontend.url=${FRONTEND_URL} frontend.url=${FRONTEND_URL}
# Stream HLS # Paths
storage.stream=${STREAM_DATA} media.temp.inputs=/tmp/inputs/
media.temp.outputs=/tmp/outputs/
media.users=/app/media/
media.streams=/app/stream/
## Server Configuration ## Server Configuration
server.servlet.session.timeout=30m server.servlet.session.timeout=30m
# Serialize Instant as ISO-8601 UTC string (not epoch seconds)
spring.jackson.serialization.write-dates-as-timestamps=false

View File

@@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS users (
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
profile_picture_url VARCHAR(255), profile_picture_url VARCHAR(255),
role INTEGER DEFAULT 0, role INTEGER DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ DEFAULT NOW(),
stream_key VARCHAR(64) NOT NULL UNIQUE stream_key VARCHAR(64) NOT NULL UNIQUE
); );
@@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS clips (
user_id BIGINT NOT NULL, user_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL, title VARCHAR(255) NOT NULL,
description TEXT, description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMPTZ DEFAULT NOW(),
width INTEGER NOT NULL, width INTEGER NOT NULL,
height INTEGER NOT NULL, height INTEGER NOT NULL,
fps FLOAT NOT NULL, fps FLOAT NOT NULL,