diff --git a/.gitignore b/.gitignore index e37d9bb..98511d4 100644 --- a/.gitignore +++ b/.gitignore @@ -39,7 +39,7 @@ build/ .vscode/ ### Generated Files ### -videos/ +/videos outputs/ generated diff --git a/bruno/VoD-System/Clips/Delete Clip.bru b/bruno/VoD-System/Clips/Delete Clip.bru new file mode 100644 index 0000000..e7583d6 --- /dev/null +++ b/bruno/VoD-System/Clips/Delete Clip.bru @@ -0,0 +1,16 @@ +meta { + name: Delete Clip + type: http + seq: 5 +} + +delete { + url: {{base_url}}/clips/6 + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Clips/Downloads/Get Clip Media.bru b/bruno/VoD-System/Clips/Downloads/Get Clip Media.bru new file mode 100644 index 0000000..6e5def3 --- /dev/null +++ b/bruno/VoD-System/Clips/Downloads/Get Clip Media.bru @@ -0,0 +1,16 @@ +meta { + name: Get Clip Media + type: http + seq: 1 +} + +get { + url: {{base_url}}/clips/5/media + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru b/bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru new file mode 100644 index 0000000..41f0f13 --- /dev/null +++ b/bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru @@ -0,0 +1,15 @@ +meta { + name: Get Clip Thumbnail + type: http + seq: 2 +} + +get { + url: {{base_url}}/clips/5/thumbnail + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/bruno/VoD-System/Clips/Downloads/folder.bru b/bruno/VoD-System/Clips/Downloads/folder.bru new file mode 100644 index 0000000..7b0de5c --- /dev/null +++ b/bruno/VoD-System/Clips/Downloads/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Downloads + seq: 4 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Clips/Get Clip.bru b/bruno/VoD-System/Clips/Get Clip.bru new file mode 100644 index 0000000..89a935e --- /dev/null +++ b/bruno/VoD-System/Clips/Get Clip.bru @@ -0,0 +1,16 @@ +meta { + name: Get Clip + type: http + seq: 1 +} + +get { + url: {{base_url}}/clips/5 + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Clips/Get all clips.bru b/bruno/VoD-System/Clips/Get all clips.bru index d78388d..4d33b6b 100644 --- a/bruno/VoD-System/Clips/Get all clips.bru +++ b/bruno/VoD-System/Clips/Get all clips.bru @@ -1,7 +1,7 @@ meta { name: Get all clips type: http - seq: 1 + seq: 2 } get { diff --git a/bruno/VoD-System/Clips/Update Clip.bru b/bruno/VoD-System/Clips/Update Clip.bru index a7ee47c..7aa91b8 100644 --- a/bruno/VoD-System/Clips/Update Clip.bru +++ b/bruno/VoD-System/Clips/Update Clip.bru @@ -1,7 +1,7 @@ meta { name: Update Clip type: http - seq: 2 + seq: 3 } patch { diff --git a/bruno/VoD-System/Clips/folder.bru b/bruno/VoD-System/Clips/folder.bru index b1359ee..ad6dce9 100644 --- a/bruno/VoD-System/Clips/folder.bru +++ b/bruno/VoD-System/Clips/folder.bru @@ -1,5 +1,6 @@ meta { name: Clips + seq: 1 } auth { diff --git a/bruno/VoD-System/Download/Converted Download.bru b/bruno/VoD-System/Download/Converted Download.bru deleted file mode 100644 index d68bf22..0000000 --- a/bruno/VoD-System/Download/Converted Download.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Converted Download - type: http - seq: 2 -} - -get { - url: {{base_url}}/download/output/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Download/Original Download.bru b/bruno/VoD-System/Download/Original Download.bru deleted file mode 100644 index 91d8e26..0000000 --- a/bruno/VoD-System/Download/Original Download.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Original Download - type: http - seq: 1 -} - -get { - url: {{base_url}}/download/input/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Download/folder.bru b/bruno/VoD-System/Download/folder.bru deleted file mode 100644 index be111cc..0000000 --- a/bruno/VoD-System/Download/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Download -} diff --git a/bruno/VoD-System/Edit/Edit.bru b/bruno/VoD-System/Edit/Edit.bru deleted file mode 100644 index 63a5e17..0000000 --- a/bruno/VoD-System/Edit/Edit.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: Edit - type: http - seq: 1 -} - -post { - url: {{base_url}}/edit/{{uuid}} - body: formUrlEncoded - auth: inherit -} - -body:form-urlencoded { - startPoint: 10 - duration: 40 - title: best possible title -} diff --git a/bruno/VoD-System/Edit/Process.bru b/bruno/VoD-System/Edit/Process.bru deleted file mode 100644 index a44770d..0000000 --- a/bruno/VoD-System/Edit/Process.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Process - type: http - seq: 2 -} - -get { - url: {{base_url}}/process/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Edit/Progress.bru b/bruno/VoD-System/Edit/Progress.bru deleted file mode 100644 index c54718f..0000000 --- a/bruno/VoD-System/Edit/Progress.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Progress - type: http - seq: 3 -} - -get { - url: {{base_url}}/progress/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Edit/folder.bru b/bruno/VoD-System/Edit/folder.bru deleted file mode 100644 index 8dd7263..0000000 --- a/bruno/VoD-System/Edit/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Edit -} diff --git a/bruno/VoD-System/Jobs/Download Video.bru b/bruno/VoD-System/Jobs/Download Video.bru new file mode 100644 index 0000000..2179683 --- /dev/null +++ b/bruno/VoD-System/Jobs/Download Video.bru @@ -0,0 +1,15 @@ +meta { + name: Download Video + type: http + seq: 2 +} + +get { + url: {{base_url}}/jobs/{{uuid}}/download + body: none + auth: inherit +} + +settings { + encodeUrl: true +} diff --git a/bruno/VoD-System/Jobs/Get Job.bru b/bruno/VoD-System/Jobs/Get Job.bru new file mode 100644 index 0000000..87ca912 --- /dev/null +++ b/bruno/VoD-System/Jobs/Get Job.bru @@ -0,0 +1,16 @@ +meta { + name: Get Job + type: http + seq: 1 +} + +get { + url: {{base_url}}/jobs/{{uuid}} + body: none + auth: inherit +} + +settings { + encodeUrl: true + timeout: 0 +} diff --git a/bruno/VoD-System/Jobs/folder.bru b/bruno/VoD-System/Jobs/folder.bru new file mode 100644 index 0000000..8828051 --- /dev/null +++ b/bruno/VoD-System/Jobs/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Jobs + seq: 5 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Media/Clip Stream.bru b/bruno/VoD-System/Media/Clip Stream.bru new file mode 100644 index 0000000..1010702 --- /dev/null +++ b/bruno/VoD-System/Media/Clip Stream.bru @@ -0,0 +1,21 @@ +meta { + name: Clip Stream + type: http + seq: 3 +} + +post { + url: {{base_url}}/media/clip + body: json + auth: inherit +} + +body:json { + { + "duration": 60.0 + } +} + +script:post-response { + bru.setEnvVar("uuid", res.getBody().data.uuid); +} diff --git a/bruno/VoD-System/Media/Compress.bru b/bruno/VoD-System/Media/Compress.bru new file mode 100644 index 0000000..0a1e443 --- /dev/null +++ b/bruno/VoD-System/Media/Compress.bru @@ -0,0 +1,22 @@ +meta { + name: Compress + type: http + seq: 1 +} + +post { + url: {{base_url}}/media/compress + body: multipartForm + auth: inherit +} + +body:multipart-form { + file: @file(/Users/dylan/Downloads/Is Russia at a Breaking Point_.mp4) + title: hello + startPoint: 10 + duration: 30 +} + +script:post-response { + bru.setEnvVar("uuid", res.getBody().data.uuid); +} diff --git a/bruno/VoD-System/Media/Save Stream Section.bru b/bruno/VoD-System/Media/Save Stream Section.bru new file mode 100644 index 0000000..ef727ae --- /dev/null +++ b/bruno/VoD-System/Media/Save Stream Section.bru @@ -0,0 +1,22 @@ +meta { + name: Save Stream Section + type: http + seq: 2 +} + +post { + url: {{base_url}}/media/save + body: json + auth: inherit +} + +body:json { + { + "startTime": "2026-06-21T17:27:00Z", + "endTime": "2026-06-21T17:28:00Z" + } +} + +script:post-response { + bru.setEnvVar("uuid", res.getBody().data.uuid); +} diff --git a/bruno/VoD-System/Media/folder.bru b/bruno/VoD-System/Media/folder.bru new file mode 100644 index 0000000..3c64875 --- /dev/null +++ b/bruno/VoD-System/Media/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Media + seq: 2 +} + +auth { + mode: inherit +} diff --git a/bruno/VoD-System/Metadata/Converted Metadata.bru b/bruno/VoD-System/Metadata/Converted Metadata.bru deleted file mode 100644 index 0bda004..0000000 --- a/bruno/VoD-System/Metadata/Converted Metadata.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Converted Metadata - type: http - seq: 2 -} - -get { - url: {{base_url}}/metadata/converted/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Metadata/Original Metadata.bru b/bruno/VoD-System/Metadata/Original Metadata.bru deleted file mode 100644 index 1a56e14..0000000 --- a/bruno/VoD-System/Metadata/Original Metadata.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: Original Metadata - type: http - seq: 1 -} - -get { - url: {{base_url}}/metadata/original/{{uuid}} - body: none - auth: inherit -} diff --git a/bruno/VoD-System/Metadata/folder.bru b/bruno/VoD-System/Metadata/folder.bru deleted file mode 100644 index b670ec8..0000000 --- a/bruno/VoD-System/Metadata/folder.bru +++ /dev/null @@ -1,3 +0,0 @@ -meta { - name: Metadata -} diff --git a/bruno/VoD-System/Streams/Save Clip.bru b/bruno/VoD-System/Streams/Save Clip.bru deleted file mode 100644 index e3d2338..0000000 --- a/bruno/VoD-System/Streams/Save Clip.bru +++ /dev/null @@ -1,22 +0,0 @@ -meta { - name: Save Clip - type: http - seq: 3 -} - -post { - url: {{base_url}}/stream/clip - body: json - auth: inherit -} - -body:json { - { - "duration": 60 - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/bruno/VoD-System/Streams/Save Section.bru b/bruno/VoD-System/Streams/Save Section.bru deleted file mode 100644 index 4d77571..0000000 --- a/bruno/VoD-System/Streams/Save Section.bru +++ /dev/null @@ -1,23 +0,0 @@ -meta { - name: Save Section - type: http - seq: 2 -} - -post { - url: {{base_url}}/stream/save - body: json - auth: inherit -} - -body:json { - { - "startTime": "2026-06-20T15:00:00Z", - "endTime": "2026-06-20T15:01:00Z" - } -} - -settings { - encodeUrl: true - timeout: 0 -} diff --git a/bruno/VoD-System/Streams/folder.bru b/bruno/VoD-System/Streams/folder.bru index 2aa144d..6af7cf9 100644 --- a/bruno/VoD-System/Streams/folder.bru +++ b/bruno/VoD-System/Streams/folder.bru @@ -1,12 +1,8 @@ meta { name: Streams - seq: 7 + seq: 4 } auth { - mode: bearer -} - -auth:bearer { - token: {{token}} + mode: inherit } diff --git a/bruno/VoD-System/Upload.bru b/bruno/VoD-System/Upload.bru deleted file mode 100644 index 7a6c424..0000000 --- a/bruno/VoD-System/Upload.bru +++ /dev/null @@ -1,19 +0,0 @@ -meta { - name: Upload - type: http - seq: 1 -} - -post { - url: {{base_url}}/upload - body: multipartForm - auth: inherit -} - -body:multipart-form { - file: @file(/Users/faoite/Downloads/Why Australia's Economy is Stalling.mp4) -} - -script:post-response { - bru.setEnvVar("uuid", res.body.data.uuid); -} diff --git a/bruno/VoD-System/Auth/Get Google Token.bru b/bruno/VoD-System/Users/Get Google Token.bru similarity index 100% rename from bruno/VoD-System/Auth/Get Google Token.bru rename to bruno/VoD-System/Users/Get Google Token.bru diff --git a/bruno/VoD-System/Auth/Get User.bru b/bruno/VoD-System/Users/Get User.bru similarity index 74% rename from bruno/VoD-System/Auth/Get User.bru rename to bruno/VoD-System/Users/Get User.bru index 28036c9..20c0ad5 100644 --- a/bruno/VoD-System/Auth/Get User.bru +++ b/bruno/VoD-System/Users/Get User.bru @@ -5,7 +5,7 @@ meta { } get { - url: {{base_url}}/auth/user + url: {{base_url}}/users/me body: none auth: inherit } diff --git a/bruno/VoD-System/Auth/Login.bru b/bruno/VoD-System/Users/Login.bru similarity index 87% rename from bruno/VoD-System/Auth/Login.bru rename to bruno/VoD-System/Users/Login.bru index a317e24..781ebba 100644 --- a/bruno/VoD-System/Auth/Login.bru +++ b/bruno/VoD-System/Users/Login.bru @@ -5,7 +5,7 @@ meta { } post { - url: {{base_url}}/auth/login + url: {{base_url}}/users/login body: json auth: none } diff --git a/bruno/VoD-System/Users/folder.bru b/bruno/VoD-System/Users/folder.bru new file mode 100644 index 0000000..babc4ab --- /dev/null +++ b/bruno/VoD-System/Users/folder.bru @@ -0,0 +1,8 @@ +meta { + name: Users + seq: 3 +} + +auth { + mode: inherit +} diff --git a/docker-compose.yml b/docker-compose.yml index 796f1cb..7c498dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,8 +8,6 @@ services: POSTGRES_DB: ${POSTGRES_DB} volumes: - ${POSTGRES_DATA}:/var/lib/postgresql/data - ports: - - "5432:5432" backend: build: @@ -21,8 +19,8 @@ services: env_file: - .env volumes: - - ${MEDIA_DATA}:/app/data/media - - ${STREAM_DATA}:/stream + - ${MEDIA_DATA}:/app/media + - ${STREAM_DATA}:/app/stream ports: - "8080:8080" restart: unless-stopped diff --git a/frontend/src/components/Box.tsx b/frontend/src/components/Box.tsx index 55536fa..31a3872 100644 --- a/frontend/src/components/Box.tsx +++ b/frontend/src/components/Box.tsx @@ -1,16 +1,14 @@ import clsx from "clsx"; -type props = { - children: React.ReactNode, - className?: string -} +type Props = { + children: React.ReactNode; + className?: string; +}; -const Box = ({children, className}: props) => { - return ( -
- { children } -
- ) -} +const Box = ({ children, className }: Props) => ( +
+ {children} +
+); -export default Box; \ No newline at end of file +export default Box; diff --git a/frontend/src/components/Dropdown.tsx b/frontend/src/components/Dropdown.tsx index 882d031..db384e0 100644 --- a/frontend/src/components/Dropdown.tsx +++ b/frontend/src/components/Dropdown.tsx @@ -1,54 +1,45 @@ -import React, {useEffect, useRef} from "react"; +import React, { useState, useEffect, useRef } from "react"; import clsx from "clsx"; type DropdownItemProps = { item: string; onClick: (item: string) => void; className?: string; -} +}; -const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => { - return ( -
  • onClick(item)} - > - {item} -
  • - ); -} +const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => ( +
  • onClick(item)} + > + {item} +
  • +); type DropdownProps = { label: React.ReactNode; children: React.ReactNode; className?: string; -} +}; const Dropdown = ({ label, children, className }: DropdownProps) => { - const [isOpen, setIsOpen] = React.useState(false); + const [isOpen, setIsOpen] = useState(false); const ref = useRef(null); - const toggleDropdown = () => { - setIsOpen(!isOpen); - }; - useEffect(() => { - function handleClickOutside(event: { target: any }) { - if (ref.current && !ref.current.contains(event.target)) { + const handleClickOutside = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { setIsOpen(false); } - } - - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); }, []); return ( -
    +
    - - - +
    ); -} \ No newline at end of file +} diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index c4131c5..084aafc 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -25,9 +25,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className const [confirmDelete, setConfirmDelete] = useState(false); const menuRef = useRef(null); - setTimeout(() => { - setTimeAgo(dateToTimeAgo(stringToDate(createdAt))); - }, 1000); + useEffect(() => { + const interval = setInterval(() => { + setTimeAgo(dateToTimeAgo(stringToDate(createdAt))); + }, 1000); + return () => clearInterval(interval); + }, [createdAt]); useEffect(() => { isThumbnailAvailable(id) @@ -58,7 +61,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
    Video Thumbnail diff --git a/frontend/src/pages/ClipEdit.tsx b/frontend/src/pages/ClipEdit.tsx index 64d6c4d..3ce8c99 100644 --- a/frontend/src/pages/ClipEdit.tsx +++ b/frontend/src/pages/ClipEdit.tsx @@ -1,11 +1,11 @@ -import { useParams } from 'react-router-dom'; +import { useLocation } from 'react-router-dom'; 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 ConfigBox from "../components/video/ConfigBox.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx"; -import {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints" +import { compress, getJob } from "../utils/endpoints" import type { VideoMetadata } from "../utils/types.ts"; import Box from "../components/Box.tsx"; import MetadataBox from "../components/video/MetadataBox.tsx"; @@ -13,13 +13,16 @@ import MetadataBox from "../components/video/MetadataBox.tsx"; const API_URL = import.meta.env.VITE_API_URL; const ClipEdit = () => { - const { id } = useParams(); + const location = useLocation(); + const localFile: File = location.state?.file; + const videoRef = useRef(null); - const videoUrl = API_URL + `/api/v1/download/input/${id}` + const [videoUrl, setVideoUrl] = useState(''); + const [uploadedId, setUploadedId] = useState(null); + const [metadata, setMetadata] = useState(null); const [playbackValue, setPlaybackValue] = useState(0); const [outputMetadata, setOutputMetadata] = useState({ - // default values title: "", description: "", startPoint: 0, @@ -33,41 +36,66 @@ const ClipEdit = () => { const [downloadable, setDownloadable] = useState(false); const [error, setError] = useState(null); - const sendData = async() => { - if (!id) return; + useEffect(() => { + if (!localFile) return; + const url = URL.createObjectURL(localFile); + setVideoUrl(url); + return () => URL.revokeObjectURL(url); + }, [localFile]); - editFile(id, outputMetadata) - .then(() => { + const handleVideoMetadataLoaded = () => { + const video = videoRef.current; + if (!video) return; + setMetadata({ + title: '', + description: '', + startPoint: 0, + duration: video.duration, + width: video.videoWidth, + height: video.videoHeight, + fps: 30, + fileSize: localFile?.size ?? 0, + }); + }; - processFile(id) - .catch((err: Error) => setError(`Failed to process file: ${err.message}`)); + const sendData = async () => { + if (!localFile) return; - }) - .catch((err: Error) => setError(`Failed to edit file: ${err.message}`)); + let jobId: string; + try { + jobId = await compress(localFile, outputMetadata); + setUploadedId(jobId); + } catch (err: unknown) { + setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`); + return; + } - const interval = setInterval(async() => await pollProgress(id, interval), 500); - } + const interval = setInterval(async () => await pollProgress(jobId, interval), 500); + }; - const pollProgress = async (id: string, intervalId: number) => { - getProgress(id) - .then((progress) => { - setProgress(progress.process.progress); + const pollProgress = async (jobId: string, intervalId: number) => { + getJob(jobId) + .then((job) => { + setProgress(job.progress); - if (progress.process.complete) { + if (job.state === 'FAILED') { + setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`); + clearInterval(intervalId); + } else if (job.isComplete) { clearInterval(intervalId); setDownloadable(true); - } else { - setDownloadable(false) } - }) + }) .catch((err: Error) => { setError(`Failed to fetch progress: ${err.message}`); clearInterval(intervalId); }); - } + }; const handleDownload = async () => { - const response = await fetch(API_URL + `/api/v1/download/output/${id}`); + if (!uploadedId) return; + + const response = await fetch(API_URL + `/api/v1/jobs/${uploadedId}/download`, { credentials: 'include' }); if (!response.ok) { console.error('Download failed'); @@ -79,43 +107,27 @@ const ClipEdit = () => { const a = document.createElement('a'); a.href = url; - a.download = `${id}.mp4`; + a.download = `${uploadedId}.mp4`; document.body.appendChild(a); a.click(); a.remove(); window.URL.revokeObjectURL(url); }; - useEffect(() => { - if (!id) return; - - getMetadata(id) - .then((data) => setMetadata(data ?? null)) - .catch((err) => console.error("Metadata fetch failed:", err)); - }, [id]); - return (
    - + ref={videoRef} + src={videoUrl} + className={"w-full rounded-lg shadow-sm bg-black"} + onLoadedMetadata={handleVideoMetadataLoaded} + /> - - + + - {metadata && {
    ); -} +}; -export default ClipEdit; \ No newline at end of file +export default ClipEdit; diff --git a/frontend/src/pages/ClipUpload.tsx b/frontend/src/pages/ClipUpload.tsx index c373e89..cf4cbb4 100644 --- a/frontend/src/pages/ClipUpload.tsx +++ b/frontend/src/pages/ClipUpload.tsx @@ -1,6 +1,5 @@ import {useState} from "react"; import {useNavigate} from "react-router-dom"; -import {uploadFile, convertFile, getProgress} from "../utils/endpoints" import BlueButton from "../components/buttons/BlueButton.tsx"; import Box from "../components/Box.tsx"; @@ -8,63 +7,14 @@ const ClipUpload = () => { const [file, setFile] = useState(null); const navigate = useNavigate(); const [error, setError] = useState(null); - const [progress, setProgress] = useState(0); - const isVideoFileSupported = (file: File): boolean => { - const video = document.createElement("video"); - - if (file.type && video.canPlayType(file.type) !== "") { - return true; - } - - const extension = file.name.split(".").pop()?.toLowerCase(); - const extensionToMime: Record = { - mp4: "video/mp4", - webm: "video/webm", - ogg: "video/ogg", - mov: "video/quicktime", - }; - - if (extension && extensionToMime[extension]) { - return video.canPlayType(extensionToMime[extension]) !== ""; - } - - return false; - }; - - const press = (() => { + const press = () => { if (!file) { setError("Please choose a file"); return; } - - uploadFile(file) - .then(uuid => { - if (isVideoFileSupported(file)) { - navigate(`/create/${uuid}`) - } else { - convertFile(uuid); - const interval = setInterval(async() => await pollProgress(uuid, interval), 500); - } - }) - .catch((e: Error) => setError(`Failed to upload file: ${e.message}`)); - }); - - const pollProgress = async (id: string, intervalId: number) => { - getProgress(id) - .then((progress) => { - setProgress(progress.conversion.progress); - - if (progress.conversion.complete) { - clearInterval(intervalId); - navigate(`/create/${id}`) - } - }) - .catch((err: Error) => { - setError(`Failed to fetch progress: ${err.message}`); - clearInterval(intervalId); - }); - } + navigate('/create/new', { state: { file } }); + }; return (
    @@ -84,8 +34,6 @@ const ClipUpload = () => { Upload {error &&

    {error}

    } - -
    ) diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 73e7258..c2f704d 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -3,8 +3,8 @@ import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lu const features = [ { icon: Upload, - title: "Upload & convert", - description: "Drop in a video file. Non-native formats are converted automatically before editing.", + title: "Upload & compress", + description: "Drop in a video file, set your clip range and export settings, then compress.", }, { icon: Scissors, @@ -71,7 +71,7 @@ const Home = () => { {/* Format note */}

    - Supports MP4, MOV, WebM, and OGG. Non-native formats are converted on upload. + Supports MP4, MOV, WebM, and OGG.

    ); diff --git a/frontend/src/utils/endpoints.ts b/frontend/src/utils/endpoints.ts index 590a323..d75b09b 100644 --- a/frontend/src/utils/endpoints.ts +++ b/frontend/src/utils/endpoints.ts @@ -1,4 +1,4 @@ -import type {VideoMetadata, APIResponse, User, Clip, ProgressResult } from "./types.ts"; +import type { VideoMetadata, APIResponse, User, Clip, JobResponse } from "./types.ts"; const API_URL = import.meta.env.VITE_API_URL; @@ -6,258 +6,115 @@ export class AuthError extends Error { constructor() { super("Not authenticated"); this.name = "AuthError"; } } -/** - * Login function - * @param GoogleToken - The Google token received from the frontend. - * @return A promise that resolves to a JWT - */ -const login = async (GoogleToken: string): Promise => { - const response = await fetch(API_URL + '/api/v1/auth/login', { +const login = async (googleToken: string): Promise => { + const response = await fetch(API_URL + '/api/v1/users/login', { method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ token: GoogleToken }), - credentials: 'include' + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token: googleToken }), + credentials: 'include', }); - if (!response.ok) { - throw new Error(`Login failed: ${response.status}`); - } + if (!response.ok) throw new Error(`Login failed: ${response.status}`); const result: APIResponse = await response.json(); - - if (result.status === "error") { - throw new Error(`Login failed: ${result.message}`); - } + if (result.status === 'error') throw new Error(`Login failed: ${result.message}`); return result.data.token; -} +}; -const logout = async () => { - const response = await fetch(API_URL + '/api/v1/auth/logout', { +const logout = async (): Promise => { + const response = await fetch(API_URL + '/api/v1/users/logout', { method: 'POST', - credentials: 'include' + credentials: 'include', }); - if (!response.ok) { - throw new Error(`Logout failed: ${response.status}`); - } + if (!response.ok) throw new Error(`Logout failed: ${response.status}`); const result: APIResponse = await response.json(); + if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`); +}; - if (result.status === "error") { - throw new Error(`Logout failed: ${result.message}`); - } -} - -/** - * Uploads a file to the backend. - * @param file - The file to upload. - */ -const uploadFile = async (file: File): Promise => { +const compress = async (file: File, options: VideoMetadata): Promise => { const formData = new FormData(); formData.append('file', file); + formData.append('title', options.title); + if (options.description) formData.append('description', options.description); + formData.append('startPoint', options.startPoint.toString()); + formData.append('duration', options.duration.toString()); + if (options.fps) formData.append('fps', options.fps.toString()); + if (options.width) formData.append('width', options.width.toString()); + if (options.height) formData.append('height', options.height.toString()); + if (options.fileSize) formData.append('fileSize', options.fileSize.toString()); - const response = await fetch(API_URL + '/api/v1/upload', { + const response = await fetch(API_URL + '/api/v1/media/compress', { method: 'POST', body: formData, + credentials: 'include', }); - const result: APIResponse = await response.json(); + if (!response.ok) throw new Error(`Compression failed: ${response.status}`); - if (result.status == "error") { - throw new Error(`Failed to upload file: ${result.message}`); - } + const result: APIResponse = await response.json(); + if (result.status === 'error') throw new Error(`Compression failed: ${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) => { - const formData = new URLSearchParams(); +const getJob = async (uuid: string): Promise => { + const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' }); - for (const [key, value] of Object.entries(videoMetadata)) { - if (value !== undefined && value !== null) { - formData.append(key, value.toString()); - } - } - - const response = await fetch(API_URL + `/api/v1/edit/${uuid}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - body: formData.toString(), - }); - - if (!response.ok) { - throw new Error(`Failed to edit file: ${response.status}`); - } + if (!response.ok) throw new Error(`Failed to fetch job: ${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) => { - const response = await fetch(API_URL + `/api/v1/process/${uuid}`, {credentials: "include"}); - - if (!response.ok) { - throw new Error(`Failed to process file: ${response.status}`); - } - - const result: APIResponse = await response.json(); - - if (result.status === "error") { - throw new Error("Failed to process file: " + result.message); - } -}; - -const convertFile = async (uuid: string) => { - const response = await fetch(API_URL + `/api/v1/convert/${uuid}`); - - if (!response.ok) { - throw new Error(`Failed to convert file: ${response.status}`); - } - - const result: APIResponse = await response.json(); - - if (result.status === "error") { - throw new Error("Failed to convert file: " + result.message); - } -}; - -/** - * Fetches the processing progress percentage. - * @param uuid - The UUID of the video file. - */ -const getProgress = async (uuid: string): Promise => { - const response = await fetch(API_URL + `/api/v1/progress/${uuid}`); - - if (!response.ok) { - throw new Error(`Failed to fetch progress: ${response.status}`); - } - - const result = await response.json(); - - if (result.status === "error") { - throw new Error(`Failed to fetch progress: ${result.message}`); - } - - if (!result.data) { - throw new Error('Invalid progress data received'); - } + if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`); return result.data; }; -/** - * Fetches original metadata from the backend. - * @param uuid - The UUID of the video file. - */ -const getMetadata = async (uuid: string): Promise => { - const response = await fetch(API_URL + `/api/v1/metadata/original/${uuid}`, {credentials: "include"}); +const getUser = async (): Promise => { + const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' }); + if (!response.ok) return null; - 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}`); - } + const result: APIResponse = await response.json(); + if (result.status === 'error') return null; return result.data; }; -/** - * Fetches the current user information. Returns null if not authenticated. - */ -const getUser = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/auth/user', {credentials: "include"}); - - if (!response.ok) { - return null; - } - - const result = await response.json(); - - if (result.status === "error") { - return null; - } - - console.log(result.data); - - return result.data; -} - -/** - * Fetches all clips for the current user. - */ const getClips = async (): Promise => { - const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include'}); + const response = await fetch(API_URL + '/api/v1/clips', { credentials: 'include' }); if (!response.ok) { 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'); - } -} + const result: APIResponse = await response.json(); + return result.data; +}; -/** - * Fetches a clip by its ID. - * @param id - */ const getClipById = async (id: string): Promise => { - const response = await fetch(API_URL + `/api/v1/clips/${id}`, {credentials: "include",}); + const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' }); if (!response.ok) { if (response.status === 401 || response.status === 403) throw new AuthError(); 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'}`); - } + const result: APIResponse = await response.json(); + return result.data; }; -const getVideoBlob = async(id: string): Promise => { - const response = await fetch(API_URL + `/api/v1/download/clip/${id}`, {credentials: "include",}); +const getVideoBlob = async (id: string): Promise => { + const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' }); if (!response.ok) { if (response.status === 401 || response.status === 403) throw new AuthError(); - throw new Error(`Failed to fetch video: ${id}: ${response.status}`) + throw new Error(`Failed to fetch video ${id}: ${response.status}`); } - try { - return response.blob(); - } catch { - throw new Error(`Failed to convert Clip Return Object to blob`); - } -} + return response.blob(); +}; const patchClip = async (id: number, data: { title?: string; description?: string }): Promise => { const response = await fetch(API_URL + `/api/v1/clips/${id}`, { @@ -267,14 +124,10 @@ const patchClip = async (id: number, data: { title?: string; description?: strin body: JSON.stringify(data), }); - if (!response.ok) { - throw new Error(`Failed to update clip: ${response.status}`); - } + if (!response.ok) throw new Error(`Failed to update clip: ${response.status}`); const result: APIResponse = await response.json(); - if (result.status === 'error') { - throw new Error(`Failed to update clip: ${result.message}`); - } + if (result.status === 'error') throw new Error(`Failed to update clip: ${result.message}`); return result.data; }; @@ -285,14 +138,10 @@ const deleteClip = async (id: number): Promise => { credentials: 'include', }); - if (!response.ok) { - throw new Error(`Failed to delete clip: ${response.status}`); - } + if (!response.ok) throw new Error(`Failed to delete clip: ${response.status}`); const result: APIResponse = await response.json(); - if (result.status === 'error') { - throw new Error(`Failed to delete clip: ${result.message}`); - } + if (result.status === 'error') throw new Error(`Failed to delete clip: ${result.message}`); }; const getStreamStatus = async (): Promise => { @@ -303,27 +152,21 @@ const getStreamStatus = async (): Promise => { }; const isThumbnailAvailable = async (id: number): Promise => { - const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"}); + const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' }); return response.ok; - - -} +}; export { login, logout, - getStreamStatus, - uploadFile, - editFile, - processFile, - convertFile, - getProgress, - getMetadata, + compress, + getJob, getUser, getClips, getClipById, getVideoBlob, patchClip, deleteClip, - isThumbnailAvailable -}; \ No newline at end of file + getStreamStatus, + isThumbnailAvailable, +}; diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 04cc9f6..a75eede 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -33,15 +33,13 @@ type Clip = { createdAt: string, } -type ProgressResult = { - process: { - progress: number, - complete: boolean - }; - conversion: { - progress: number, - complete: boolean - }; +type JobResponse = { + uuid: string, + progress: number, + isComplete: boolean, + state: 'READY' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED', + errorOutput: string | null, + createdAt: string, }; export type { @@ -49,5 +47,5 @@ export type { VideoMetadata, User, Clip, - ProgressResult + JobResponse } \ No newline at end of file diff --git a/frontend/src/utils/utils.ts b/frontend/src/utils/utils.ts index 79dd989..de79921 100644 --- a/frontend/src/utils/utils.ts +++ b/frontend/src/utils/utils.ts @@ -2,76 +2,34 @@ 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)}`; - } + const pad = (n: number) => n.toString().padStart(2, '0'); + if (h > 0) return `${h}:${pad(m)}:${pad(s)}`; + return `${m}:${pad(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"); - } + const date = new Date(dateString.substring(0, 23)); + 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"; + const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000); + const units: [number, string][] = [ + [31536000, 'year'], + [2592000, 'month'], + [86400, 'day'], + [3600, 'hour'], + [60, 'minute'], + ]; + for (const [threshold, unit] of units) { + if (secondsAgo >= threshold) { + const count = Math.floor(secondsAgo / threshold); + return `${count} ${unit}${count !== 1 ? 's' : ''} ago`; } - - return `${minutes} minutes ago`; - } else if (secondsAgo < 86400) { - const hours = Math.floor(secondsAgo / 3600); - - if (hours === 1) { - return "1 hour ago"; - } - - return `${hours} hours ago`; - } else if (secondsAgo < 2592000) { - const days = Math.floor(secondsAgo / 86400); - - if (days === 1) { - return "1 day ago"; - } - - return `${days} days ago`; - } else if (secondsAgo < 31536000) { - const months = Math.floor(secondsAgo / 2592000); - - if (months === 1) { - return "1 month ago"; - } - - return `${months} months ago`; - } else { - const years = Math.floor(secondsAgo / 31536000); - - if (years === 1) { - return "1 year ago"; - } - - return `${years} years ago`; } + const s = Math.max(0, secondsAgo); + return `${s} second${s !== 1 ? 's' : ''} ago`; } -export { - formatTime, - stringToDate, - dateToTimeAgo -} \ No newline at end of file +export { formatTime, stringToDate, dateToTimeAgo }; diff --git a/src/main/Dockerfile b/src/main/Dockerfile index 328e231..3b0af77 100644 --- a/src/main/Dockerfile +++ b/src/main/Dockerfile @@ -15,12 +15,6 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -ENV POSTGRES_DATA=/app/db -ENV MEDIA_DATA=/app/data/media -ENV STREAM_DATA=/app/data/media/streams - -VOLUME ["app/db", "app/data/media/streams", "/app/data/media"] - WORKDIR /app COPY --from=builder /app/target/*.jar app.jar EXPOSE 8080 diff --git a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java index 81bf0e6..2e70119 100644 --- a/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java +++ b/src/main/java/com/ddf/vodsystem/configuration/SecurityConfig.java @@ -30,11 +30,10 @@ public class SecurityConfig { .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll() - .requestMatchers("/api/v1/download/clip/**","/api/v1/clips/", "/api/v1/clips/**").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/**", "/api/v1/convert/**").permitAll() - .requestMatchers("/api/v1/metadata/**").permitAll() + .requestMatchers("/api/v1/media/**").authenticated() + .requestMatchers("/api/v1/jobs/**").authenticated() + .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated() + .requestMatchers("/api/v1/users/login").permitAll() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .anyRequest().authenticated() ) diff --git a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java index d4d0e8d..6a6cbfc 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java @@ -5,6 +5,10 @@ import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest; import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.services.ClipService; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -16,6 +20,7 @@ import java.util.Optional; public class ClipController { private final ClipService clipService; private static final String SUCCESS = "success"; + private static final String FILENAME_HEADER = "inline; filename=\"%s\""; public ClipController(ClipService clipService) { this.clipService = clipService; @@ -79,6 +84,34 @@ public class ClipController { ); } + @GetMapping("/{id}/media") + public ResponseEntity downloadClip(@PathVariable Long id) { + Resource resource = clipService.downloadClip(id); + + if (resource == null || !resource.exists()) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) + .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) + .body(resource); + } + + @GetMapping("/{id}/thumbnail") + public ResponseEntity downloadThumbnail(@PathVariable Long id) { + Resource resource = clipService.downloadThumbnail(id); + + if (resource == null || !resource.exists()) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) + .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) + .body(resource); + } + private ClipResponse convertToDTO(Clip clip) { return new ClipResponse( clip.getId(), diff --git a/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java b/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java deleted file mode 100644 index f91928c..0000000 --- a/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.ddf.vodsystem.controllers; - -import com.ddf.vodsystem.services.DownloadService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.core.io.Resource; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.MediaTypeFactory; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("api/v1/download") -public class DownloadController { - private final DownloadService downloadService; - private static final String FILENAME_HEADER = "inline; filename=\"%s\""; - - @Autowired - public DownloadController(DownloadService downloadService) { - this.downloadService = downloadService; - } - - @GetMapping("/output/{uuid}") - public ResponseEntity downloadFile(@PathVariable String uuid) { - Resource resource = downloadService.downloadOutput(uuid); - - if (resource == null || !resource.exists()) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) - .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) - .body(resource); - } - - @GetMapping("/input/{uuid}") - public ResponseEntity downloadInput(@PathVariable String uuid) { - Resource resource = downloadService.downloadInput(uuid); - - if (resource == null || !resource.exists()) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) - .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) - .body(resource); - } - - @GetMapping("/clip/{id}") - public ResponseEntity downloadClip(@PathVariable Long id) { - Resource resource = downloadService.downloadClip(id); - - if (resource == null || !resource.exists()) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) - .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) - .body(resource); - } - - @GetMapping("/thumbnail/{id}") - public ResponseEntity downloadThumbnail(@PathVariable Long id) { - Resource resource = downloadService.downloadThumbnail(id); - - if (resource == null || !resource.exists()) { - return ResponseEntity.notFound().build(); - } - - return ResponseEntity.ok() - .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) - .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) - .body(resource); - } -} diff --git a/src/main/java/com/ddf/vodsystem/controllers/EditController.java b/src/main/java/com/ddf/vodsystem/controllers/EditController.java deleted file mode 100644 index 180dd71..0000000 --- a/src/main/java/com/ddf/vodsystem/controllers/EditController.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.ddf.vodsystem.controllers; - -import com.ddf.vodsystem.dto.JobStatus; -import com.ddf.vodsystem.dto.ClipOptions; -import com.ddf.vodsystem.services.EditService; -import com.ddf.vodsystem.dto.APIResponse; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - - -@RestController -@RequestMapping("api/v1") -public class EditController { - private final EditService editService; - private static final String SUCCESS = "success"; - - public EditController(EditService editService) { - this.editService = editService; - } - - @PostMapping("edit/{uuid}") - public ResponseEntity> edit(@PathVariable("uuid") String uuid, @ModelAttribute ClipOptions clipOptions) { - editService.edit(uuid, clipOptions); - return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Editing started for UUID: " + uuid, null)); - } - - @GetMapping("/process/{uuid}") - public ResponseEntity> process(@PathVariable("uuid") String uuid) { - editService.process(uuid); - return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Processing started for UUID: " + uuid, null)); - } - - @GetMapping("/convert/{uuid}") - public ResponseEntity> convert(@PathVariable("uuid") String uuid) { - editService.convert(uuid); - return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Conversion started for UUID: " + uuid, null)); - } - - @GetMapping("/progress/{uuid}") - public ResponseEntity> getStatus(@PathVariable("uuid") String uuid) { - JobStatus status = editService.getStatus(uuid); - return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Status for UUID: " + uuid, status)); - } -} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/JobController.java b/src/main/java/com/ddf/vodsystem/controllers/JobController.java new file mode 100644 index 0000000..8bf6352 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/JobController.java @@ -0,0 +1,70 @@ +package com.ddf.vodsystem.controllers; + +import com.ddf.vodsystem.controllers.dto.JobResponse; +import com.ddf.vodsystem.dto.APIResponse; +import com.ddf.vodsystem.dto.Job; +import com.ddf.vodsystem.exceptions.ClipNotFound; +import com.ddf.vodsystem.exceptions.NotReadyException; +import com.ddf.vodsystem.services.JobRegistryService; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.MediaTypeFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/v1/jobs") +public class JobController { + private final JobRegistryService jobRegistryService; + private static final String SUCCESS = "success"; + private static final String FILENAME_HEADER = "inline; filename=\"%s\""; + + public JobController(JobRegistryService jobRegistryService){ + this.jobRegistryService = jobRegistryService; + } + + @GetMapping("/{uuid}") + public ResponseEntity> getJob(@PathVariable String uuid) { + Job job = jobRegistryService.getJob(uuid); + JobResponse jobResponse = new JobResponse( + job.getUuid(), + job.getProgressTracker().getProgress(), + job.getProgressTracker().isComplete(), + job.getState(), + job.getErrorOutput(), + job.getCreatedAt() + ); + + return ResponseEntity.ok(new APIResponse<>( + SUCCESS, + "Job successfully retreived", + jobResponse + )); + } + + @GetMapping("/{uuid}/download") + public ResponseEntity downloadJobFile(@PathVariable String uuid) { + Job job = jobRegistryService.getJob(uuid); + + if (job.getDownload() == null) { + throw new NotReadyException("Job " + uuid + " not ready for download."); + } + + Resource resource = new FileSystemResource(job.getDownload()); + + if (!resource.exists()) { + throw new ClipNotFound("Job download file " + job.getDownload() + " does not exist."); + } + + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename())) + .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM)) + .body(resource); + } + +} diff --git a/src/main/java/com/ddf/vodsystem/controllers/MediaController.java b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java new file mode 100644 index 0000000..7643e7b --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/MediaController.java @@ -0,0 +1,63 @@ +package com.ddf.vodsystem.controllers; + +import com.ddf.vodsystem.controllers.dto.ClipSectionRequest; +import com.ddf.vodsystem.controllers.dto.SaveSectionRequest; +import com.ddf.vodsystem.controllers.dto.UUIDResponse; +import com.ddf.vodsystem.dto.Job; +import com.ddf.vodsystem.dto.ClipOptions; +import com.ddf.vodsystem.dto.APIResponse; +import com.ddf.vodsystem.services.MediaService; +import jakarta.validation.Valid; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; + +@RestController +@RequestMapping("/api/v1/media") +public class MediaController { + private final MediaService mediaService; + private static final String SUCCESS = "success"; + + public MediaController(MediaService mediaService) { + this.mediaService = mediaService; + } + + @PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> compress( + @RequestParam("file")MultipartFile file, + @Valid @ModelAttribute ClipOptions options + ) throws IOException, InterruptedException { + Job job = mediaService.compress(file, options); + return ResponseEntity.ok(new APIResponse<>( + SUCCESS, + "Compression successfully started", + new UUIDResponse(job.getUuid()) + )); + } + + + @PostMapping("/save") + public ResponseEntity> save( + @RequestBody SaveSectionRequest saveSectionRequest) throws IOException { + Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime()); + return ResponseEntity.ok(new APIResponse<>( + SUCCESS, + "Section saving successfully started", + new UUIDResponse(job.getUuid()) + )); + } + + @PostMapping("/clip") + public ResponseEntity> clip( + @RequestBody ClipSectionRequest clipSectionRequest) throws IOException { + Job job = mediaService.clip(clipSectionRequest.duration()); + return ResponseEntity.ok(new APIResponse<>( + SUCCESS, + "Clipping successfully started", + new UUIDResponse(job.getUuid()) + )); + } +} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java b/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java deleted file mode 100644 index 45f99e8..0000000 --- a/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.ddf.vodsystem.controllers; - -import com.ddf.vodsystem.dto.ClipOptions; -import com.ddf.vodsystem.dto.APIResponse; -import com.ddf.vodsystem.services.JobRegistryService; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -@RestController -@RequestMapping("/api/v1/metadata") -public class MetadataController { - private final JobRegistryService jobRegistryService; - - public MetadataController( - JobRegistryService jobRegistryService) { - this.jobRegistryService = jobRegistryService; - } - - @GetMapping("/original/{uuid}") - public ResponseEntity> getMetadata(@PathVariable String uuid) { - ClipOptions originalMetadata = jobRegistryService.getJob(uuid).getInputClipOptions(); - - if (originalMetadata == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new APIResponse<>("error", "Original metadata not found", null)); - } - - return ResponseEntity.ok() - .body(new APIResponse<>("success", "Original metadata retrieved", originalMetadata)); - } - - @GetMapping("/converted/{uuid}") - public ResponseEntity> getConvertedMetadata(@PathVariable String uuid) { - ClipOptions convertedMetadata = jobRegistryService.getJob(uuid).getOutputClipOptions(); - - if (convertedMetadata == null) { - return ResponseEntity.status(HttpStatus.NOT_FOUND) - .body(new APIResponse<>("error", "Converted metadata not found", null)); - } - - return ResponseEntity.ok() - .body(new APIResponse<>("success", "Converted metadata retrieved", convertedMetadata)); - } -} diff --git a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java index b75f4e1..21b9762 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/StreamController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/StreamController.java @@ -1,9 +1,7 @@ package com.ddf.vodsystem.controllers; -import com.ddf.vodsystem.controllers.dto.ClipSectionRequest; import com.ddf.vodsystem.controllers.dto.StreamResponse; import com.ddf.vodsystem.dto.APIResponse; -import com.ddf.vodsystem.controllers.dto.SaveSectionRequest; import com.ddf.vodsystem.entities.Stream; import com.ddf.vodsystem.services.StreamService; import org.slf4j.Logger; @@ -11,7 +9,6 @@ import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; -import java.io.IOException; import java.util.List; import java.util.Optional; @@ -49,20 +46,6 @@ public class StreamController { return ResponseEntity.ok(new APIResponse<>("success", "Heartbeat recorded", null)); } - @PostMapping("/save") - public ResponseEntity> saveSection( - @RequestBody SaveSectionRequest request) throws IOException, InterruptedException { - streamService.saveSection(request.startTime(), request.endTime()); - return ResponseEntity.accepted().body(new APIResponse<>("success", "Section saved", null)); - } - - @PostMapping("/clip") - public ResponseEntity> saveClip( - @RequestBody ClipSectionRequest request) throws IOException, InterruptedException { - streamService.clip(request.duration()); - return ResponseEntity.accepted().body(new APIResponse<>("success", "Clip saved", null)); - } - @GetMapping("/history/{userId}") public ResponseEntity>> getStreamHistory(@PathVariable Long userId) { List history = streamService.getStreamHistory(userId); diff --git a/src/main/java/com/ddf/vodsystem/controllers/UploadController.java b/src/main/java/com/ddf/vodsystem/controllers/UploadController.java deleted file mode 100644 index 01f2698..0000000 --- a/src/main/java/com/ddf/vodsystem/controllers/UploadController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.ddf.vodsystem.controllers; - -import com.ddf.vodsystem.dto.APIResponse; -import com.ddf.vodsystem.services.UploadService; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; -import org.springframework.web.multipart.MultipartFile; - -@RestController -@RequestMapping("api/v1/upload") -public class UploadController { - - private final UploadService uploadService; - - @Autowired - public UploadController(UploadService uploadService) { - this.uploadService = uploadService; - } - - @PostMapping() - public ResponseEntity> uploadVideo(@RequestParam("file") MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new IllegalArgumentException("Invalid file"); - } - - String uuid = uploadService.upload(file); - APIResponse response = new APIResponse<>("success", "File uploaded successfully", java.util.Collections.singletonMap("uuid", uuid)); - return new ResponseEntity<>(response, HttpStatus.OK); - } -} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/UserController.java b/src/main/java/com/ddf/vodsystem/controllers/UserController.java index 940c31d..dead6ab 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/UserController.java +++ b/src/main/java/com/ddf/vodsystem/controllers/UserController.java @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*; import java.util.Optional; @RestController -@RequestMapping("/api/v1/auth/") +@RequestMapping("/api/v1/users") public class UserController { private final UserService userService; @@ -25,7 +25,7 @@ public class UserController { this.userService = userService; } - @GetMapping("/user") + @GetMapping("/me") public ResponseEntity> user() { Optional user = userService.getLoggedInUser(); diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java index d2f26de..da2bb92 100644 --- a/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/ClipResponse.java @@ -1,6 +1,6 @@ package com.ddf.vodsystem.controllers.dto; -import java.time.LocalDateTime; +import java.time.Instant; public record ClipResponse( Long id, @@ -8,5 +8,5 @@ public record ClipResponse( String title, String description, Float duration, - LocalDateTime createdAt + Instant createdAt ) {} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/JobResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/JobResponse.java new file mode 100644 index 0000000..1c347b1 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/JobResponse.java @@ -0,0 +1,15 @@ +package com.ddf.vodsystem.controllers.dto; + +import com.ddf.vodsystem.dto.JobState; + +import java.time.Instant; + +public record JobResponse ( + String uuid, + float progress, + boolean isComplete, + JobState state, + String errorOutput, + Instant createdAt +) { +} diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/UUIDResponse.java b/src/main/java/com/ddf/vodsystem/controllers/dto/UUIDResponse.java new file mode 100644 index 0000000..568097b --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/UUIDResponse.java @@ -0,0 +1,3 @@ +package com.ddf.vodsystem.controllers.dto; + +public record UUIDResponse(String uuid) {} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java b/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java index 735a3ea..7041658 100644 --- a/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java +++ b/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java @@ -1,15 +1,31 @@ package com.ddf.vodsystem.dto; +import jakarta.validation.constraints.*; import lombok.Data; @Data public class ClipOptions { + @NotBlank private String title; + private String description; + + @NotNull + @Positive private Float startPoint; + + @NotNull @Positive private Float duration; + + @DecimalMin("1.0") @DecimalMax("120.0") private Float fps; + + @Positive private Integer width; + + @Positive private Integer height; + + @Positive private Float fileSize; } diff --git a/src/main/java/com/ddf/vodsystem/dto/Job.java b/src/main/java/com/ddf/vodsystem/dto/Job.java index 6e560d3..877b41d 100644 --- a/src/main/java/com/ddf/vodsystem/dto/Job.java +++ b/src/main/java/com/ddf/vodsystem/dto/Job.java @@ -1,28 +1,21 @@ package com.ddf.vodsystem.dto; -import java.io.File; import lombok.Data; +import java.nio.file.Path; +import java.time.Instant; + @Data public class Job { private String uuid; - private File inputFile; - private File outputFile; + private ProgressTracker progressTracker = new ProgressTracker(); + private JobState state = JobState.READY; + private String errorOutput; - // configs - private ClipOptions inputClipOptions; - private ClipOptions outputClipOptions = new ClipOptions(); + private Path download; + private Instant createdAt = Instant.now(); - // job status - private JobStatus status = new JobStatus(); - - public Job(String uuid, - File inputFile, - File outputFile, - ClipOptions inputClipOptions) { + public Job(String uuid) { this.uuid = uuid; - this.inputFile = inputFile; - this.outputFile = outputFile; - this.inputClipOptions = inputClipOptions; } } diff --git a/src/main/java/com/ddf/vodsystem/dto/JobState.java b/src/main/java/com/ddf/vodsystem/dto/JobState.java new file mode 100644 index 0000000..de5d4fa --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/JobState.java @@ -0,0 +1,8 @@ +package com.ddf.vodsystem.dto; + +public enum JobState { + READY, + PROCESSING, + SUCCEEDED, + FAILED +} diff --git a/src/main/java/com/ddf/vodsystem/dto/JobStatus.java b/src/main/java/com/ddf/vodsystem/dto/JobStatus.java deleted file mode 100644 index 397587b..0000000 --- a/src/main/java/com/ddf/vodsystem/dto/JobStatus.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.ddf.vodsystem.dto; - -import lombok.Data; - -@Data -public class JobStatus { - private ProgressTracker process = new ProgressTracker(); - private ProgressTracker conversion = new ProgressTracker(); - private boolean failed = false; - private String failedReason; -} - - diff --git a/src/main/java/com/ddf/vodsystem/entities/Clip.java b/src/main/java/com/ddf/vodsystem/entities/Clip.java index 924b0b5..eafa28e 100644 --- a/src/main/java/com/ddf/vodsystem/entities/Clip.java +++ b/src/main/java/com/ddf/vodsystem/entities/Clip.java @@ -5,7 +5,7 @@ import jakarta.persistence.*; import lombok.Data; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "clips") @@ -28,7 +28,7 @@ public class Clip { private String description; @Column(name = "created_at") - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "width", nullable = false) private Integer width; diff --git a/src/main/java/com/ddf/vodsystem/entities/Stream.java b/src/main/java/com/ddf/vodsystem/entities/Stream.java index 325b9c3..e91805b 100644 --- a/src/main/java/com/ddf/vodsystem/entities/Stream.java +++ b/src/main/java/com/ddf/vodsystem/entities/Stream.java @@ -4,7 +4,7 @@ import jakarta.persistence.*; import lombok.Data; import com.fasterxml.jackson.annotation.JsonIgnore; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "streams") @@ -21,11 +21,11 @@ public class Stream { private User user; @Column(name = "start_time") - private LocalDateTime startDate; + private Instant startDate; @Column(name = "end_time") - private LocalDateTime endDate; + private Instant endDate; @Column(name = "last_seen") - private LocalDateTime lastSeen; + private Instant lastSeen; } diff --git a/src/main/java/com/ddf/vodsystem/entities/User.java b/src/main/java/com/ddf/vodsystem/entities/User.java index 1d7bf69..d486403 100644 --- a/src/main/java/com/ddf/vodsystem/entities/User.java +++ b/src/main/java/com/ddf/vodsystem/entities/User.java @@ -3,7 +3,7 @@ package com.ddf.vodsystem.entities; import jakarta.persistence.*; import lombok.Data; -import java.time.LocalDateTime; +import java.time.Instant; @Entity @Table(name = "users") @@ -33,7 +33,7 @@ public class User { private Integer role; // 0: user, 1: admin @Column(name = "created_at") - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "stream_key") private String streamKey; diff --git a/src/main/java/com/ddf/vodsystem/exceptions/NotReadyException.java b/src/main/java/com/ddf/vodsystem/exceptions/NotReadyException.java new file mode 100644 index 0000000..3d0026a --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/exceptions/NotReadyException.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.exceptions; + +public class NotReadyException extends RuntimeException { + public NotReadyException(String message) { + super(message); + } +} diff --git a/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java index e1ce754..e577e36 100644 --- a/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java +++ b/src/main/java/com/ddf/vodsystem/repositories/StreamRepository.java @@ -5,7 +5,7 @@ import com.ddf.vodsystem.entities.User; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Optional; @@ -13,5 +13,5 @@ import java.util.Optional; public interface StreamRepository extends JpaRepository { Optional findByUserAndEndDateIsNull(User user); List findByUser(User user); - List findByEndDateIsNullAndLastSeenBefore(LocalDateTime cutoff); + List findByEndDateIsNullAndLastSeenBefore(Instant cutoff); } diff --git a/src/main/java/com/ddf/vodsystem/security/JwtFilter.java b/src/main/java/com/ddf/vodsystem/security/JwtFilter.java index 1af18e2..e72ad72 100644 --- a/src/main/java/com/ddf/vodsystem/security/JwtFilter.java +++ b/src/main/java/com/ddf/vodsystem/security/JwtFilter.java @@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -60,9 +61,12 @@ public class JwtFilter extends OncePerRequestFilter { } Optional authentication = getAuthentication(jwt); - authentication.ifPresent(value -> - SecurityContextHolder.getContext().setAuthentication(value) - ); + authentication.ifPresent(auth -> { + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(auth); + SecurityContextHolder.setContext(context); + }); + filterChain.doFilter(request, response); } diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index 7b692ae..83b1f93 100644 --- a/src/main/java/com/ddf/vodsystem/services/ClipService.java +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -7,20 +7,20 @@ import com.ddf.vodsystem.entities.*; import java.io.File; import java.io.IOException; import java.nio.file.Files; -import java.time.LocalDateTime; +import java.nio.file.Path; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.concurrent.ExecutionException; -import com.ddf.vodsystem.exceptions.ClipNotFound; -import com.ddf.vodsystem.exceptions.FFMPEGException; -import com.ddf.vodsystem.exceptions.NotAuthenticated; -import com.ddf.vodsystem.exceptions.StorageException; +import com.ddf.vodsystem.exceptions.*; import com.ddf.vodsystem.repositories.ClipRepository; import com.ddf.vodsystem.services.media.MetadataService; import com.ddf.vodsystem.services.media.ThumbnailService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; import org.springframework.stereotype.Service; @Service @@ -146,6 +146,52 @@ public class ClipService { return user.get().getId().equals(clip.getUser().getId()); } + public Resource downloadClip(Long id) { + Optional possibleClip = getClipById(id); + + if (possibleClip.isEmpty()) { + throw new ClipNotFound("Clip " + id + " doesn't exist"); + } + + Clip clip = possibleClip.get(); + + if (!isAuthenticatedForClip(clip)) { + throw new NotAuthenticated("Not authenticated for this clip"); + } + + String path = clip.getVideoPath(); + Path file = directoryService.resolvePath(path); + + if (!Files.exists(file)) { + throw new JobNotFound("Clip file not found"); + } + + return new FileSystemResource(file); + } + + public Resource downloadThumbnail(Long id) { + Optional possibleClip = getClipById(id); + + if (possibleClip.isEmpty()) { + throw new ClipNotFound("Clip " + id + " doesn't exist"); + } + + Clip clip = possibleClip.get(); + + if (!isAuthenticatedForClip(clip)) { + throw new NotAuthenticated("Not authenticated for this clip thumbnail"); + } + + String path = clip.getThumbnailPath(); + Path file = directoryService.resolvePath(path); + + if (!Files.exists(file)) { + throw new JobNotFound("Thumbnail file not found"); + } + + return new FileSystemResource(file); + } + /** * Persists a clip to the database * @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description @@ -156,36 +202,36 @@ public class ClipService { */ public Clip saveClip(ClipOptions options, User user, - String videoPath, - String thumbnailPath) { + Path videoPath, + Path thumbnailPath) { Clip clip = new Clip(); clip.setUser(user); clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip"); clip.setDescription(options.getDescription() != null ? options.getDescription() : ""); - clip.setCreatedAt(LocalDateTime.now()); + clip.setCreatedAt(Instant.now()); clip.setWidth(options.getWidth()); clip.setHeight(options.getHeight()); clip.setFps(options.getFps()); clip.setDuration(options.getDuration() - options.getStartPoint()); clip.setFileSize(options.getFileSize()); - clip.setVideoPath(videoPath); - clip.setThumbnailPath(thumbnailPath); + clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString()); + clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString()); return clipRepository.save(clip); } public void persistClip(String title, String description, User user, - File clipFile, + Path clipFile, String fileName) { - File newClipFile; - File thumbnailFile; + Path newClipFile; + Path thumbnailFile; // Move temp file from temp dir to output dir try { - newClipFile = directoryService.getUserClipsFile(user.getId(), fileName); - thumbnailFile = directoryService.getUserThumbnailsFile(user.getId(), fileName + ".png"); - directoryService.cutFile(clipFile, newClipFile); + newClipFile = directoryService.getClipsDir(user.getId()).resolve(fileName); + thumbnailFile = directoryService.getThumbnailsDir(user.getId()).resolve(fileName + ".png"); + directoryService.copyFile(clipFile, newClipFile); } catch (IOException e) { throw new StorageException("Failed to move clip from temporary directory to output directory", e); } @@ -211,7 +257,7 @@ public class ClipService { clipMetadata.setTitle(title); clipMetadata.setDescription(description); - Clip clip = saveClip(clipMetadata, user, newClipFile.getAbsolutePath(), thumbnailFile.getAbsolutePath()); + Clip clip = saveClip(clipMetadata, user, newClipFile, thumbnailFile); logger.info("Clip created successfully with ID: {}", clip.getId()); } diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java index c60fdea..92cd20a 100644 --- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -10,7 +10,7 @@ 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.util.stream.Stream; import java.nio.file.StandardCopyOption; import org.slf4j.Logger; @@ -20,146 +20,120 @@ public class DirectoryService { private static final Logger logger = org.slf4j.LoggerFactory.getLogger(DirectoryService.class); - @Value("${storage.outputs}") - private String outputDir; + @Value("${media.users}") + private String usersDir; - @Value("${STREAM_DATA}") - private String streamDir; + @Value("${media.streams}") + private String streamsDir; - @Value("${storage.temp.inputs}") + @Value("${media.temp.inputs}") private String tempInputsDir; - @Value("${storage.temp.outputs}") + @Value("${media.temp.outputs}") private String tempOutputsDir; private static final long TEMP_DIR_TIME_LIMIT = 3 * 60 * 60 * (long) 1000; // 3 hours private static final long TEMP_DIR_CLEANUP_RATE = 30 * 60 * (long) 1000; // 30 minutes - public File getTempInputFile(String filename) { - String dir = tempInputsDir + File.separator + filename; - return new File(dir); + private static final String VODS_DIR_NAME = "vods"; + private static final String CLIPS_DIR_NAME = "clips"; + private static final String THUMBNAIL_DIR_NAME = "thumbnails"; + + public Path getTempInputDir() { + return Path.of(tempInputsDir); } - public File getTempOutputFile(String filename) { - String dir = tempOutputsDir + File.separator + filename; - return new File(dir); + public Path getTempOutputDir() { + return Path.of(tempOutputsDir); } - public File getUserFolder(Long userId) throws IOException { - if (userId == null) { - throw new IllegalArgumentException("User ID cannot be null or empty"); - } - - String dir = outputDir + File.separator + userId; - createDirectory(dir); - return new File(dir); + public Path getUserDir(Long userId) throws IOException { + Path userDir = Path.of(usersDir + File.separator + userId); + Files.createDirectories(userDir); + return userDir; } - public File getUserClipsFile(Long userId, String filename) throws IOException { - if (filename == null || filename.isEmpty()) { - throw new IllegalArgumentException("File name cannot be null or empty"); - } - - File userDir = getUserFolder(userId); - String fullPath = userDir.getAbsolutePath() + File.separator + "clips" + File.separator + filename; - File file = new File(fullPath); - - createDirectory(file.getParent()); - return file; + public Path getVodsDir(Long userId) throws IOException { + Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME); + Files.createDirectories(vodDir); + return vodDir; } - public File getVodFile(Long userId, String filename) throws IOException { - if (filename == null || filename.isEmpty()) { - throw new IllegalArgumentException("File name cannot be null or empty"); - } - - String userDir = getUserFolder(userId).getAbsolutePath(); - File file = new File(userDir + File.separator + "vods" + File.separator + filename); - createDirectory(file.getParent()); - - return file; + public Path getClipsDir(Long userId) throws IOException { + Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME); + Files.createDirectories(clipsDir); + return clipsDir; } - public File getStreamDir(String streamKey) throws IOException { - File file = new File(streamDir + File.separator + streamKey); - createDirectory(file.getAbsolutePath()); - return file; + public Path getThumbnailsDir(Long userId) throws IOException { + Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME); + Files.createDirectories(thumbnailsDir); + return thumbnailsDir; } - public File getUserThumbnailsFile(Long userId, String filename) throws IOException { - if (filename == null || filename.isEmpty()) { - throw new IllegalArgumentException("File name cannot be null or empty"); - } - - String userDir = getUserFolder(userId).getAbsolutePath(); - File file = new File(userDir + File.separator + "thumbnails" + File.separator + filename); - createDirectory(file.getParent()); - - return file; + public Path getStreamDir(String streamKey) throws IOException { + Path streamFolder = Path.of(streamsDir, streamKey); + Files.createDirectories(streamFolder); + return streamFolder; } - public void saveMultipartFile(File file, MultipartFile multipartFile) throws IOException { - createDirectory(file.getParent()); - Path filePath = Paths.get(file.getAbsolutePath()); - Files.copy(multipartFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING); + public Path relativisePath(Path path) { + Path media = Path.of(usersDir).toAbsolutePath(); + Path newPath = path.toAbsolutePath(); + return media.relativize(newPath); } - public void copyFile(File source, File target) throws IOException { - Path sourcePath = Paths.get(source.getAbsolutePath()); - Path destPath = Paths.get(target.getAbsolutePath()); - - Files.createDirectories(destPath.getParent()); - Files.copy(sourcePath, destPath, StandardCopyOption.REPLACE_EXISTING); - logger.info("Copied file from {} to {}", sourcePath, destPath); + public Path resolvePath(String path) { + Path media = Path.of(usersDir).toAbsolutePath(); + Path newPath = Path.of(path); + return media.resolve(newPath); } - public void cutFile(File source, File target) throws IOException { - copyFile(source, target); - Files.deleteIfExists(source.toPath()); - logger.info("Deleted source file: {}", source.getAbsolutePath()); + public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException { + Files.createDirectories(path.getParent()); + Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING); } - 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); - } + public void copyFile(Path source, Path target) throws IOException { + Files.createDirectories(target.getParent()); + Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING); + logger.info("Copied file from {} to {}", source, target); } private void cleanUpDirectory(String dir) throws IOException { - File file = new File(dir); - File[] files = file.listFiles(); + Path dirPath = Path.of(dir); - if (files == null) { + if (Files.notExists(dirPath)) { logger.warn("No files found in directory: {}", dir); return; } - for (File f : files){ - if (f.isFile() && f.lastModified() < (System.currentTimeMillis() - TEMP_DIR_TIME_LIMIT)) { - Files.delete(f.toPath()); - } + try (Stream files = Files.list(dirPath)) { + files.filter(Files::isRegularFile) + .filter(p -> { + try { + return Files.getLastModifiedTime(p).toMillis() + < System.currentTimeMillis() - TEMP_DIR_TIME_LIMIT; + } catch (IOException e) { + logger.warn("Could not read last modified time for {}", p); + return false; + } + }) + .forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + logger.warn("Could not delete file {}", p); + } + }); } } @PostConstruct public void createDirectoriesIfNotExist() throws IOException { - createDirectory(tempInputsDir); - createDirectory(tempOutputsDir); - createDirectory(outputDir); + Files.createDirectories(Path.of(tempInputsDir)); + Files.createDirectories(Path.of(tempOutputsDir)); + Files.createDirectories(Path.of(usersDir)); } @Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE) diff --git a/src/main/java/com/ddf/vodsystem/services/DownloadService.java b/src/main/java/com/ddf/vodsystem/services/DownloadService.java deleted file mode 100644 index 6373a32..0000000 --- a/src/main/java/com/ddf/vodsystem/services/DownloadService.java +++ /dev/null @@ -1,86 +0,0 @@ -package com.ddf.vodsystem.services; - -import com.ddf.vodsystem.entities.Clip; -import com.ddf.vodsystem.exceptions.JobNotFinished; -import com.ddf.vodsystem.exceptions.JobNotFound; -import com.ddf.vodsystem.dto.Job; -import com.ddf.vodsystem.exceptions.NotAuthenticated; -import com.ddf.vodsystem.repositories.ClipRepository; -import org.springframework.core.io.FileSystemResource; -import org.springframework.core.io.Resource; -import org.springframework.stereotype.Service; - -import java.io.File; - -@Service -public class DownloadService { - - private final JobRegistryService jobRegistryService; - private final ClipRepository clipRepository; - private final ClipService clipService; - - public DownloadService(JobRegistryService jobRegistryService, - ClipRepository clipRepository, - ClipService clipService) { - this.jobRegistryService = jobRegistryService; - this.clipRepository = clipRepository; - this.clipService = clipService; - } - - public Resource downloadInput(String uuid) { - Job job = jobRegistryService.getJob(uuid); - - File file = job.getInputFile(); - return new FileSystemResource(file); - } - - public Resource downloadOutput(String uuid) { - Job job = jobRegistryService.getJob(uuid); - - if (!job.getStatus().getProcess().isComplete()) { - throw new JobNotFinished("Job is not finished"); - } - - File file = job.getOutputFile(); - return new FileSystemResource(file); - } - - public Resource downloadClip(Clip clip) { - if (!clipService.isAuthenticatedForClip(clip)) { - throw new NotAuthenticated("Not authenticated for this clip"); - } - - String path = clip.getVideoPath(); - File file = new File(path); - - if (!file.exists()) { - throw new JobNotFound("Clip file not found"); - } - - return new FileSystemResource(file); - } - - public Resource downloadClip(Long id) { - Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id)); - return downloadClip(clip); - } - - public Resource downloadThumbnail(Clip clip) { - if (!clipService.isAuthenticatedForClip(clip)) { - throw new NotAuthenticated("Not authenticated for this clip thumbnail"); - } - - String path = clip.getThumbnailPath(); - File file = new File(path); - if (!file.exists()) { - throw new JobNotFound("Thumbnail file not found"); - } - - return new FileSystemResource(file); - } - - public Resource downloadThumbnail(Long id) { - Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id)); - return downloadThumbnail(clip); - } -} diff --git a/src/main/java/com/ddf/vodsystem/services/EditService.java b/src/main/java/com/ddf/vodsystem/services/EditService.java deleted file mode 100644 index 62d7559..0000000 --- a/src/main/java/com/ddf/vodsystem/services/EditService.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.ddf.vodsystem.services; - -import com.ddf.vodsystem.dto.JobStatus; -import com.ddf.vodsystem.dto.ClipOptions; -import com.ddf.vodsystem.dto.Job; -import com.ddf.vodsystem.services.media.MetadataService; -import org.springframework.stereotype.Service; - -@Service -public class EditService { - private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(EditService.class); - private final MetadataService metadataService; - private final JobRegistryService jobRegistryService; - private final JobOrchestrationService jobOrchestrationService; - - public EditService(JobOrchestrationService jobOrchestrationService, - MetadataService metadataService, - JobRegistryService jobRegistryService) { - this.metadataService = metadataService; - this.jobRegistryService = jobRegistryService; - this.jobOrchestrationService = jobOrchestrationService; - - } - - public void edit(String uuid, ClipOptions clipOptions) { - Job job = jobRegistryService.getJob(uuid); - metadataService.validateMetadata(job.getInputClipOptions(), clipOptions); - job.setOutputClipOptions(clipOptions); - - logger.info("Job {} - Updated clip config to {}", job.getUuid(), clipOptions); - } - - public void process(String uuid) { - Job job = jobRegistryService.getJob(uuid); - jobOrchestrationService.processJob(job); - - logger.info("Job {} - Started processing", uuid); - } - - public void convert(String uuid) { - Job job = jobRegistryService.getJob(uuid); - jobOrchestrationService.convertJob(job); - - logger.info("Job {} - Started converting", uuid); - } - - public JobStatus getStatus(String uuid) { - Job job = jobRegistryService.getJob(uuid); - return job.getStatus(); - } -} diff --git a/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java b/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java deleted file mode 100644 index 09e577b..0000000 --- a/src/main/java/com/ddf/vodsystem/services/JobOrchestrationService.java +++ /dev/null @@ -1,156 +0,0 @@ -package com.ddf.vodsystem.services; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.util.Optional; - -import com.ddf.vodsystem.dto.Job; -import com.ddf.vodsystem.entities.User; -import com.ddf.vodsystem.exceptions.StorageException; -import com.ddf.vodsystem.services.media.CompressionService; -import com.ddf.vodsystem.services.media.RemuxService; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -/** - * Orchestrates the processing pipeline for a {@link Job}: converting (remuxing) - * the input file and, separately, compressing/persisting the output clip. - *

    - * Job storage and lookup are handled by {@link JobRegistryService}; this class - * is only responsible for acting on a {@link Job} instance once it's been retrieved. - * Long-running steps (remux, compress) are kicked off asynchronously via - * {@link java.util.concurrent.CompletableFuture}-returning service calls rather - * than blocked on, so callers should not assume the job is finished when these - * methods return. - */ -@Service -public class JobOrchestrationService { - private static final Logger logger = LoggerFactory.getLogger(JobOrchestrationService.class); - private final ClipService clipService; - private final RemuxService remuxService; - private final DirectoryService directoryService; - private final CompressionService compressionService; - private final UserService userService; - - /** - * Constructs the orchestration service with its collaborating services. - * - * @param clipService used to persist a completed clip's metadata once processing succeeds - * @param remuxService performs the remux step during conversion - * @param compressionService performs the compression step during processing - * @param directoryService used for filesystem operations (e.g. copying the input file to a temp location) - * @param userService used to resolve the currently logged-in user when persisting a clip - */ - public JobOrchestrationService(ClipService clipService, - RemuxService remuxService, - CompressionService compressionService, - DirectoryService directoryService, - UserService userService) { - this.clipService = clipService; - this.remuxService = remuxService; - this.directoryService = directoryService; - this.compressionService = compressionService; - this.userService = userService; - } - - /** - * Converts a job's input file in place by remuxing it. - *

    - * The original input file is first copied to a temporary {@code .temp} file so the - * remux can read from a stable source; on successful completion the temp file is - * deleted. The remux itself runs asynchronously — this method returns once the - * remux has been kicked off, not once it has finished. Progress/completion is - * tracked via {@code job.getStatus().getConversion()}, which is reset at the - * start of this call and marked complete in the remux's completion callback. - * - * @param job the job whose input file should be converted - * @throws StorageException if the temporary file cannot be created, or if it - * cannot be deleted after a successful remux - */ - public void convertJob(Job job) { - logger.info("Converting job: {}", job.getUuid()); - File tempFile = new File(job.getInputFile().getAbsolutePath() + ".temp"); - - try { - directoryService.copyFile(job.getInputFile(), tempFile); - } catch (IOException e) { - throw new StorageException("Could not create temporary file: " + tempFile, e); - } - - job.getStatus().getConversion().reset(); - - try { - remuxService.remux( - tempFile, - job.getInputFile(), - job.getStatus().getConversion(), - job.getInputClipOptions().getDuration()) - .thenRun(() -> { - job.getStatus().getConversion().markComplete(); - - try { - Files.deleteIfExists(Paths.get(tempFile.getAbsolutePath())); - } catch (IOException e) { - throw new StorageException("Could not delete path at: " + tempFile.getAbsolutePath(), e); - } - }).whenComplete((ignored, throwable) -> { - if (throwable != null) { - logger.error("Remux failed for jobId={}", job.getUuid(), throwable); - } else { - logger.info("Remux completed for jobId={}", job.getUuid()); - } - }); - - } catch (IOException | InterruptedException e) { - logger.error("Error converting job {}: {}", job.getUuid(), e.getMessage()); - Thread.currentThread().interrupt(); - } - } - - /** - * Compresses a job's output file according to its output clip options and, on - * success, persists the resulting clip against the currently logged-in user (if any). - *

    - * Compression runs asynchronously — this method returns once compression has been - * kicked off, not once it has finished. Progress is tracked via - * {@code job.getStatus().getProcess()}, which is reset at the start of this call. - * If compression or persistence fails, the job's status is marked failed with the - * triggering exception's message. - * - * @param job the job to process - */ - public void processJob(Job job) { - logger.info("Job ready: {}", job.getUuid()); - job.getStatus().getProcess().reset(); - - try { - Optional user = userService.getLoggedInUser(); - compressionService.compress(job.getInputFile(), job.getOutputFile(), job.getOutputClipOptions(), job.getStatus().getProcess()) - .thenRun(() -> user.ifPresent(userVal -> - clipService.persistClip( - job.getOutputClipOptions().getTitle(), - job.getOutputClipOptions().getDescription(), - userVal, - job.getOutputFile(), - job.getInputFile().getName() - ) - )).exceptionally( - ex -> { - job.getStatus().setFailed(true); - job.getStatus().setFailedReason(ex.getMessage()); - logger.error("Error processing job {}: {}", job.getUuid(), ex.getMessage()); - return null; - } - ); - } catch (IOException | InterruptedException e) { - job.getStatus().setFailed(true); - job.getStatus().setFailedReason(e.getMessage()); - logger.error("Error processing job {}: {}", job.getUuid(), e.getMessage()); - Thread.currentThread().interrupt(); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java b/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java index 266ea7e..c4b7d4b 100644 --- a/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java +++ b/src/main/java/com/ddf/vodsystem/services/JobRegistryService.java @@ -6,12 +6,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; +import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; /** * In-memory store for {@link Job} instances, keyed by job UUID. - * Provides add/lookup only — actual job processing is handled by - * {@link JobOrchestrationService}. + * Provides add/lookup only */ @Service public class JobRegistryService { @@ -19,13 +19,16 @@ public class JobRegistryService { private final ConcurrentHashMap jobs = new ConcurrentHashMap<>(); /** - * Adds a new job to the job map. - * - * @param job the job to add + * Creates and returns a new job + * @return Job */ - public void add(Job job) { - logger.info("Added job: {}", job.getUuid()); - jobs.put(job.getUuid(), job); + public Job generateJob() { + String uuid = UUID.randomUUID().toString(); + Job job = new Job(uuid); + + jobs.put(uuid, job); + logger.info("New job with UUID {} created", uuid); + return job; } /** diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java new file mode 100644 index 0000000..8a791a9 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java @@ -0,0 +1,228 @@ +package com.ddf.vodsystem.services; + +import com.ddf.vodsystem.dto.ClipOptions; +import com.ddf.vodsystem.dto.Job; +import com.ddf.vodsystem.dto.JobState; +import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.exceptions.NotAuthenticated; +import com.ddf.vodsystem.services.media.CompressionService; +import com.ddf.vodsystem.services.media.StreamActionsService; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +@Service +public class MediaService { + private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(MediaService.class); + private final JobRegistryService jobRegistryService; + private final DirectoryService directoryService; + private final CompressionService compressionService; + private final UserService userService; + private final ClipService clipService; + + private static final float CLIP_MAX_LENGTH = 180; + private static final int HLS_SEGMENT_LENGTH = 3; + private final StreamActionsService streamActionsService; + + public MediaService(JobRegistryService jobRegistryService, + DirectoryService directoryService, + CompressionService compressionService, + UserService userService, + ClipService clipService, StreamActionsService streamActionsService) { + this.jobRegistryService = jobRegistryService; + this.directoryService = directoryService; + this.compressionService = compressionService; + this.userService = userService; + this.clipService = clipService; + this.streamActionsService = streamActionsService; + } + + public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException { + Job job = jobRegistryService.generateJob(); + + Optional user = userService.getLoggedInUser(); + String filename = job.getUuid() + ".mp4"; + + Path tempInputFile = directoryService.getTempInputDir().resolve(filename); + Path tempOutputFile = directoryService.getTempOutputDir().resolve(filename); + directoryService.saveMultipartFile(tempInputFile, file); + + job.setState(JobState.PROCESSING); + compressionService.compress( + tempInputFile, + tempOutputFile, + clipOptions, + job.getProgressTracker() + ).thenRun(() -> { + job.setDownload(tempOutputFile); + job.setState(JobState.SUCCEEDED); + logger.info("Compression job {} succeeded", job.getUuid()); + + // save clip if user context + user.ifPresent(userVal -> + clipService.persistClip( + clipOptions.getTitle(), + clipOptions.getDescription(), + userVal, + tempOutputFile, + filename + ) + ); + }).exceptionally(ex -> { + job.setState(JobState.FAILED); + job.setErrorOutput(ex.getMessage()); + logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage()); + return null; + }); + + return job; + } + + /** + * Saves a clip of the currently authenticated user's stream between two points in time. + *

    + * Locates the recorded {@code .ts} segments overlapping the requested window, computes the + * trim offset into the first (partially-overlapping) segment and the total clip duration, + * then delegates the actual extraction to {@link StreamActionsService#saveSection}. + * + * @param startTime the inclusive start of the section to save; must be before {@code endTime} + * @param endTime the exclusive end of the section to save + * @throws IllegalArgumentException if {@code startTime} is not before {@code endTime}, + * or if no stream segments exist in the given range + * @throws NotAuthenticated if no user is currently authenticated + * @throws IOException if reading the stream directory or its segments fails + */ + public Job saveSection(Instant startTime, Instant endTime) throws IOException { + if (startTime.isAfter(endTime)) { + throw new IllegalArgumentException("Start time must be before end time"); + } + + User user = userService.getLoggedInUser() + .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); + + Job job = jobRegistryService.generateJob(); + Path streamDirectory = directoryService.getStreamDir(user.getStreamKey()); + List fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime); + + if (fileSegments.isEmpty()) { + throw new IllegalArgumentException("No stream segments found in the given time range"); + } + + long firstSegmentMs = parseTimestampMs(fileSegments.getFirst()); + float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); + float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f; + + job.setState(JobState.PROCESSING); + Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4"); + streamActionsService.saveSection( + fileSegments, + trimOffset, + duration, + outputFile, + job.getProgressTracker() + ).thenRun(() -> { + job.setDownload(outputFile); + job.setState(JobState.SUCCEEDED); + logger.info("Stream save of job {} succeeded", job.getUuid()); + }).exceptionally(ex -> { + job.setState(JobState.FAILED); + job.setErrorOutput(ex.getMessage()); + logger.error("Stream section save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage()); + return null; + }); + + return job; + } + + /** + * Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream. + *

    + * Computes a time window ending at the current instant and beginning {@code duration} seconds + * earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}. + * + * @param duration the length of the clip in seconds, measured back from now; must be greater + * than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values + * are supported and resolved to the millisecond. + * @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]}, + * or if no stream segments exist in the computed window + * @throws NotAuthenticated if no user is currently authenticated + * @throws IOException if reading the stream directory or its segments fails + */ + public Job clip(float duration) throws IOException { + if (duration <= 0 || duration > CLIP_MAX_LENGTH) { + throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds"); + } + + // minusSeconds() is possible, but only does integer seconds, not float + Instant endTime = Instant.now(); + Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000))); + + User user = userService.getLoggedInUser() + .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); + + Job job = jobRegistryService.generateJob(); + Path streamDirectory = directoryService.getStreamDir(user.getStreamKey()); + List fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime); + + if (fileSegments.isEmpty()) { + throw new IllegalArgumentException("No stream segments found in the given time range"); + } + + long firstSegmentMs = parseTimestampMs(fileSegments.getFirst()); + float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); + + Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4"); + streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile, job.getProgressTracker()) + .thenRun(() -> { + job.setDownload(outputFile); + job.setState(JobState.SUCCEEDED); + logger.info("Stream clip of job {} succeeded", job.getUuid()); + clipService.persistClip( + Instant.now().toString(), + "", + user, + outputFile, + outputFile.getFileName().toString() + ); + } + ).exceptionally(ex -> { + job.setState(JobState.FAILED); + job.setErrorOutput(ex.getMessage()); + logger.error("Clip save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage()); + return null; + }); + + return job; + } + + private List getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException { + long startMs = startTime.toEpochMilli(); + long endMs = endTime.toEpochMilli(); + + try (Stream files = Files.list(streamDirectory)) { + return files + .filter(p -> p.getFileName().toString().endsWith(".ts")) + .filter(p -> { + long segMs = parseTimestampMs(p); + return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs; + }) + .sorted(Comparator.comparingLong(this::parseTimestampMs)) + .collect(Collectors.toList()); + } + } + + private long parseTimestampMs(Path path) { + String name = path.getFileName().toString().replace(".ts", ""); + long value = Long.parseLong(name); + // nginx hls_fragment_naming system uses seconds; convert to ms + return value < 1_000_000_000_000L ? value * 1000L : value; + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/StreamService.java b/src/main/java/com/ddf/vodsystem/services/StreamService.java index 309ffa9..4603511 100644 --- a/src/main/java/com/ddf/vodsystem/services/StreamService.java +++ b/src/main/java/com/ddf/vodsystem/services/StreamService.java @@ -6,45 +6,28 @@ import com.ddf.vodsystem.exceptions.AlreadyStreaming; import com.ddf.vodsystem.exceptions.KeyNotFound; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.repositories.StreamRepository; -import com.ddf.vodsystem.services.media.StreamActionsService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.io.File; -import java.io.IOException; -import java.time.Duration; import java.time.Instant; -import java.time.LocalDateTime; import java.util.*; -import java.util.stream.Collectors; @Service public class StreamService { private static final Logger logger = LoggerFactory.getLogger(StreamService.class); private static final int HEARTBEAT_TIMEOUT_SECONDS = 60; - private static final float CLIP_MAX_LENGTH = 180; - private static final int HLS_SEGMENT_LENGTH = 3; private final StreamRepository streamRepository; private final UserService userService; - private final StreamActionsService streamActionsService; - private final DirectoryService directoryService; - private final ClipService clipService; public StreamService(StreamRepository streamRepository, - UserService userService, - StreamActionsService streamActionsService, - DirectoryService directoryService, - ClipService clipService) { + UserService userService) { this.streamRepository = streamRepository; this.userService = userService; - this.streamActionsService = streamActionsService; - this.directoryService = directoryService; - this.clipService = clipService; } public Stream startStream(String streamKey) { @@ -54,7 +37,7 @@ public class StreamService { throw new AlreadyStreaming("User " + user.getUsername() + " is already streaming."); }); - LocalDateTime now = LocalDateTime.now(); + Instant now = Instant.now(); Stream stream = new Stream(); stream.setUser(user); stream.setStartDate(now); @@ -69,7 +52,7 @@ public class StreamService { .orElseThrow(() -> new IllegalStateException( "No active stream found for user " + user.getUsername())); - stream.setEndDate(LocalDateTime.now()); + stream.setEndDate(Instant.now()); streamRepository.saveAndFlush(stream); } @@ -80,7 +63,7 @@ public class StreamService { .orElseThrow(() -> new IllegalStateException( "No active stream found for user " + user.getUsername())); - stream.setLastSeen(LocalDateTime.now()); + stream.setLastSeen(Instant.now()); streamRepository.saveAndFlush(stream); } @@ -111,100 +94,14 @@ public class StreamService { return streamRepository.findByUser(streamUser); } - /** - * Saves a clip of the currently authenticated user's stream between two points in time. - *

    - * Locates the recorded {@code .ts} segments overlapping the requested window, computes the - * trim offset into the first (partially-overlapping) segment and the total clip duration, - * then delegates the actual extraction to {@link StreamActionsService#saveSection}. - * - * @param startTime the inclusive start of the section to save; must be before {@code endTime} - * @param endTime the exclusive end of the section to save - * @throws IllegalArgumentException if {@code startTime} is not before {@code endTime}, - * or if no stream segments exist in the given range - * @throws NotAuthenticated if no user is currently authenticated - * @throws IOException if reading the stream directory or its segments fails - */ - public void saveSection(Instant startTime, Instant endTime) throws IOException { - if (startTime.isAfter(endTime)) { - throw new IllegalArgumentException("Start time must be before end time"); - } - - User user = userService.getLoggedInUser() - .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); - - File streamDirectory = directoryService.getStreamDir(user.getStreamKey()); - List fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime); - - if (fileSegments.isEmpty()) { - throw new IllegalArgumentException("No stream segments found in the given time range"); - } - - long firstSegmentMs = parseTimestampMs(fileSegments.getFirst()); - float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); - float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f; - - File outputFile = directoryService.getVodFile(user.getId(), UUID.randomUUID() + ".mp4"); - streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile); - } - - /** - * Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream. - *

    - * Computes a time window ending at the current instant and beginning {@code duration} seconds - * earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}. - * - * @param duration the length of the clip in seconds, measured back from now; must be greater - * than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values - * are supported and resolved to the millisecond. - * @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]}, - * or if no stream segments exist in the computed window - * @throws NotAuthenticated if no user is currently authenticated - * @throws IOException if reading the stream directory or its segments fails - */ - public void clip(float duration) throws IOException { - if (duration <= 0 || duration > CLIP_MAX_LENGTH) { - throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds"); - } - - // minusSeconds() is possible, but only does integer seconds, not float - Instant endTime = Instant.now(); - Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000))); - - User user = userService.getLoggedInUser() - .orElseThrow(() -> new NotAuthenticated("User is not authenticated")); - - File streamDirectory = directoryService.getStreamDir(user.getStreamKey()); - List fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime); - - if (fileSegments.isEmpty()) { - throw new IllegalArgumentException("No stream segments found in the given time range"); - } - - long firstSegmentMs = parseTimestampMs(fileSegments.getFirst()); - float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f); - - File outputFile = directoryService.getTempOutputFile(UUID.randomUUID() + ".mp4"); - streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile) - .thenRun(() -> - clipService.persistClip( - Instant.now().toString(), - "", - user, - outputFile, - outputFile.getName() - ) - ); - } - @Scheduled(fixedDelay = 30_000) @Transactional public void endStaleStreams() { - LocalDateTime cutoff = LocalDateTime.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS); + Instant cutoff = Instant.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS); List stale = streamRepository.findByEndDateIsNullAndLastSeenBefore(cutoff); if (stale.isEmpty()) return; - LocalDateTime now = LocalDateTime.now(); + Instant now = Instant.now(); for (Stream stream : stale) { stream.setEndDate(now); logger.warn("Stream {} for user {} ended due to heartbeat timeout", stream.getId(), stream.getUser().getUsername()); @@ -216,28 +113,4 @@ public class StreamService { return userService.getUserByStreamKey(streamKey) .orElseThrow(() -> new KeyNotFound("Stream key not found: " + streamKey)); } - - private List getSegmentsInRange(String streamDirectory, Instant startTime, Instant endTime) { - File dir = new File(streamDirectory); - File[] tsFiles = dir.listFiles((d, name) -> name.endsWith(".ts")); - if (tsFiles == null) return List.of(); - - long startMs = startTime.toEpochMilli(); - long endMs = endTime.toEpochMilli(); - - return Arrays.stream(tsFiles) - .filter(f -> { - long segMs = parseTimestampMs(f); - return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs; - }) - .sorted(Comparator.comparingLong(this::parseTimestampMs)) - .collect(Collectors.toList()); - } - - private long parseTimestampMs(File file) { - String name = file.getName().replace(".ts", ""); - long value = Long.parseLong(name); - // nginx hls_fragment_naming system uses seconds; convert to ms - return value < 1_000_000_000_000L ? value * 1000L : value; - } } diff --git a/src/main/java/com/ddf/vodsystem/services/UploadService.java b/src/main/java/com/ddf/vodsystem/services/UploadService.java deleted file mode 100644 index c25bc7a..0000000 --- a/src/main/java/com/ddf/vodsystem/services/UploadService.java +++ /dev/null @@ -1,79 +0,0 @@ -package com.ddf.vodsystem.services; - -import com.ddf.vodsystem.dto.Job; -import com.ddf.vodsystem.dto.ClipOptions; -import com.ddf.vodsystem.exceptions.FFMPEGException; -import com.ddf.vodsystem.exceptions.StorageException; -import com.ddf.vodsystem.services.media.MetadataService; -import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; - -import java.io.File; -import java.io.IOException; -import java.nio.ByteBuffer; -import java.util.Base64; -import java.util.UUID; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -@Service -public class UploadService { - private static final Logger logger = LoggerFactory.getLogger(UploadService.class); - - private final JobRegistryService jobRegistryService; - private final MetadataService metadataService; - private final DirectoryService directoryService; - - public UploadService(JobRegistryService jobRegistryService, - MetadataService metadataService, - DirectoryService directoryService) { - this.jobRegistryService = jobRegistryService; - this.metadataService = metadataService; - this.directoryService = directoryService; - } - - public String upload(MultipartFile file) { - // generate uuid, filename - String uuid = generateShortUUID(); - String extension = directoryService.getFileExtension(file.getOriginalFilename()); - - File inputFile = directoryService.getTempInputFile(uuid + "." + extension); - File outputFile = directoryService.getTempOutputFile(uuid + ".mp4"); - - try { - directoryService.saveMultipartFile(inputFile, file); - } catch (IOException e) { - throw new StorageException("Could not save Multipart file at: " + inputFile, e); - } - - logger.info("Multi-part file uploaded at {}", inputFile); - - ClipOptions clipOptions = getMetadataWithTimeout(inputFile); - Job job = new Job(uuid, inputFile, outputFile, clipOptions); - jobRegistryService.add(job); - - return uuid; - } - - private static String generateShortUUID() { - UUID uuid = UUID.randomUUID(); - ByteBuffer bb = ByteBuffer.wrap(new byte[16]); - bb.putLong(uuid.getMostSignificantBits()); - bb.putLong(uuid.getLeastSignificantBits()); - return Base64.getUrlEncoder().withoutPadding().encodeToString(bb.array()); - } - - private ClipOptions getMetadataWithTimeout(File file) { - try { - return metadataService.getVideoMetadata(file).get(5, TimeUnit.SECONDS); - } catch (ExecutionException | TimeoutException | InterruptedException e) { - Thread.currentThread().interrupt(); - throw new FFMPEGException(e.getMessage()); - } - } - -} diff --git a/src/main/java/com/ddf/vodsystem/services/UserService.java b/src/main/java/com/ddf/vodsystem/services/UserService.java index 51beeaf..ae5d28c 100644 --- a/src/main/java/com/ddf/vodsystem/services/UserService.java +++ b/src/main/java/com/ddf/vodsystem/services/UserService.java @@ -17,7 +17,7 @@ import org.springframework.stereotype.Service; import java.io.IOException; import java.security.GeneralSecurityException; import java.security.SecureRandom; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Collections; import java.util.HexFormat; import java.util.Optional; @@ -77,7 +77,7 @@ public class UserService { if (existingUser.isEmpty()) { user.setRole(0); - user.setCreatedAt(LocalDateTime.now()); + user.setCreatedAt(Instant.now()); user.setStreamKey(generateStreamKey()); return userRepository.saveAndFlush(user); } diff --git a/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java b/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java index 25b3a3e..e3478d5 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java +++ b/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java @@ -1,14 +1,19 @@ package com.ddf.vodsystem.services.media; import com.ddf.vodsystem.dto.CommandOutput; +import com.ddf.vodsystem.dto.ProgressTracker; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.util.List; import java.util.function.Consumer; +import java.util.regex.Matcher; +import java.util.regex.Pattern; public class CommandRunner { + private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); + public static CommandOutput run(List command, Consumer onOutput) throws IOException, InterruptedException { ProcessBuilder processBuilder = new ProcessBuilder(command); processBuilder.redirectErrorStream(true); @@ -40,6 +45,14 @@ public class CommandRunner { return run(command, null); } + public static void setProgress(String line, ProgressTracker progress, float length) { + Matcher matcher = timePattern.matcher(line); + if (matcher.find()) { + float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f; + progress.setProgress(timeInMs / length); + } + } + private CommandRunner() { // Private constructor to prevent instantiation } diff --git a/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java index 425c165..883bf3c 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java @@ -8,8 +8,8 @@ import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -26,15 +26,19 @@ public class CompressionService { private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); @Async("ffmpegTaskExecutor") - public CompletableFuture compress(File inputFile, - File outputFile, + public CompletableFuture compress(Path inputFile, + Path outputFile, ClipOptions clipOptions, ProgressTracker progress ) throws IOException, InterruptedException { - logger.info("Compressing video from {} to {}", inputFile.getAbsolutePath(), outputFile.getAbsolutePath()); + logger.info("Compressing video from {} to {}", inputFile.toAbsolutePath(), outputFile.toAbsolutePath()); - List command = buildCommand(inputFile, outputFile, clipOptions); - CommandOutput result = CommandRunner.run(command, line -> setProgress(line, progress, clipOptions.getDuration())); + List command = buildCommand( + inputFile, + outputFile, + clipOptions + ); + CommandOutput result = CommandRunner.run(command, line -> CommandRunner.setProgress(line, progress, clipOptions.getDuration())); progress.markComplete(); return CompletableFuture.completedFuture(result); @@ -93,14 +97,14 @@ public class CompressionService { return command; } - private List buildInputs(File inputFile, Float startPoint, Float length) { + private List buildInputs(Path 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(inputFile.toAbsolutePath().toString()); command.add("-t"); command.add(Float.toString(length)); @@ -108,7 +112,7 @@ public class CompressionService { return command; } - private List buildCommand(File inputFile, File outputFile, ClipOptions clipOptions) { + private List buildCommand(Path inputFile, Path outputFile, ClipOptions clipOptions) { List command = new ArrayList<>(); command.add("ffmpeg"); command.add("-progress"); @@ -126,7 +130,7 @@ public class CompressionService { } // Output file - command.add(outputFile.getAbsolutePath()); + command.add(outputFile.toAbsolutePath().toString()); return command; } } diff --git a/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java b/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java index ea9290b..85f3330 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java @@ -10,8 +10,8 @@ import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; @@ -21,8 +21,8 @@ public class MetadataService { private static final Logger logger = LoggerFactory.getLogger(MetadataService.class); @Async("ffmpegTaskExecutor") - public Future getVideoMetadata(File file) { - logger.info("Getting metadata for file {}", file.getAbsolutePath()); + public Future getVideoMetadata(Path file) { + logger.info("Getting metadata for file {}", file); List command = List.of( "ffprobe", @@ -30,7 +30,7 @@ public class MetadataService { "-print_format", "json", "-show_format", "-select_streams", "v:0", "-show_entries", "stream=duration,width,height,r_frame_rate:format=size,duration", - "-i", file.getAbsolutePath() + "-i", file.toAbsolutePath().toString() ); ObjectMapper mapper = new ObjectMapper(); @@ -51,48 +51,6 @@ public class MetadataService { } } - public void validateMetadata(ClipOptions inputFileMetadata, ClipOptions outputFileMetadata) { - Float start = outputFileMetadata.getStartPoint(); - Float duration = outputFileMetadata.getDuration(); - Float fileSize = outputFileMetadata.getFileSize(); - Integer width = outputFileMetadata.getWidth(); - Integer height = outputFileMetadata.getHeight(); - Float fps = outputFileMetadata.getFps(); - - if (start == null) { - outputFileMetadata.setStartPoint(0f); - } - - if (duration == null) { - outputFileMetadata.setDuration(inputFileMetadata.getDuration()); - } - - if (start != null && start < 0) { - throw new IllegalArgumentException("Start point cannot be negative"); - } - - if (duration != null && duration < 0) { - throw new IllegalArgumentException("Duration cannot be negative"); - } - - if (fileSize != null && fileSize < 100) { - throw new IllegalArgumentException("File size cannot be less than 100kb"); - } - - if (width != null && width < 1) { - throw new IllegalArgumentException("Width cannot be less than 1"); - } - - if (height != null && height < 1) { - throw new IllegalArgumentException("Height cannot be less than 1"); - } - - if (fps != null && fps < 1) { - throw new IllegalArgumentException("FPS cannot be less than 1"); - } - } - - private ClipOptions parseVideoMetadata(JsonNode node) { ClipOptions metadata = new ClipOptions(); metadata.setStartPoint(0f); diff --git a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java index 091c1bb..201cc22 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -1,11 +1,12 @@ package com.ddf.vodsystem.services.media; +import com.ddf.vodsystem.dto.ProgressTracker; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.File; +import java.nio.file.Path; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; @@ -46,15 +47,16 @@ public class StreamActionsService { * failure */ @Async("ffmpegTaskExecutor") - public CompletableFuture saveSection( - List segments, + public CompletableFuture saveSection( + List segments, float trimOffset, float duration, - File outputFile + Path outputFile, + ProgressTracker progressTracker ) { try { String concatInput = segments.stream() - .map(File::getAbsolutePath) + .map(p -> p.toAbsolutePath().toString()) .collect(Collectors.joining("|")); List command = List.of( @@ -64,16 +66,17 @@ public class StreamActionsService { "-ss", String.valueOf(trimOffset), "-t", String.valueOf(duration), "-c", "copy", - outputFile.getAbsolutePath() + outputFile.toAbsolutePath().toString() ); logger.info("Saving section with ({} segments) to '{}'", - segments.size(), outputFile.getAbsolutePath()); - CommandRunner.run(command, line -> logger.debug("{}", line)); + segments.size(), outputFile); + CommandRunner.run(command, line -> CommandRunner.setProgress(line, progressTracker, duration)); + progressTracker.markComplete(); return CompletableFuture.completedFuture(outputFile); } catch (Exception e) { - logger.error("Failed to save section to '{}'", outputFile.getAbsolutePath(), e); + logger.error("Failed to save section to '{}'", outputFile, e); return CompletableFuture.failedFuture(e); } } diff --git a/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java b/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java index 1ee0da7..7e11508 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java @@ -5,8 +5,8 @@ import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.File; import java.io.IOException; +import java.nio.file.Path; import java.util.List; @Service @@ -14,15 +14,15 @@ public class ThumbnailService { private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class); @Async("ffmpegTaskExecutor") - public void createThumbnail(File inputFile, File outputFile, Float timeInVideo) throws IOException, InterruptedException { + public void createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) throws IOException, InterruptedException { logger.info("Creating thumbnail at {} seconds", timeInVideo); List command = List.of( "ffmpeg", "-ss", timeInVideo.toString(), - "-i", inputFile.getAbsolutePath(), + "-i", inputFile.toAbsolutePath().toString(), "-frames:v", "1", - outputFile.getAbsolutePath() + outputFile.toAbsolutePath().toString() ); CommandRunner.run(command); diff --git a/src/main/resources/application-local.properties b/src/main/resources/application-local.properties index 7b02d27..ec2e735 100644 --- a/src/main/resources/application-local.properties +++ b/src/main/resources/application-local.properties @@ -1 +1,6 @@ -spring.docker.compose.file=docker-compose.dev.yml \ No newline at end of file +spring.docker.compose.file=docker-compose.dev.yml + +media.temp.inputs=./videos/inputs +media.temp.outputs=./videos/outputs +media.users=${MEDIA_DATA} +media.streams=${STREAM_DATA} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 38aa1bd..9701c07 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -3,9 +3,6 @@ spring.application.name=vodSystem # VODs spring.servlet.multipart.max-file-size=2GB spring.servlet.multipart.max-request-size=2GB -storage.temp.inputs=videos/inputs/ -storage.temp.outputs=videos/outputs/ -storage.outputs=${MEDIA_DATA} # Database spring.datasource.url=${POSTGRES_URL} @@ -21,15 +18,20 @@ spring.sql.init.schema-locations=classpath:db/schema.sql spring.security.oauth2.client.registration.google.client-id=${GOOGLE_CLIENT_ID} spring.security.oauth2.client.registration.google.client-secret=${GOOGLE_CLIENT_SECRET} spring.security.oauth2.client.registration.google.scope=profile,email - jwt.secret.key=${JWT_SECRET_KEY} jwt.expiration=3600000 # Frontend frontend.url=${FRONTEND_URL} -# Stream HLS -storage.stream=${STREAM_DATA} +# Paths +media.temp.inputs=/tmp/inputs/ +media.temp.outputs=/tmp/outputs/ +media.users=/app/media/ +media.streams=/app/stream/ ## Server Configuration -server.servlet.session.timeout=30m \ No newline at end of file +server.servlet.session.timeout=30m + +# Serialize Instant as ISO-8601 UTC string (not epoch seconds) +spring.jackson.serialization.write-dates-as-timestamps=false \ No newline at end of file diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index d77fd70..2cc5bab 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -10,7 +10,7 @@ CREATE TABLE IF NOT EXISTS users ( name VARCHAR(100) NOT NULL, profile_picture_url VARCHAR(255), role INTEGER DEFAULT 0, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT NOW(), stream_key VARCHAR(64) NOT NULL UNIQUE ); @@ -29,7 +29,7 @@ CREATE TABLE IF NOT EXISTS clips ( user_id BIGINT NOT NULL, title VARCHAR(255) NOT NULL, description TEXT, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + created_at TIMESTAMPTZ DEFAULT NOW(), width INTEGER NOT NULL, height INTEGER NOT NULL, fps FLOAT NOT NULL,