refactor(media)!: unify processing under asynchronous job workflow #3

Merged
dylan merged 14 commits from refactor/generalize-job-abstraction into master 2026-06-25 20:26:09 +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 Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts"; 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

@@ -4,7 +4,6 @@ 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);
useEffect(() => {
if (!localFile) return;
const url = URL.createObjectURL(localFile);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [localFile]);
const handleVideoMetadataLoaded = () => {
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,
});
};
const sendData = async () => { const sendData = async () => {
if (!id) return; if (!localFile) return;
editFile(id, outputMetadata) let jobId: string;
.then(() => { try {
jobId = await compress(localFile, outputMetadata);
processFile(id) setUploadedId(jobId);
.catch((err: Error) => setError(`Failed to process file: ${err.message}`)); } catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
}) return;
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`));
const interval = setInterval(async() => await pollProgress(id, interval), 500);
} }
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,207 +6,81 @@ 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' });
@@ -215,49 +89,32 @@ const getClips = async (): Promise<Clip[]> => {
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);
export { return `${s} second${s !== 1 ? 's' : ''} ago`;
formatTime,
stringToDate,
dateToTimeAgo
} }
export { 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,