Merge pull request #8 from ThisBirchWood/3-clip-saving

Implemented Clip Saving and page to view existing clips
This commit is contained in:
Dylan De Faoite
2025-07-16 00:02:12 +02:00
committed by GitHub
40 changed files with 1177 additions and 425 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -5,6 +5,8 @@ import ClipUpload from './pages/ClipUpload';
import ClipEdit from './pages/ClipEdit'; import ClipEdit from './pages/ClipEdit';
import Home from './pages/Home'; import Home from './pages/Home';
import {useEffect} from "react"; import {useEffect} from "react";
import MyClips from './pages/MyClips';
import VideoPlayer from "./pages/VideoPlayer.tsx";
function App() { function App() {
@@ -19,6 +21,8 @@ function App() {
<Route path="/" element={<Home />} /> <Route path="/" element={<Home />} />
<Route path="/create" element={<ClipUpload />} /> <Route path="/create" element={<ClipUpload />} />
<Route path="/create/:id" element={<ClipEdit />} /> <Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} />
<Route path="/video/:id" element={<VideoPlayer />} />
</Route> </Route>
</Routes> </Routes>
</Router> </Router>

View File

@@ -7,7 +7,7 @@ type prop = {
className?: string; className?: string;
} }
export default function ClipConfig({setMetadata, className}: prop) { export default function ConfigBox({setMetadata, className}: prop) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
var vals = e.target.value.split(","); var vals = e.target.value.split(",");
setMetadata((prevState: VideoMetadata) => ({ setMetadata((prevState: VideoMetadata) => ({

View File

@@ -0,0 +1,41 @@
import clsx from "clsx";
import type {VideoMetadata} from "../../utils/types.ts";
import Selector from "../Selector.tsx";
type MetadataBoxProps = {
setMetadata: Function
className?: string;
}
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
return (
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}>
<h2 className={"text-3xl font-bold mb-4 col-span-2"}>Metadata Settings</h2>
<Selector label={"Title"}>
<input
type="text"
placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
title: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
/>
</Selector>
<Selector label={"Description"}>
<textarea
placeholder="Enter description"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
...prevState,
description: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2 pb-10 resize-none max-h"}
/>
</Selector>
</div>
)
}
export default MetadataBox;

View File

@@ -2,6 +2,7 @@ import { useEffect, useState} from "react";
import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react'; import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import { formatTime } from "../../utils/utils.ts";
type Props = { type Props = {
@@ -10,20 +11,6 @@ type Props = {
className?: string; className?: string;
}; };
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const padded = (n: number) => n.toString().padStart(2, '0');
if (h > 0) {
return `${h}:${padded(m)}:${padded(s)}`;
} else {
return `${m}:${padded(s)}`;
}
}
export default function Playbar({ video, videoMetadata, className }: Props) { 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);

View File

@@ -0,0 +1,80 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
import { useState } from "react";
type VideoCardProps = {
id: number,
title: string,
duration: number,
thumbnailPath: string | null,
videoPath: string,
createdAt: string,
className?: string
}
const fallbackThumbnail = "../../../public/default_thumbnail.png";
const VideoCard = ({
id,
title,
duration,
thumbnailPath,
createdAt,
className
}: VideoCardProps) => {
const initialSrc = thumbnailPath && thumbnailPath.trim() !== "" ? thumbnailPath : fallbackThumbnail;
const [imgSrc, setImgSrc] = useState(initialSrc);
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
setTimeout(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)))
}, 1000);
return (
<Link to={"/video/" + id}>
<div className={clsx("flex flex-col", className)}>
<div className={"relative inline-block"}>
<img
src={imgSrc}
alt="Video Thumbnail"
onError={() => {
if (imgSrc !== fallbackThumbnail) {
setImgSrc(fallbackThumbnail);
}
}}
/>
<p className="
absolute
top-2
left-2
bg-black bg-opacity-60
text-white
px-2
py-1
rounded
pointer-events-none
text-sm
z-1
">
{formatTime(duration)}
</p>
</div>
<div className={"flex flex-col justify-between p-2"}>
<p>{title == "" ? "(No Title)" : title}</p>
<p
className={"text-gray-600 text-sm"}
>
{timeAgo}
</p>
</div>
</div>
</Link>
);
}
export default VideoCard;

View File

@@ -3,11 +3,12 @@ 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 ClipConfig from "./../components/video/ClipConfig"; 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 {editFile, getMetadata, processFile, getProgress} 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";
const ClipEdit = () => { const ClipEdit = () => {
const { id } = useParams(); const { id } = useParams();
@@ -17,6 +18,8 @@ const ClipEdit = () => {
const [playbackValue, setPlaybackValue] = useState(0); const [playbackValue, setPlaybackValue] = useState(0);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({ const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
// default values // default values
title: "",
description: "",
startPoint: 0, startPoint: 0,
endPoint: 5, endPoint: 5,
width: 1280, width: 1280,
@@ -31,30 +34,32 @@ const ClipEdit = () => {
const sendData = async() => { const sendData = async() => {
if (!id) return; if (!id) return;
setDownloadable(false); editFile(id, outputMetadata)
.then(() => {
const edited = await editFile(id, outputMetadata, setError); processFile(id)
.catch((err: Error) => setError(`Failed to process file: ${err.message}`));
if (!edited) { })
return; .catch((err: Error) => setError(`Failed to edit file: ${err.message}`));
}
const processed = await processFile(id, setError); const interval = setInterval(async() => await pollProgress(id, interval), 500);
}
if (!processed) { const pollProgress = async (id: string, intervalId: number) => {
return; getProgress(id)
} .then((progress) => {
setProgress(progress);
const interval = setInterval(async () => { if (progress >= 1) {
const progress = await getProgress(id); clearInterval(intervalId);
setProgress(progress); setDownloadable(true);
}
if (progress >= 1) { })
clearInterval(interval); .catch((err: Error) => {
setDownloadable(true); setError(`Failed to fetch progress: ${err.message}`);
console.log("Downloadable"); clearInterval(intervalId);
} });
}, 500);
} }
const handleDownload = async () => { const handleDownload = async () => {
@@ -98,7 +103,10 @@ const ClipEdit = () => {
<Box className={"w-4/5 h-full m-auto"}> <Box className={"w-4/5 h-full m-auto"}>
<ClipConfig <MetadataBox
setMetadata={setOutputMetadata}
/>
<ConfigBox
setMetadata={setOutputMetadata} setMetadata={setOutputMetadata}
/> />
</Box> </Box>

View File

@@ -10,13 +10,14 @@ const clipUpload = () => {
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const press = (() => { const press = (() => {
if (file) { if (!file) {
uploadFile(file, setError)
.then(uuid => navigate(`/create/${uuid}`))
.catch(e => console.error(e));
} else {
setError("Please choose a file"); setError("Please choose a file");
return;
} }
uploadFile(file)
.then(uuid => navigate(`/create/${uuid}`))
.catch((e: Error) => setError(`Failed to upload file: ${e.message}`));
}); });
return ( return (

View File

@@ -0,0 +1,36 @@
import VideoCard from "../components/video/VideoCard";
import {useEffect, useState} from "react";
import { getClips } from "../utils/endpoints";
import type { Clip } from "../utils/types";
const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null);
useEffect(() => {
getClips()
.then((data) => setClips(data))
.catch((err) => setError(err));
}, []);
return (
<div className={"flex flex-row"}>
{clips.map((clip) => (
<VideoCard
id={clip.id}
key={clip.videoPath}
title={clip.title}
duration={clip.duration}
thumbnailPath={clip.thumbnailPath}
videoPath={clip.videoPath}
createdAt={clip.createdAt}
className={"w-40 m-5"}
/>
))}
{error}
</div>
);
}
export default MyClips;

View File

@@ -0,0 +1,88 @@
import {useEffect, useState} from "react";
import {useParams} from "react-router-dom";
import type {Clip} from "../utils/types";
import {getClipById} from "../utils/endpoints.ts";
import Box from "../components/Box.tsx"
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts";
const VideoPlayer = () => {
const { id } = useParams();
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null);
const [clip, setClip] = useState<Clip | null>(null);
const [timeAgo, setTimeAgo] = useState<String>("");
useEffect(() => {
// Fetch the video URL from the server
fetch(`/api/v1/download/clip/${id}`)
.then(response => {
if (!response.ok) {
throw new Error("Failed to load video");
}
return response.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
setVideoUrl(url);
})
.catch(err => {
console.error("Error fetching video:", err);
setError("Failed to load video. Please try again later.");
});
if (!id) {
setError("Clip ID is required.");
return;
}
getClipById(id)
.then((fetchedClip) => {setClip(fetchedClip)})
.catch((err) => {
console.error("Error fetching clip:", err);
setError("Failed to load clip details. Please try again later.");
});
}, [id]);
// Update timeAgo live every second
useEffect(() => {
if (!clip || !clip.createdAt) return;
const update = () => {
const date = stringToDate(clip.createdAt);
setTimeAgo(dateToTimeAgo(date));
};
update(); // initial update
const interval = setInterval(update, 1000);
return () => clearInterval(interval); // cleanup
}, [clip]);
return (
<div className={"w-9/10 m-auto"}>
<video
className={"w-full h-full rounded-lg m-auto"}
controls
autoPlay
src={videoUrl}
onError={(e) => {
setError(e.currentTarget.error?.message || "An error occurred while playing the video.");
}}
>
Your browser does not support the video tag.
</video>
{error && <div className="text-red-500 mt-2">{error}</div>}
{!videoUrl && !error && <div className="text-gray-500 mt-2">Loading video...</div>}
<Box className={"p-2 m-2 flex flex-col"}>
<p className={"text-2xl font-bold text-gray-600"}>{clip?.title ? clip?.title : "(No Title)"}</p>
<p>{timeAgo}</p>
</Box>
</div>
);
};
export default VideoPlayer;

View File

@@ -1,37 +1,33 @@
import type {VideoMetadata, APIResponse, User} from "./types.ts"; import type {VideoMetadata, APIResponse, User, Clip} from "./types.ts";
/** /**
* Uploads a file to the backend. * Uploads a file to the backend.
* @param file - The file to upload.
*/ */
const uploadFile = async (file: File, setError: Function): Promise<string> => { const uploadFile = async (file: File): Promise<string> => {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
try { const response = await fetch('/api/v1/upload', {
const response = await fetch('/api/v1/upload', { method: 'POST',
method: 'POST', body: formData,
body: formData, });
});
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status == "error") { if (result.status == "error") {
setError(result.message); throw new Error(`Failed to upload file: ${result.message}`);
}
return result.data.uuid;
} catch (error: unknown) {
throw new Error(`Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`);
} }
return result.data.uuid;
}; };
/** /**
* Submits metadata changes to the backend. * Submits metadata changes to the backend.
* @param uuid - The UUID of the video file to edit.
* @param videoMetadata - The metadata to update.
*/ */
const editFile = async ( const editFile = async (uuid: string, videoMetadata: VideoMetadata) => {
uuid: string,
videoMetadata: VideoMetadata,
setError: Function ): Promise<boolean> => {
const formData = new URLSearchParams(); const formData = new URLSearchParams();
for (const [key, value] of Object.entries(videoMetadata)) { for (const [key, value] of Object.entries(videoMetadata)) {
@@ -40,112 +36,153 @@ const editFile = async (
} }
} }
try { const response = await fetch(`/api/v1/edit/${uuid}`, {
const response = await fetch(`/api/v1/edit/${uuid}`, { method: 'POST',
method: 'POST', headers: {
headers: { 'Content-Type': 'application/x-www-form-urlencoded',
'Content-Type': 'application/x-www-form-urlencoded', },
}, body: formData.toString(),
body: formData.toString(), });
});
const result: APIResponse = await response.json(); if (!response.ok) {
throw new Error(`Failed to edit file: ${response.status}`);
if (result.status === "error") {
setError(result.message);
return false;
}
return true;
} catch (error: unknown) {
console.error('Error editing file:', error);
return false;
} }
const result: APIResponse = await response.json();
if (result.status === "error") {
throw new Error(`Failed to edit file: ${result.message}`);
}
}; };
/** /**
* Triggers file processing. * Triggers file processing.
* @param uuid - The UUID of the video file to process.
*/ */
const processFile = async (uuid: string, setError: Function): Promise<boolean> => { const processFile = async (uuid: string) => {
try { const response = await fetch(`/api/v1/process/${uuid}`);
const response = await fetch(`/api/v1/process/${uuid}`);
const result: APIResponse = await response.json(); if (!response.ok) {
if (result.status === "error") { throw new Error(`Failed to process file: ${response.status}`);
setError(result.message); }
return false;
}
return response.ok; const result: APIResponse = await response.json();
} catch (error: unknown) {
console.error('Error processing file:', error); if (result.status === "error") {
return false; throw new Error("Failed to process file: " + result.message);
} }
}; };
/** /**
* Fetches the processing progress percentage. * Fetches the processing progress percentage.
* @param uuid - The UUID of the video file.
*/ */
const getProgress = async (uuid: string): Promise<number> => { const getProgress = async (uuid: string): Promise<number> => {
try { const response = await fetch(`/api/v1/progress/${uuid}`);
const response = await fetch(`/api/v1/progress/${uuid}`);
if (!response.ok) { if (!response.ok) {
console.error('Failed to fetch progress:', response.status); throw new Error(`Failed to fetch progress: ${response.status}`);
return 0;
}
const result = await response.json();
return result.data?.progress ?? 0;
} catch (error: unknown) {
console.error('Error getting progress:', error);
return 0;
} }
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch progress: ${result.message}`);
}
if (!result.data || typeof result.data.progress !== 'number') {
throw new Error('Invalid progress data received');
}
return result.data.progress;
}; };
/** /**
* Fetches original metadata from the backend. * Fetches original metadata from the backend.
* @param uuid - The UUID of the video file.
*/ */
const getMetadata = async (uuid: string): Promise<VideoMetadata> => { const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
try { const response = await fetch(`/api/v1/metadata/original/${uuid}`);
const response = await fetch(`/api/v1/metadata/original/${uuid}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch metadata: ${response.status}`); throw new Error(`Failed to fetch metadata: ${response.status}`);
}
const result = await response.json();
return result.data;
} catch (error: unknown) {
console.error('Error fetching metadata:', error);
return {
startPoint: 0,
endPoint: 0,
fps: 0,
width: 0,
height: 0,
fileSize: 0,
};
} }
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch metadata: ${result.message}`);
}
return result.data;
}; };
/**
* Fetches the current user information. Returns null if not authenticated.
*/
const getUser = async (): Promise<null | User > => { const getUser = async (): Promise<null | User > => {
try { const response = await fetch('/api/v1/auth/user', {credentials: "include",});
const response = await fetch('/api/v1/auth/user', {credentials: "include",});
const result = await response.json(); if (!response.ok) {
return result.data;
} catch (error: unknown) {
console.error('Error fetching user:', error);
return null; return null;
} }
const result = await response.json();
if (result.status === "error") {
return null;
}
return result.data;
} }
/**
* Fetches all clips for the current user.
*/
const getClips = async (): Promise<Clip[]> => {
const response = await fetch('/api/v1/clips/', { credentials: 'include' });
if (!response.ok) {
const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch clips: ${errorResult.message}`);
}
try {
const result: APIResponse = await response.json();
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 response = await fetch(`/api/v1/clips/${id}`, {credentials: "include",});
if (!response.ok) {
throw new Error(`Failed to fetch clip: ${response.status}`);
}
try{
const result: APIResponse = await response.json();
return result.data;
} catch (error: unknown) {
throw new Error(`Failed to parse clip response:
${error instanceof Error ? error.message : 'Unknown error'}`);
}
};
export { export {
uploadFile, uploadFile,
editFile, editFile,
processFile, processFile,
getProgress, getProgress,
getMetadata, getMetadata,
getUser getUser,
getClips,
getClipById
}; };

View File

@@ -1,4 +1,6 @@
type VideoMetadata = { type VideoMetadata = {
title: string,
description: string,
startPoint: number, startPoint: number,
endPoint: number, endPoint: number,
fps: number, fps: number,
@@ -19,8 +21,22 @@ type User = {
profilePicture: string profilePicture: string
} }
type Clip = {
id: number,
title: string,
description: string,
duration: number,
thumbnailPath: string,
videoPath: string,
fps: number,
width: number,
height: number,
createdAt: string,
}
export type { export type {
APIResponse, APIResponse,
VideoMetadata, VideoMetadata,
User User,
Clip
} }

View File

@@ -0,0 +1,77 @@
function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const padded = (n: number) => n.toString().padStart(2, '0');
if (h > 0) {
return `${h}:${padded(m)}:${padded(s)}`;
} else {
return `${m}:${padded(s)}`;
}
}
function stringToDate(dateString: string): Date {
const validIso = dateString.substring(0, 23);
const date = new Date(validIso);
if (isNaN(date.getTime())) {
throw new Error("Invalid date string");
}
return date;
}
function dateToTimeAgo(date: Date): string {
const now = new Date();
const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000);
if (secondsAgo < 60) {
return `${secondsAgo} seconds ago`;
} else if (secondsAgo < 3600) {
const minutes = Math.floor(secondsAgo / 60);
if (minutes === 1) {
return "1 minute 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`;
}
}
export {
formatTime,
stringToDate,
dateToTimeAgo
}

View File

@@ -2,8 +2,10 @@ package com.ddf.vodsystem;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableScheduling
public class VodSystemApplication { public class VodSystemApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -29,6 +29,7 @@ public class SecurityConfig {
http http
.csrf(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/download/clip/**").authenticated()
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll() .requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll()
.requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll() .requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll()
.requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**").permitAll() .requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**").permitAll()

View File

@@ -1,6 +1,7 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.entities.APIResponse; import com.ddf.vodsystem.entities.APIResponse;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
@@ -14,21 +15,17 @@ import java.util.Map;
@RestController @RestController
@RequestMapping("/api/v1/auth/") @RequestMapping("/api/v1/auth/")
public class AuthController { public class AuthController {
@GetMapping("/user") @GetMapping("/user")
public ResponseEntity<APIResponse<Map<String, Object>>> user(@AuthenticationPrincipal OAuth2User principal) { public ResponseEntity<APIResponse<Map<String, Object>>> user(@AuthenticationPrincipal OAuth2User principal) {
if (principal == null) { if (principal == null) {
return ResponseEntity.status(HttpStatus.FORBIDDEN). throw new NotAuthenticated("User is not authenticated");
body(new APIResponse<>(
"error",
"User not authenticated",
null
));
} }
if (principal.getAttribute("email") == null if (
principal.getAttribute("email") == null
|| principal.getAttribute("name") == null || principal.getAttribute("name") == null
|| principal.getAttribute("picture") == null) { || principal.getAttribute("picture") == null)
{
return ResponseEntity.status(HttpStatus.BAD_REQUEST). return ResponseEntity.status(HttpStatus.BAD_REQUEST).
body(new APIResponse<>( body(new APIResponse<>(
"error", "error",

View File

@@ -0,0 +1,53 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.entities.APIResponse;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.ClipService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import java.util.List;
@Controller
@RequestMapping("/api/v1/clips")
public class ClipController {
private final ClipService clipService;
public ClipController(ClipService clipService) {
this.clipService = clipService;
}
@GetMapping("/")
public ResponseEntity<APIResponse<List<Clip>>> getClips(@AuthenticationPrincipal OAuth2User principal) {
if (principal == null) {
throw new NotAuthenticated("User is not authenticated");
}
List<Clip> clips = clipService.getClipsByUser();
return ResponseEntity.ok(
new APIResponse<>("success", "Clips retrieved successfully", clips)
);
}
@GetMapping("/{id}")
public ResponseEntity<APIResponse<Clip>> getClipById(@AuthenticationPrincipal OAuth2User principal, @PathVariable Long id) {
if (principal == null) {
throw new NotAuthenticated("User is not authenticated");
}
Clip clip = clipService.getClipById(id);
if (clip == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(
new APIResponse<>("success", "Clip retrieved successfully", clip)
);
}
}

View File

@@ -7,6 +7,8 @@ import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory; import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -49,4 +51,18 @@ public class DownloadController {
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource); .body(resource);
} }
@GetMapping("/clip/{id}")
public ResponseEntity<Resource> downloadClip(@AuthenticationPrincipal OAuth2User principal, @PathVariable Long id) {
Resource resource = downloadService.downloadClip(id);
if (resource == null || !resource.exists()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + resource.getFilename() + "\"")
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
} }

View File

@@ -6,6 +6,7 @@ import lombok.AllArgsConstructor;
import lombok.Data; import lombok.Data;
import com.ddf.vodsystem.entities.APIResponse; import com.ddf.vodsystem.entities.APIResponse;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;

View File

@@ -4,6 +4,7 @@ import com.ddf.vodsystem.entities.APIResponse;
import com.ddf.vodsystem.exceptions.FFMPEGException; import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.JobNotFinished; import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound; import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpMediaTypeNotSupportedException;
@@ -22,50 +23,57 @@ public class GlobalExceptionHandler {
@ExceptionHandler({ MultipartException.class }) @ExceptionHandler({ MultipartException.class })
public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) { public ResponseEntity<APIResponse<Void>> handleMultipartException(MultipartException ex) {
logger.error("MultipartException: {}", ex.getMessage(), ex); logger.error("MultipartException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Multipart request error: " + ex.getMessage(), null); APIResponse<Void> response = new APIResponse<>(ERROR, "Multipart request error: " + ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
} }
@ExceptionHandler({ MissingServletRequestPartException.class }) @ExceptionHandler({ MissingServletRequestPartException.class })
public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) { public ResponseEntity<APIResponse<Void>> handleMissingPart(MissingServletRequestPartException ex) {
logger.error("MissingServletRequestPartException: {}", ex.getMessage(), ex); logger.error("MissingServletRequestPartException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Required file part is missing.", null); APIResponse<Void> response = new APIResponse<>(ERROR, "Required file part is missing.", null);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
} }
@ExceptionHandler({ HttpMediaTypeNotSupportedException.class }) @ExceptionHandler({ HttpMediaTypeNotSupportedException.class })
public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) { public ResponseEntity<APIResponse<Void>> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) {
logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage(), ex); logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "Unsupported media type: expected multipart/form-data.", null); APIResponse<Void> response = new APIResponse<>(ERROR, "Unsupported media type: expected multipart/form-data.", null);
return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response); return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response);
} }
@ExceptionHandler(IllegalArgumentException.class) @ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) { public ResponseEntity<APIResponse<Void>> handleIllegalArgument(IllegalArgumentException ex) {
logger.error("IllegalArgumentException: {}", ex.getMessage(), ex); logger.error("IllegalArgumentException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null); APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response);
} }
@ExceptionHandler(JobNotFound.class) @ExceptionHandler(JobNotFound.class)
public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) { public ResponseEntity<APIResponse<Void>> handleFileNotFound(JobNotFound ex) {
logger.error("JobNotFound: {}", ex.getMessage(), ex); logger.error("JobNotFound: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null); APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
} }
@ExceptionHandler(JobNotFinished.class) @ExceptionHandler(JobNotFinished.class)
public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) { public ResponseEntity<APIResponse<Void>> handleJobNotFinished(JobNotFinished ex) {
logger.error("JobNotFinished: {}", ex.getMessage(), ex); logger.error("JobNotFinished: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null); APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); return ResponseEntity.status(HttpStatus.ACCEPTED).body(response);
} }
@ExceptionHandler(FFMPEGException.class) @ExceptionHandler(FFMPEGException.class)
public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) { public ResponseEntity<APIResponse<Void>> handleFFMPEGException(FFMPEGException ex) {
logger.error("FFMPEGException: {}", ex.getMessage(), ex); logger.error("FFMPEGException: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "FFMPEG Error: Please upload a valid file", null); APIResponse<Void> response = new APIResponse<>(ERROR, "FFMPEG Error: Please upload a valid file", null);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
} }
@ExceptionHandler(NotAuthenticated.class)
public ResponseEntity<APIResponse<Void>> handleNotAuthenticated(NotAuthenticated ex) {
logger.error("NotAuthenticated: {}", ex.getMessage());
APIResponse<Void> response = new APIResponse<>(ERROR, "User is not authenticated", null);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response);
}
} }

View File

@@ -1,8 +1,9 @@
package com.ddf.vodsystem.entities; package com.ddf.vodsystem.entities;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import lombok.ToString;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@@ -16,7 +17,7 @@ public class Clip {
private Long id; private Long id;
@ManyToOne(fetch = FetchType.LAZY) @ManyToOne(fetch = FetchType.LAZY)
@ToString.Exclude @JsonIgnore
@JoinColumn(name = "user_id", nullable = false) @JoinColumn(name = "user_id", nullable = false)
private User user; private User user;
@@ -36,13 +37,13 @@ public class Clip {
private Integer height; private Integer height;
@Column(name = "fps", nullable = false) @Column(name = "fps", nullable = false)
private Integer fps; private Float fps;
@Column(name = "duration", nullable = false) @Column(name = "duration", nullable = false)
private Integer duration; private Float duration;
@Column(name = "file_size", nullable = false) @Column(name = "file_size", nullable = false)
private Long fileSize; private Float fileSize;
@Column(name = "video_path", nullable = false, length = 255) @Column(name = "video_path", nullable = false, length = 255)
private String videoPath; private String videoPath;

View File

@@ -1,6 +1,9 @@
package com.ddf.vodsystem.entities; package com.ddf.vodsystem.entities;
import java.io.File; import java.io.File;
import java.util.concurrent.atomic.AtomicReference;
import org.springframework.security.core.context.SecurityContext;
import lombok.Data; import lombok.Data;
@@ -14,9 +17,12 @@ public class Job {
private VideoMetadata inputVideoMetadata; private VideoMetadata inputVideoMetadata;
private VideoMetadata outputVideoMetadata = new VideoMetadata(); private VideoMetadata outputVideoMetadata = new VideoMetadata();
// security
private SecurityContext securityContext;
// job status // job status
private JobStatus status = JobStatus.NOT_READY; private JobStatus status = JobStatus.NOT_READY;
private Float progress = 0.0f; private AtomicReference<Float> progress = new AtomicReference<>(0f);
public Job(String uuid, File inputFile, File outputFile, VideoMetadata inputVideoMetadata) { public Job(String uuid, File inputFile, File outputFile, VideoMetadata inputVideoMetadata) {
this.uuid = uuid; this.uuid = uuid;

View File

@@ -4,6 +4,8 @@ import lombok.Data;
@Data @Data
public class VideoMetadata { public class VideoMetadata {
private String title;
private String description;
private Float startPoint; private Float startPoint;
private Float endPoint; private Float endPoint;
private Float fps; private Float fps;

View File

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

View File

@@ -1,9 +1,17 @@
package com.ddf.vodsystem.repositories; package com.ddf.vodsystem.repositories;
import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.entities.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.List;
@Repository @Repository
public interface ClipRepository extends JpaRepository<Clip, Long> { public interface ClipRepository extends JpaRepository<Clip, Long> {
@Query("SELECT c FROM Clip c WHERE c.user = ?1")
List<Clip> findByUser(User user);
} }

View File

@@ -0,0 +1,36 @@
package com.ddf.vodsystem.security;
import com.ddf.vodsystem.entities.User;
import lombok.Getter;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.oauth2.core.user.OAuth2User;
import java.util.Collection;
import java.util.Map;
@Getter
public class CustomOAuth2User implements OAuth2User {
private final OAuth2User oauth2User;
private final User user;
public CustomOAuth2User(OAuth2User oauth2User, User user) {
this.oauth2User = oauth2User;
this.user = user;
}
@Override
public Map<String, Object> getAttributes() {
return oauth2User.getAttributes();
}
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return oauth2User.getAuthorities();
}
@Override
public String getName() {
return oauth2User.getName();
}
}

View File

@@ -4,16 +4,16 @@ import com.ddf.vodsystem.repositories.UserRepository;
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest;
import org.springframework.security.oauth2.client.userinfo.OAuth2UserService;
import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.OAuth2AuthenticationException;
import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.Optional;
@Service @Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService { public class CustomOAuth2UserService implements OAuth2UserService<OAuth2UserRequest, OAuth2User> {
private final UserRepository userRepository; private final UserRepository userRepository;
@@ -22,26 +22,26 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService {
} }
@Override @Override
public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { public CustomOAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
OAuth2User oAuth2User = super.loadUser(userRequest); var delegate = new DefaultOAuth2UserService();
var oAuth2User = delegate.loadUser(userRequest);
String email = oAuth2User.getAttribute("email"); String email = oAuth2User.getAttribute("email");
String name = oAuth2User.getAttribute("name"); String name = oAuth2User.getAttribute("name");
String googleId = oAuth2User.getAttribute("sub"); String googleId = oAuth2User.getAttribute("sub");
Optional<User> existingUser = userRepository.findByGoogleId(googleId); User user = userRepository.findByGoogleId(googleId)
.orElseGet(() -> {
User newUser = new User();
newUser.setEmail(email);
newUser.setName(name);
newUser.setGoogleId(googleId);
newUser.setUsername(email);
newUser.setRole(0);
newUser.setCreatedAt(LocalDateTime.now());
return userRepository.save(newUser);
});
if (existingUser.isEmpty()) { return new CustomOAuth2User(oAuth2User, user);
User user = new User();
user.setEmail(email);
user.setName(name);
user.setGoogleId(googleId);
user.setUsername(email);
user.setRole(0);
user.setCreatedAt(LocalDateTime.now());
userRepository.save(user);
}
return oAuth2User;
} }
} }

View File

@@ -0,0 +1,107 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.*;
import java.io.File;
import java.io.IOException;
import java.time.LocalDateTime;
import java.util.List;
import com.ddf.vodsystem.repositories.ClipRepository;
import com.ddf.vodsystem.security.CustomOAuth2User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
@Service
public class ClipService {
private static final Logger logger = LoggerFactory.getLogger(ClipService.class);
private final ClipRepository clipRepository;
private final MetadataService metadataService;
private final DirectoryService directoryService;
private final FfmpegService ffmpegService;
public ClipService(ClipRepository clipRepository,
MetadataService metadataService,
DirectoryService directoryService,
FfmpegService ffmpegService) {
this.clipRepository = clipRepository;
this.metadataService = metadataService;
this.directoryService = directoryService;
this.ffmpegService = ffmpegService;
}
/**
* Runs the FFMPEG command to create a video clip based on the provided job.
* Updates the job status and progress as the command executes.
* This method validates the input and output video metadata,
* Updates the job VideoMetadata with the output file size,
*
* @param job the job containing input and output video metadata
* @throws IOException if an I/O error occurs during command execution
* @throws InterruptedException if the thread is interrupted while waiting for the process to finish
*
*/
public void run(Job job) throws IOException, InterruptedException {
metadataService.normalizeVideoMetadata(job.getInputVideoMetadata(), job.getOutputVideoMetadata());
ffmpegService.runWithProgress(job.getInputFile(), job.getOutputFile(), job.getOutputVideoMetadata(), job.getProgress());
Float fileSize = metadataService.getFileSize(job.getOutputFile());
job.getOutputVideoMetadata().setFileSize(fileSize);
User user = getUser();
if (user != null) {
persistClip(job.getOutputVideoMetadata(), user, job);
}
job.setStatus(JobStatus.FINISHED);
logger.info("FFMPEG finished successfully for job: {}", job.getUuid());
}
public List<Clip> getClipsByUser() {
User user = getUser();
if (user == null) {
logger.warn("No authenticated user found");
return List.of();
}
return clipRepository.findByUser(user);
}
private User getUser() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof CustomOAuth2User oAuth2user) {
return oAuth2user.getUser();
}
return null;
}
private void persistClip(VideoMetadata videoMetadata, User user, Job job) {
// Move clip from temp to output directory
String fileExtension = directoryService.getFileExtension(job.getOutputFile().getAbsolutePath());
File outputFile = directoryService.getOutputFile(job.getUuid(), fileExtension);
directoryService.copyFile(job.getOutputFile(), outputFile);
// Save clip to database
Clip clip = new Clip();
clip.setUser(user);
clip.setTitle(videoMetadata.getTitle() != null ? videoMetadata.getTitle() : "Untitled Clip");
clip.setDescription(videoMetadata.getDescription());
clip.setCreatedAt(LocalDateTime.now());
clip.setWidth(videoMetadata.getWidth());
clip.setHeight(videoMetadata.getHeight());
clip.setFps(videoMetadata.getFps());
clip.setDuration(videoMetadata.getEndPoint() - videoMetadata.getStartPoint());
clip.setFileSize(videoMetadata.getFileSize());
clip.setVideoPath(outputFile.getPath());
clipRepository.save(clip);
}
public Clip getClipById(Long id) {
return clipRepository.findById(id).orElse(null);
}
}

View File

@@ -1,144 +0,0 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.VideoMetadata;
import com.ddf.vodsystem.entities.JobStatus;
import com.ddf.vodsystem.entities.Job;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@Service
public class CompressionService {
private static final Logger logger = LoggerFactory.getLogger(CompressionService.class);
private static final float AUDIO_RATIO = 0.15f;
private static final float MAX_AUDIO_BITRATE = 128f;
private static final float BITRATE_MULTIPLIER = 0.9f;
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
private void validateVideoMetadata(VideoMetadata inputFileMetadata, VideoMetadata outputFileMetadata) {
if (outputFileMetadata.getStartPoint() == null) {
outputFileMetadata.setStartPoint(0f);
}
if (outputFileMetadata.getEndPoint() == null) {
outputFileMetadata.setEndPoint(inputFileMetadata.getEndPoint());
}
}
private void buildFilters(ArrayList<String> command, Float fps, Integer width, Integer height) {
List<String> filters = new ArrayList<>();
if (fps != null) {
filters.add("fps=" + fps);
}
if (!(width == null && height == null)) {
String w = (width != null) ? width.toString() : "-1";
String h = (height != null) ? height.toString() : "-1";
filters.add("scale=" + w + ":" + h);
}
if (!filters.isEmpty()) {
command.add("-vf");
command.add(String.join(",", filters));
}
}
private void buildBitrate(ArrayList<String> command, Float length, Float fileSize) {
float bitrate = ((fileSize * 8) / length) * BITRATE_MULTIPLIER;
float audioBitrate = bitrate * AUDIO_RATIO;
float videoBitrate;
if (audioBitrate > MAX_AUDIO_BITRATE) {
audioBitrate = MAX_AUDIO_BITRATE;
videoBitrate = bitrate - MAX_AUDIO_BITRATE;
} else {
videoBitrate = bitrate * (1 - AUDIO_RATIO);
}
command.add("-b:v");
command.add(videoBitrate + "k");
command.add("-b:a");
command.add(audioBitrate + "k");
}
private void buildInputs(ArrayList<String> command, File inputFile, Float startPoint, Float length) {
command.add("-ss");
command.add(startPoint.toString());
command.add("-i");
command.add(inputFile.getAbsolutePath());
command.add("-t");
command.add(Float.toString(length));
}
private ProcessBuilder buildCommand(File inputFile, File outputFile, VideoMetadata videoMetadata) {
ArrayList<String> command = new ArrayList<>();
command.add("ffmpeg");
command.add("-progress");
command.add("pipe:1");
command.add("-y");
Float length = videoMetadata.getEndPoint() - videoMetadata.getStartPoint();
buildInputs(command, inputFile, videoMetadata.getStartPoint(), length);
buildFilters(command, videoMetadata.getFps(), videoMetadata.getWidth(), videoMetadata.getHeight());
if (videoMetadata.getFileSize() != null) {
buildBitrate(command, length, videoMetadata.getFileSize());
}
// Output file
command.add(outputFile.getAbsolutePath());
logger.info("Running command: {}", command);
return new ProcessBuilder(command);
}
public void run(Job job) throws IOException, InterruptedException {
logger.info("FFMPEG starting...");
validateVideoMetadata(job.getInputVideoMetadata(), job.getOutputVideoMetadata());
ProcessBuilder pb = buildCommand(job.getInputFile(), job.getOutputFile(), job.getOutputVideoMetadata());
Process process = pb.start();
job.setStatus(JobStatus.RUNNING);
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
float length = job.getOutputVideoMetadata().getEndPoint() - job.getOutputVideoMetadata().getStartPoint();
String line;
while ((line = reader.readLine()) != null) {
logger.debug(line);
Matcher matcher = timePattern.matcher(line);
if (matcher.find()) {
Float progress = Long.parseLong(matcher.group(1))/(length*1000000);
job.setProgress(progress);
}
}
if (process.waitFor() != 0) {
job.setStatus(JobStatus.FAILED);
throw new FFMPEGException("FFMPEG process failed");
}
job.setStatus(JobStatus.FINISHED);
logger.info("FFMPEG finished");
}
}

View File

@@ -0,0 +1,136 @@
package com.ddf.vodsystem.services;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import org.slf4j.Logger;
@Service
public class DirectoryService {
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(DirectoryService.class);
@Value("${storage.outputs}")
private String outputDir;
@Value("${storage.temp.inputs}")
private String tempInputsDir;
@Value("${storage.temp.outputs}")
private String tempOutputsDir;
private static final long TEMP_DIR_TIMELIMIT = 3 * 60 * 60 * (long) 1000; // 3 hours
private static final long TEMP_DIR_CLEANUP_RATE = 30 * 60 * (long) 1000; // 30 minutes
public File getTempInputFile(String id, String extension) {
String dir = tempInputsDir + File.separator + id + (extension.isEmpty() ? "" : "." + extension);
return new File(dir);
}
public File getTempOutputFile(String id, String extension) {
String dir = tempOutputsDir + File.separator + id + (extension.isEmpty() ? "" : "." + extension);
return new File(dir);
}
public File getOutputFile(String id, String extension) {
if (id == null || id.length() < 2) {
throw new IllegalArgumentException("ID must be at least 2 characters long");
}
// Create subdirectories from first 2 characters of the ID
String shard1 = id.substring(0, 2);
String shard2 = id.substring(2);
String dir = outputDir +
File.separator +
shard1 +
File.separator +
shard2 +
(extension.isEmpty() ? "" : "." + extension);
return new File(dir);
}
public void saveAtDir(File file, MultipartFile multipartFile) {
try {
createDirectory(file.getAbsolutePath());
Path filePath = Paths.get(file.getAbsolutePath());
Files.copy(multipartFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
logger.error(e.getMessage());
}
}
public void copyFile(File source, File target) {
Path sourcePath = Paths.get(source.getAbsolutePath());
Path destPath = Paths.get(target.getAbsolutePath());
try {
Files.createDirectories(destPath.getParent());
Files.copy(sourcePath, destPath, StandardCopyOption.REPLACE_EXISTING);
logger.info("Copied file from {} to {}", sourcePath, destPath);
} catch (IOException e) {
logger.error(e.getMessage());
}
}
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 {
File file = new File(dir);
File[] files = file.listFiles();
if (files == null) {
logger.warn("No files found in directory: {}", dir);
return;
}
for (File f : files){
if (f.isFile() && f.lastModified() < (System.currentTimeMillis() - TEMP_DIR_TIMELIMIT)) {
Files.delete(f.toPath());
}
}
}
@PostConstruct
public void createDirectoriesIfNotExist() throws IOException {
createDirectory(tempInputsDir);
createDirectory(tempOutputsDir);
createDirectory(outputDir);
}
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)
public void cleanTempDirectories() throws IOException {
cleanUpDirectory(tempInputsDir);
cleanUpDirectory(tempOutputsDir);
}
}

View File

@@ -1,9 +1,11 @@
package com.ddf.vodsystem.services; package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.entities.JobStatus; import com.ddf.vodsystem.entities.JobStatus;
import com.ddf.vodsystem.exceptions.JobNotFinished; import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound; import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.entities.Job; import com.ddf.vodsystem.entities.Job;
import com.ddf.vodsystem.repositories.ClipRepository;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource; import org.springframework.core.io.Resource;
@@ -15,10 +17,12 @@ import java.io.File;
public class DownloadService { public class DownloadService {
private final JobService jobService; private final JobService jobService;
private final ClipRepository clipRepository;
@Autowired @Autowired
public DownloadService(JobService jobService) { public DownloadService(JobService jobService, ClipRepository clipRepository) {
this.jobService = jobService; this.jobService = jobService;
this.clipRepository = clipRepository;
} }
public Resource downloadInput(String uuid) { public Resource downloadInput(String uuid) {
@@ -46,4 +50,19 @@ public class DownloadService {
File file = job.getOutputFile(); File file = job.getOutputFile();
return new FileSystemResource(file); return new FileSystemResource(file);
} }
public Resource downloadClip(Clip 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);
}
} }

View File

@@ -1,13 +1,12 @@
package com.ddf.vodsystem.services; package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.VideoMetadata; import com.ddf.vodsystem.entities.*;
import com.ddf.vodsystem.entities.Job;
import com.ddf.vodsystem.entities.JobStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
public class EditService { public class EditService {
private final JobService jobService; private final JobService jobService;
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(EditService.class);
public EditService(JobService jobService) { public EditService(JobService jobService) {
this.jobService = jobService; this.jobService = jobService;
@@ -30,7 +29,7 @@ public class EditService {
return 1f; return 1f;
} }
return job.getProgress(); return job.getProgress().get();
} }
private void validateClipConfig(VideoMetadata videoMetadata) { private void validateClipConfig(VideoMetadata videoMetadata) {
@@ -69,4 +68,5 @@ public class EditService {
throw new IllegalArgumentException("FPS cannot be less than 1"); throw new IllegalArgumentException("FPS cannot be less than 1");
} }
} }
} }

View File

@@ -0,0 +1,148 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.VideoMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicReference;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
public class FfmpegService {
private static final Logger logger = LoggerFactory.getLogger(FfmpegService.class);
private static final float AUDIO_RATIO = 0.15f;
private static final float MAX_AUDIO_BITRATE = 128f;
private static final float BITRATE_MULTIPLIER = 0.9f;
private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)");
public void runWithProgress(File inputFile, File outputFile, VideoMetadata videoMetadata, AtomicReference<Float> progress) throws IOException, InterruptedException {
logger.info("Starting FFMPEG process");
List<String> command = buildCommand(inputFile, outputFile, videoMetadata);
String strCommand = String.join(" ", command);
logger.info("FFMPEG command: {}", strCommand);
ProcessBuilder processBuilder = new ProcessBuilder(command);
processBuilder.redirectErrorStream(true);
Process process = processBuilder.start();
logger.info("FFMPEG process started with PID: {}", process.pid());
updateJobProgress(process, progress, videoMetadata.getEndPoint() - videoMetadata.getStartPoint());
process.waitFor();
logger.info("FFMPEG process completed successfully");
}
public void run(File inputFile, File outputFile, VideoMetadata videoMetadata) throws IOException, InterruptedException {
runWithProgress(inputFile, outputFile, videoMetadata, new AtomicReference<>(0f));
}
private void updateJobProgress(Process process, AtomicReference<Float> progress, Float length) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
String line;
while ((line = reader.readLine()) != null) {
logger.debug(line);
Matcher matcher = timePattern.matcher(line);
if (matcher.find()) {
Float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f;
progress.set(timeInMs/length);
}
}
}
private List<String> buildFilters(Float fps, Integer width, Integer height) {
List<String> command = new ArrayList<>();
command.add("-vf");
List<String> filters = new ArrayList<>();
if (fps != null) {
logger.info("Frame rate set to {}", fps);
filters.add("fps=" + fps);
}
if (!(width == null && height == null)) {
logger.info("Scaling video to width: {}, height: {}", width, height);
String w = (width != null) ? width.toString() : "-1";
String h = (height != null) ? height.toString() : "-1";
filters.add("scale=" + w + ":" + h);
}
logger.info("Adding video filters");
command.add(String.join(",", filters));
return command;
}
private List<String> buildBitrate(Float length, Float fileSize) {
List<String> command = new ArrayList<>();
float bitrate = ((fileSize * 8) / length) * BITRATE_MULTIPLIER;
float audioBitrate = bitrate * AUDIO_RATIO;
float videoBitrate;
if (audioBitrate > MAX_AUDIO_BITRATE) {
audioBitrate = MAX_AUDIO_BITRATE;
videoBitrate = bitrate - MAX_AUDIO_BITRATE;
} else {
videoBitrate = bitrate * (1 - AUDIO_RATIO);
}
command.add("-b:v");
command.add(videoBitrate + "k");
command.add("-b:a");
command.add(audioBitrate + "k");
return command;
}
private List<String> buildInputs(File inputFile, Float startPoint, Float length) {
List<String> command = new ArrayList<>();
command.add("-ss");
command.add(startPoint.toString());
command.add("-i");
command.add(inputFile.getAbsolutePath());
command.add("-t");
command.add(Float.toString(length));
return command;
}
private List<String> buildCommand(File inputFile, File outputFile, VideoMetadata videoMetadata) {
List<String> command = new ArrayList<>();
command.add("ffmpeg");
command.add("-progress");
command.add("pipe:1");
command.add("-y");
Float length = videoMetadata.getEndPoint() - videoMetadata.getStartPoint();
command.addAll(buildInputs(inputFile, length, length));
if (videoMetadata.getFps() != null || videoMetadata.getWidth() != null || videoMetadata.getHeight() != null) {
command.addAll(buildFilters(videoMetadata.getFps(), videoMetadata.getWidth(), videoMetadata.getHeight()));
}
if (videoMetadata.getFileSize() != null) {
command.addAll(buildBitrate(length, videoMetadata.getFileSize()));
}
// Output file
command.add(outputFile.getAbsolutePath());
return command;
}
}

View File

@@ -7,6 +7,8 @@ import java.util.concurrent.LinkedBlockingQueue;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import com.ddf.vodsystem.entities.Job; import com.ddf.vodsystem.entities.Job;
@@ -24,14 +26,14 @@ public class JobService {
private static final Logger logger = LoggerFactory.getLogger(JobService.class); private static final Logger logger = LoggerFactory.getLogger(JobService.class);
private final ConcurrentHashMap<String, Job> jobs = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, Job> jobs = new ConcurrentHashMap<>();
private final BlockingQueue<Job> jobQueue = new LinkedBlockingQueue<>(); private final BlockingQueue<Job> jobQueue = new LinkedBlockingQueue<>();
private final CompressionService compressionService; private final ClipService clipService;
/** /**
* Constructs a JobService with the given CompressionService. * Constructs a JobService with the given CompressionService.
* @param compressionService the compression service to use for processing jobs * @param clipService the compression service to use for processing jobs
*/ */
public JobService(CompressionService compressionService) { public JobService(ClipService clipService) {
this.compressionService = compressionService; this.clipService = clipService;
} }
/** /**
@@ -65,7 +67,9 @@ public class JobService {
*/ */
public void jobReady(String uuid) { public void jobReady(String uuid) {
Job job = getJob(uuid); Job job = getJob(uuid);
job.setProgress(0f);
SecurityContext context = SecurityContextHolder.getContext();
job.setSecurityContext(context);
logger.info("Job ready: {}", job.getUuid()); logger.info("Job ready: {}", job.getUuid());
job.setStatus(JobStatus.PENDING); job.setStatus(JobStatus.PENDING);
@@ -77,11 +81,21 @@ public class JobService {
* @param job the job to process * @param job the job to process
*/ */
private void processJob(Job job) { private void processJob(Job job) {
SecurityContext previousContext = SecurityContextHolder.getContext(); // optional, for restoring later
try { try {
compressionService.run(job); if (job.getSecurityContext() != null) {
SecurityContextHolder.setContext(job.getSecurityContext());
}
clipService.run(job);
} catch (IOException | InterruptedException e) { } catch (IOException | InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
logger.error("Error while running job {}", job.getUuid(), e); logger.error("Error while running job {}", job.getUuid(), e);
} finally {
// 🔄 Restore previous context to avoid leaking across jobs
SecurityContextHolder.setContext(previousContext);
} }
} }
@@ -98,6 +112,7 @@ public class JobService {
Job job = jobQueue.take(); // Blocks until a job is available Job job = jobQueue.take(); // Blocks until a job is available
logger.info("Starting job {}", job.getUuid()); logger.info("Starting job {}", job.getUuid());
job.setStatus(JobStatus.RUNNING);
processJob(job); processJob(job);
} catch (InterruptedException e) { } catch (InterruptedException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();

View File

@@ -1,6 +1,5 @@
package com.ddf.vodsystem.services; package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Job;
import com.ddf.vodsystem.entities.VideoMetadata; import com.ddf.vodsystem.entities.VideoMetadata;
import com.ddf.vodsystem.exceptions.FFMPEGException; import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@@ -16,13 +15,7 @@ import java.io.InputStreamReader;
@Service @Service
public class MetadataService { public class MetadataService {
private static Logger logger = LoggerFactory.getLogger(MetadataService.class); private static final Logger logger = LoggerFactory.getLogger(MetadataService.class);
private final JobService jobService;
public MetadataService(JobService jobService) {
this.jobService = jobService;
}
public VideoMetadata getVideoMetadata(File file) { public VideoMetadata getVideoMetadata(File file) {
logger.info("Getting metadata for file {}", file.getAbsolutePath()); logger.info("Getting metadata for file {}", file.getAbsolutePath());
@@ -50,14 +43,34 @@ public class MetadataService {
} }
} }
public VideoMetadata getInputFileMetadata(String uuid) { public Float getFileSize(File file) {
Job job = jobService.getJob(uuid); logger.info("Getting file size for {}", file.getAbsolutePath());
return getVideoMetadata(job.getInputFile()); VideoMetadata metadata = getVideoMetadata(file);
if (metadata.getFileSize() == null) {
throw new FFMPEGException("File size not found");
}
return metadata.getFileSize();
} }
public VideoMetadata getOutputFileMetadata(String uuid) { public Float getVideoDuration(File file) {
Job job = jobService.getJob(uuid); logger.info("Getting video duration for {}", file.getAbsolutePath());
return getVideoMetadata(job.getOutputFile()); VideoMetadata metadata = getVideoMetadata(file);
if (metadata.getEndPoint() == null) {
throw new FFMPEGException("Video duration not found");
}
return metadata.getEndPoint();
}
public void normalizeVideoMetadata(VideoMetadata inputFileMetadata, VideoMetadata outputFileMetadata) {
if (outputFileMetadata.getStartPoint() == null) {
outputFileMetadata.setStartPoint(0f);
}
if (outputFileMetadata.getEndPoint() == null) {
outputFileMetadata.setEndPoint(inputFileMetadata.getEndPoint());
}
} }
private JsonNode readStandardOutput(Process process) throws IOException{ private JsonNode readStandardOutput(Process process) throws IOException{

View File

@@ -2,19 +2,12 @@ package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Job; import com.ddf.vodsystem.entities.Job;
import com.ddf.vodsystem.entities.VideoMetadata; import com.ddf.vodsystem.entities.VideoMetadata;
import jakarta.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import java.io.File; import java.io.File;
import java.io.IOException;
import java.nio.ByteBuffer; import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardCopyOption;
import java.util.Base64; import java.util.Base64;
import java.util.UUID; import java.util.UUID;
@@ -25,35 +18,30 @@ import org.slf4j.LoggerFactory;
public class UploadService { public class UploadService {
private static final Logger logger = LoggerFactory.getLogger(UploadService.class); private static final Logger logger = LoggerFactory.getLogger(UploadService.class);
@Value("${temp.vod.storage}")
private String inputDir;
@Value("${temp.vod.output}")
private String outputDir;
private final JobService jobService; private final JobService jobService;
private final MetadataService metadataService; private final MetadataService metadataService;
private final DirectoryService directoryService;
@Autowired @Autowired
public UploadService(JobService jobService, MetadataService metadataService) { public UploadService(JobService jobService,
MetadataService metadataService,
DirectoryService directoryService) {
this.jobService = jobService; this.jobService = jobService;
this.metadataService = metadataService; this.metadataService = metadataService;
this.directoryService = directoryService;
} }
public String upload(MultipartFile file) { public String upload(MultipartFile file) {
// generate uuid, filename // generate uuid, filename
String uuid = generateShortUUID(); String uuid = generateShortUUID();
String extension = getFileExtension(file.getOriginalFilename()); String extension = directoryService.getFileExtension(file.getOriginalFilename());
String filename = uuid + (extension.isEmpty() ? "" : "." + extension);
Path inputPath = Paths.get(inputDir, filename); File inputFile = directoryService.getTempInputFile(uuid, extension);
File inputFile = inputPath.toFile(); File outputFile = directoryService.getTempOutputFile(uuid, extension);
directoryService.saveAtDir(inputFile, file);
Path outputPath = Paths.get(outputDir, filename);
File outputFile = outputPath.toFile();
moveToFile(file, inputFile);
// add job // add job
logger.info("Uploaded file and creating job with UUID: {}", uuid);
VideoMetadata videoMetadata = metadataService.getVideoMetadata(inputFile); VideoMetadata videoMetadata = metadataService.getVideoMetadata(inputFile);
Job job = new Job(uuid, inputFile, outputFile, videoMetadata); Job job = new Job(uuid, inputFile, outputFile, videoMetadata);
jobService.add(job); jobService.add(job);
@@ -61,15 +49,6 @@ public class UploadService {
return uuid; return uuid;
} }
private void moveToFile(MultipartFile inputFile, File outputFile) {
try {
Path filePath = Paths.get(outputFile.getAbsolutePath());
Files.copy(inputFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
logger.error(e.getMessage());
}
}
private static String generateShortUUID() { private static String generateShortUUID() {
UUID uuid = UUID.randomUUID(); UUID uuid = UUID.randomUUID();
ByteBuffer bb = ByteBuffer.wrap(new byte[16]); ByteBuffer bb = ByteBuffer.wrap(new byte[16]);
@@ -78,39 +57,4 @@ public class UploadService {
return Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array()); return Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array());
} }
private static 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 createDirectories() throws IOException {
// Create INPUT_DIR if it doesn't exist
Path inputDirPath = Paths.get(inputDir);
if (Files.notExists(inputDirPath)) {
Files.createDirectories(inputDirPath);
logger.info("Created directory: {}", inputDir);
}
// Create OUTPUT_DIR if it doesn't exist
Path outputDirPath = Paths.get(outputDir);
if (Files.notExists(outputDirPath)) {
Files.createDirectories(outputDirPath);
logger.info("Created directory: {}", outputDir);
}
}
@PostConstruct
public void init() {
try {
createDirectories();
} catch (IOException e) {
logger.error("Failed to create directories: " + e.getMessage(), e);
}
}
} }

View File

@@ -7,7 +7,7 @@ spring.jpa.hibernate.ddl-auto=update
spring.jpa.show-sql=true spring.jpa.show-sql=true
spring.sql.init.mode=always spring.sql.init.mode=always
spring.sql.init.schema-locations=classpath:db/schema.sql spring.sql.init.schema-locations=classpath:db/schema.sql
spring.sql.init.data-locations=classpath:db/data.sql #spring.sql.init.data-locations=classpath:db/data.sql
# Security # Security
spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID}

View File

@@ -4,8 +4,9 @@ spring.profiles.active=local
# 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
temp.vod.storage=videos/inputs/ storage.temp.inputs=videos/inputs/
temp.vod.output=videos/outputs/ storage.temp.outputs=videos/outputs/
storage.outputs=videos/clips/
# Logging ## Server Configuration
logging.level.org.springframework.web=DEBUG server.servlet.session.timeout=30m

View File

@@ -11,15 +11,15 @@ VALUES
( 'google-uid-009', 'detective', 'holmes@bakerstreet.uk', 'Sherlock Holmes'), ( 'google-uid-009', 'detective', 'holmes@bakerstreet.uk', 'Sherlock Holmes'),
( 'google-uid-010', 'timey', 'docbrown@delorean.net', 'Dr. Emmett Brown'); ( 'google-uid-010', 'timey', 'docbrown@delorean.net', 'Dr. Emmett Brown');
INSERT INTO clips (id, user_id, title, description, width, height, fps, duration, file_size, video_path) INSERT INTO clips (user_id, title, description, width, height, fps, duration, file_size, video_path)
VALUES VALUES
(1, 4, 'Fireworks Over Hobbiton', 'A magical display of fireworks by Gandalf.', 1920, 1080, 30, 120, 104857600, '/videos/fireworks_hobbiton.mp4'), (4, 'Fireworks Over Hobbiton', 'A magical display of fireworks by Gandalf.', 1920, 1080, 30, 120, 104857600, '/videos/fireworks_hobbiton.mp4'),
(2, 5, 'Catnap Chronicles', 'Sir Whiskers McFluff naps in 12 different positions.', 1280, 720, 24, 60, 52428800, '/videos/catnap_chronicles.mp4'), (5, 'Catnap Chronicles', 'Sir Whiskers McFluff naps in 12 different positions.', 1280, 720, 24, 60, 52428800, '/videos/catnap_chronicles.mp4'),
(3, 6, 'Bite My Shiny Metal...', 'Bender shows off his new upgrades.', 1920, 1080, 60, 45, 73400320, '/videos/bender_upgrades.mp4'), (6, 'Bite My Shiny Metal...', 'Bender shows off his new upgrades.', 1920, 1080, 60, 45, 73400320, '/videos/bender_upgrades.mp4'),
(4, 7, 'Rainbow Dash', 'Princess Sparklehoof gallops across a double rainbow.', 1920, 1080, 30, 90, 67108864, '/videos/rainbow_dash.mp4'), (7, 'Rainbow Dash', 'Princess Sparklehoof gallops across a double rainbow.', 1920, 1080, 30, 90, 67108864, '/videos/rainbow_dash.mp4'),
(5, 8, 'Pirate Karaoke Night', 'Blackbeard sings sea shanties with his crew.', 1280, 720, 25, 180, 157286400, '/videos/pirate_karaoke.mp4'), ( 8, 'Pirate Karaoke Night', 'Blackbeard sings sea shanties with his crew.', 1280, 720, 25, 180, 157286400, '/videos/pirate_karaoke.mp4'),
(6, 9, 'The Case of the Missing Sandwich', 'Sherlock Holmes investigates a lunchtime mystery.', 1920, 1080, 30, 75, 50331648, '/videos/missing_sandwich.mp4'), ( 9, 'The Case of the Missing Sandwich', 'Sherlock Holmes investigates a lunchtime mystery.', 1920, 1080, 30, 75, 50331648, '/videos/missing_sandwich.mp4'),
(7, 10, '88 Miles Per Hour', 'Doc Brown demonstrates time travel with style.', 1920, 1080, 60, 30, 41943040, '/videos/88mph.mp4'), ( 10, '88 Miles Per Hour', 'Doc Brown demonstrates time travel with style.', 1920, 1080, 60, 30, 41943040, '/videos/88mph.mp4'),
(8, 1, 'Alice in Videoland', 'Alice explores a surreal digital wonderland.', 1280, 720, 30, 150, 94371840, '/videos/alice_videoland.mp4'), ( 1, 'Alice in Videoland', 'Alice explores a surreal digital wonderland.', 1280, 720, 30, 150, 94371840, '/videos/alice_videoland.mp4'),
(9, 2, 'Bob''s Building Bonanza', 'Bob constructs a house out of cheese.', 1920, 1080, 24, 200, 209715200, '/videos/bob_cheesehouse.mp4'), ( 2, 'Bob''s Building Bonanza', 'Bob constructs a house out of cheese.', 1920, 1080, 24, 200, 209715200, '/videos/bob_cheesehouse.mp4'),
(10, 3, 'Carol''s Coding Catastrophe', 'Carol debugs a spaghetti codebase.', 1280, 720, 30, 100, 73400320, '/videos/carol_coding.mp4'); ( 3, 'Carol''s Coding Catastrophe', 'Carol debugs a spaghetti codebase.', 1280, 720, 30, 100, 73400320, '/videos/carol_coding.mp4');

View File

@@ -1,5 +1,5 @@
DROP TABLE IF EXISTS clips; -- DROP TABLE IF EXISTS clips;
DROP TABLE IF EXISTS users; -- DROP TABLE IF EXISTS users;
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY, id BIGSERIAL PRIMARY KEY,
@@ -19,9 +19,9 @@ CREATE TABLE IF NOT EXISTS clips (
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
width INTEGER NOT NULL, width INTEGER NOT NULL,
height INTEGER NOT NULL, height INTEGER NOT NULL,
fps INTEGER NOT NULL, fps FLOAT NOT NULL,
duration INTEGER NOT NULL, duration FLOAT NOT NULL,
file_size BIGINT NOT NULL, file_size FLOAT NOT NULL,
video_path VARCHAR(255) NOT NULL, video_path VARCHAR(255) NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
); );