Merge pull request 'Refactor job into being a simple UUID tracker' (#3) from refactor/generalize-job-abstraction into master
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/3
This commit was merged in pull request #3.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -39,7 +39,7 @@ build/
|
||||
.vscode/
|
||||
|
||||
### Generated Files ###
|
||||
videos/
|
||||
/videos
|
||||
outputs/
|
||||
|
||||
generated
|
||||
|
||||
16
bruno/VoD-System/Clips/Delete Clip.bru
Normal file
16
bruno/VoD-System/Clips/Delete Clip.bru
Normal file
@@ -0,0 +1,16 @@
|
||||
meta {
|
||||
name: Delete Clip
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
delete {
|
||||
url: {{base_url}}/clips/6
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
16
bruno/VoD-System/Clips/Downloads/Get Clip Media.bru
Normal file
16
bruno/VoD-System/Clips/Downloads/Get Clip Media.bru
Normal file
@@ -0,0 +1,16 @@
|
||||
meta {
|
||||
name: Get Clip Media
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/clips/5/media
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
15
bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru
Normal file
15
bruno/VoD-System/Clips/Downloads/Get Clip Thumbnail.bru
Normal file
@@ -0,0 +1,15 @@
|
||||
meta {
|
||||
name: Get Clip Thumbnail
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/clips/5/thumbnail
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
8
bruno/VoD-System/Clips/Downloads/folder.bru
Normal file
8
bruno/VoD-System/Clips/Downloads/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Downloads
|
||||
seq: 4
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
16
bruno/VoD-System/Clips/Get Clip.bru
Normal file
16
bruno/VoD-System/Clips/Get Clip.bru
Normal file
@@ -0,0 +1,16 @@
|
||||
meta {
|
||||
name: Get Clip
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/clips/5
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Get all clips
|
||||
type: http
|
||||
seq: 1
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
meta {
|
||||
name: Update Clip
|
||||
type: http
|
||||
seq: 2
|
||||
seq: 3
|
||||
}
|
||||
|
||||
patch {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
meta {
|
||||
name: Clips
|
||||
seq: 1
|
||||
}
|
||||
|
||||
auth {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Converted Download
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/download/output/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Original Download
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/download/input/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
meta {
|
||||
name: Download
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Process
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/process/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Progress
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/progress/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
meta {
|
||||
name: Edit
|
||||
}
|
||||
15
bruno/VoD-System/Jobs/Download Video.bru
Normal file
15
bruno/VoD-System/Jobs/Download Video.bru
Normal file
@@ -0,0 +1,15 @@
|
||||
meta {
|
||||
name: Download Video
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/jobs/{{uuid}}/download
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
}
|
||||
16
bruno/VoD-System/Jobs/Get Job.bru
Normal file
16
bruno/VoD-System/Jobs/Get Job.bru
Normal file
@@ -0,0 +1,16 @@
|
||||
meta {
|
||||
name: Get Job
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/jobs/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
settings {
|
||||
encodeUrl: true
|
||||
timeout: 0
|
||||
}
|
||||
8
bruno/VoD-System/Jobs/folder.bru
Normal file
8
bruno/VoD-System/Jobs/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Jobs
|
||||
seq: 5
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
21
bruno/VoD-System/Media/Clip Stream.bru
Normal file
21
bruno/VoD-System/Media/Clip Stream.bru
Normal file
@@ -0,0 +1,21 @@
|
||||
meta {
|
||||
name: Clip Stream
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/media/clip
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"duration": 60.0
|
||||
}
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("uuid", res.getBody().data.uuid);
|
||||
}
|
||||
22
bruno/VoD-System/Media/Compress.bru
Normal file
22
bruno/VoD-System/Media/Compress.bru
Normal file
@@ -0,0 +1,22 @@
|
||||
meta {
|
||||
name: Compress
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/media/compress
|
||||
body: multipartForm
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:multipart-form {
|
||||
file: @file(/Users/dylan/Downloads/Is Russia at a Breaking Point_.mp4)
|
||||
title: hello
|
||||
startPoint: 10
|
||||
duration: 30
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("uuid", res.getBody().data.uuid);
|
||||
}
|
||||
22
bruno/VoD-System/Media/Save Stream Section.bru
Normal file
22
bruno/VoD-System/Media/Save Stream Section.bru
Normal file
@@ -0,0 +1,22 @@
|
||||
meta {
|
||||
name: Save Stream Section
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/media/save
|
||||
body: json
|
||||
auth: inherit
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"startTime": "2026-06-21T17:27:00Z",
|
||||
"endTime": "2026-06-21T17:28:00Z"
|
||||
}
|
||||
}
|
||||
|
||||
script:post-response {
|
||||
bru.setEnvVar("uuid", res.getBody().data.uuid);
|
||||
}
|
||||
8
bruno/VoD-System/Media/folder.bru
Normal file
8
bruno/VoD-System/Media/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Media
|
||||
seq: 2
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Converted Metadata
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/metadata/converted/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
meta {
|
||||
name: Original Metadata
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/metadata/original/{{uuid}}
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
meta {
|
||||
name: Metadata
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,12 +1,8 @@
|
||||
meta {
|
||||
name: Streams
|
||||
seq: 7
|
||||
seq: 4
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{token}}
|
||||
mode: inherit
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/auth/user
|
||||
url: {{base_url}}/users/me
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
||||
@@ -5,7 +5,7 @@ meta {
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/auth/login
|
||||
url: {{base_url}}/users/login
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
8
bruno/VoD-System/Users/folder.bru
Normal file
8
bruno/VoD-System/Users/folder.bru
Normal file
@@ -0,0 +1,8 @@
|
||||
meta {
|
||||
name: Users
|
||||
seq: 3
|
||||
}
|
||||
|
||||
auth {
|
||||
mode: inherit
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
|
||||
{ children }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const Box = ({ children, className }: Props) => (
|
||||
<div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Box;
|
||||
@@ -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 (
|
||||
<li className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
|
||||
<li
|
||||
className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
|
||||
onClick={() => onClick(item)}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
);
|
||||
|
||||
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<HTMLDivElement>(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 (
|
||||
<div className={clsx(className, "relative inline-block text-left")}
|
||||
ref={ref}>
|
||||
<div className={clsx(className, "relative inline-block text-left")} ref={ref}>
|
||||
<button
|
||||
onClick={toggleDropdown}
|
||||
onClick={() => setIsOpen(v => !v)}
|
||||
className="inline-flex items-center gap-1 rounded-lg px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100 transition-colors duration-150"
|
||||
>
|
||||
{label}
|
||||
@@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export { Dropdown, DropdownItem };
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import React from "react";
|
||||
|
||||
type props = {
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
label: String;
|
||||
}
|
||||
label: string;
|
||||
};
|
||||
|
||||
const Selector = ({children, label}: props) => {
|
||||
return (
|
||||
<div className={"flex items-center gap-2"}>
|
||||
<label className={"w-full text-sm text-gray-600"}>
|
||||
{ label }
|
||||
</label>
|
||||
<div className={"w-px h-5 bg-gray-200 mx-2"} />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const Selector = ({ children, label }: Props) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="w-full text-sm text-gray-600">{label}</label>
|
||||
<div className="w-px h-5 bg-gray-200 mx-2" />
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default Selector;
|
||||
@@ -31,7 +31,6 @@ const Sidebar = ({user, className}: props) => {
|
||||
<Divider />
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
|
||||
{/*<SidebarButton url="/vods" logo={<Video size={18}/>} label="VoDs" />*/}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import MenuButton from "./MenuButton.tsx";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
|
||||
type props = {
|
||||
type Props = {
|
||||
url: string;
|
||||
logo: React.ReactNode;
|
||||
label: String;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const SidebarButton = ({url, logo, label}: props) => {
|
||||
const SidebarButton = ({url, logo, label}: Props) => {
|
||||
const { pathname } = useLocation();
|
||||
const isActive = url === "/" ? pathname === "/" : pathname.startsWith(url);
|
||||
|
||||
|
||||
@@ -1,42 +1,32 @@
|
||||
import RangeSlider from 'react-range-slider-input';
|
||||
import 'react-range-slider-input/dist/style.css';
|
||||
import {useRef} from "react";
|
||||
import clsx from 'clsx';
|
||||
import { useRef, type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
|
||||
type Props = {
|
||||
videoRef: HTMLVideoElement | null;
|
||||
videoMetadata: VideoMetadata;
|
||||
setSliderValue: Function;
|
||||
setMetadata: Function;
|
||||
setSliderValue: Dispatch<SetStateAction<number>>;
|
||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function ClipRangeSlider({videoRef,
|
||||
videoMetadata,
|
||||
setSliderValue,
|
||||
setMetadata,
|
||||
className}: Props) {
|
||||
const previousRangeSliderInput = useRef<[number, number]>([0, 0]);
|
||||
export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
|
||||
const previousRange = useRef<[number, number]>([0, 0]);
|
||||
|
||||
const handleRangeSliderInput = (val: [number, number]) => {
|
||||
const handleRangeInput = (val: [number, number]) => {
|
||||
if (!videoRef) return;
|
||||
|
||||
if (previousRangeSliderInput.current[0] != val[0]) {
|
||||
if (previousRange.current[0] !== val[0]) {
|
||||
videoRef.currentTime = val[0];
|
||||
setSliderValue(val[0]);
|
||||
} else if (previousRangeSliderInput.current[1] != val[1]) {
|
||||
} else if (previousRange.current[1] !== val[1]) {
|
||||
videoRef.currentTime = val[1];
|
||||
setSliderValue(val[1]);
|
||||
}
|
||||
|
||||
setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
startPoint: val[0],
|
||||
duration: val[1] - val[0]
|
||||
}
|
||||
))
|
||||
previousRangeSliderInput.current = val;
|
||||
setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
|
||||
previousRange.current = val;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -44,9 +34,9 @@ export default function ClipRangeSlider({videoRef,
|
||||
min={0}
|
||||
max={videoMetadata.duration}
|
||||
step={0.1}
|
||||
onInput={handleRangeSliderInput}
|
||||
className={clsx(className)}
|
||||
id={"range-slider"}
|
||||
onInput={handleRangeInput}
|
||||
className={className}
|
||||
id="range-slider"
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,37 +1,28 @@
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
import Selector from "../Selector.tsx";
|
||||
import clsx from "clsx";
|
||||
|
||||
type prop = {
|
||||
setMetadata: Function;
|
||||
type Props = {
|
||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||
className?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const inputClass = "border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors";
|
||||
|
||||
export default function ConfigBox({setMetadata, className}: prop) {
|
||||
export default function ConfigBox({ setMetadata, className }: Props) {
|
||||
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const vals = e.target.value.split(",");
|
||||
setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
width: parseInt(vals[0]),
|
||||
height: parseInt(vals[1])
|
||||
}))
|
||||
}
|
||||
const [width, height] = e.target.value.split(",").map(Number);
|
||||
setMetadata(prev => ({ ...prev, width, height }));
|
||||
};
|
||||
|
||||
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
fps: parseInt(e.target.value)
|
||||
}))
|
||||
}
|
||||
setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
|
||||
};
|
||||
|
||||
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
fileSize: parseInt(e.target.value) * 1000
|
||||
}))
|
||||
}
|
||||
setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("flex flex-col gap-3 p-6", className)}>
|
||||
@@ -64,5 +55,5 @@ export default function ConfigBox({setMetadata, className}: prop) {
|
||||
/>
|
||||
</Selector>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
import clsx from "clsx";
|
||||
import type {VideoMetadata} from "../../utils/types.ts";
|
||||
import { type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
|
||||
type MetadataBoxProps = {
|
||||
setMetadata: Function
|
||||
type Props = {
|
||||
setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
|
||||
className?: string;
|
||||
}
|
||||
};
|
||||
|
||||
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => {
|
||||
return (
|
||||
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter title"
|
||||
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({
|
||||
...prevState,
|
||||
title: e.target.value
|
||||
}))}
|
||||
className="border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const MetadataBox = ({ setMetadata, className }: Props) => (
|
||||
<div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
|
||||
<p className="w-full font-semibold text-gray-800 text-sm">Title</p>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter title"
|
||||
onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
|
||||
className="border border-gray-300 bg-white rounded-md w-full p-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/20 focus:border-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MetadataBox;
|
||||
|
||||
@@ -1,52 +1,37 @@
|
||||
import {useEffect} from "react";
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, type Dispatch, type SetStateAction } from "react";
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
|
||||
type Props = {
|
||||
videoRef: HTMLVideoElement | null;
|
||||
videoMetadata: VideoMetadata;
|
||||
sliderValue: number;
|
||||
setSliderValue: Function;
|
||||
setSliderValue: Dispatch<SetStateAction<number>>;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export default function PlaybackSlider({videoRef,
|
||||
videoMetadata,
|
||||
sliderValue,
|
||||
setSliderValue,
|
||||
className}: Props) {
|
||||
|
||||
export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
|
||||
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!videoRef) return;
|
||||
|
||||
videoRef.currentTime = e.target.valueAsNumber;
|
||||
setSliderValue(e.target.valueAsNumber);
|
||||
}
|
||||
};
|
||||
|
||||
// update slider
|
||||
useEffect(() => {
|
||||
if (!videoRef) return;
|
||||
|
||||
const updateSlider = () => {
|
||||
setSliderValue(videoRef.currentTime);
|
||||
};
|
||||
|
||||
const updateSlider = () => setSliderValue(videoRef.currentTime);
|
||||
videoRef.addEventListener("timeupdate", updateSlider);
|
||||
|
||||
return () => {
|
||||
videoRef.removeEventListener("timeupdate", updateSlider);
|
||||
};
|
||||
return () => videoRef.removeEventListener("timeupdate", updateSlider);
|
||||
}, [videoRef]);
|
||||
|
||||
return (
|
||||
<input
|
||||
type={"range"}
|
||||
type="range"
|
||||
min={0}
|
||||
max={videoMetadata.duration}
|
||||
value={sliderValue}
|
||||
onChange={updateVideo}
|
||||
step={0.1}
|
||||
className={clsx(className)}
|
||||
className={className}
|
||||
/>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useEffect, useState} from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import type { VideoMetadata } from "../../utils/types.ts";
|
||||
import { formatTime } from "../../utils/utils.ts";
|
||||
|
||||
|
||||
type Props = {
|
||||
video: HTMLVideoElement | null;
|
||||
videoMetadata: VideoMetadata;
|
||||
@@ -15,9 +14,10 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [volume, setVolume] = useState(100);
|
||||
|
||||
const Icon = volume === 0 ? VolumeX : volume < 50 ? Volume1 : Volume2;
|
||||
|
||||
const togglePlay = () => {
|
||||
if (!video) return;
|
||||
|
||||
if (video.paused) {
|
||||
video.play();
|
||||
setIsPlaying(true);
|
||||
@@ -27,35 +27,20 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
let Icon;
|
||||
// update icon
|
||||
if (volume == 0) {
|
||||
Icon = VolumeX;
|
||||
} else if (volume < 50) {
|
||||
Icon = Volume1;
|
||||
} else {
|
||||
Icon = Volume2;
|
||||
}
|
||||
|
||||
const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!video) return;
|
||||
const newVolume = parseInt(e.target.value);
|
||||
video.volume = newVolume / 100;
|
||||
setVolume(newVolume);
|
||||
};
|
||||
|
||||
let volume = parseInt(e.target.value)
|
||||
|
||||
video.volume = volume / 100;
|
||||
setVolume(volume);
|
||||
}
|
||||
|
||||
// Sync state with video element changes (e.g., if someone presses spacebar or clicks on the video)
|
||||
// Sync isPlaying when the video is controlled outside this component (e.g. keyboard shortcuts)
|
||||
useEffect(() => {
|
||||
if (!video) return;
|
||||
|
||||
const handlePlay = () => setIsPlaying(true);
|
||||
const handlePause = () => setIsPlaying(false);
|
||||
|
||||
video.addEventListener("play", handlePlay);
|
||||
video.addEventListener("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("play", handlePlay);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
@@ -64,28 +49,26 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
|
||||
|
||||
return (
|
||||
<div className={clsx("flex justify-between items-center mb-2", className)}>
|
||||
<div className={"flex gap-1"}>
|
||||
<div className="flex gap-1">
|
||||
<Icon size={24} />
|
||||
<input
|
||||
type='range'
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
onChange={updateVolume}
|
||||
value={volume}
|
||||
className={"w-20"}
|
||||
onChange={updateVolume}
|
||||
className="w-20"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={togglePlay}
|
||||
className={"hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"}
|
||||
className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
|
||||
>
|
||||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||||
</button>
|
||||
|
||||
<label>
|
||||
<span>
|
||||
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
|
||||
</label>
|
||||
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -25,9 +25,12 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const menuRef = useRef<HTMLDivElement>(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
|
||||
<div className={clsx("flex flex-col group cursor-pointer", className)}>
|
||||
<div className="relative overflow-hidden rounded-lg">
|
||||
<img
|
||||
src={thumbnailAvailable ? API_URL + `/api/v1/download/thumbnail/${id}` : fallbackThumbnail}
|
||||
src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
|
||||
alt="Video Thumbnail"
|
||||
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
|
||||
@@ -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<HTMLVideoElement | null>(null);
|
||||
const videoUrl = API_URL + `/api/v1/download/input/${id}`
|
||||
const [videoUrl, setVideoUrl] = useState<string>('');
|
||||
const [uploadedId, setUploadedId] = useState<string | null>(null);
|
||||
|
||||
const [metadata, setMetadata] = useState<VideoMetadata | null>(null);
|
||||
const [playbackValue, setPlaybackValue] = useState(0);
|
||||
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
|
||||
// default values
|
||||
title: "",
|
||||
description: "",
|
||||
startPoint: 0,
|
||||
@@ -33,41 +36,66 @@ const ClipEdit = () => {
|
||||
const [downloadable, setDownloadable] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
|
||||
<video
|
||||
ref={videoRef}
|
||||
className={"w-full rounded-lg shadow-sm bg-black"}>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<source src={videoUrl} type="video/webm" />
|
||||
<source src={videoUrl} type="video/ogg" />
|
||||
Your browser does not support the video tag. Bzzzz.
|
||||
</video>
|
||||
|
||||
ref={videoRef}
|
||||
src={videoUrl}
|
||||
className={"w-full rounded-lg shadow-sm bg-black"}
|
||||
onLoadedMetadata={handleVideoMetadataLoaded}
|
||||
/>
|
||||
|
||||
<Box className={"w-4/5 h-full m-auto"}>
|
||||
<MetadataBox
|
||||
setMetadata={setOutputMetadata}
|
||||
/>
|
||||
<ConfigBox
|
||||
setMetadata={setOutputMetadata}
|
||||
/>
|
||||
<MetadataBox setMetadata={setOutputMetadata} />
|
||||
<ConfigBox setMetadata={setOutputMetadata} />
|
||||
</Box>
|
||||
|
||||
|
||||
{metadata &&
|
||||
<Box className={"mt-4 p-5"}>
|
||||
<Playbar
|
||||
@@ -158,6 +170,6 @@ const ClipEdit = () => {
|
||||
</Box>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default ClipEdit;
|
||||
@@ -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<File | null>(null);
|
||||
const navigate = useNavigate();
|
||||
const [error, setError] = useState<null | string>(null);
|
||||
const [progress, setProgress] = useState<number>(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<string, string> = {
|
||||
mp4: "video/mp4",
|
||||
webm: "video/webm",
|
||||
ogg: "video/ogg",
|
||||
mov: "video/quicktime",
|
||||
};
|
||||
|
||||
if (extension && extensionToMime[extension]) {
|
||||
return video.canPlayType(extensionToMime[extension]) !== "";
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const press = (() => {
|
||||
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 (
|
||||
<div className="flex items-start justify-center p-8 min-h-full">
|
||||
@@ -84,8 +34,6 @@ const ClipUpload = () => {
|
||||
<BlueButton onClick={press}>Upload</BlueButton>
|
||||
|
||||
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
|
||||
|
||||
<progress value={progress} className="w-full h-1 rounded bg-gray-200" />
|
||||
</Box>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -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 */}
|
||||
<p className="text-xs text-gray-400">
|
||||
Supports MP4, MOV, WebM, and OGG. Non-native formats are converted on upload.
|
||||
Supports MP4, MOV, WebM, and OGG.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<string> => {
|
||||
const response = await fetch(API_URL + '/api/v1/auth/login', {
|
||||
const login = async (googleToken: string): Promise<string> => {
|
||||
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<void> => {
|
||||
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<string> => {
|
||||
const compress = async (file: File, options: VideoMetadata): Promise<string> => {
|
||||
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<JobResponse> => {
|
||||
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<ProgressResult> => {
|
||||
const response = await fetch(API_URL + `/api/v1/progress/${uuid}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch progress: ${response.status}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === "error") {
|
||||
throw new Error(`Failed to fetch progress: ${result.message}`);
|
||||
}
|
||||
|
||||
if (!result.data) {
|
||||
throw new Error('Invalid progress data received');
|
||||
}
|
||||
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<VideoMetadata> => {
|
||||
const response = await fetch(API_URL + `/api/v1/metadata/original/${uuid}`, {credentials: "include"});
|
||||
const getUser = async (): Promise<User | null> => {
|
||||
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<null | User > => {
|
||||
const response = await fetch(API_URL + '/api/v1/auth/user', {credentials: "include"});
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === "error") {
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(result.data);
|
||||
|
||||
return result.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches all clips for the current user.
|
||||
*/
|
||||
const getClips = async (): Promise<Clip[]> => {
|
||||
const 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<Clip | null> => {
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {credentials: "include",});
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}`, { credentials: 'include' });
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.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<Blob> => {
|
||||
const response = await fetch(API_URL + `/api/v1/download/clip/${id}`, {credentials: "include",});
|
||||
const getVideoBlob = async (id: string): Promise<Blob> => {
|
||||
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<Clip> => {
|
||||
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<void> => {
|
||||
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<boolean> => {
|
||||
@@ -303,27 +152,21 @@ const getStreamStatus = async (): Promise<boolean> => {
|
||||
};
|
||||
|
||||
const isThumbnailAvailable = async (id: number): Promise<boolean> => {
|
||||
const response = await fetch(API_URL + `/api/v1/download/thumbnail/${id}`, {credentials: "include"});
|
||||
const response = await fetch(API_URL + `/api/v1/clips/${id}/thumbnail`, { credentials: 'include' });
|
||||
return response.ok;
|
||||
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
export {
|
||||
login,
|
||||
logout,
|
||||
getStreamStatus,
|
||||
uploadFile,
|
||||
editFile,
|
||||
processFile,
|
||||
convertFile,
|
||||
getProgress,
|
||||
getMetadata,
|
||||
compress,
|
||||
getJob,
|
||||
getUser,
|
||||
getClips,
|
||||
getClipById,
|
||||
getVideoBlob,
|
||||
patchClip,
|
||||
deleteClip,
|
||||
isThumbnailAvailable
|
||||
getStreamStatus,
|
||||
isThumbnailAvailable,
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
export { formatTime, stringToDate, dateToTimeAgo };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
@@ -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<Resource> downloadClip(@PathVariable Long id) {
|
||||
Resource resource = clipService.downloadClip(id);
|
||||
|
||||
if (resource == null || !resource.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
@GetMapping("/{id}/thumbnail")
|
||||
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
||||
Resource resource = clipService.downloadThumbnail(id);
|
||||
|
||||
if (resource == null || !resource.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
private ClipResponse convertToDTO(Clip clip) {
|
||||
return new ClipResponse(
|
||||
clip.getId(),
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
package com.ddf.vodsystem.controllers;
|
||||
|
||||
import com.ddf.vodsystem.services.DownloadService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/v1/download")
|
||||
public class DownloadController {
|
||||
private final DownloadService downloadService;
|
||||
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
|
||||
|
||||
@Autowired
|
||||
public DownloadController(DownloadService downloadService) {
|
||||
this.downloadService = downloadService;
|
||||
}
|
||||
|
||||
@GetMapping("/output/{uuid}")
|
||||
public ResponseEntity<Resource> downloadFile(@PathVariable String uuid) {
|
||||
Resource resource = downloadService.downloadOutput(uuid);
|
||||
|
||||
if (resource == null || !resource.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
@GetMapping("/input/{uuid}")
|
||||
public ResponseEntity<Resource> downloadInput(@PathVariable String uuid) {
|
||||
Resource resource = downloadService.downloadInput(uuid);
|
||||
|
||||
if (resource == null || !resource.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
@GetMapping("/clip/{id}")
|
||||
public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
|
||||
Resource resource = downloadService.downloadClip(id);
|
||||
|
||||
if (resource == null || !resource.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
@GetMapping("/thumbnail/{id}")
|
||||
public ResponseEntity<Resource> downloadThumbnail(@PathVariable Long id) {
|
||||
Resource resource = downloadService.downloadThumbnail(id);
|
||||
|
||||
if (resource == null || !resource.exists()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.body(resource);
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package com.ddf.vodsystem.controllers;
|
||||
|
||||
import com.ddf.vodsystem.dto.JobStatus;
|
||||
import com.ddf.vodsystem.dto.ClipOptions;
|
||||
import com.ddf.vodsystem.services.EditService;
|
||||
import com.ddf.vodsystem.dto.APIResponse;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/v1")
|
||||
public class EditController {
|
||||
private final EditService editService;
|
||||
private static final String SUCCESS = "success";
|
||||
|
||||
public EditController(EditService editService) {
|
||||
this.editService = editService;
|
||||
}
|
||||
|
||||
@PostMapping("edit/{uuid}")
|
||||
public ResponseEntity<APIResponse<Void>> edit(@PathVariable("uuid") String uuid, @ModelAttribute ClipOptions clipOptions) {
|
||||
editService.edit(uuid, clipOptions);
|
||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Editing started for UUID: " + uuid, null));
|
||||
}
|
||||
|
||||
@GetMapping("/process/{uuid}")
|
||||
public ResponseEntity<APIResponse<Void>> process(@PathVariable("uuid") String uuid) {
|
||||
editService.process(uuid);
|
||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Processing started for UUID: " + uuid, null));
|
||||
}
|
||||
|
||||
@GetMapping("/convert/{uuid}")
|
||||
public ResponseEntity<APIResponse<Void>> convert(@PathVariable("uuid") String uuid) {
|
||||
editService.convert(uuid);
|
||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Conversion started for UUID: " + uuid, null));
|
||||
}
|
||||
|
||||
@GetMapping("/progress/{uuid}")
|
||||
public ResponseEntity<APIResponse<JobStatus>> getStatus(@PathVariable("uuid") String uuid) {
|
||||
JobStatus status = editService.getStatus(uuid);
|
||||
return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Status for UUID: " + uuid, status));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.ddf.vodsystem.controllers;
|
||||
|
||||
import com.ddf.vodsystem.controllers.dto.JobResponse;
|
||||
import com.ddf.vodsystem.dto.APIResponse;
|
||||
import com.ddf.vodsystem.dto.Job;
|
||||
import com.ddf.vodsystem.exceptions.ClipNotFound;
|
||||
import com.ddf.vodsystem.exceptions.NotReadyException;
|
||||
import com.ddf.vodsystem.services.JobRegistryService;
|
||||
import org.springframework.core.io.FileSystemResource;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/v1/jobs")
|
||||
public class JobController {
|
||||
private final JobRegistryService jobRegistryService;
|
||||
private static final String SUCCESS = "success";
|
||||
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
|
||||
|
||||
public JobController(JobRegistryService jobRegistryService){
|
||||
this.jobRegistryService = jobRegistryService;
|
||||
}
|
||||
|
||||
@GetMapping("/{uuid}")
|
||||
public ResponseEntity<APIResponse<JobResponse>> getJob(@PathVariable String uuid) {
|
||||
Job job = jobRegistryService.getJob(uuid);
|
||||
JobResponse jobResponse = new JobResponse(
|
||||
job.getUuid(),
|
||||
job.getProgressTracker().getProgress(),
|
||||
job.getProgressTracker().isComplete(),
|
||||
job.getState(),
|
||||
job.getErrorOutput(),
|
||||
job.getCreatedAt()
|
||||
);
|
||||
|
||||
return ResponseEntity.ok(new APIResponse<>(
|
||||
SUCCESS,
|
||||
"Job successfully retreived",
|
||||
jobResponse
|
||||
));
|
||||
}
|
||||
|
||||
@GetMapping("/{uuid}/download")
|
||||
public ResponseEntity<Resource> downloadJobFile(@PathVariable String uuid) {
|
||||
Job job = jobRegistryService.getJob(uuid);
|
||||
|
||||
if (job.getDownload() == null) {
|
||||
throw new NotReadyException("Job " + uuid + " not ready for download.");
|
||||
}
|
||||
|
||||
Resource resource = new FileSystemResource(job.getDownload());
|
||||
|
||||
if (!resource.exists()) {
|
||||
throw new ClipNotFound("Job download file " + job.getDownload() + " does not exist.");
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION, String.format(FILENAME_HEADER, resource.getFilename()))
|
||||
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
|
||||
.body(resource);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.ddf.vodsystem.controllers;
|
||||
|
||||
import com.ddf.vodsystem.controllers.dto.ClipSectionRequest;
|
||||
import com.ddf.vodsystem.controllers.dto.SaveSectionRequest;
|
||||
import com.ddf.vodsystem.controllers.dto.UUIDResponse;
|
||||
import com.ddf.vodsystem.dto.Job;
|
||||
import com.ddf.vodsystem.dto.ClipOptions;
|
||||
import com.ddf.vodsystem.dto.APIResponse;
|
||||
import com.ddf.vodsystem.services.MediaService;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/media")
|
||||
public class MediaController {
|
||||
private final MediaService mediaService;
|
||||
private static final String SUCCESS = "success";
|
||||
|
||||
public MediaController(MediaService mediaService) {
|
||||
this.mediaService = mediaService;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/compress", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
public ResponseEntity<APIResponse<UUIDResponse>> compress(
|
||||
@RequestParam("file")MultipartFile file,
|
||||
@Valid @ModelAttribute ClipOptions options
|
||||
) throws IOException, InterruptedException {
|
||||
Job job = mediaService.compress(file, options);
|
||||
return ResponseEntity.ok(new APIResponse<>(
|
||||
SUCCESS,
|
||||
"Compression successfully started",
|
||||
new UUIDResponse(job.getUuid())
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@PostMapping("/save")
|
||||
public ResponseEntity<APIResponse<UUIDResponse>> save(
|
||||
@RequestBody SaveSectionRequest saveSectionRequest) throws IOException {
|
||||
Job job = mediaService.saveSection(saveSectionRequest.startTime(), saveSectionRequest.endTime());
|
||||
return ResponseEntity.ok(new APIResponse<>(
|
||||
SUCCESS,
|
||||
"Section saving successfully started",
|
||||
new UUIDResponse(job.getUuid())
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/clip")
|
||||
public ResponseEntity<APIResponse<UUIDResponse>> clip(
|
||||
@RequestBody ClipSectionRequest clipSectionRequest) throws IOException {
|
||||
Job job = mediaService.clip(clipSectionRequest.duration());
|
||||
return ResponseEntity.ok(new APIResponse<>(
|
||||
SUCCESS,
|
||||
"Clipping successfully started",
|
||||
new UUIDResponse(job.getUuid())
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.ddf.vodsystem.controllers;
|
||||
|
||||
import com.ddf.vodsystem.dto.ClipOptions;
|
||||
import com.ddf.vodsystem.dto.APIResponse;
|
||||
import com.ddf.vodsystem.services.JobRegistryService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/metadata")
|
||||
public class MetadataController {
|
||||
private final JobRegistryService jobRegistryService;
|
||||
|
||||
public MetadataController(
|
||||
JobRegistryService jobRegistryService) {
|
||||
this.jobRegistryService = jobRegistryService;
|
||||
}
|
||||
|
||||
@GetMapping("/original/{uuid}")
|
||||
public ResponseEntity<APIResponse<ClipOptions>> getMetadata(@PathVariable String uuid) {
|
||||
ClipOptions originalMetadata = jobRegistryService.getJob(uuid).getInputClipOptions();
|
||||
|
||||
if (originalMetadata == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(new APIResponse<>("error", "Original metadata not found", null));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.body(new APIResponse<>("success", "Original metadata retrieved", originalMetadata));
|
||||
}
|
||||
|
||||
@GetMapping("/converted/{uuid}")
|
||||
public ResponseEntity<APIResponse<ClipOptions>> getConvertedMetadata(@PathVariable String uuid) {
|
||||
ClipOptions convertedMetadata = jobRegistryService.getJob(uuid).getOutputClipOptions();
|
||||
|
||||
if (convertedMetadata == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(new APIResponse<>("error", "Converted metadata not found", null));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.body(new APIResponse<>("success", "Converted metadata retrieved", convertedMetadata));
|
||||
}
|
||||
}
|
||||
@@ -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<APIResponse<Void>> saveSection(
|
||||
@RequestBody SaveSectionRequest request) throws IOException, InterruptedException {
|
||||
streamService.saveSection(request.startTime(), request.endTime());
|
||||
return ResponseEntity.accepted().body(new APIResponse<>("success", "Section saved", null));
|
||||
}
|
||||
|
||||
@PostMapping("/clip")
|
||||
public ResponseEntity<APIResponse<Void>> saveClip(
|
||||
@RequestBody ClipSectionRequest request) throws IOException, InterruptedException {
|
||||
streamService.clip(request.duration());
|
||||
return ResponseEntity.accepted().body(new APIResponse<>("success", "Clip saved", null));
|
||||
}
|
||||
|
||||
@GetMapping("/history/{userId}")
|
||||
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
|
||||
List<Stream> history = streamService.getStreamHistory(userId);
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.ddf.vodsystem.controllers;
|
||||
|
||||
import com.ddf.vodsystem.dto.APIResponse;
|
||||
import com.ddf.vodsystem.services.UploadService;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("api/v1/upload")
|
||||
public class UploadController {
|
||||
|
||||
private final UploadService uploadService;
|
||||
|
||||
@Autowired
|
||||
public UploadController(UploadService uploadService) {
|
||||
this.uploadService = uploadService;
|
||||
}
|
||||
|
||||
@PostMapping()
|
||||
public ResponseEntity<APIResponse<Object>> uploadVideo(@RequestParam("file") MultipartFile file) {
|
||||
if (file == null || file.isEmpty()) {
|
||||
throw new IllegalArgumentException("Invalid file");
|
||||
}
|
||||
|
||||
String uuid = uploadService.upload(file);
|
||||
APIResponse<Object> response = new APIResponse<>("success", "File uploaded successfully", java.util.Collections.singletonMap("uuid", uuid));
|
||||
return new ResponseEntity<>(response, HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
@@ -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<APIResponse<User>> user() {
|
||||
Optional<User> user = userService.getLoggedInUser();
|
||||
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package com.ddf.vodsystem.controllers.dto;
|
||||
|
||||
public record UUIDResponse(String uuid) {}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
8
src/main/java/com/ddf/vodsystem/dto/JobState.java
Normal file
8
src/main/java/com/ddf/vodsystem/dto/JobState.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package com.ddf.vodsystem.dto;
|
||||
|
||||
public enum JobState {
|
||||
READY,
|
||||
PROCESSING,
|
||||
SUCCEEDED,
|
||||
FAILED
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.ddf.vodsystem.exceptions;
|
||||
|
||||
public class NotReadyException extends RuntimeException {
|
||||
public NotReadyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -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<Stream, Long> {
|
||||
Optional<Stream> findByUserAndEndDateIsNull(User user);
|
||||
List<Stream> findByUser(User user);
|
||||
List<Stream> findByEndDateIsNullAndLastSeenBefore(LocalDateTime cutoff);
|
||||
List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff);
|
||||
}
|
||||
|
||||
@@ -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> 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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Clip> possibleClip = getClipById(id);
|
||||
|
||||
if (possibleClip.isEmpty()) {
|
||||
throw new ClipNotFound("Clip " + id + " doesn't exist");
|
||||
}
|
||||
|
||||
Clip clip = possibleClip.get();
|
||||
|
||||
if (!isAuthenticatedForClip(clip)) {
|
||||
throw new NotAuthenticated("Not authenticated for this clip");
|
||||
}
|
||||
|
||||
String path = clip.getVideoPath();
|
||||
Path file = directoryService.resolvePath(path);
|
||||
|
||||
if (!Files.exists(file)) {
|
||||
throw new JobNotFound("Clip file not found");
|
||||
}
|
||||
|
||||
return new FileSystemResource(file);
|
||||
}
|
||||
|
||||
public Resource downloadThumbnail(Long id) {
|
||||
Optional<Clip> possibleClip = getClipById(id);
|
||||
|
||||
if (possibleClip.isEmpty()) {
|
||||
throw new ClipNotFound("Clip " + id + " doesn't exist");
|
||||
}
|
||||
|
||||
Clip clip = possibleClip.get();
|
||||
|
||||
if (!isAuthenticatedForClip(clip)) {
|
||||
throw new NotAuthenticated("Not authenticated for this clip thumbnail");
|
||||
}
|
||||
|
||||
String path = clip.getThumbnailPath();
|
||||
Path file = directoryService.resolvePath(path);
|
||||
|
||||
if (!Files.exists(file)) {
|
||||
throw new JobNotFound("Thumbnail file not found");
|
||||
}
|
||||
|
||||
return new FileSystemResource(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists a clip to the database
|
||||
* @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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Path> 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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package com.ddf.vodsystem.services;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Optional;
|
||||
|
||||
import com.ddf.vodsystem.dto.Job;
|
||||
import com.ddf.vodsystem.entities.User;
|
||||
import com.ddf.vodsystem.exceptions.StorageException;
|
||||
import com.ddf.vodsystem.services.media.CompressionService;
|
||||
import com.ddf.vodsystem.services.media.RemuxService;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Orchestrates the processing pipeline for a {@link Job}: converting (remuxing)
|
||||
* the input file and, separately, compressing/persisting the output clip.
|
||||
* <p>
|
||||
* Job storage and lookup are handled by {@link JobRegistryService}; this class
|
||||
* is only responsible for acting on a {@link Job} instance once it's been retrieved.
|
||||
* Long-running steps (remux, compress) are kicked off asynchronously via
|
||||
* {@link java.util.concurrent.CompletableFuture}-returning service calls rather
|
||||
* than blocked on, so callers should not assume the job is finished when these
|
||||
* methods return.
|
||||
*/
|
||||
@Service
|
||||
public class JobOrchestrationService {
|
||||
private static final Logger logger = LoggerFactory.getLogger(JobOrchestrationService.class);
|
||||
private final ClipService clipService;
|
||||
private final RemuxService remuxService;
|
||||
private final DirectoryService directoryService;
|
||||
private final CompressionService compressionService;
|
||||
private final UserService userService;
|
||||
|
||||
/**
|
||||
* Constructs the orchestration service with its collaborating services.
|
||||
*
|
||||
* @param clipService used to persist a completed clip's metadata once processing succeeds
|
||||
* @param remuxService performs the remux step during conversion
|
||||
* @param compressionService performs the compression step during processing
|
||||
* @param directoryService used for filesystem operations (e.g. copying the input file to a temp location)
|
||||
* @param userService used to resolve the currently logged-in user when persisting a clip
|
||||
*/
|
||||
public JobOrchestrationService(ClipService clipService,
|
||||
RemuxService remuxService,
|
||||
CompressionService compressionService,
|
||||
DirectoryService directoryService,
|
||||
UserService userService) {
|
||||
this.clipService = clipService;
|
||||
this.remuxService = remuxService;
|
||||
this.directoryService = directoryService;
|
||||
this.compressionService = compressionService;
|
||||
this.userService = userService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a job's input file in place by remuxing it.
|
||||
* <p>
|
||||
* The original input file is first copied to a temporary {@code .temp} file so the
|
||||
* remux can read from a stable source; on successful completion the temp file is
|
||||
* deleted. The remux itself runs asynchronously — this method returns once the
|
||||
* remux has been kicked off, not once it has finished. Progress/completion is
|
||||
* tracked via {@code job.getStatus().getConversion()}, which is reset at the
|
||||
* start of this call and marked complete in the remux's completion callback.
|
||||
*
|
||||
* @param job the job whose input file should be converted
|
||||
* @throws StorageException if the temporary file cannot be created, or if it
|
||||
* cannot be deleted after a successful remux
|
||||
*/
|
||||
public void convertJob(Job job) {
|
||||
logger.info("Converting job: {}", job.getUuid());
|
||||
File tempFile = new File(job.getInputFile().getAbsolutePath() + ".temp");
|
||||
|
||||
try {
|
||||
directoryService.copyFile(job.getInputFile(), tempFile);
|
||||
} catch (IOException e) {
|
||||
throw new StorageException("Could not create temporary file: " + tempFile, e);
|
||||
}
|
||||
|
||||
job.getStatus().getConversion().reset();
|
||||
|
||||
try {
|
||||
remuxService.remux(
|
||||
tempFile,
|
||||
job.getInputFile(),
|
||||
job.getStatus().getConversion(),
|
||||
job.getInputClipOptions().getDuration())
|
||||
.thenRun(() -> {
|
||||
job.getStatus().getConversion().markComplete();
|
||||
|
||||
try {
|
||||
Files.deleteIfExists(Paths.get(tempFile.getAbsolutePath()));
|
||||
} catch (IOException e) {
|
||||
throw new StorageException("Could not delete path at: " + tempFile.getAbsolutePath(), e);
|
||||
}
|
||||
}).whenComplete((ignored, throwable) -> {
|
||||
if (throwable != null) {
|
||||
logger.error("Remux failed for jobId={}", job.getUuid(), throwable);
|
||||
} else {
|
||||
logger.info("Remux completed for jobId={}", job.getUuid());
|
||||
}
|
||||
});
|
||||
|
||||
} catch (IOException | InterruptedException e) {
|
||||
logger.error("Error converting job {}: {}", job.getUuid(), e.getMessage());
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compresses a job's output file according to its output clip options and, on
|
||||
* success, persists the resulting clip against the currently logged-in user (if any).
|
||||
* <p>
|
||||
* Compression runs asynchronously — this method returns once compression has been
|
||||
* kicked off, not once it has finished. Progress is tracked via
|
||||
* {@code job.getStatus().getProcess()}, which is reset at the start of this call.
|
||||
* If compression or persistence fails, the job's status is marked failed with the
|
||||
* triggering exception's message.
|
||||
*
|
||||
* @param job the job to process
|
||||
*/
|
||||
public void processJob(Job job) {
|
||||
logger.info("Job ready: {}", job.getUuid());
|
||||
job.getStatus().getProcess().reset();
|
||||
|
||||
try {
|
||||
Optional<User> user = userService.getLoggedInUser();
|
||||
compressionService.compress(job.getInputFile(), job.getOutputFile(), job.getOutputClipOptions(), job.getStatus().getProcess())
|
||||
.thenRun(() -> user.ifPresent(userVal ->
|
||||
clipService.persistClip(
|
||||
job.getOutputClipOptions().getTitle(),
|
||||
job.getOutputClipOptions().getDescription(),
|
||||
userVal,
|
||||
job.getOutputFile(),
|
||||
job.getInputFile().getName()
|
||||
)
|
||||
)).exceptionally(
|
||||
ex -> {
|
||||
job.getStatus().setFailed(true);
|
||||
job.getStatus().setFailedReason(ex.getMessage());
|
||||
logger.error("Error processing job {}: {}", job.getUuid(), ex.getMessage());
|
||||
return null;
|
||||
}
|
||||
);
|
||||
} catch (IOException | InterruptedException e) {
|
||||
job.getStatus().setFailed(true);
|
||||
job.getStatus().setFailedReason(e.getMessage());
|
||||
logger.error("Error processing job {}: {}", job.getUuid(), e.getMessage());
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, Job> 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
228
src/main/java/com/ddf/vodsystem/services/MediaService.java
Normal file
228
src/main/java/com/ddf/vodsystem/services/MediaService.java
Normal file
@@ -0,0 +1,228 @@
|
||||
package com.ddf.vodsystem.services;
|
||||
|
||||
import com.ddf.vodsystem.dto.ClipOptions;
|
||||
import com.ddf.vodsystem.dto.Job;
|
||||
import com.ddf.vodsystem.dto.JobState;
|
||||
import com.ddf.vodsystem.entities.User;
|
||||
import com.ddf.vodsystem.exceptions.NotAuthenticated;
|
||||
import com.ddf.vodsystem.services.media.CompressionService;
|
||||
import com.ddf.vodsystem.services.media.StreamActionsService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Service
|
||||
public class MediaService {
|
||||
private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(MediaService.class);
|
||||
private final JobRegistryService jobRegistryService;
|
||||
private final DirectoryService directoryService;
|
||||
private final CompressionService compressionService;
|
||||
private final UserService userService;
|
||||
private final ClipService clipService;
|
||||
|
||||
private static final float CLIP_MAX_LENGTH = 180;
|
||||
private static final int HLS_SEGMENT_LENGTH = 3;
|
||||
private final StreamActionsService streamActionsService;
|
||||
|
||||
public MediaService(JobRegistryService jobRegistryService,
|
||||
DirectoryService directoryService,
|
||||
CompressionService compressionService,
|
||||
UserService userService,
|
||||
ClipService clipService, StreamActionsService streamActionsService) {
|
||||
this.jobRegistryService = jobRegistryService;
|
||||
this.directoryService = directoryService;
|
||||
this.compressionService = compressionService;
|
||||
this.userService = userService;
|
||||
this.clipService = clipService;
|
||||
this.streamActionsService = streamActionsService;
|
||||
}
|
||||
|
||||
public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException {
|
||||
Job job = jobRegistryService.generateJob();
|
||||
|
||||
Optional<User> user = userService.getLoggedInUser();
|
||||
String filename = job.getUuid() + ".mp4";
|
||||
|
||||
Path tempInputFile = directoryService.getTempInputDir().resolve(filename);
|
||||
Path tempOutputFile = directoryService.getTempOutputDir().resolve(filename);
|
||||
directoryService.saveMultipartFile(tempInputFile, file);
|
||||
|
||||
job.setState(JobState.PROCESSING);
|
||||
compressionService.compress(
|
||||
tempInputFile,
|
||||
tempOutputFile,
|
||||
clipOptions,
|
||||
job.getProgressTracker()
|
||||
).thenRun(() -> {
|
||||
job.setDownload(tempOutputFile);
|
||||
job.setState(JobState.SUCCEEDED);
|
||||
logger.info("Compression job {} succeeded", job.getUuid());
|
||||
|
||||
// save clip if user context
|
||||
user.ifPresent(userVal ->
|
||||
clipService.persistClip(
|
||||
clipOptions.getTitle(),
|
||||
clipOptions.getDescription(),
|
||||
userVal,
|
||||
tempOutputFile,
|
||||
filename
|
||||
)
|
||||
);
|
||||
}).exceptionally(ex -> {
|
||||
job.setState(JobState.FAILED);
|
||||
job.setErrorOutput(ex.getMessage());
|
||||
logger.error("Compression job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a clip of the currently authenticated user's stream between two points in time.
|
||||
* <p>
|
||||
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
|
||||
* trim offset into the first (partially-overlapping) segment and the total clip duration,
|
||||
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
|
||||
*
|
||||
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
|
||||
* @param endTime the exclusive end of the section to save
|
||||
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
|
||||
* or if no stream segments exist in the given range
|
||||
* @throws NotAuthenticated if no user is currently authenticated
|
||||
* @throws IOException if reading the stream directory or its segments fails
|
||||
*/
|
||||
public Job saveSection(Instant startTime, Instant endTime) throws IOException {
|
||||
if (startTime.isAfter(endTime)) {
|
||||
throw new IllegalArgumentException("Start time must be before end time");
|
||||
}
|
||||
|
||||
User user = userService.getLoggedInUser()
|
||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||
|
||||
Job job = jobRegistryService.generateJob();
|
||||
Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
|
||||
List<Path> fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
|
||||
|
||||
if (fileSegments.isEmpty()) {
|
||||
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||
}
|
||||
|
||||
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
|
||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
|
||||
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
|
||||
|
||||
job.setState(JobState.PROCESSING);
|
||||
Path outputFile = directoryService.getVodsDir(user.getId()).resolve(UUID.randomUUID() + ".mp4");
|
||||
streamActionsService.saveSection(
|
||||
fileSegments,
|
||||
trimOffset,
|
||||
duration,
|
||||
outputFile,
|
||||
job.getProgressTracker()
|
||||
).thenRun(() -> {
|
||||
job.setDownload(outputFile);
|
||||
job.setState(JobState.SUCCEEDED);
|
||||
logger.info("Stream save of job {} succeeded", job.getUuid());
|
||||
}).exceptionally(ex -> {
|
||||
job.setState(JobState.FAILED);
|
||||
job.setErrorOutput(ex.getMessage());
|
||||
logger.error("Stream section save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
|
||||
* <p>
|
||||
* Computes a time window ending at the current instant and beginning {@code duration} seconds
|
||||
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
|
||||
*
|
||||
* @param duration the length of the clip in seconds, measured back from now; must be greater
|
||||
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
|
||||
* are supported and resolved to the millisecond.
|
||||
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
|
||||
* or if no stream segments exist in the computed window
|
||||
* @throws NotAuthenticated if no user is currently authenticated
|
||||
* @throws IOException if reading the stream directory or its segments fails
|
||||
*/
|
||||
public Job clip(float duration) throws IOException {
|
||||
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
|
||||
}
|
||||
|
||||
// minusSeconds() is possible, but only does integer seconds, not float
|
||||
Instant endTime = Instant.now();
|
||||
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
|
||||
|
||||
User user = userService.getLoggedInUser()
|
||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||
|
||||
Job job = jobRegistryService.generateJob();
|
||||
Path streamDirectory = directoryService.getStreamDir(user.getStreamKey());
|
||||
List<Path> fileSegments = getSegmentsInRange(streamDirectory, startTime, endTime);
|
||||
|
||||
if (fileSegments.isEmpty()) {
|
||||
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||
}
|
||||
|
||||
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
|
||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
|
||||
|
||||
Path outputFile = directoryService.getTempOutputDir().resolve(UUID.randomUUID() + ".mp4");
|
||||
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile, job.getProgressTracker())
|
||||
.thenRun(() -> {
|
||||
job.setDownload(outputFile);
|
||||
job.setState(JobState.SUCCEEDED);
|
||||
logger.info("Stream clip of job {} succeeded", job.getUuid());
|
||||
clipService.persistClip(
|
||||
Instant.now().toString(),
|
||||
"",
|
||||
user,
|
||||
outputFile,
|
||||
outputFile.getFileName().toString()
|
||||
);
|
||||
}
|
||||
).exceptionally(ex -> {
|
||||
job.setState(JobState.FAILED);
|
||||
job.setErrorOutput(ex.getMessage());
|
||||
logger.error("Clip save job with UUID {} failed due to: {}", job.getUuid(), ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
private List<Path> getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException {
|
||||
long startMs = startTime.toEpochMilli();
|
||||
long endMs = endTime.toEpochMilli();
|
||||
|
||||
try (Stream<Path> files = Files.list(streamDirectory)) {
|
||||
return files
|
||||
.filter(p -> p.getFileName().toString().endsWith(".ts"))
|
||||
.filter(p -> {
|
||||
long segMs = parseTimestampMs(p);
|
||||
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
||||
})
|
||||
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
private long parseTimestampMs(Path path) {
|
||||
String name = path.getFileName().toString().replace(".ts", "");
|
||||
long value = Long.parseLong(name);
|
||||
// nginx hls_fragment_naming system uses seconds; convert to ms
|
||||
return value < 1_000_000_000_000L ? value * 1000L : value;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
* <p>
|
||||
* Locates the recorded {@code .ts} segments overlapping the requested window, computes the
|
||||
* trim offset into the first (partially-overlapping) segment and the total clip duration,
|
||||
* then delegates the actual extraction to {@link StreamActionsService#saveSection}.
|
||||
*
|
||||
* @param startTime the inclusive start of the section to save; must be before {@code endTime}
|
||||
* @param endTime the exclusive end of the section to save
|
||||
* @throws IllegalArgumentException if {@code startTime} is not before {@code endTime},
|
||||
* or if no stream segments exist in the given range
|
||||
* @throws NotAuthenticated if no user is currently authenticated
|
||||
* @throws IOException if reading the stream directory or its segments fails
|
||||
*/
|
||||
public void saveSection(Instant startTime, Instant endTime) throws IOException {
|
||||
if (startTime.isAfter(endTime)) {
|
||||
throw new IllegalArgumentException("Start time must be before end time");
|
||||
}
|
||||
|
||||
User user = userService.getLoggedInUser()
|
||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||
|
||||
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
|
||||
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
|
||||
|
||||
if (fileSegments.isEmpty()) {
|
||||
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||
}
|
||||
|
||||
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
|
||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
|
||||
float duration = (endTime.toEpochMilli() - startTime.toEpochMilli()) / 1000f;
|
||||
|
||||
File outputFile = directoryService.getVodFile(user.getId(), UUID.randomUUID() + ".mp4");
|
||||
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves a clip of the last {@code duration} seconds of the currently authenticated user's stream.
|
||||
* <p>
|
||||
* Computes a time window ending at the current instant and beginning {@code duration} seconds
|
||||
* earlier (with millisecond precision), then delegates to {@link #saveSection(Instant, Instant)}.
|
||||
*
|
||||
* @param duration the length of the clip in seconds, measured back from now; must be greater
|
||||
* than {@code 0} and no greater than {@link #CLIP_MAX_LENGTH}. Fractional values
|
||||
* are supported and resolved to the millisecond.
|
||||
* @throws IllegalArgumentException if {@code duration} is not in the range {@code (0, CLIP_MAX_LENGTH]},
|
||||
* or if no stream segments exist in the computed window
|
||||
* @throws NotAuthenticated if no user is currently authenticated
|
||||
* @throws IOException if reading the stream directory or its segments fails
|
||||
*/
|
||||
public void clip(float duration) throws IOException {
|
||||
if (duration <= 0 || duration > CLIP_MAX_LENGTH) {
|
||||
throw new IllegalArgumentException("Clip length must be between 0 and " + CLIP_MAX_LENGTH + " seconds");
|
||||
}
|
||||
|
||||
// minusSeconds() is possible, but only does integer seconds, not float
|
||||
Instant endTime = Instant.now();
|
||||
Instant startTime = endTime.minus(Duration.ofMillis((long) (duration * 1000)));
|
||||
|
||||
User user = userService.getLoggedInUser()
|
||||
.orElseThrow(() -> new NotAuthenticated("User is not authenticated"));
|
||||
|
||||
File streamDirectory = directoryService.getStreamDir(user.getStreamKey());
|
||||
List<File> fileSegments = getSegmentsInRange(streamDirectory.getAbsolutePath(), startTime, endTime);
|
||||
|
||||
if (fileSegments.isEmpty()) {
|
||||
throw new IllegalArgumentException("No stream segments found in the given time range");
|
||||
}
|
||||
|
||||
long firstSegmentMs = parseTimestampMs(fileSegments.getFirst());
|
||||
float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstSegmentMs) / 1000f);
|
||||
|
||||
File outputFile = directoryService.getTempOutputFile(UUID.randomUUID() + ".mp4");
|
||||
streamActionsService.saveSection(fileSegments, trimOffset, duration, outputFile)
|
||||
.thenRun(() ->
|
||||
clipService.persistClip(
|
||||
Instant.now().toString(),
|
||||
"",
|
||||
user,
|
||||
outputFile,
|
||||
outputFile.getName()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@Scheduled(fixedDelay = 30_000)
|
||||
@Transactional
|
||||
public void endStaleStreams() {
|
||||
LocalDateTime cutoff = LocalDateTime.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS);
|
||||
Instant cutoff = Instant.now().minusSeconds(HEARTBEAT_TIMEOUT_SECONDS);
|
||||
List<Stream> stale = streamRepository.findByEndDateIsNullAndLastSeenBefore(cutoff);
|
||||
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<File> getSegmentsInRange(String streamDirectory, Instant startTime, Instant endTime) {
|
||||
File dir = new File(streamDirectory);
|
||||
File[] tsFiles = dir.listFiles((d, name) -> name.endsWith(".ts"));
|
||||
if (tsFiles == null) return List.of();
|
||||
|
||||
long startMs = startTime.toEpochMilli();
|
||||
long endMs = endTime.toEpochMilli();
|
||||
|
||||
return Arrays.stream(tsFiles)
|
||||
.filter(f -> {
|
||||
long segMs = parseTimestampMs(f);
|
||||
return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs;
|
||||
})
|
||||
.sorted(Comparator.comparingLong(this::parseTimestampMs))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private long parseTimestampMs(File file) {
|
||||
String name = file.getName().replace(".ts", "");
|
||||
long value = Long.parseLong(name);
|
||||
// nginx hls_fragment_naming system uses seconds; convert to ms
|
||||
return value < 1_000_000_000_000L ? value * 1000L : value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<String> command, Consumer<String> 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
|
||||
}
|
||||
|
||||
@@ -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<CommandOutput> compress(File inputFile,
|
||||
File outputFile,
|
||||
public CompletableFuture<CommandOutput> 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<String> command = buildCommand(inputFile, outputFile, clipOptions);
|
||||
CommandOutput result = CommandRunner.run(command, line -> setProgress(line, progress, clipOptions.getDuration()));
|
||||
List<String> 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<String> buildInputs(File inputFile, Float startPoint, Float length) {
|
||||
private List<String> buildInputs(Path inputFile, Float startPoint, Float length) {
|
||||
List<String> command = new ArrayList<>();
|
||||
|
||||
command.add("-ss");
|
||||
command.add(startPoint.toString());
|
||||
|
||||
command.add("-i");
|
||||
command.add(inputFile.getAbsolutePath());
|
||||
command.add(inputFile.toAbsolutePath().toString());
|
||||
|
||||
command.add("-t");
|
||||
command.add(Float.toString(length));
|
||||
@@ -108,7 +112,7 @@ public class CompressionService {
|
||||
return command;
|
||||
}
|
||||
|
||||
private List<String> buildCommand(File inputFile, File outputFile, ClipOptions clipOptions) {
|
||||
private List<String> buildCommand(Path inputFile, Path outputFile, ClipOptions clipOptions) {
|
||||
List<String> command = new ArrayList<>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ClipOptions> getVideoMetadata(File file) {
|
||||
logger.info("Getting metadata for file {}", file.getAbsolutePath());
|
||||
public Future<ClipOptions> getVideoMetadata(Path file) {
|
||||
logger.info("Getting metadata for file {}", file);
|
||||
|
||||
List<String> 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);
|
||||
|
||||
@@ -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<File> saveSection(
|
||||
List<File> segments,
|
||||
public CompletableFuture<Path> saveSection(
|
||||
List<Path> 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<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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);
|
||||
|
||||
@@ -1 +1,6 @@
|
||||
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}
|
||||
@@ -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
|
||||
|
||||
# Serialize Instant as ISO-8601 UTC string (not epoch seconds)
|
||||
spring.jackson.serialization.write-dates-as-timestamps=false
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user