Merge pull request #8 from ThisBirchWood/3-clip-saving
Implemented Clip Saving and page to view existing clips
This commit is contained in:
BIN
frontend/public/default_thumbnail.png
Normal file
BIN
frontend/public/default_thumbnail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -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>
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
41
frontend/src/components/video/MetadataBox.tsx
Normal file
41
frontend/src/components/video/MetadataBox.tsx
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
80
frontend/src/components/video/VideoCard.tsx
Normal file
80
frontend/src/components/video/VideoCard.tsx
Normal 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;
|
||||||
@@ -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 interval = setInterval(async() => await pollProgress(id, interval), 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
const processed = await processFile(id, setError);
|
const pollProgress = async (id: string, intervalId: number) => {
|
||||||
|
getProgress(id)
|
||||||
if (!processed) {
|
.then((progress) => {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = setInterval(async () => {
|
|
||||||
const progress = await getProgress(id);
|
|
||||||
setProgress(progress);
|
setProgress(progress);
|
||||||
|
|
||||||
if (progress >= 1) {
|
if (progress >= 1) {
|
||||||
clearInterval(interval);
|
clearInterval(intervalId);
|
||||||
setDownloadable(true);
|
setDownloadable(true);
|
||||||
console.log("Downloadable");
|
|
||||||
}
|
}
|
||||||
}, 500);
|
})
|
||||||
|
.catch((err: Error) => {
|
||||||
|
setError(`Failed to fetch progress: ${err.message}`);
|
||||||
|
clearInterval(intervalId);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
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>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
36
frontend/src/pages/MyClips.tsx
Normal file
36
frontend/src/pages/MyClips.tsx
Normal 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;
|
||||||
88
frontend/src/pages/VideoPlayer.tsx
Normal file
88
frontend/src/pages/VideoPlayer.tsx
Normal 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;
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
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,
|
||||||
@@ -16,22 +16,18 @@ const uploadFile = async (file: File, setError: Function): Promise<string> => {
|
|||||||
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;
|
return result.data.uuid;
|
||||||
} catch (error: unknown) {
|
|
||||||
throw new Error(`Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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,7 +36,6 @@ 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: {
|
||||||
@@ -49,64 +44,65 @@ const editFile = async (
|
|||||||
body: formData.toString(),
|
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") {
|
if (result.status === "error") {
|
||||||
setError(result.message);
|
throw new Error(`Failed to edit file: ${result.message}`);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error('Error editing file:', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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();
|
const result = await response.json();
|
||||||
return result.data?.progress ?? 0;
|
|
||||||
} catch (error: unknown) {
|
if (result.status === "error") {
|
||||||
console.error('Error getting progress:', error);
|
throw new Error(`Failed to fetch progress: ${result.message}`);
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -114,38 +110,79 @@ const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
return result.data;
|
|
||||||
} catch (error: unknown) {
|
|
||||||
console.error('Error fetching metadata:', error);
|
|
||||||
|
|
||||||
return {
|
if (result.status === "error") {
|
||||||
startPoint: 0,
|
throw new Error(`Failed to fetch metadata: ${result.message}`);
|
||||||
endPoint: 0,
|
|
||||||
fps: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0,
|
|
||||||
fileSize: 0,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
};
|
};
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
77
frontend/src/utils/utils.ts
Normal file
77
frontend/src/utils/utils.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.*;
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package com.ddf.vodsystem.exceptions;
|
||||||
|
|
||||||
|
public class NotAuthenticated extends RuntimeException {
|
||||||
|
public NotAuthenticated(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/main/java/com/ddf/vodsystem/services/ClipService.java
Normal file
107
src/main/java/com/ddf/vodsystem/services/ClipService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
136
src/main/java/com/ddf/vodsystem/services/DirectoryService.java
Normal file
136
src/main/java/com/ddf/vodsystem/services/DirectoryService.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
148
src/main/java/com/ddf/vodsystem/services/FfmpegService.java
Normal file
148
src/main/java/com/ddf/vodsystem/services/FfmpegService.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
public VideoMetadata getOutputFileMetadata(String uuid) {
|
return metadata.getFileSize();
|
||||||
Job job = jobService.getJob(uuid);
|
}
|
||||||
return getVideoMetadata(job.getOutputFile());
|
|
||||||
|
public Float getVideoDuration(File file) {
|
||||||
|
logger.info("Getting video duration for {}", file.getAbsolutePath());
|
||||||
|
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{
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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');
|
||||||
@@ -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
|
||||||
);
|
);
|
||||||
Reference in New Issue
Block a user