diff --git a/frontend/public/default_thumbnail.png b/frontend/public/default_thumbnail.png new file mode 100644 index 0000000..81eb652 Binary files /dev/null and b/frontend/public/default_thumbnail.png differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 03e1fa6..3b611f5 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,6 +5,8 @@ import ClipUpload from './pages/ClipUpload'; import ClipEdit from './pages/ClipEdit'; import Home from './pages/Home'; import {useEffect} from "react"; +import MyClips from './pages/MyClips'; +import VideoPlayer from "./pages/VideoPlayer.tsx"; function App() { @@ -19,6 +21,8 @@ function App() { } /> } /> } /> + } /> + } /> diff --git a/frontend/src/components/video/ClipConfig.tsx b/frontend/src/components/video/ConfigBox.tsx similarity index 97% rename from frontend/src/components/video/ClipConfig.tsx rename to frontend/src/components/video/ConfigBox.tsx index fe18d2c..42e5052 100644 --- a/frontend/src/components/video/ClipConfig.tsx +++ b/frontend/src/components/video/ConfigBox.tsx @@ -7,7 +7,7 @@ type prop = { className?: string; } -export default function ClipConfig({setMetadata, className}: prop) { +export default function ConfigBox({setMetadata, className}: prop) { const updateRes = (e: React.ChangeEvent) => { var vals = e.target.value.split(","); setMetadata((prevState: VideoMetadata) => ({ diff --git a/frontend/src/components/video/MetadataBox.tsx b/frontend/src/components/video/MetadataBox.tsx new file mode 100644 index 0000000..a69ba6c --- /dev/null +++ b/frontend/src/components/video/MetadataBox.tsx @@ -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 ( + + Metadata Settings + + + setMetadata((prevState: VideoMetadata) => ({ + ...prevState, + title: e.target.value + }))} + className={"border-black bg-gray-200 rounded-md w-full p-2"} + /> + + + + 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"} + /> + + + ) +} + +export default MetadataBox; \ No newline at end of file diff --git a/frontend/src/components/video/Playbar.tsx b/frontend/src/components/video/Playbar.tsx index f0ea19c..2dd68d6 100644 --- a/frontend/src/components/video/Playbar.tsx +++ b/frontend/src/components/video/Playbar.tsx @@ -2,6 +2,7 @@ import { useEffect, useState} from "react"; import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react'; import clsx from 'clsx'; import type { VideoMetadata } from "../../utils/types.ts"; +import { formatTime } from "../../utils/utils.ts"; type Props = { @@ -10,20 +11,6 @@ type Props = { 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) { const [isPlaying, setIsPlaying] = useState(false); const [volume, setVolume] = useState(100); diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx new file mode 100644 index 0000000..25c2767 --- /dev/null +++ b/frontend/src/components/video/VideoCard.tsx @@ -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 ( + + + + { + if (imgSrc !== fallbackThumbnail) { + setImgSrc(fallbackThumbnail); + } + }} + /> + + + {formatTime(duration)} + + + + + + {title == "" ? "(No Title)" : title} + + {timeAgo} + + + + + ); +} + +export default VideoCard; diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index db93065..864d486 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -3,11 +3,12 @@ import { useEffect, useRef, useState } from "react"; import Playbar from "./../components/video/Playbar"; import PlaybackSlider from "./../components/video/PlaybackSlider"; 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 {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints" import type { VideoMetadata } from "../utils/types.ts"; import Box from "../components/Box.tsx"; +import MetadataBox from "../components/video/MetadataBox.tsx"; const ClipEdit = () => { const { id } = useParams(); @@ -17,6 +18,8 @@ const ClipEdit = () => { const [playbackValue, setPlaybackValue] = useState(0); const [outputMetadata, setOutputMetadata] = useState({ // default values + title: "", + description: "", startPoint: 0, endPoint: 5, width: 1280, @@ -31,30 +34,32 @@ const ClipEdit = () => { const sendData = async() => { if (!id) return; - setDownloadable(false); + editFile(id, outputMetadata) + .then(() => { - const edited = await editFile(id, outputMetadata, setError); + processFile(id) + .catch((err: Error) => setError(`Failed to process file: ${err.message}`)); - if (!edited) { - return; - } + }) + .catch((err: Error) => setError(`Failed to edit file: ${err.message}`)); - const processed = await processFile(id, setError); + const interval = setInterval(async() => await pollProgress(id, interval), 500); + } - if (!processed) { - return; - } + const pollProgress = async (id: string, intervalId: number) => { + getProgress(id) + .then((progress) => { + setProgress(progress); - const interval = setInterval(async () => { - const progress = await getProgress(id); - setProgress(progress); - - if (progress >= 1) { - clearInterval(interval); - setDownloadable(true); - console.log("Downloadable"); - } - }, 500); + if (progress >= 1) { + clearInterval(intervalId); + setDownloadable(true); + } + }) + .catch((err: Error) => { + setError(`Failed to fetch progress: ${err.message}`); + clearInterval(intervalId); + }); } const handleDownload = async () => { @@ -98,7 +103,10 @@ const ClipEdit = () => { - + diff --git a/frontend/src/pages/ClipUpload.tsx b/frontend/src/pages/ClipUpload.tsx index 9743ed5..3f441cc 100644 --- a/frontend/src/pages/ClipUpload.tsx +++ b/frontend/src/pages/ClipUpload.tsx @@ -10,13 +10,14 @@ const clipUpload = () => { const [error, setError] = useState(null); const press = (() => { - if (file) { - uploadFile(file, setError) - .then(uuid => navigate(`/create/${uuid}`)) - .catch(e => console.error(e)); - } else { + if (!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 ( diff --git a/frontend/src/pages/MyClips.tsx b/frontend/src/pages/MyClips.tsx new file mode 100644 index 0000000..8e19ae5 --- /dev/null +++ b/frontend/src/pages/MyClips.tsx @@ -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([]); + const [error, setError] = useState(null); + + useEffect(() => { + getClips() + .then((data) => setClips(data)) + .catch((err) => setError(err)); + }, []); + + return ( + + {clips.map((clip) => ( + + ))} + + {error} + + ); +} + +export default MyClips; diff --git a/frontend/src/pages/VideoPlayer.tsx b/frontend/src/pages/VideoPlayer.tsx new file mode 100644 index 0000000..c73f45b --- /dev/null +++ b/frontend/src/pages/VideoPlayer.tsx @@ -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(undefined); + const [error, setError] = useState(null); + const [clip, setClip] = useState(null); + const [timeAgo, setTimeAgo] = useState(""); + + 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 ( + + { + setError(e.currentTarget.error?.message || "An error occurred while playing the video."); + }} + > + Your browser does not support the video tag. + + + {error && {error}} + {!videoUrl && !error && Loading video...} + + + {clip?.title ? clip?.title : "(No Title)"} + {timeAgo} + + + + ); +}; + +export default VideoPlayer; \ No newline at end of file diff --git a/frontend/src/utils/endpoints.ts b/frontend/src/utils/endpoints.ts index a5b20fe..c777edb 100644 --- a/frontend/src/utils/endpoints.ts +++ b/frontend/src/utils/endpoints.ts @@ -1,37 +1,33 @@ -import type {VideoMetadata, APIResponse, User} from "./types.ts"; +import type {VideoMetadata, APIResponse, User, Clip} from "./types.ts"; /** * Uploads a file to the backend. + * @param file - The file to upload. */ -const uploadFile = async (file: File, setError: Function): Promise => { +const uploadFile = async (file: File): Promise => { const formData = new FormData(); formData.append('file', file); - try { - const response = await fetch('/api/v1/upload', { - method: 'POST', - body: formData, - }); + const response = await fetch('/api/v1/upload', { + method: 'POST', + body: formData, + }); - const result: APIResponse = await response.json(); + const result: APIResponse = await response.json(); - if (result.status == "error") { - setError(result.message); - } - - return result.data.uuid; - } catch (error: unknown) { - throw new Error(`Failed to upload file: ${error instanceof Error ? error.message : 'Unknown error'}`); + if (result.status == "error") { + throw new Error(`Failed to upload file: ${result.message}`); } + + return result.data.uuid; }; /** * 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 ( - uuid: string, - videoMetadata: VideoMetadata, - setError: Function ): Promise => { +const editFile = async (uuid: string, videoMetadata: VideoMetadata) => { const formData = new URLSearchParams(); for (const [key, value] of Object.entries(videoMetadata)) { @@ -40,112 +36,153 @@ const editFile = async ( } } - try { - const response = await fetch(`/api/v1/edit/${uuid}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: formData.toString(), - }); + const response = await fetch(`/api/v1/edit/${uuid}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: formData.toString(), + }); - const result: APIResponse = await response.json(); - - if (result.status === "error") { - setError(result.message); - return false; - } - - return true; - } catch (error: unknown) { - console.error('Error editing file:', error); - return false; + if (!response.ok) { + throw new Error(`Failed to edit file: ${response.status}`); } + + const result: APIResponse = await response.json(); + + if (result.status === "error") { + throw new Error(`Failed to edit file: ${result.message}`); + } + }; + /** * Triggers file processing. + * @param uuid - The UUID of the video file to process. */ -const processFile = async (uuid: string, setError: Function): Promise => { - try { - const response = await fetch(`/api/v1/process/${uuid}`); +const processFile = async (uuid: string) => { + const response = await fetch(`/api/v1/process/${uuid}`); - const result: APIResponse = await response.json(); - if (result.status === "error") { - setError(result.message); - return false; - } + if (!response.ok) { + throw new Error(`Failed to process file: ${response.status}`); + } - return response.ok; - } catch (error: unknown) { - console.error('Error processing file:', error); - return false; + const result: APIResponse = await response.json(); + + if (result.status === "error") { + throw new Error("Failed to process file: " + result.message); } }; /** * Fetches the processing progress percentage. + * @param uuid - The UUID of the video file. */ const getProgress = async (uuid: string): Promise => { - try { - const response = await fetch(`/api/v1/progress/${uuid}`); + const response = await fetch(`/api/v1/progress/${uuid}`); - if (!response.ok) { - console.error('Failed to fetch progress:', response.status); - return 0; - } - - const result = await response.json(); - return result.data?.progress ?? 0; - } catch (error: unknown) { - console.error('Error getting progress:', error); - return 0; + if (!response.ok) { + throw new Error(`Failed to fetch progress: ${response.status}`); } + + const result = await response.json(); + + if (result.status === "error") { + throw new Error(`Failed to fetch progress: ${result.message}`); + } + + if (!result.data || typeof result.data.progress !== 'number') { + throw new Error('Invalid progress data received'); + } + + return result.data.progress; }; /** * Fetches original metadata from the backend. + * @param uuid - The UUID of the video file. */ const getMetadata = async (uuid: string): Promise => { - try { - const response = await fetch(`/api/v1/metadata/original/${uuid}`); + const response = await fetch(`/api/v1/metadata/original/${uuid}`); - if (!response.ok) { - throw new Error(`Failed to fetch metadata: ${response.status}`); - } - - const result = await response.json(); - return result.data; - } catch (error: unknown) { - console.error('Error fetching metadata:', error); - - return { - startPoint: 0, - endPoint: 0, - fps: 0, - width: 0, - height: 0, - fileSize: 0, - }; + if (!response.ok) { + throw new Error(`Failed to fetch metadata: ${response.status}`); } + + const result = await response.json(); + + if (result.status === "error") { + throw new Error(`Failed to fetch metadata: ${result.message}`); + } + + return result.data; }; +/** + * Fetches the current user information. Returns null if not authenticated. + */ const getUser = async (): Promise => { - 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(); - return result.data; - } catch (error: unknown) { - console.error('Error fetching user:', error); + if (!response.ok) { 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 => { + 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 => { + 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 { uploadFile, editFile, processFile, getProgress, getMetadata, - getUser + getUser, + getClips, + getClipById }; \ No newline at end of file diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index ffb6931..4036a90 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -1,4 +1,6 @@ type VideoMetadata = { + title: string, + description: string, startPoint: number, endPoint: number, fps: number, @@ -19,8 +21,22 @@ type User = { 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 { APIResponse, VideoMetadata, - User + User, + Clip } \ No newline at end of file diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts new file mode 100644 index 0000000..79dd989 --- /dev/null +++ b/frontend/src/utils/utils.ts @@ -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 +} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/VodSystemApplication.java b/src/main/java/com/ddf/vodsystem/VodSystemApplication.java index f8c3ef9..17f4d61 100644 --- a/src/main/java/com/ddf/vodsystem/VodSystemApplication.java +++ b/src/main/java/com/ddf/vodsystem/VodSystemApplication.java @@ -2,8 +2,10 @@ package com.ddf.vodsystem; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class VodSystemApplication { public static void main(String[] args) { diff --git a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java index d7d8d04..e577c86 100644 --- a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java +++ b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java @@ -29,6 +29,7 @@ public class SecurityConfig { http .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/download/clip/**").authenticated() .requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll() .requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll() .requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**").permitAll() diff --git a/src/main/java/com/ddf/vodsystem/controllers/AuthController.java b/src/main/java/com/ddf/vodsystem/controllers/AuthController.java index de455e0..1197a6e 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/AuthController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/AuthController.java @@ -1,6 +1,7 @@ package com.ddf.vodsystem.controllers; import com.ddf.vodsystem.entities.APIResponse; +import com.ddf.vodsystem.exceptions.NotAuthenticated; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -14,21 +15,17 @@ import java.util.Map; @RestController @RequestMapping("/api/v1/auth/") public class AuthController { - @GetMapping("/user") public ResponseEntity>> user(@AuthenticationPrincipal OAuth2User principal) { if (principal == null) { - return ResponseEntity.status(HttpStatus.FORBIDDEN). - body(new APIResponse<>( - "error", - "User not authenticated", - null - )); + throw new NotAuthenticated("User is not authenticated"); } - if (principal.getAttribute("email") == null + if ( + principal.getAttribute("email") == null || principal.getAttribute("name") == null - || principal.getAttribute("picture") == null) { + || principal.getAttribute("picture") == null) + { return ResponseEntity.status(HttpStatus.BAD_REQUEST). body(new APIResponse<>( "error", diff --git a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java new file mode 100644 index 0000000..a20cceb --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java @@ -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>> getClips(@AuthenticationPrincipal OAuth2User principal) { + if (principal == null) { + throw new NotAuthenticated("User is not authenticated"); + } + + List clips = clipService.getClipsByUser(); + return ResponseEntity.ok( + new APIResponse<>("success", "Clips retrieved successfully", clips) + ); + } + + @GetMapping("/{id}") + public ResponseEntity> 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) + ); + } +} diff --git a/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java b/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java index 608b048..26dce88 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java @@ -7,6 +7,8 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.MediaTypeFactory; 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.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -49,4 +51,18 @@ public class DownloadController { .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) .body(resource); } + + @GetMapping("/clip/{id}") + public ResponseEntity 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); + } } diff --git a/src/main/java/com/ddf/vodsystem/controllers/EditController.java b/src/main/java/com/ddf/vodsystem/controllers/EditController.java index 4c6c2ca..d341902 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/EditController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/EditController.java @@ -6,6 +6,7 @@ import lombok.AllArgsConstructor; import lombok.Data; import com.ddf.vodsystem.entities.APIResponse; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; diff --git a/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java b/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java index dfedadf..12234f7 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java +++ b/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java @@ -4,6 +4,7 @@ import com.ddf.vodsystem.entities.APIResponse; import com.ddf.vodsystem.exceptions.FFMPEGException; import com.ddf.vodsystem.exceptions.JobNotFinished; import com.ddf.vodsystem.exceptions.JobNotFound; +import com.ddf.vodsystem.exceptions.NotAuthenticated; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.HttpMediaTypeNotSupportedException; @@ -22,50 +23,57 @@ public class GlobalExceptionHandler { @ExceptionHandler({ MultipartException.class }) public ResponseEntity> handleMultipartException(MultipartException ex) { - logger.error("MultipartException: {}", ex.getMessage(), ex); + logger.error("MultipartException: {}", ex.getMessage()); APIResponse response = new APIResponse<>(ERROR, "Multipart request error: " + ex.getMessage(), null); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @ExceptionHandler({ MissingServletRequestPartException.class }) public ResponseEntity> handleMissingPart(MissingServletRequestPartException ex) { - logger.error("MissingServletRequestPartException: {}", ex.getMessage(), ex); + logger.error("MissingServletRequestPartException: {}", ex.getMessage()); APIResponse response = new APIResponse<>(ERROR, "Required file part is missing.", null); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @ExceptionHandler({ HttpMediaTypeNotSupportedException.class }) public ResponseEntity> handleUnsupportedMediaType(HttpMediaTypeNotSupportedException ex) { - logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage(), ex); + logger.error("HttpMediaTypeNotSupportedException: {}", ex.getMessage()); APIResponse response = new APIResponse<>(ERROR, "Unsupported media type: expected multipart/form-data.", null); return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(response); } @ExceptionHandler(IllegalArgumentException.class) public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { - logger.error("IllegalArgumentException: {}", ex.getMessage(), ex); + logger.error("IllegalArgumentException: {}", ex.getMessage()); APIResponse response = new APIResponse<>(ERROR, ex.getMessage(), null); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); } @ExceptionHandler(JobNotFound.class) public ResponseEntity> handleFileNotFound(JobNotFound ex) { - logger.error("JobNotFound: {}", ex.getMessage(), ex); + logger.error("JobNotFound: {}", ex.getMessage()); APIResponse response = new APIResponse<>(ERROR, ex.getMessage(), null); return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response); } @ExceptionHandler(JobNotFinished.class) public ResponseEntity> handleJobNotFinished(JobNotFinished ex) { - logger.error("JobNotFinished: {}", ex.getMessage(), ex); + logger.error("JobNotFinished: {}", ex.getMessage()); APIResponse response = new APIResponse<>(ERROR, ex.getMessage(), null); return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); } @ExceptionHandler(FFMPEGException.class) public ResponseEntity> handleFFMPEGException(FFMPEGException ex) { - logger.error("FFMPEGException: {}", ex.getMessage(), ex); + logger.error("FFMPEGException: {}", ex.getMessage()); APIResponse response = new APIResponse<>(ERROR, "FFMPEG Error: Please upload a valid file", null); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response); } + + @ExceptionHandler(NotAuthenticated.class) + public ResponseEntity> handleNotAuthenticated(NotAuthenticated ex) { + logger.error("NotAuthenticated: {}", ex.getMessage()); + APIResponse response = new APIResponse<>(ERROR, "User is not authenticated", null); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(response); + } } \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/entities/Clip.java b/src/main/java/com/ddf/vodsystem/entities/Clip.java index f58d6ea..0ae1c77 100644 --- a/src/main/java/com/ddf/vodsystem/entities/Clip.java +++ b/src/main/java/com/ddf/vodsystem/entities/Clip.java @@ -1,8 +1,9 @@ package com.ddf.vodsystem.entities; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import lombok.Data; -import lombok.ToString; + import java.time.LocalDateTime; @@ -16,7 +17,7 @@ public class Clip { private Long id; @ManyToOne(fetch = FetchType.LAZY) - @ToString.Exclude + @JsonIgnore @JoinColumn(name = "user_id", nullable = false) private User user; @@ -36,13 +37,13 @@ public class Clip { private Integer height; @Column(name = "fps", nullable = false) - private Integer fps; + private Float fps; @Column(name = "duration", nullable = false) - private Integer duration; + private Float duration; @Column(name = "file_size", nullable = false) - private Long fileSize; + private Float fileSize; @Column(name = "video_path", nullable = false, length = 255) private String videoPath; diff --git a/src/main/java/com/ddf/vodsystem/entities/Job.java b/src/main/java/com/ddf/vodsystem/entities/Job.java index 3038af4..5e59c31 100644 --- a/src/main/java/com/ddf/vodsystem/entities/Job.java +++ b/src/main/java/com/ddf/vodsystem/entities/Job.java @@ -1,6 +1,9 @@ package com.ddf.vodsystem.entities; import java.io.File; +import java.util.concurrent.atomic.AtomicReference; + +import org.springframework.security.core.context.SecurityContext; import lombok.Data; @@ -14,9 +17,12 @@ public class Job { private VideoMetadata inputVideoMetadata; private VideoMetadata outputVideoMetadata = new VideoMetadata(); + // security + private SecurityContext securityContext; + // job status private JobStatus status = JobStatus.NOT_READY; - private Float progress = 0.0f; + private AtomicReference progress = new AtomicReference<>(0f); public Job(String uuid, File inputFile, File outputFile, VideoMetadata inputVideoMetadata) { this.uuid = uuid; diff --git a/src/main/java/com/ddf/vodsystem/entities/VideoMetadata.java b/src/main/java/com/ddf/vodsystem/entities/VideoMetadata.java index 144bf99..c3e5621 100644 --- a/src/main/java/com/ddf/vodsystem/entities/VideoMetadata.java +++ b/src/main/java/com/ddf/vodsystem/entities/VideoMetadata.java @@ -4,6 +4,8 @@ import lombok.Data; @Data public class VideoMetadata { + private String title; + private String description; private Float startPoint; private Float endPoint; private Float fps; diff --git a/src/main/java/com/ddf/vodsystem/exceptions/NotAuthenticated.java b/src/main/java/com/ddf/vodsystem/exceptions/NotAuthenticated.java new file mode 100644 index 0000000..2790650 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/exceptions/NotAuthenticated.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.exceptions; + +public class NotAuthenticated extends RuntimeException { + public NotAuthenticated(String message) { + super(message); + } +} diff --git a/src/main/java/com/ddf/vodsystem/repositories/ClipRepository.java b/src/main/java/com/ddf/vodsystem/repositories/ClipRepository.java index 5c804ff..fd1bc8e 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/ClipRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/ClipRepository.java @@ -1,9 +1,17 @@ package com.ddf.vodsystem.repositories; 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.Query; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface ClipRepository extends JpaRepository { + @Query("SELECT c FROM Clip c WHERE c.user = ?1") + List findByUser(User user); } + + diff --git a/src/main/java/com/ddf/vodsystem/security/CustomOAuth2User.java b/src/main/java/com/ddf/vodsystem/security/CustomOAuth2User.java new file mode 100644 index 0000000..0c204c8 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/security/CustomOAuth2User.java @@ -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 getAttributes() { + return oauth2User.getAttributes(); + } + + @Override + public Collection extends GrantedAuthority> getAuthorities() { + return oauth2User.getAuthorities(); + } + + @Override + public String getName() { + return oauth2User.getName(); + } +} diff --git a/src/main/java/com/ddf/vodsystem/security/CustomOAuth2UserService.java b/src/main/java/com/ddf/vodsystem/security/CustomOAuth2UserService.java index 2cf1212..a611b0a 100644 --- a/src/main/java/com/ddf/vodsystem/security/CustomOAuth2UserService.java +++ b/src/main/java/com/ddf/vodsystem/security/CustomOAuth2UserService.java @@ -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.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import java.time.LocalDateTime; -import java.util.Optional; @Service -public class CustomOAuth2UserService extends DefaultOAuth2UserService { +public class CustomOAuth2UserService implements OAuth2UserService { private final UserRepository userRepository; @@ -22,26 +22,26 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { } @Override - public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { - OAuth2User oAuth2User = super.loadUser(userRequest); + public CustomOAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + var delegate = new DefaultOAuth2UserService(); + var oAuth2User = delegate.loadUser(userRequest); String email = oAuth2User.getAttribute("email"); String name = oAuth2User.getAttribute("name"); String googleId = oAuth2User.getAttribute("sub"); - Optional 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()) { - 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; + return new CustomOAuth2User(oAuth2User, user); } } diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java new file mode 100644 index 0000000..8bae4dc --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -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 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); + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/CompressionService.java b/src/main/java/com/ddf/vodsystem/services/CompressionService.java deleted file mode 100644 index 8df71a5..0000000 --- a/src/main/java/com/ddf/vodsystem/services/CompressionService.java +++ /dev/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 command, Float fps, Integer width, Integer height) { - List 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 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 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 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"); - } - -} diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java new file mode 100644 index 0000000..3a9841f --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -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); + } +} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/services/DownloadService.java b/src/main/java/com/ddf/vodsystem/services/DownloadService.java index 8d35875..504cc44 100644 --- a/src/main/java/com/ddf/vodsystem/services/DownloadService.java +++ b/src/main/java/com/ddf/vodsystem/services/DownloadService.java @@ -1,9 +1,11 @@ package com.ddf.vodsystem.services; +import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.entities.JobStatus; import com.ddf.vodsystem.exceptions.JobNotFinished; import com.ddf.vodsystem.exceptions.JobNotFound; import com.ddf.vodsystem.entities.Job; +import com.ddf.vodsystem.repositories.ClipRepository; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -15,10 +17,12 @@ import java.io.File; public class DownloadService { private final JobService jobService; + private final ClipRepository clipRepository; @Autowired - public DownloadService(JobService jobService) { + public DownloadService(JobService jobService, ClipRepository clipRepository) { this.jobService = jobService; + this.clipRepository = clipRepository; } public Resource downloadInput(String uuid) { @@ -46,4 +50,19 @@ public class DownloadService { File file = job.getOutputFile(); 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); + } } diff --git a/src/main/java/com/ddf/vodsystem/services/EditService.java b/src/main/java/com/ddf/vodsystem/services/EditService.java index 83b4caf..e69e2f9 100644 --- a/src/main/java/com/ddf/vodsystem/services/EditService.java +++ b/src/main/java/com/ddf/vodsystem/services/EditService.java @@ -1,13 +1,12 @@ package com.ddf.vodsystem.services; -import com.ddf.vodsystem.entities.VideoMetadata; -import com.ddf.vodsystem.entities.Job; -import com.ddf.vodsystem.entities.JobStatus; +import com.ddf.vodsystem.entities.*; import org.springframework.stereotype.Service; @Service public class EditService { private final JobService jobService; + private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(EditService.class); public EditService(JobService jobService) { this.jobService = jobService; @@ -30,7 +29,7 @@ public class EditService { return 1f; } - return job.getProgress(); + return job.getProgress().get(); } private void validateClipConfig(VideoMetadata videoMetadata) { @@ -69,4 +68,5 @@ public class EditService { throw new IllegalArgumentException("FPS cannot be less than 1"); } } + } diff --git a/src/main/java/com/ddf/vodsystem/services/FfmpegService.java b/src/main/java/com/ddf/vodsystem/services/FfmpegService.java new file mode 100644 index 0000000..741ca6e --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/FfmpegService.java @@ -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 progress) throws IOException, InterruptedException { + logger.info("Starting FFMPEG process"); + + List 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 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 buildFilters(Float fps, Integer width, Integer height) { + List command = new ArrayList<>(); + command.add("-vf"); + + List 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 buildBitrate(Float length, Float fileSize) { + List 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 buildInputs(File inputFile, Float startPoint, Float length) { + List 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 buildCommand(File inputFile, File outputFile, VideoMetadata videoMetadata) { + List 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; + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/JobService.java b/src/main/java/com/ddf/vodsystem/services/JobService.java index cb0aa46..242a736 100644 --- a/src/main/java/com/ddf/vodsystem/services/JobService.java +++ b/src/main/java/com/ddf/vodsystem/services/JobService.java @@ -7,6 +7,8 @@ import java.util.concurrent.LinkedBlockingQueue; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import com.ddf.vodsystem.entities.Job; @@ -24,14 +26,14 @@ public class JobService { private static final Logger logger = LoggerFactory.getLogger(JobService.class); private final ConcurrentHashMap jobs = new ConcurrentHashMap<>(); private final BlockingQueue jobQueue = new LinkedBlockingQueue<>(); - private final CompressionService compressionService; + private final ClipService clipService; /** * 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) { - this.compressionService = compressionService; + public JobService(ClipService clipService) { + this.clipService = clipService; } /** @@ -65,7 +67,9 @@ public class JobService { */ public void jobReady(String uuid) { Job job = getJob(uuid); - job.setProgress(0f); + + SecurityContext context = SecurityContextHolder.getContext(); + job.setSecurityContext(context); logger.info("Job ready: {}", job.getUuid()); job.setStatus(JobStatus.PENDING); @@ -77,11 +81,21 @@ public class JobService { * @param job the job to process */ private void processJob(Job job) { + SecurityContext previousContext = SecurityContextHolder.getContext(); // optional, for restoring later try { - compressionService.run(job); + if (job.getSecurityContext() != null) { + SecurityContextHolder.setContext(job.getSecurityContext()); + } + + clipService.run(job); + } catch (IOException | InterruptedException e) { Thread.currentThread().interrupt(); 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 logger.info("Starting job {}", job.getUuid()); + job.setStatus(JobStatus.RUNNING); processJob(job); } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/src/main/java/com/ddf/vodsystem/services/MetadataService.java b/src/main/java/com/ddf/vodsystem/services/MetadataService.java index b89c159..080dd16 100644 --- a/src/main/java/com/ddf/vodsystem/services/MetadataService.java +++ b/src/main/java/com/ddf/vodsystem/services/MetadataService.java @@ -1,6 +1,5 @@ package com.ddf.vodsystem.services; -import com.ddf.vodsystem.entities.Job; import com.ddf.vodsystem.entities.VideoMetadata; import com.ddf.vodsystem.exceptions.FFMPEGException; import com.fasterxml.jackson.databind.JsonNode; @@ -16,13 +15,7 @@ import java.io.InputStreamReader; @Service public class MetadataService { - private static Logger logger = LoggerFactory.getLogger(MetadataService.class); - - private final JobService jobService; - - public MetadataService(JobService jobService) { - this.jobService = jobService; - } + private static final Logger logger = LoggerFactory.getLogger(MetadataService.class); public VideoMetadata getVideoMetadata(File file) { logger.info("Getting metadata for file {}", file.getAbsolutePath()); @@ -50,14 +43,34 @@ public class MetadataService { } } - public VideoMetadata getInputFileMetadata(String uuid) { - Job job = jobService.getJob(uuid); - return getVideoMetadata(job.getInputFile()); + public Float getFileSize(File file) { + logger.info("Getting file size for {}", file.getAbsolutePath()); + VideoMetadata metadata = getVideoMetadata(file); + + if (metadata.getFileSize() == null) { + throw new FFMPEGException("File size not found"); + } + + return metadata.getFileSize(); } - public VideoMetadata getOutputFileMetadata(String uuid) { - 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{ diff --git a/src/main/java/com/ddf/vodsystem/services/UploadService.java b/src/main/java/com/ddf/vodsystem/services/UploadService.java index 18c81e7..9afbc75 100644 --- a/src/main/java/com/ddf/vodsystem/services/UploadService.java +++ b/src/main/java/com/ddf/vodsystem/services/UploadService.java @@ -2,19 +2,12 @@ package com.ddf.vodsystem.services; import com.ddf.vodsystem.entities.Job; import com.ddf.vodsystem.entities.VideoMetadata; -import jakarta.annotation.PostConstruct; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.File; -import java.io.IOException; import java.nio.ByteBuffer; -import java.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.UUID; @@ -25,35 +18,30 @@ import org.slf4j.LoggerFactory; public class UploadService { 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 MetadataService metadataService; + private final DirectoryService directoryService; @Autowired - public UploadService(JobService jobService, MetadataService metadataService) { + public UploadService(JobService jobService, + MetadataService metadataService, + DirectoryService directoryService) { this.jobService = jobService; this.metadataService = metadataService; + this.directoryService = directoryService; } public String upload(MultipartFile file) { // generate uuid, filename String uuid = generateShortUUID(); - String extension = getFileExtension(file.getOriginalFilename()); - String filename = uuid + (extension.isEmpty() ? "" : "." + extension); + String extension = directoryService.getFileExtension(file.getOriginalFilename()); - Path inputPath = Paths.get(inputDir, filename); - File inputFile = inputPath.toFile(); - - Path outputPath = Paths.get(outputDir, filename); - File outputFile = outputPath.toFile(); - - moveToFile(file, inputFile); + File inputFile = directoryService.getTempInputFile(uuid, extension); + File outputFile = directoryService.getTempOutputFile(uuid, extension); + directoryService.saveAtDir(inputFile, file); // add job + logger.info("Uploaded file and creating job with UUID: {}", uuid); VideoMetadata videoMetadata = metadataService.getVideoMetadata(inputFile); Job job = new Job(uuid, inputFile, outputFile, videoMetadata); jobService.add(job); @@ -61,15 +49,6 @@ public class UploadService { 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() { UUID uuid = UUID.randomUUID(); ByteBuffer bb = ByteBuffer.wrap(new byte[16]); @@ -78,39 +57,4 @@ public class UploadService { 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); - } - } } diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 99edc4e..d3d61e3 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -7,7 +7,7 @@ spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true spring.sql.init.mode=always 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 spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 773728b..51bb1cf 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -4,8 +4,9 @@ spring.profiles.active=local # VODs spring.servlet.multipart.max-file-size=2GB spring.servlet.multipart.max-request-size=2GB -temp.vod.storage=videos/inputs/ -temp.vod.output=videos/outputs/ +storage.temp.inputs=videos/inputs/ +storage.temp.outputs=videos/outputs/ +storage.outputs=videos/clips/ -# Logging -logging.level.org.springframework.web=DEBUG \ No newline at end of file +## Server Configuration +server.servlet.session.timeout=30m diff --git a/src/main/resources/db/data.sql b/src/main/resources/db/data.sql index ddb0c87..8b831b3 100644 --- a/src/main/resources/db/data.sql +++ b/src/main/resources/db/data.sql @@ -11,15 +11,15 @@ VALUES ( 'google-uid-009', 'detective', 'holmes@bakerstreet.uk', 'Sherlock Holmes'), ( '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 - (1, 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'), - (3, 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'), - (5, 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'), - (7, 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'), - (9, 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'); \ No newline at end of file + (4, 'Fireworks Over Hobbiton', 'A magical display of fireworks by Gandalf.', 1920, 1080, 30, 120, 104857600, '/videos/fireworks_hobbiton.mp4'), + (5, 'Catnap Chronicles', 'Sir Whiskers McFluff naps in 12 different positions.', 1280, 720, 24, 60, 52428800, '/videos/catnap_chronicles.mp4'), + (6, 'Bite My Shiny Metal...', 'Bender shows off his new upgrades.', 1920, 1080, 60, 45, 73400320, '/videos/bender_upgrades.mp4'), + (7, 'Rainbow Dash', 'Princess Sparklehoof gallops across a double rainbow.', 1920, 1080, 30, 90, 67108864, '/videos/rainbow_dash.mp4'), + ( 8, 'Pirate Karaoke Night', 'Blackbeard sings sea shanties with his crew.', 1280, 720, 25, 180, 157286400, '/videos/pirate_karaoke.mp4'), + ( 9, 'The Case of the Missing Sandwich', 'Sherlock Holmes investigates a lunchtime mystery.', 1920, 1080, 30, 75, 50331648, '/videos/missing_sandwich.mp4'), + ( 10, '88 Miles Per Hour', 'Doc Brown demonstrates time travel with style.', 1920, 1080, 60, 30, 41943040, '/videos/88mph.mp4'), + ( 1, 'Alice in Videoland', 'Alice explores a surreal digital wonderland.', 1280, 720, 30, 150, 94371840, '/videos/alice_videoland.mp4'), + ( 2, 'Bob''s Building Bonanza', 'Bob constructs a house out of cheese.', 1920, 1080, 24, 200, 209715200, '/videos/bob_cheesehouse.mp4'), + ( 3, 'Carol''s Coding Catastrophe', 'Carol debugs a spaghetti codebase.', 1280, 720, 30, 100, 73400320, '/videos/carol_coding.mp4'); \ No newline at end of file diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index bb261e5..60dc57a 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -1,5 +1,5 @@ -DROP TABLE IF EXISTS clips; -DROP TABLE IF EXISTS users; +-- DROP TABLE IF EXISTS clips; +-- DROP TABLE IF EXISTS users; CREATE TABLE IF NOT EXISTS users ( id BIGSERIAL PRIMARY KEY, @@ -19,9 +19,9 @@ CREATE TABLE IF NOT EXISTS clips ( created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, width INTEGER NOT NULL, height INTEGER NOT NULL, - fps INTEGER NOT NULL, - duration INTEGER NOT NULL, - file_size BIGINT NOT NULL, + fps FLOAT NOT NULL, + duration FLOAT NOT NULL, + file_size FLOAT NOT NULL, video_path VARCHAR(255) NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); \ No newline at end of file
+ {formatTime(duration)} +
{title == "" ? "(No Title)" : title}
+ {timeAgo} +
{clip?.title ? clip?.title : "(No Title)"}
{timeAgo}