36 Commits

Author SHA1 Message Date
d6c703457c Merge pull request 'Update prod to master' (#4) from master into prod
Reviewed-on: https://gitea.vodsystem.ddns.net/dylan/vod-system/pulls/4
2026-06-25 23:28:51 +01:00
0b2c875889 fix(datetime): local datetime parsing 2026-06-25 23:17:03 +01:00
7db25194bc fix(datetime): incorrect datetime parsing 2026-06-25 23:10:32 +01:00
3d57007074 fix(maintainability): clean up reliability issues 2026-06-25 20:39:06 +01:00
5e00965baa 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
2026-06-25 20:26:08 +01:00
0e13e44feb fix(tailwind): top-level gitignore cause tailwind css issues
20:22
Scoped the bare video entry in the root .gitignore to its intended path. Tailwind V4 respects all .gitignore files in the project when scanning for utility classes, so the bare entry was silently preventing components/video/* from being scanned and its classes from appearing in the computed CSS output. The bug was absent in Docker as the root .gitignore was never copied into the container.
2026-06-25 20:22:44 +01:00
42294703ae chore(backend): remove LocalDateTime usage, replaced with Instant 2026-06-25 20:21:48 +01:00
755a18fc3a chore: update gitignore 2026-06-23 21:39:53 +01:00
8b299dc4fa fix(directories): inputs absolute paths into db 2026-06-23 21:31:45 +01:00
5651aa4ce9 refactor(directories): switch from File to Path objs 2026-06-23 20:53:24 +01:00
8b49a10a8c refactor(frontend): remove dead code 2026-06-22 22:50:07 +01:00
e36913d671 fix(frontend): update frontend to match new endpoints 2026-06-22 22:42:13 +01:00
7d8a62d331 docs(bruno): missing post response script 2026-06-22 22:34:18 +01:00
c571e12099 docs(bruno): remove old endpoints 2026-06-22 22:10:33 +01:00
e9abdfb44a docs(bruno): new bruno files 2026-06-22 22:09:10 +01:00
dc5af8f868 docs(bruno): overhaul bruno to match new API 2026-06-22 22:08:56 +01:00
da6bf16d07 refactor(media): consolidate upload/edit/download into MediaService
Merge UploadService, EditService, JobOrchestrationService, and
DownloadService into a single MediaService with a unified Job lifecycle
(READY → PROCESSING → SUCCEEDED/FAILED via JobState enum).

- Add MediaController (/api/v1/media) exposing compress, save, and clip
- Add JobController (/api/v1/job) for status polling and file download
- Move clip/thumbnail download into ClipController (/{id}/media, /{id}/thumbnail)
- Move stream saveSection/clip from StreamService into MediaService
- Simplify Job DTO: remove dual ClipOptions/files, add ProgressTracker and download file
- Refactor JobRegistryService.add() → generateJob() to co-locate creation and registration
- Wire FFmpeg out_time_ms progress parsing into StreamActionsService via CommandRunner
- Rename GET /api/v1/auth/user → GET /api/v1/user/me
- Delete DownloadController, MetadataController, UploadController
2026-06-22 20:54:16 +01:00
b732c76aef Merge branch 'master' into refactor/generalize-job-abstraction 2026-06-22 18:32:45 +01:00
6c1f1112dc feat(stream): clipping stream now saves to db 2026-06-21 18:31:26 +01:00
9185a41147 feat(frontend): remove server-side upload process and switch to browser process 2026-06-20 22:04:42 +01:00
b05ae62230 refactor(dto): use record for Token DTO 2026-06-20 19:01:59 +01:00
a6850d3d65 refactor(jobs): split JobService into JobRegistryService and JobOrchestrationService 2026-06-20 18:50:14 +01:00
7cee64beb3 refactor(stream): centralize stream directory resolution in DirectoryService 2026-06-20 18:38:47 +01:00
a0efa3eb8c feat(stream): add streaming endpoint and indicator 2026-06-20 16:21:18 +01:00
2b1f8ce710 refactor(dto): move API DTOs 2026-06-20 16:04:19 +01:00
06f392a3ff refactor(dto): use Record instead of Lombok data 2026-06-20 14:54:36 +01:00
07305f0513 fix(frontend): add no auth screen for clips 2026-06-20 14:49:10 +01:00
b84950a2b8 feat(frontend): update home page 2026-06-20 14:38:35 +01:00
637d6c94ed feat(frontend): add edit clip page 2026-06-20 14:30:22 +01:00
bc5c50d025 docs(bruno): add edit endpoint 2026-06-20 14:24:17 +01:00
3797fe1feb feat(frontend): improve video player 2026-06-20 14:24:03 +01:00
fd1b0e19a1 feat(clips): add edit functionality for title & description 2026-06-20 14:23:50 +01:00
8059963210 feat(frontend): order clips by newest first 2026-06-20 14:04:00 +01:00
85d6f568db feat(frontend): add delete option for clips 2026-06-20 13:02:47 +01:00
91c3259331 feat(frontend): restyle and design frontend 2026-06-20 12:52:51 +01:00
1062f72661 refactor(directories): improve error handling 2026-06-20 12:38:40 +01:00
116 changed files with 2268 additions and 2094 deletions

5
.gitignore vendored
View File

@@ -39,8 +39,9 @@ build/
.vscode/ .vscode/
### Generated Files ### ### Generated Files ###
videos/ /videos
outputs/ /outputs
frontend/.flowbite-react
generated generated
.mvn .mvn

View 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
}

View 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
}

View 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
}

View File

@@ -0,0 +1,8 @@
meta {
name: Downloads
seq: 4
}
auth {
mode: inherit
}

View 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
}

View File

@@ -1,7 +1,7 @@
meta { meta {
name: Get all clips name: Get all clips
type: http type: http
seq: 1 seq: 2
} }
get { get {

View File

@@ -1,18 +1,18 @@
meta { meta {
name: Save Clip name: Update Clip
type: http type: http
seq: 3 seq: 3
} }
post { patch {
url: {{base_url}}/stream/clip url: {{base_url}}/clips/5
body: json body: json
auth: inherit auth: inherit
} }
body:json { body:json {
{ {
"duration": 60 "title": "NOT BZ"
} }
} }

View File

@@ -0,0 +1,8 @@
meta {
name: Clips
seq: 1
}
auth {
mode: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Download
type: http
seq: 2
}
get {
url: {{base_url}}/download/output/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Download
type: http
seq: 1
}
get {
url: {{base_url}}/download/input/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Download
}

View File

@@ -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
}

View File

@@ -1,11 +0,0 @@
meta {
name: Process
type: http
seq: 2
}
get {
url: {{base_url}}/process/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Progress
type: http
seq: 3
}
get {
url: {{base_url}}/progress/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Edit
}

View 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
}

View 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
}

View File

@@ -0,0 +1,8 @@
meta {
name: Jobs
seq: 5
}
auth {
mode: inherit
}

View 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);
}

View 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);
}

View 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);
}

View File

@@ -0,0 +1,8 @@
meta {
name: Media
seq: 2
}
auth {
mode: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Converted Metadata
type: http
seq: 2
}
get {
url: {{base_url}}/metadata/converted/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,11 +0,0 @@
meta {
name: Original Metadata
type: http
seq: 1
}
get {
url: {{base_url}}/metadata/original/{{uuid}}
body: none
auth: inherit
}

View File

@@ -1,3 +0,0 @@
meta {
name: Metadata
}

View File

@@ -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-20T00:15:00Z",
"endTime": "2026-06-20T00:16:00Z"
}
}
settings {
encodeUrl: true
timeout: 0
}

View File

@@ -1,12 +1,8 @@
meta { meta {
name: Streams name: Streams
seq: 7 seq: 4
} }
auth { auth {
mode: bearer mode: inherit
}
auth:bearer {
token: {{token}}
} }

View File

@@ -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);
}

View File

@@ -5,7 +5,7 @@ meta {
} }
get { get {
url: {{base_url}}/auth/user url: {{base_url}}/users/me
body: none body: none
auth: inherit auth: inherit
} }

View File

@@ -5,7 +5,7 @@ meta {
} }
post { post {
url: {{base_url}}/auth/login url: {{base_url}}/users/login
body: json body: json
auth: none auth: none
} }

View File

@@ -0,0 +1,8 @@
meta {
name: Users
seq: 3
}
auth {
mode: inherit
}

View File

@@ -0,0 +1,7 @@
auth {
mode: bearer
}
auth:bearer {
token: {{token}}
}

View File

@@ -8,8 +8,6 @@ services:
POSTGRES_DB: ${POSTGRES_DB} POSTGRES_DB: ${POSTGRES_DB}
volumes: volumes:
- ${POSTGRES_DATA}:/var/lib/postgresql/data - ${POSTGRES_DATA}:/var/lib/postgresql/data
ports:
- "5432:5432"
backend: backend:
build: build:
@@ -21,8 +19,8 @@ services:
env_file: env_file:
- .env - .env
volumes: volumes:
- ${MEDIA_DATA}:/app/data/media - ${MEDIA_DATA}:/app/media
- ${STREAM_DATA}:/stream - ${STREAM_DATA}:/app/stream
ports: ports:
- "8080:8080" - "8080:8080"
restart: unless-stopped restart: unless-stopped

View File

@@ -7,6 +7,7 @@ import Home from './pages/Home';
import {useEffect} from "react"; import {useEffect} from "react";
import MyClips from './pages/MyClips'; import MyClips from './pages/MyClips';
import VideoPlayer from "./pages/VideoPlayer.tsx"; import VideoPlayer from "./pages/VideoPlayer.tsx";
import EditClip from "./pages/EditClip.tsx";
import Profile from "./pages/Profile.tsx"; import Profile from "./pages/Profile.tsx";
import { GoogleOAuthProvider } from '@react-oauth/google'; import { GoogleOAuthProvider } from '@react-oauth/google';
@@ -26,6 +27,7 @@ function App() {
<Route path="/create/:id" element={<ClipEdit />} /> <Route path="/create/:id" element={<ClipEdit />} />
<Route path="/my-clips" element={<MyClips />} /> <Route path="/my-clips" element={<MyClips />} />
<Route path="/video/:id" element={<VideoPlayer />} /> <Route path="/video/:id" element={<VideoPlayer />} />
<Route path="/clips/:id/edit" element={<EditClip />} />
<Route path="/profile" element={<Profile />} /> <Route path="/profile" element={<Profile />} />
</Route> </Route>
</Routes> </Routes>

View File

@@ -1,16 +1,14 @@
import clsx from "clsx"; import clsx from "clsx";
type props = { type Props = {
children: React.ReactNode, children: React.ReactNode;
className?: string className?: string;
} };
const Box = ({children, className}: props) => { const Box = ({ children, className }: Props) => (
return ( <div className={clsx("bg-white border border-gray-200 shadow-sm rounded-lg", className)}>
<div className={clsx("bg-gray-200 shadow-lg rounded-lg", className)}> {children}
{ children }
</div> </div>
) );
}
export default Box; export default Box;

View File

@@ -1,60 +1,51 @@
import React, {useEffect, useRef} from "react"; import React, { useState, useEffect, useRef } from "react";
import clsx from "clsx"; import clsx from "clsx";
type DropdownItemProps = { type DropdownItemProps = {
item: string; item: string;
onClick: (item: string) => void; onClick: (item: string) => void;
className?: string; className?: string;
} };
const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => { const DropdownItem = ({ item, onClick, className }: DropdownItemProps) => (
return ( <li
<li className={clsx(className, "cursor-pointer hover:bg-gray-100 px-4 py-2 align-middle")} className={clsx("cursor-pointer hover:bg-gray-50 px-3 py-2 text-sm text-gray-700", className)}
onClick={() => onClick(item)} onClick={() => onClick(item)}
> >
{item} {item}
</li> </li>
); );
}
type DropdownProps = { type DropdownProps = {
label: string; label: React.ReactNode;
children: React.ReactNode; children: React.ReactNode;
className?: string; className?: string;
} };
const Dropdown = ({ label, children, className }: DropdownProps) => { const Dropdown = ({ label, children, className }: DropdownProps) => {
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
useEffect(() => { useEffect(() => {
function handleClickOutside(event: { target: any }) { const handleClickOutside = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(event.target)) { if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false); setIsOpen(false);
} }
}
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
}; };
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []); }, []);
return ( return (
<div className={clsx(className, "relative inline-block text-left")} <div className={clsx(className, "relative inline-block text-left")} ref={ref}>
ref={ref}>
<button <button
onClick={toggleDropdown} onClick={() => setIsOpen(v => !v)}
className={"inline-flex justify-between w-full rounded-xl px-4 py-2 bg-white text-sm font-medium text-gray-600 hover:bg-gray-50"} 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} {label}
</button> </button>
{isOpen && ( {isOpen && (
<ul className={"absolute w-30 origin-top-right rounded-md bg-white shadow-lg font-medium"}> <ul className="absolute right-0 mt-1 w-36 origin-top-right rounded-lg bg-white border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
{children} {children}
</ul> </ul>
)} )}
@@ -62,6 +53,4 @@ const Dropdown = ({ label, children, className }: DropdownProps) => {
); );
}; };
export { Dropdown, DropdownItem }; export { Dropdown, DropdownItem };

View File

@@ -1,21 +1,16 @@
import React from "react"; import React from "react";
type props = { type Props = {
children: React.ReactNode; children: React.ReactNode;
label: String; label: string;
} };
const Selector = ({children, label}: props) => { const Selector = ({ children, label }: Props) => (
return ( <div className="flex items-center gap-2">
<div className={"flex items-center gap-2"}> <label className="w-full text-sm text-gray-600">{label}</label>
<label <div className="w-px h-5 bg-gray-200 mx-2" />
className={"w-full"}>
{ label }
</label>
<div className={"w-px h-6 bg-gray-400 mx-3"} />
{children} {children}
</div> </div>
) );
}
export default Selector; export default Selector;

View File

@@ -1,7 +1,6 @@
import clsx from "clsx"; import clsx from "clsx";
import SidebarButton from "./buttons/SidebarButton.tsx"; import SidebarButton from "./buttons/SidebarButton.tsx";
import { Plus, Film, Home, User as UserIcon } from 'lucide-react'; import { Plus, Film, Home, User as UserIcon } from 'lucide-react';
import Box from "./Box.tsx";
import type {User} from "../utils/types.ts"; import type {User} from "../utils/types.ts";
type props = { type props = {
@@ -9,41 +8,43 @@ type props = {
className?: string className?: string
} }
const Divider = () => <hr className="my-1.5 mx-2 border-gray-100" />;
const Sidebar = ({user, className}: props) => { const Sidebar = ({user, className}: props) => {
return ( return (
<Box className={clsx("h-screen flex flex-col mr-5", className)}> <aside className={clsx("h-screen flex flex-col bg-white border-r border-gray-200", className)}>
<div className="px-4 pt-5 pb-3">
<img <img
className={"w-45 mx-auto"} className="w-36 mx-auto"
src={"../../public/logo.png"} src="../../public/logo.png"
alt={"VoD System Logo"} alt="VoD System Logo"
/>
<SidebarButton
url={"/"}
logo={<Home size={20}/>}
label={"Home"}
/> />
</div>
<nav className="flex flex-col flex-1 px-2 pb-4">
<div className="flex flex-col gap-0.5">
<SidebarButton url="/" logo={<Home size={18}/>} label="Home" />
<SidebarButton url="/create" logo={<Plus size={18}/>} label="Create Clip" />
</div>
<SidebarButton {user && (
url={"/create"}
logo={<Plus size={20}/>}
label={"Create Clip"}
/>
{ user && (
<> <>
<SidebarButton <Divider />
url={"/my-clips"} <div className="flex flex-col gap-0.5">
logo={<Film size={20}/>} <SidebarButton url="/my-clips" logo={<Film size={18}/>} label="Clips" />
label={"My Clips"} </div>
/>
<SidebarButton
url={"/profile"}
logo={<UserIcon size={20}/>}
label={"Profile"}
/>
</> </>
)} )}
</Box>
<div className="flex-1" />
{user && (
<>
<Divider />
<SidebarButton url="/profile" logo={<UserIcon size={18}/>} label="Profile" />
</>
)}
</nav>
</aside>
); );
}; };

View File

@@ -16,6 +16,7 @@ type props = {
setSidebarToggled: (toggled: boolean) => void; setSidebarToggled: (toggled: boolean) => void;
user: User | null; user: User | null;
fetchUser: () => void; fetchUser: () => void;
isStreaming: boolean;
className?: string; className?: string;
} }
@@ -24,6 +25,7 @@ const Topbar = ({
setSidebarToggled, setSidebarToggled,
user, user,
fetchUser, fetchUser,
isStreaming,
className}: props) => { className}: props) => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -56,7 +58,7 @@ const Topbar = ({
} }
return ( return (
<div className={clsx(className, "flex justify-between")}> <div className={clsx(className, "flex justify-between items-center px-4 py-2 bg-white border-b border-gray-200")}>
<MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}> <MenuButton onClick={() => setSidebarToggled(!sidebarToggled)}>
{sidebarToggled ? <Menu size={24}/> : <X size={24}/>} {sidebarToggled ? <Menu size={24}/> : <X size={24}/>}
</MenuButton> </MenuButton>
@@ -69,7 +71,17 @@ const Topbar = ({
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
/> />
<Dropdown label={user.name}> <Dropdown label={
<span className="flex items-center gap-1.5">
{user.name}
{isStreaming && (
<span className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-red-500 opacity-75" />
<span className="relative inline-flex rounded-full h-2 w-2 bg-red-500" />
</span>
)}
</span>
}>
<DropdownItem item="Profile" <DropdownItem item="Profile"
onClick={() => navigate("/profile")} /> onClick={() => navigate("/profile")} />
<DropdownItem item="Logout" <DropdownItem item="Logout"

View File

@@ -5,7 +5,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const BlueButton: React.FC<Props> = ({ className = "", ...props }) => { const BlueButton: React.FC<Props> = ({ className = "", ...props }) => {
return ( return (
<button <button
className={`bg-primary text-text rounded-lg hover:bg-primary-pressed transition-colors duration-100 h-10 ${className}`} className={`bg-primary text-white font-medium rounded-lg hover:bg-primary-pressed transition-colors duration-150 h-10 px-4 ${className}`}
{...props} {...props}
/> />
); );

View File

@@ -6,7 +6,7 @@ type Props = React.ButtonHTMLAttributes<HTMLButtonElement>;
const MenuButton: React.FC<Props> = ({ className = "", ...props }) => { const MenuButton: React.FC<Props> = ({ className = "", ...props }) => {
return ( return (
<button <button
className={clsx("p-2 rounded-lg hover:bg-gray-300 hover:text-black transition-colors duration-200", className)} className={clsx("p-2 rounded-md text-gray-600 hover:bg-gray-100 hover:text-gray-900 transition-colors duration-150", className)}
{...props} {...props}
/> />
); );

View File

@@ -1,19 +1,19 @@
import MenuButton from "./MenuButton.tsx"; import MenuButton from "./MenuButton.tsx";
import { Link } from "react-router-dom"; import { Link, useLocation } from "react-router-dom";
type props = { type Props = {
url: string; url: string;
logo: React.ReactNode; 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);
return ( return (
<Link <Link className="w-full" to={url}>
className={"w-full"} <MenuButton className={`flex items-center gap-2.5 w-full px-3 py-2 text-sm font-medium rounded-md ${isActive ? "bg-gray-100 text-gray-900" : "text-gray-600"}`}>
to={url}
>
<MenuButton className={"flex items-center gap-2 w-full"}>
{logo}{label} {logo}{label}
</MenuButton> </MenuButton>
</Link> </Link>

View File

@@ -1,42 +1,32 @@
import RangeSlider from 'react-range-slider-input'; import RangeSlider from 'react-range-slider-input';
import 'react-range-slider-input/dist/style.css'; import 'react-range-slider-input/dist/style.css';
import {useRef} from "react"; import { useRef, type Dispatch, type SetStateAction } from "react";
import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
type Props = { type Props = {
videoRef: HTMLVideoElement | null; videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata; videoMetadata: VideoMetadata;
setSliderValue: Function; setSliderValue: Dispatch<SetStateAction<number>>;
setMetadata: Function; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
}; };
export default function ClipRangeSlider({videoRef, export default function ClipRangeSlider({ videoRef, videoMetadata, setSliderValue, setMetadata, className }: Props) {
videoMetadata, const previousRange = useRef<[number, number]>([0, 0]);
setSliderValue,
setMetadata,
className}: Props) {
const previousRangeSliderInput = useRef<[number, number]>([0, 0]);
const handleRangeSliderInput = (val: [number, number]) => { const handleRangeInput = (val: [number, number]) => {
if (!videoRef) return; if (!videoRef) return;
if (previousRangeSliderInput.current[0] != val[0]) { if (previousRange.current[0] !== val[0]) {
videoRef.currentTime = val[0]; videoRef.currentTime = val[0];
setSliderValue(val[0]); setSliderValue(val[0]);
} else if (previousRangeSliderInput.current[1] != val[1]) { } else if (previousRange.current[1] !== val[1]) {
videoRef.currentTime = val[1]; videoRef.currentTime = val[1];
setSliderValue(val[1]); setSliderValue(val[1]);
} }
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, startPoint: val[0], duration: val[1] - val[0] }));
...prevState, previousRange.current = val;
startPoint: val[0],
duration: val[1] - val[0]
}
))
previousRangeSliderInput.current = val;
}; };
return ( return (
@@ -44,9 +34,9 @@ export default function ClipRangeSlider({videoRef,
min={0} min={0}
max={videoMetadata.duration} max={videoMetadata.duration}
step={0.1} step={0.1}
onInput={handleRangeSliderInput} onInput={handleRangeInput}
className={clsx(className)} className={className}
id={"range-slider"} id="range-slider"
/> />
) );
} }

View File

@@ -1,46 +1,36 @@
import { type Dispatch, type SetStateAction } from "react";
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import Selector from "../Selector.tsx"; import Selector from "../Selector.tsx";
import clsx from "clsx"; import clsx from "clsx";
type prop = { type Props = {
setMetadata: Function; setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
} };
export default function ConfigBox({setMetadata, className}: prop) { 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 }: Props) {
const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateRes = (e: React.ChangeEvent<HTMLSelectElement>) => {
const vals = e.target.value.split(","); const [width, height] = e.target.value.split(",").map(Number);
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, width, height }));
...prevState, };
width: parseInt(vals[0]),
height: parseInt(vals[1])
}))
}
const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => { const updateFps = (e: React.ChangeEvent<HTMLSelectElement>) => {
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, fps: parseInt(e.target.value) }));
...prevState, };
fps: parseInt(e.target.value)
}))
}
const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => { const updateFileSize = (e: React.ChangeEvent<HTMLInputElement>) => {
setMetadata((prevState: VideoMetadata) => ({ setMetadata(prev => ({ ...prev, fileSize: parseInt(e.target.value) * 1000 }));
...prevState, };
fileSize: parseInt(e.target.value) * 1000
}))
}
return ( return (
<div className={clsx("flex flex-col gap-2 p-10", className)}> <div className={clsx("flex flex-col gap-3 p-6", className)}>
<h2 className={"text-xl font-bold"}>Export</h2> <h2 className="text-sm font-semibold text-gray-800 mb-1">Export Settings</h2>
<Selector label={"Resolution"}> <Selector label="Resolution">
<select id="resolution" <select id="resolution" name="resolution" defaultValue="1280,720"
name="resolution" onChange={updateRes} className={inputClass}>
defaultValue="1280,720"
onChange={updateRes}
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<option value="3840,2160">2160p</option> <option value="3840,2160">2160p</option>
<option value="2560,1440">1440p</option> <option value="2560,1440">1440p</option>
<option value="1920,1080">1080p</option> <option value="1920,1080">1080p</option>
@@ -50,27 +40,20 @@ export default function ConfigBox({setMetadata, className}: prop) {
</select> </select>
</Selector> </Selector>
<Selector label={"FPS"}> <Selector label="FPS">
<select id="fps" <select id="fps" name="fps" defaultValue="30"
name="fps" onChange={updateFps} className={inputClass}>
defaultValue="30"
onChange={updateFps}
className={"border-black bg-gray-200 rounded-md w-full p-2"}>
<option value="60">60</option> <option value="60">60</option>
<option value="30">30</option> <option value="30">30</option>
<option value="15">15</option> <option value="15">15</option>
</select> </select>
</Selector> </Selector>
<Selector label={"File Size Limit (MB)"}> <Selector label="File Size Limit (MB)">
<input type="number" <input type="number" min="1" defaultValue="10"
min="1" onChange={updateFileSize} className={inputClass}
defaultValue="10"
onChange={updateFileSize}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
/> />
</Selector> </Selector>
</div> </div>
) );
} }

View File

@@ -16,18 +16,20 @@ const ExportWidget = ({dataSend, handleDownload, downloadable, progress}: props)
Export Export
</BlueButton> </BlueButton>
<div className="h-10 flex items-center">
{ downloadable ? { downloadable ?
(<BlueButton (<BlueButton className="w-full" onClick={handleDownload}>
onClick={handleDownload}>
Download Download
</BlueButton>) </BlueButton>)
:( :(
<progress <progress
value={progress} value={progress}
className={"bg-gray-300 rounded-lg h-1"}> className="w-full h-1.5 rounded bg-gray-200">
</progress> ) </progress>
)
} }
</div> </div>
</div>
) )
} }

View File

@@ -1,28 +1,22 @@
import clsx from "clsx"; 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 = { type Props = {
setMetadata: Function setMetadata: Dispatch<SetStateAction<VideoMetadata>>;
className?: string; className?: string;
} };
const MetadataBox = ({setMetadata, className}: MetadataBoxProps) => { const MetadataBox = ({ setMetadata, className }: Props) => (
return ( <div className={clsx("flex flex-col content-between p-6 gap-2", className)}>
<div className={clsx("flex flex-col content-between p-10 gap-2", className)}> <p className="w-full font-semibold text-gray-800 text-sm">Title</p>
{/*<h2 className={"text-2xl font-bold col-span-2"}>Metadata</h2>*/}
<p className={"w-full font-bold text-xl "}>Title</p>
<input <input
type="text" type="text"
placeholder="Enter title" placeholder="Enter title"
onChange={(e) => setMetadata((prevState: VideoMetadata) => ({ onChange={(e) => setMetadata(prev => ({ ...prev, title: e.target.value }))}
...prevState, 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"
title: e.target.value
}))}
className={"border-black bg-gray-200 rounded-md w-full p-2"}
/> />
</div> </div>
) );
}
export default MetadataBox; export default MetadataBox;

View File

@@ -1,52 +1,37 @@
import {useEffect} from "react"; import { useEffect, type Dispatch, type SetStateAction } from "react";
import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
type Props = { type Props = {
videoRef: HTMLVideoElement | null; videoRef: HTMLVideoElement | null;
videoMetadata: VideoMetadata; videoMetadata: VideoMetadata;
sliderValue: number; sliderValue: number;
setSliderValue: Function; setSliderValue: Dispatch<SetStateAction<number>>;
className?: string; className?: string;
}; };
export default function PlaybackSlider({videoRef, export default function PlaybackSlider({ videoRef, videoMetadata, sliderValue, setSliderValue, className }: Props) {
videoMetadata,
sliderValue,
setSliderValue,
className}: Props) {
const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => { const updateVideo = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!videoRef) return; if (!videoRef) return;
videoRef.currentTime = e.target.valueAsNumber; videoRef.currentTime = e.target.valueAsNumber;
setSliderValue(e.target.valueAsNumber); setSliderValue(e.target.valueAsNumber);
} };
// update slider
useEffect(() => { useEffect(() => {
if (!videoRef) return; if (!videoRef) return;
const updateSlider = () => setSliderValue(videoRef.currentTime);
const updateSlider = () => {
setSliderValue(videoRef.currentTime);
};
videoRef.addEventListener("timeupdate", updateSlider); videoRef.addEventListener("timeupdate", updateSlider);
return () => videoRef.removeEventListener("timeupdate", updateSlider);
return () => {
videoRef.removeEventListener("timeupdate", updateSlider);
};
}, [videoRef]); }, [videoRef]);
return ( return (
<input <input
type={"range"} type="range"
min={0} min={0}
max={videoMetadata.duration} max={videoMetadata.duration}
value={sliderValue} value={sliderValue}
onChange={updateVideo} onChange={updateVideo}
step={0.1} step={0.1}
className={clsx(className)} className={className}
/> />
) );
} }

View File

@@ -1,10 +1,9 @@
import { useEffect, useState} from "react"; import { useEffect, useState } from "react";
import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react'; import { Volume1, Volume2, VolumeX, Play, Pause } from 'lucide-react';
import clsx from 'clsx'; import clsx from 'clsx';
import type { VideoMetadata } from "../../utils/types.ts"; import type { VideoMetadata } from "../../utils/types.ts";
import { formatTime } from "../../utils/utils.ts"; import { formatTime } from "../../utils/utils.ts";
type Props = { type Props = {
video: HTMLVideoElement | null; video: HTMLVideoElement | null;
videoMetadata: VideoMetadata; videoMetadata: VideoMetadata;
@@ -15,9 +14,10 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
const [isPlaying, setIsPlaying] = useState(false); const [isPlaying, setIsPlaying] = useState(false);
const [volume, setVolume] = useState(100); const [volume, setVolume] = useState(100);
const Icon = volume === 0 ? VolumeX : volume < 50 ? Volume1 : Volume2;
const togglePlay = () => { const togglePlay = () => {
if (!video) return; if (!video) return;
if (video.paused) { if (video.paused) {
video.play(); video.play();
setIsPlaying(true); 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>) => { const updateVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
if (!video) return; if (!video) return;
const newVolume = parseInt(e.target.value);
video.volume = newVolume / 100;
setVolume(newVolume);
};
let volume = parseInt(e.target.value) // Sync isPlaying when the video is controlled outside this component (e.g. keyboard shortcuts)
video.volume = volume / 100;
setVolume(volume);
}
// Sync state with video element changes (e.g., if someone presses spacebar or clicks on the video)
useEffect(() => { useEffect(() => {
if (!video) return; if (!video) return;
const handlePlay = () => setIsPlaying(true); const handlePlay = () => setIsPlaying(true);
const handlePause = () => setIsPlaying(false); const handlePause = () => setIsPlaying(false);
video.addEventListener("play", handlePlay); video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause); video.addEventListener("pause", handlePause);
return () => { return () => {
video.removeEventListener("play", handlePlay); video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause); video.removeEventListener("pause", handlePause);
@@ -64,28 +49,26 @@ export default function Playbar({ video, videoMetadata, className }: Props) {
return ( return (
<div className={clsx("flex justify-between items-center mb-2", className)}> <div className={clsx("flex justify-between items-center mb-2", className)}>
<div className={"flex gap-1"}> <div className="flex gap-1">
<Icon size={24} /> <Icon size={24} />
<input <input
type='range' type="range"
min={0} min={0}
max={100} max={100}
onChange={updateVolume}
value={volume} value={volume}
className={"w-20"} onChange={updateVolume}
className="w-20"
/> />
</div> </div>
<button <button
onClick={togglePlay} onClick={togglePlay}
className={"hover:bg-gray-300 rounded-full p-1"} className="hover:bg-gray-100 rounded-full p-1.5 transition-colors duration-150 text-gray-700"
> >
{isPlaying ? <Pause size={24} /> : <Play size={24} />} {isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button> </button>
<span>
<label>
{formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)} {formatTime(video?.currentTime ?? 0)} / {formatTime(videoMetadata.duration ?? 0)}
</label> </span>
</div> </div>
); );
} }

View File

@@ -1,80 +1,138 @@
import clsx from "clsx"; import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts"; import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import {useEffect, useState} from "react"; import { useEffect, useRef, useState } from "react";
import { isThumbnailAvailable } from "../../utils/endpoints.ts"; import { isThumbnailAvailable } from "../../utils/endpoints.ts";
import { MoreVertical, Pencil, Trash2 } from "lucide-react";
type VideoCardProps = { type VideoCardProps = {
id: number, id: number,
title: string, title: string,
duration: number, duration: number,
createdAt: string, createdAt: string,
className?: string onEdit?: () => void,
onDelete?: () => void,
className?: string,
} }
const fallbackThumbnail = "../../../public/default_thumbnail.png"; const fallbackThumbnail = "../../../public/default_thumbnail.png";
const API_URL = import.meta.env.VITE_API_URL; const API_URL = import.meta.env.VITE_API_URL;
const VideoCard = ({ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => {
id,
title,
duration,
createdAt,
className
}: VideoCardProps) => {
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
const [thumbnailAvailable, setThumbnailAvailable] = useState(true); const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
const [menuOpen, setMenuOpen] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
setTimeout(() => { useEffect(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt))) const interval = setInterval(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)));
}, 1000); }, 1000);
return () => clearInterval(interval);
}, [createdAt]);
useEffect(() => { useEffect(() => {
isThumbnailAvailable(id) isThumbnailAvailable(id)
.then((available) => { .then(setThumbnailAvailable)
setThumbnailAvailable(available); .catch(() => setThumbnailAvailable(false));
})
.catch(() => {
setThumbnailAvailable(false);
});
}, []); }, []);
useEffect(() => {
if (!menuOpen) return;
const handleClickOutside = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setMenuOpen(false);
setConfirmDelete(false);
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [menuOpen]);
const stopAndRun = (e: React.MouseEvent, fn: () => void) => {
e.preventDefault();
e.stopPropagation();
fn();
};
return ( return (
<Link to={"/video/" + id}> <Link to={"/video/" + id}>
<div className={clsx("flex flex-col", className)}> <div className={clsx("flex flex-col group cursor-pointer", className)}>
<div className={"relative inline-block"}> <div className="relative overflow-hidden rounded-lg">
<img <img
src={thumbnailAvailable ? API_URL + `/api/v1/download/thumbnail/${id}` : fallbackThumbnail} src={thumbnailAvailable ? API_URL + `/api/v1/clips/${id}/thumbnail` : fallbackThumbnail}
alt="Video Thumbnail" alt="Video Thumbnail"
className="w-full aspect-video object-cover group-hover:scale-105 transition-transform duration-200"
/> />
<p className=" <span className="absolute top-1.5 left-1.5 bg-black/70 text-white px-1.5 py-0.5 rounded text-xs font-medium pointer-events-none">
absolute
top-2
left-2
bg-black bg-opacity-60
text-white
px-2
py-1
rounded
pointer-events-none
text-sm
z-1
">
{formatTime(duration)} {formatTime(duration)}
</p> </span>
{(onEdit || onDelete) && (
<div
ref={menuRef}
className="absolute top-1.5 right-1.5"
onClick={(e) => e.preventDefault()}
>
<button
onClick={(e) => stopAndRun(e, () => { setMenuOpen(v => !v); setConfirmDelete(false); })}
className="bg-black/70 hover:bg-black/90 text-white rounded p-0.5 transition-colors"
>
<MoreVertical size={14} />
</button>
{menuOpen && (
<div className="absolute right-0 top-7 w-32 bg-white rounded-lg border border-gray-200 shadow-md z-50 py-1 overflow-hidden">
{!confirmDelete && (<>
{onEdit && (
<button
onClick={(e) => stopAndRun(e, onEdit)}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-gray-700 hover:bg-gray-50"
>
<Pencil size={13} /> Edit
</button>
)}
{onDelete && (
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(true))}
className="flex items-center gap-2 w-full px-3 py-2 text-sm text-red-500 hover:bg-red-50"
>
<Trash2 size={13} /> Delete
</button>
)}
</>)}
{confirmDelete && (
<div className="px-3 py-2">
<p className="text-xs text-gray-600 mb-2">Delete this clip?</p>
<div className="flex gap-1.5">
<button
onClick={(e) => stopAndRun(e, onDelete!)}
className="flex-1 text-xs bg-red-500 text-white rounded px-2 py-1 hover:bg-red-600 transition-colors"
>
Delete
</button>
<button
onClick={(e) => stopAndRun(e, () => setConfirmDelete(false))}
className="flex-1 text-xs bg-gray-100 text-gray-600 rounded px-2 py-1 hover:bg-gray-200 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
</div>
)}
</div>
)}
</div> </div>
<div className="flex flex-col p-2">
<div className={"flex flex-col justify-between p-2"}> <p className="font-medium text-gray-900 text-sm leading-snug line-clamp-2">
<p>{title == "" ? "(No Title)" : title}</p> {title === "" ? "(No Title)" : title}
<p
className={"text-gray-600 text-sm"}
>
{timeAgo}
</p> </p>
<p className="text-gray-500 text-xs mt-0.5">{timeAgo}</p>
</div> </div>
</div> </div>
</Link> </Link>

View File

@@ -2,95 +2,44 @@
@plugin "flowbite-react/plugin/tailwindcss"; @plugin "flowbite-react/plugin/tailwindcss";
@source "../.flowbite-react/class-list.json"; @source "../.flowbite-react/class-list.json";
@theme { @theme {
--font-display: "Satoshi", "sans-serif"; --font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
/* Breakpoints */ /* Breakpoints */
--breakpoint-3xl: 1920px; --breakpoint-3xl: 1920px;
--color-primary: oklch(0.55 0.21 254); /* Modern Blue (#2563EB) */ --color-primary: oklch(0.55 0.21 254);
--color-secondary: oklch(0.94 0.01 250); /* Light Gray (#E5E7EB) */
--color-accent: oklch(0.68 0.20 288); /* Indigo Accent (#6366F1) */
--color-text: oklch(0.17 0.01 270); /* Dark Slate (#111827) */
--color-background: oklch(0.98 0.005 250);/* Soft off-white (#F9FAFB) */
--color-primary-pressed: oklch(0.45 0.21 254); --color-primary-pressed: oklch(0.45 0.21 254);
--color-secondary: oklch(0.94 0.01 250);
--color-accent: oklch(0.68 0.20 288);
--color-text: oklch(0.17 0.01 270);
--color-background: oklch(0.97 0.005 250);
--color-surface: oklch(1 0 0);
--color-border: oklch(0.91 0.008 250);
/* Easing */ /* Easing */
--ease-fluid: cubic-bezier(0.3, 0, 0, 1); --ease-fluid: cubic-bezier(0.3, 0, 0, 1);
--ease-snappy: cubic-bezier(0.2, 0, 0, 1); --ease-snappy: cubic-bezier(0.2, 0, 0, 1);
} }
#range-slider .range-slider__range{ #range-slider .range-slider__range {
background: var(--color-primary); background: var(--color-primary);
} }
#range-slider .range-slider__thumb{ #range-slider .range-slider__thumb {
background: var(--color-primary); background: var(--color-primary);
} }
:root { :root {
font-family: Helvetica; font-family: "Inter", ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
line-height: 1.5; line-height: 1.5;
font-weight: 400; font-weight: 400;
font-synthesis: none; font-synthesis: none;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
} }
/*a {*/ body {
/* font-weight: 500;*/ background-color: var(--color-background);
/* color: #646cff;*/ }
/* text-decoration: inherit;*/
/*}*/
/*a:hover {*/
/* color: #535bf2;*/
/*}*/
/*body {*/
/* margin: 0;*/
/* display: flex;*/
/* place-items: center;*/
/* min-width: 320px;*/
/* min-height: 100vh;*/
/*}*/
/*h1 {*/
/* font-size: 3.2em;*/
/* line-height: 1.1;*/
/*}*/
/*button {*/
/* border-radius: 8px;*/
/* border: 1px solid transparent;*/
/* padding: 0.6em 1.2em;*/
/* font-size: 1em;*/
/* font-weight: 500;*/
/* font-family: inherit;*/
/* background-color: #1a1a1a;*/
/* cursor: pointer;*/
/* transition: border-color 0.25s;*/
/*}*/
/*button:hover {*/
/* border-color: #646cff;*/
/*}*/
/*button:focus,*/
/*button:focus-visible {*/
/* outline: 4px auto -webkit-focus-ring-color;*/
/*}*/
/*@media (prefers-color-scheme: light) {*/
/* :root {*/
/* color: #213547;*/
/* background-color: #ffffff;*/
/* }*/
/* a:hover {*/
/* color: #747bff;*/
/* }*/
/* button {*/
/* background-color: #f9f9f9;*/
/* }*/
/*}*/

View File

@@ -4,11 +4,12 @@ import Topbar from '../components/Topbar'
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import {useEffect, useState} from "react"; import {useEffect, useState} from "react";
import type {User} from "../utils/types"; import type {User} from "../utils/types";
import { getUser } from "../utils/endpoints"; import { getUser, getStreamStatus } from "../utils/endpoints";
const MainLayout = () => { const MainLayout = () => {
const [sidebarToggled, setSidebarToggled] = useState(false); const [sidebarToggled, setSidebarToggled] = useState(false);
const [user, setUser] = useState<null | User>(null); const [user, setUser] = useState<null | User>(null);
const [isStreaming, setIsStreaming] = useState(false);
const fetchUser = async () => { const fetchUser = async () => {
try { try {
@@ -23,6 +24,14 @@ const MainLayout = () => {
fetchUser(); fetchUser();
}, []); }, []);
useEffect(() => {
if (!user) return;
const poll = () => getStreamStatus().then(setIsStreaming).catch(() => {});
poll();
const interval = setInterval(poll, 10000);
return () => clearInterval(interval);
}, [user]);
return ( return (
<div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}> <div className={`transition-all duration-300 grid h-screen ${sidebarToggled ? "grid-cols-[0px_1fr]" : "grid-cols-[240px_1fr]"} grid-rows-[auto_1fr]`}>
<Sidebar <Sidebar
@@ -33,8 +42,9 @@ const MainLayout = () => {
sidebarToggled={sidebarToggled} sidebarToggled={sidebarToggled}
setSidebarToggled={setSidebarToggled} setSidebarToggled={setSidebarToggled}
user={user} user={user}
fetchUser={fetchUser}/> fetchUser={fetchUser}
<div className="overflow-auto"> isStreaming={isStreaming}/>
<div className="overflow-auto bg-background">
<Outlet /> <Outlet />
</div> </div>
</div> </div>

View File

@@ -1,11 +1,11 @@
import { useParams } from 'react-router-dom'; import { useLocation } from 'react-router-dom';
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Playbar from "./../components/video/Playbar"; import Playbar from "./../components/video/Playbar";
import PlaybackSlider from "./../components/video/PlaybackSlider"; import PlaybackSlider from "./../components/video/PlaybackSlider";
import ClipRangeSlider from "./../components/video/ClipRangeSlider"; import ClipRangeSlider from "./../components/video/ClipRangeSlider";
import ConfigBox from "../components/video/ConfigBox.tsx"; import ConfigBox from "../components/video/ConfigBox.tsx";
import ExportWidget from "../components/video/ExportWidget.tsx"; import ExportWidget from "../components/video/ExportWidget.tsx";
import {editFile, getMetadata, processFile, getProgress} from "../utils/endpoints" import { compress, getJob } from "../utils/endpoints"
import type { VideoMetadata } from "../utils/types.ts"; import type { VideoMetadata } from "../utils/types.ts";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
import MetadataBox from "../components/video/MetadataBox.tsx"; 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 API_URL = import.meta.env.VITE_API_URL;
const ClipEdit = () => { const ClipEdit = () => {
const { id } = useParams(); const location = useLocation();
const localFile: File = location.state?.file;
const videoRef = useRef<HTMLVideoElement | null>(null); 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 [metadata, setMetadata] = useState<VideoMetadata | null>(null);
const [playbackValue, setPlaybackValue] = useState(0); const [playbackValue, setPlaybackValue] = useState(0);
const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({ const [outputMetadata, setOutputMetadata] = useState<VideoMetadata>({
// default values
title: "", title: "",
description: "", description: "",
startPoint: 0, startPoint: 0,
@@ -33,41 +36,66 @@ const ClipEdit = () => {
const [downloadable, setDownloadable] = useState(false); const [downloadable, setDownloadable] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const sendData = async() => { useEffect(() => {
if (!id) return; if (!localFile) return;
const url = URL.createObjectURL(localFile);
setVideoUrl(url);
return () => URL.revokeObjectURL(url);
}, [localFile]);
editFile(id, outputMetadata) const handleVideoMetadataLoaded = () => {
.then(() => { 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) const sendData = async () => {
.catch((err: Error) => setError(`Failed to process file: ${err.message}`)); if (!localFile) return;
}) let jobId: string;
.catch((err: Error) => setError(`Failed to edit file: ${err.message}`)); try {
jobId = await compress(localFile, outputMetadata);
const interval = setInterval(async() => await pollProgress(id, interval), 500); setUploadedId(jobId);
} catch (err: unknown) {
setError(`Failed to start compression: ${err instanceof Error ? err.message : 'Unknown error'}`);
return;
} }
const pollProgress = async (id: string, intervalId: number) => { const interval = setInterval(async () => await pollProgress(jobId, interval), 500);
getProgress(id) };
.then((progress) => {
setProgress(progress.process.progress);
if (progress.process.complete) { const pollProgress = async (jobId: string, intervalId: number) => {
getJob(jobId)
.then((job) => {
setProgress(job.progress);
if (job.state === 'FAILED') {
setError(`Compression failed: ${job.errorOutput ?? 'Unknown error'}`);
clearInterval(intervalId);
} else if (job.isComplete) {
clearInterval(intervalId); clearInterval(intervalId);
setDownloadable(true); setDownloadable(true);
} else {
setDownloadable(false)
} }
}) })
.catch((err: Error) => { .catch((err: Error) => {
setError(`Failed to fetch progress: ${err.message}`); setError(`Failed to fetch progress: ${err.message}`);
clearInterval(intervalId); clearInterval(intervalId);
}); });
} };
const handleDownload = async () => { 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) { if (!response.ok) {
console.error('Download failed'); console.error('Download failed');
@@ -79,43 +107,27 @@ const ClipEdit = () => {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `${id}.mp4`; a.download = `${uploadedId}.mp4`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
a.remove(); a.remove();
window.URL.revokeObjectURL(url); 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 ( return (
<div className={"grid grid-cols-[7fr_3fr]"}> <div className={"grid grid-cols-[7fr_3fr] gap-4 p-4"}>
<video <video
ref={videoRef} ref={videoRef}
className={"w-full rounded-lg shadow-lg border border-gray-300 bg-black m-auto"}> src={videoUrl}
<source src={videoUrl} type="video/mp4" /> className={"w-full rounded-lg shadow-sm bg-black"}
<source src={videoUrl} type="video/webm" /> onLoadedMetadata={handleVideoMetadataLoaded}
<source src={videoUrl} type="video/ogg" /> />
Your browser does not support the video tag. Bzzzz.
</video>
<Box className={"w-4/5 h-full m-auto"}> <Box className={"w-4/5 h-full m-auto"}>
<MetadataBox <MetadataBox setMetadata={setOutputMetadata} />
setMetadata={setOutputMetadata} <ConfigBox setMetadata={setOutputMetadata} />
/>
<ConfigBox
setMetadata={setOutputMetadata}
/>
</Box> </Box>
{metadata && {metadata &&
<Box className={"mt-4 p-5"}> <Box className={"mt-4 p-5"}>
<Playbar <Playbar
@@ -158,6 +170,6 @@ const ClipEdit = () => {
</Box> </Box>
</div> </div>
); );
} };
export default ClipEdit; export default ClipEdit;

View File

@@ -1,6 +1,5 @@
import {useState} from "react"; import {useState} from "react";
import {useNavigate} from "react-router-dom"; import {useNavigate} from "react-router-dom";
import {uploadFile, convertFile, getProgress} from "../utils/endpoints"
import BlueButton from "../components/buttons/BlueButton.tsx"; import BlueButton from "../components/buttons/BlueButton.tsx";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
@@ -8,90 +7,35 @@ const ClipUpload = () => {
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const [progress, setProgress] = useState<number>(0);
const isVideoFileSupported = (file: File): boolean => { const press = () => {
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 = (() => {
if (!file) { if (!file) {
setError("Please choose a file"); setError("Please choose a file");
return; return;
} }
navigate('/create/new', { state: { file } });
};
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);
});
}
return ( return (
<Box className={"flex flex-col justify-between gap-3 p-5"}> <div className="flex items-start justify-center p-8 min-h-full">
<Box className="flex flex-col gap-4 p-6 w-full max-w-md">
<h1 className="text-lg font-semibold text-gray-900">Upload Clip</h1>
<input <input
type="file" type="file"
onChange={(e) => { onChange={(e) => {
const selected = e.target.files?.[0] ?? null; const selected = e.target.files?.[0] ?? null;
setFile(selected); setFile(selected);
setError(null);
}} }}
className={"h-100 cursor-pointer rounded-lg border border-dashed border-gray-400 bg-white p-4 text-center hover:bg-gray-50 transition"} className="h-36 cursor-pointer rounded-lg border-2 border-dashed border-gray-300 bg-gray-50 p-4 text-center text-sm text-gray-500 hover:bg-gray-100 hover:border-primary transition-colors duration-150"
/> />
<BlueButton <BlueButton onClick={press}>Upload</BlueButton>
onClick={press}>
Upload
</BlueButton>
<label className={"text-center text-red-500"}>{error}</label> {error && <p className="text-sm text-red-500 text-center">{error}</p>}
<progress
value={progress}
className={"bg-gray-300 rounded-lg h-1"}>
</progress>
</Box> </Box>
</div>
) )
}; };

View File

@@ -0,0 +1,108 @@
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ArrowLeft, Check } from "lucide-react";
import { getClipById, patchClip } from "../utils/endpoints.ts";
import Box from "../components/Box.tsx";
import BlueButton from "../components/buttons/BlueButton.tsx";
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";
const EditClip = () => {
const { id } = useParams();
const navigate = useNavigate();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
getClipById(id)
.then((clip) => {
if (clip) {
setTitle(clip.title ?? "");
setDescription(clip.description ?? "");
}
})
.catch(() => setError("Failed to load clip."))
.finally(() => setLoading(false));
}, [id]);
const handleSave = async () => {
if (!id) return;
setSaving(true);
setError(null);
try {
await patchClip(Number(id), { title, description });
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to save.");
} finally {
setSaving(false);
}
};
return (
<div className="px-8 py-10 max-w-2xl mx-auto">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 mb-6 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<h1 className="text-2xl font-semibold text-gray-900 mb-6">Edit Clip</h1>
{loading ? (
<div className="text-gray-400 text-sm">Loading...</div>
) : (
<Box className="p-6 flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter title"
className={inputClass}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Enter description"
rows={4}
className={inputClass + " resize-none"}
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div className="flex items-center gap-3 pt-1">
<BlueButton onClick={handleSave} disabled={saving} className="w-28">
{saving ? "Saving…" : saved
? <span className="flex items-center gap-1.5 justify-center"><Check size={14} />Saved</span>
: "Save"}
</BlueButton>
<button
onClick={() => navigate(-1)}
className="text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
>
Cancel
</button>
</div>
</Box>
)}
</div>
);
};
export default EditClip;

View File

@@ -1,43 +1,78 @@
const Home = () => { import { Upload, Scissors, SlidersHorizontal, Radio, LayoutGrid, Play } from "lucide-react";
const features = [
{
icon: Upload,
title: "Upload & compress",
description: "Drop in a video file, set your clip range and export settings, then compress.",
},
{
icon: Scissors,
title: "Trim",
description: "Set start and end points with a range slider. Preview as you go.",
},
{
icon: SlidersHorizontal,
title: "Export settings",
description: "Choose resolution, frame rate, and an optional file size cap.",
},
{
icon: Radio,
title: "RTMP streaming",
description: "Point OBS at your stream key and go live. Find the key in your profile.",
},
{
icon: LayoutGrid,
title: "Clip library",
description: "Browse all your clips. Watch, rename, edit the description, or delete.",
},
{
icon: Play,
title: "Built-in player",
description: "Scrub, adjust volume, go fullscreen. Runs in the browser.",
},
];
const Home = () => {
return ( return (
<div className={"max-h-screen flex flex-col justify-center items-center px-6 py-12 text-gray-900"}> <div className="flex flex-col items-center px-8 py-12 min-h-full">
{/* Logo */} {/* Hero */}
<div className="flex flex-col items-center text-center max-w-2xl mb-14">
<img <img
src="/logo.png" src="/logo.png"
alt="VoD System Logo" alt="VoD System Logo"
className={"h-44 mb-8 object-contain"} className="h-24 mb-6 object-contain"
/> />
<h1 className="text-4xl font-semibold text-gray-900 mb-3 leading-tight">
{/* Description Container */} A home for your clips.
<div className={"max-w-xl text-center"}> </h1>
<h2 className={"text-3xl font-semibold mb-6 text-gray-700"}> <p className="text-base text-gray-500 leading-relaxed">
What is the VoD System? Upload, trim, and export video clips. Stream live if you need to.
</h2>
<p className={"text-lg leading-relaxed text-gray-600 mb-6"}>
The VoD System is a powerful clip management platform designed to streamline how you
handle your video content. Whether you're a content creator, streamer, or educator,
VoD System lets you:
</p>
<ul className={"list-disc list-inside text-gray-600 mb-8 space-y-2"}>
<li>Upload clips effortlessly and securely.</li>
<li>Edit and trim videos with intuitive controls.</li>
<li>Compress files to specific file sizes.</li>
<li>Organize your clips for quick access and sharing.</li>
</ul>
<p className={"text-lg leading-relaxed text-gray-600"}>
Designed with simplicity and efficiency in mind, VoD System adapts to your workflow,
making video clip management faster and more enjoyable than ever.
</p> </p>
</div> </div>
{/* File Support Note */} {/* Feature grid */}
<div className={"bg-gray-100 border border-gray-300 rounded-md p-4 text-sm text-gray-500 max-w-md mx-auto"}> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 w-full max-w-4xl mb-10">
<strong>Note:</strong> Currently, only <code>.mp4</code> files are supported for upload and processing. {features.map(({ icon: Icon, title, description }) => (
Support for additional video formats will be added in future updates. <div
key={title}
className="bg-white border border-gray-200 rounded-xl p-5 flex flex-col gap-3 shadow-sm"
>
<div className="w-9 h-9 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<Icon size={18} className="text-primary" />
</div> </div>
<div>
<p className="font-semibold text-gray-900 text-sm mb-1">{title}</p>
<p className="text-sm text-gray-500 leading-relaxed">{description}</p>
</div>
</div>
))}
</div>
{/* Format note */}
<p className="text-xs text-gray-400">
Supports MP4, MOV, WebM, and OGG.
</p>
</div> </div>
); );
}; };

View File

@@ -1,31 +1,55 @@
import VideoCard from "../components/video/VideoCard"; import VideoCard from "../components/video/VideoCard";
import {useEffect, useState} from "react"; import { useEffect, useState } from "react";
import { getClips } from "../utils/endpoints"; import { useNavigate } from "react-router-dom";
import { getClips, deleteClip } from "../utils/endpoints";
import type { Clip } from "../utils/types"; import type { Clip } from "../utils/types";
const MyClips = () => { const MyClips = () => {
const [clips, setClips] = useState<Clip[]>([]); const [clips, setClips] = useState<Clip[]>([]);
const [error, setError] = useState<null | string>(null); const [error, setError] = useState<null | string>(null);
const navigate = useNavigate();
useEffect(() => { useEffect(() => {
getClips() getClips()
.then((data) => setClips(data)) .then((data) => setClips(data.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())))
.catch((err) => setError(err)); .catch((err) => setError(err));
}, []); }, []);
const handleDelete = async (id: number) => {
try {
await deleteClip(id);
setClips(prev => prev.filter(c => c.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete clip");
}
};
return ( return (
<div className={"flex flex-row"}> <div className="p-6">
<h1 className="text-2xl font-semibold text-gray-900 mb-6">My Clips</h1>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{clips.map((clip) => ( {clips.map((clip) => (
<VideoCard <VideoCard
key={clip.id}
id={clip.id} id={clip.id}
title={clip.title} title={clip.title}
duration={clip.duration} duration={clip.duration}
createdAt={clip.createdAt} createdAt={clip.createdAt}
className={"w-40 m-5"} onEdit={() => navigate(`/clips/${clip.id}/edit`)}
onDelete={() => handleDelete(clip.id)}
/> />
))} ))}
</div>
{error} {clips.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-400">
<p className="text-base">No clips yet.</p>
<p className="text-sm mt-1">Upload your first clip to get started.</p>
</div>
)}
{error && <p className="text-sm text-red-500 mt-4">{error.toString()}</p>}
</div> </div>
); );
} }

View File

@@ -3,6 +3,7 @@ import { Eye, EyeOff, Copy, Check } from "lucide-react";
import Box from "../components/Box.tsx"; import Box from "../components/Box.tsx";
import { getUser } from "../utils/endpoints.ts"; import { getUser } from "../utils/endpoints.ts";
import type { User } from "../utils/types.ts"; import type { User } from "../utils/types.ts";
import { formatLocalDate } from "../utils/utils.ts";
const Profile = () => { const Profile = () => {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
@@ -22,7 +23,7 @@ const Profile = () => {
if (!user) { if (!user) {
return ( return (
<div className="flex justify-center items-center h-full text-gray-500 text-lg"> <div className="flex justify-center items-center h-full text-gray-400 text-base">
Please log in to view your profile. Please log in to view your profile.
</div> </div>
); );
@@ -30,51 +31,51 @@ const Profile = () => {
return ( return (
<div className="px-8 py-10 max-w-2xl mx-auto"> <div className="px-8 py-10 max-w-2xl mx-auto">
<h1 className="text-2xl font-semibold text-gray-700 mb-6">Profile</h1> <h1 className="text-2xl font-semibold text-gray-900 mb-6">Profile</h1>
<Box className="p-6 mb-6 flex items-center gap-6"> <Box className="p-6 mb-4 flex items-center gap-5">
<img <img
src={user.profilePictureUrl} src={user.profilePictureUrl}
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
alt="Profile picture" alt="Profile picture"
className="w-20 h-20 rounded-full shadow" className="w-20 h-20 rounded-full shadow-sm"
/> />
<div> <div>
<p className="text-xl font-semibold text-gray-800">{user.name}</p> <p className="text-lg font-semibold text-gray-900">{user.name}</p>
<p className="text-sm text-gray-500">@{user.username}</p> <p className="text-sm text-gray-500">@{user.username}</p>
<p className="text-sm text-gray-500">{user.email}</p> <p className="text-sm text-gray-500">{user.email}</p>
<p className="text-xs text-gray-400 mt-1"> <p className="text-xs text-gray-400 mt-1">
Member since {new Date(user.createdAt).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" })} Member since {formatLocalDate(user.createdAt)}
</p> </p>
</div> </div>
</Box> </Box>
<Box className="p-6"> <Box className="p-6">
<h2 className="text-lg font-semibold text-gray-700 mb-1">Stream Key</h2> <h2 className="text-sm font-semibold text-gray-800 mb-1">Stream Key</h2>
<p className="text-sm text-gray-500 mb-4"> <p className="text-sm text-gray-500 mb-4">
Use this key in OBS or any RTMP-compatible software to start streaming. Use this key in OBS or any RTMP-compatible software to start streaming.
Keep it private — anyone with this key can stream to your account. Keep it private — anyone with this key can stream to your account.
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 bg-gray-100 border border-gray-300 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden"> <div className="flex-1 bg-gray-50 border border-gray-200 rounded-md px-4 py-2 font-mono text-sm text-gray-700 overflow-hidden">
{keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)} {keyVisible ? user.streamKey : "•".repeat(user.streamKey.length)}
</div> </div>
<button <button
onClick={() => setKeyVisible(v => !v)} onClick={() => setKeyVisible(v => !v)}
title={keyVisible ? "Hide key" : "Show key"} title={keyVisible ? "Hide key" : "Show key"}
className="p-2 rounded-md bg-gray-300 hover:bg-gray-400 transition-colors text-gray-700" className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
> >
{keyVisible ? <EyeOff size={18} /> : <Eye size={18} />} {keyVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button> </button>
<button <button
onClick={handleCopy} onClick={handleCopy}
title="Copy key" title="Copy key"
className="p-2 rounded-md bg-gray-300 hover:bg-gray-400 transition-colors text-gray-700" className="p-2 rounded-md bg-gray-100 hover:bg-gray-200 transition-colors duration-150 text-gray-600"
> >
{copied ? <Check size={18} className="text-green-600" /> : <Copy size={18} />} {copied ? <Check size={16} className="text-green-600" /> : <Copy size={16} />}
</button> </button>
</div> </div>
</Box> </Box>

View File

@@ -1,76 +1,326 @@
import {useEffect, useState} from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import {useParams} from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import type {Clip} from "../utils/types"; import { ArrowLeft, Lock, Maximize, Minimize, Pause, Play, Volume1, Volume2, VolumeX } from "lucide-react";
import {getClipById, getVideoBlob } from "../utils/endpoints.ts"; import type { Clip } from "../utils/types";
import Box from "../components/Box.tsx" import { AuthError, getClipById, getVideoBlob } from "../utils/endpoints.ts";
import {dateToTimeAgo, stringToDate} from "../utils/utils.ts"; import Box from "../components/Box.tsx";
import { dateToTimeAgo, formatTime, stringToDate } from "../utils/utils.ts";
const VideoPlayer = () => { const VideoPlayer = () => {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate();
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressRef = useRef<HTMLDivElement>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Data
const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined); const [videoUrl, setVideoUrl] = useState<string | undefined>(undefined);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [notAuthenticated, setNotAuthenticated] = useState(false);
const [clip, setClip] = useState<Clip | null>(null); const [clip, setClip] = useState<Clip | null>(null);
const [timeAgo, setTimeAgo] = useState<String>(""); const [timeAgo, setTimeAgo] = useState("");
// Playback
const [isPlaying, setIsPlaying] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [bufferedPct, setBufferedPct] = useState(0);
const [isWaiting, setIsWaiting] = useState(false);
// Volume
const [volume, setVolume] = useState(1);
const [isMuted, setIsMuted] = useState(false);
// UI
const [isFullscreen, setIsFullscreen] = useState(false);
const [controlsVisible, setControlsVisible] = useState(true);
const [isDragging, setIsDragging] = useState(false);
// ── Data loading ────────────────────────────────────────────────────────
useEffect(() => { useEffect(() => {
if (!id) { if (!id) { setError("Clip ID is required."); return; }
setError("Clip ID is required.");
return;
}
getVideoBlob(id) getVideoBlob(id)
.then((blob) => { .then((blob) => setVideoUrl(URL.createObjectURL(blob)))
const url = URL.createObjectURL(blob); .catch((e) => { if (e instanceof AuthError) setNotAuthenticated(true); });
setVideoUrl(url);
})
getClipById(id) getClipById(id)
.then((fetchedClip) => {setClip(fetchedClip)}) .then(setClip)
.catch((err) => { .catch((e) => {
console.error("Error fetching clip:", err); if (e instanceof AuthError) setNotAuthenticated(true);
setError("Failed to load clip details. Please try again later."); else setError("Failed to load clip details.");
}); });
}, [id]); }, [id]);
// Update timeAgo live every second
useEffect(() => { useEffect(() => {
if (!clip || !clip.createdAt) return; if (!clip?.createdAt) return;
const update = () => setTimeAgo(dateToTimeAgo(stringToDate(clip.createdAt)));
const update = () => { update();
const date = stringToDate(clip.createdAt);
setTimeAgo(dateToTimeAgo(date));
};
update(); // initial update
const interval = setInterval(update, 1000); const interval = setInterval(update, 1000);
return () => clearInterval(interval);
return () => clearInterval(interval); // cleanup
}, [clip]); }, [clip]);
// ── Controls visibility ─────────────────────────────────────────────────
const scheduleHide = useCallback(() => {
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
hideTimerRef.current = setTimeout(() => setControlsVisible(false), 3000);
}, []);
const showControls = useCallback(() => {
setControlsVisible(true);
scheduleHide();
}, [scheduleHide]);
// ── Fullscreen ──────────────────────────────────────────────────────────
useEffect(() => {
const onChange = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = useCallback(() => {
if (!containerRef.current) return;
document.fullscreenElement
? document.exitFullscreen()
: containerRef.current.requestFullscreen();
}, []);
// ── Keyboard shortcuts ──────────────────────────────────────────────────
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return;
const video = videoRef.current;
if (!video) return;
switch (e.code) {
case 'Space': case 'KeyK':
e.preventDefault();
video.paused ? video.play() : video.pause();
showControls();
break;
case 'ArrowLeft':
e.preventDefault();
video.currentTime = Math.max(0, video.currentTime - 5);
showControls();
break;
case 'ArrowRight':
e.preventDefault();
video.currentTime = Math.min(video.duration, video.currentTime + 5);
showControls();
break;
case 'KeyM':
video.muted = !video.muted;
setIsMuted(video.muted);
break;
case 'KeyF':
toggleFullscreen();
break;
}
};
document.addEventListener('keydown', onKey);
return () => document.removeEventListener('keydown', onKey);
}, [showControls, toggleFullscreen]);
// ── Progress bar seeking ────────────────────────────────────────────────
const seekTo = useCallback((clientX: number) => {
const rect = progressRef.current?.getBoundingClientRect();
const video = videoRef.current;
if (!rect || !video || !duration) return;
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
video.currentTime = ratio * duration;
setCurrentTime(ratio * duration);
}, [duration]);
useEffect(() => {
if (!isDragging) return;
const onMove = (e: MouseEvent) => seekTo(e.clientX);
const onUp = () => setIsDragging(false);
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
return () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
};
}, [isDragging, seekTo]);
// ── Video element event handlers ────────────────────────────────────────
const handleTimeUpdate = () => {
const video = videoRef.current;
if (!video) return;
setCurrentTime(video.currentTime);
if (video.buffered.length > 0) {
setBufferedPct((video.buffered.end(video.buffered.length - 1) / video.duration) * 100);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const v = parseFloat(e.target.value);
setVolume(v);
setIsMuted(v === 0);
if (videoRef.current) {
videoRef.current.volume = v;
videoRef.current.muted = v === 0;
}
};
const toggleMute = () => {
const video = videoRef.current;
if (!video) return;
video.muted = !video.muted;
setIsMuted(video.muted);
};
const togglePlay = () => {
const video = videoRef.current;
if (!video) return;
video.paused ? video.play() : video.pause();
showControls();
};
const progressPct = duration > 0 ? (currentTime / duration) * 100 : 0;
const effectiveVolume = isMuted ? 0 : volume;
const VolumeIcon = effectiveVolume === 0 ? VolumeX : effectiveVolume < 0.5 ? Volume1 : Volume2;
if (notAuthenticated) {
return ( return (
<div className={"w-9/10 m-auto"}> <div className="h-full flex flex-col items-center justify-center gap-4 text-center p-8">
<video <div className="w-14 h-14 rounded-full bg-gray-100 flex items-center justify-center">
className={"w-full h-full rounded-lg m-auto"} <Lock size={24} className="text-gray-400" />
controls </div>
autoPlay <div>
src={videoUrl} <p className="text-lg font-semibold text-gray-900">Not authenticated</p>
onError={(e) => { <p className="text-sm text-gray-500 mt-1">You don't have access to this clip.</p>
setError(e.currentTarget.error?.message || "An error occurred while playing the video."); </div>
}} <button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 transition-colors duration-150"
> >
Your browser does not support the video tag. <ArrowLeft size={16} />
</video> Go back
</button>
</div>
);
}
{error && <div className="text-red-500 mt-2">{error}</div>} return (
{!videoUrl && !error && <div className="text-gray-500 mt-2">Loading video...</div>} <div className="h-full flex flex-col p-4 gap-3">
<button
onClick={() => navigate(-1)}
className="flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-900 flex-shrink-0 transition-colors duration-150"
>
<ArrowLeft size={16} />
Back
</button>
<Box className={"p-2 m-2 flex flex-col"}> {/* Player */}
<p className={"text-2xl font-bold text-gray-600"}>{clip?.title ? clip?.title : "(No Title)"}</p> <div
<p>{timeAgo}</p> ref={containerRef}
className="relative bg-black rounded-xl overflow-hidden flex-1 min-h-0 select-none"
onMouseMove={showControls}
onMouseLeave={() => isPlaying && setControlsVisible(false)}
onClick={togglePlay}
style={{ cursor: controlsVisible ? 'default' : 'none' }}
>
<video
ref={videoRef}
className="w-full h-full object-contain"
src={videoUrl}
onPlay={() => { setIsPlaying(true); scheduleHide(); }}
onPause={() => {
setIsPlaying(false);
setControlsVisible(true);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
}}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={() => { if (videoRef.current) setDuration(videoRef.current.duration); }}
onWaiting={() => setIsWaiting(true)}
onCanPlay={() => setIsWaiting(false)}
onError={(e) => setError(e.currentTarget.error?.message || "An error occurred.")}
/>
{/* Buffering spinner */}
{isWaiting && videoUrl && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-11 h-11 rounded-full border-[3px] border-white/20 border-t-white animate-spin" />
</div>
)}
{/* Controls overlay — pointer-events-none so video clicks pass through */}
<div className={`absolute inset-0 flex flex-col justify-end transition-opacity duration-300 pointer-events-none ${controlsVisible ? 'opacity-100' : 'opacity-0'}`}>
{/* Gradient */}
<div className="absolute inset-0 bg-gradient-to-t from-black/75 via-black/10 to-transparent" />
{/* Controls bar — pointer-events-auto only here */}
<div
className="relative px-4 pb-4 pt-10 pointer-events-auto"
onClick={(e) => e.stopPropagation()}
>
{/* Scrub bar */}
<div
ref={progressRef}
className="relative h-1 rounded-full bg-white/25 cursor-pointer mb-3 group/prog hover:h-1.5 transition-[height] duration-150"
onMouseDown={(e) => { setIsDragging(true); seekTo(e.clientX); }}
>
<div className="absolute inset-y-0 left-0 bg-white/35 rounded-full" style={{ width: `${bufferedPct}%` }} />
<div className="absolute inset-y-0 left-0 bg-primary rounded-full" style={{ width: `${progressPct}%` }} />
<div
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full shadow opacity-0 group-hover/prog:opacity-100 transition-opacity"
style={{ left: `calc(${progressPct}% - 6px)` }}
/>
</div>
{/* Buttons row */}
<div className="flex items-center gap-3 text-white">
<button onClick={togglePlay} className="hover:text-white/70 transition-colors">
{isPlaying
? <Pause size={20} fill="currentColor" />
: <Play size={20} fill="currentColor" />}
</button>
{/* Volume — slider expands on hover */}
<div className="flex items-center gap-1.5 group/vol">
<button onClick={toggleMute} className="hover:text-white/70 transition-colors">
<VolumeIcon size={18} />
</button>
<div className="overflow-hidden w-0 group-hover/vol:w-16 transition-[width] duration-200">
<input
type="range"
min={0} max={1} step={0.02}
value={effectiveVolume}
onChange={handleVolumeChange}
className="w-16 accent-white cursor-pointer"
/>
</div>
</div>
<span className="text-xs font-mono tabular-nums text-white/80">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
<div className="flex-1" />
<button onClick={toggleFullscreen} className="hover:text-white/70 transition-colors">
{isFullscreen ? <Minimize size={18} /> : <Maximize size={18} />}
</button>
</div>
</div>
</div>
</div>
{error && <div className="text-red-500 text-sm mt-3">{error}</div>}
{!videoUrl && !error && (
<div className="text-gray-400 text-sm mt-3 flex items-center gap-2">
<div className="w-3 h-3 rounded-full border-2 border-gray-300 border-t-gray-500 animate-spin" />
Loading video...
</div>
)}
<Box className="p-4 flex-shrink-0">
<p className="text-xl font-semibold text-gray-900">{clip?.title || "(No Title)"}</p>
<p className="text-sm text-gray-500 mt-1">{timeAgo}</p>
</Box> </Box>
</div> </div>
); );
}; };

View File

@@ -1,279 +1,172 @@
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; const API_URL = import.meta.env.VITE_API_URL;
/** export class AuthError extends Error {
* Login function constructor() { super("Not authenticated"); this.name = "AuthError"; }
* @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 login = async (GoogleToken: string): Promise<string> => { const response = await fetch(API_URL + '/api/v1/users/login', {
const response = await fetch(API_URL + '/api/v1/auth/login', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json', body: JSON.stringify({ token: googleToken }),
}, credentials: 'include',
body: JSON.stringify({ token: GoogleToken }),
credentials: 'include'
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Login failed: ${response.status}`);
throw new Error(`Login failed: ${response.status}`);
}
const result: APIResponse = await response.json(); 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; return result.data.token;
} };
const logout = async () => { const logout = async (): Promise<void> => {
const response = await fetch(API_URL + '/api/v1/auth/logout', { const response = await fetch(API_URL + '/api/v1/users/logout', {
method: 'POST', method: 'POST',
credentials: 'include' credentials: 'include',
}); });
if (!response.ok) { if (!response.ok) throw new Error(`Logout failed: ${response.status}`);
throw new Error(`Logout failed: ${response.status}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Logout failed: ${result.message}`);
};
if (result.status === "error") { const compress = async (file: File, options: VideoMetadata): Promise<string> => {
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 formData = new FormData(); const formData = new FormData();
formData.append('file', file); 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', method: 'POST',
body: formData, body: formData,
credentials: 'include',
}); });
const result: APIResponse = await response.json(); if (!response.ok) throw new Error(`Compression failed: ${response.status}`);
if (result.status == "error") { const result: APIResponse = await response.json();
throw new Error(`Failed to upload file: ${result.message}`); if (result.status === 'error') throw new Error(`Compression failed: ${result.message}`);
}
return result.data.uuid; return result.data.uuid;
}; };
/** const getJob = async (uuid: string): Promise<JobResponse> => {
* Submits metadata changes to the backend. const response = await fetch(API_URL + `/api/v1/jobs/${uuid}`, { credentials: 'include' });
* @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();
for (const [key, value] of Object.entries(videoMetadata)) { if (!response.ok) throw new Error(`Failed to fetch job: ${response.status}`);
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}`);
}
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
if (result.status === 'error') throw new Error(`Failed to fetch job: ${result.message}`);
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');
}
return result.data; return result.data;
}; };
/** const getUser = async (): Promise<User | null> => {
* Fetches original metadata from the backend. const response = await fetch(API_URL + '/api/v1/users/me', { credentials: 'include' });
* @param uuid - The UUID of the video file. if (!response.ok) return null;
*/
const getMetadata = async (uuid: string): Promise<VideoMetadata> => {
const response = await fetch(API_URL + `/api/v1/metadata/original/${uuid}`, {credentials: "include"});
if (!response.ok) { const result: APIResponse = await response.json();
throw new Error(`Failed to fetch metadata: ${response.status}`); if (result.status === 'error') return null;
}
const result = await response.json();
if (result.status === "error") {
throw new Error(`Failed to fetch metadata: ${result.message}`);
}
return result.data; 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 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) { if (!response.ok) {
const errorResult: APIResponse = await response.json(); const errorResult: APIResponse = await response.json();
throw new Error(`Failed to fetch clips: ${errorResult.message}`); throw new Error(`Failed to fetch clips: ${errorResult.message}`);
} }
try {
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
return result.data; return result.data;
} catch { };
throw new Error('Failed to parse response');
}
}
/**
* Fetches a clip by its ID.
* @param id
*/
const getClipById = async (id: string): Promise<Clip | null> => { const 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.ok) {
if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch clip: ${response.status}`); throw new Error(`Failed to fetch clip: ${response.status}`);
} }
try{
const result: APIResponse = await response.json(); const result: APIResponse = await response.json();
return result.data; return result.data;
} catch (error: unknown) {
throw new Error(`Failed to parse clip response:
${error instanceof Error ? error.message : 'Unknown error'}`);
}
}; };
const getVideoBlob = async(id: string): Promise<Blob> => { const getVideoBlob = async (id: string): Promise<Blob> => {
const response = await fetch(API_URL + `/api/v1/download/clip/${id}`, {credentials: "include",}); const response = await fetch(API_URL + `/api/v1/clips/${id}/media`, { credentials: 'include' });
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to fetch video: ${id}: ${response.status}`) if (response.status === 401 || response.status === 403) throw new AuthError();
throw new Error(`Failed to fetch video ${id}: ${response.status}`);
} }
try {
return response.blob(); return response.blob();
} catch { };
throw new Error(`Failed to convert Clip Return Object to blob`);
} const patchClip = async (id: number, data: { title?: string; description?: string }): Promise<Clip> => {
} const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(data),
});
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}`);
return result.data;
};
const deleteClip = async (id: number): Promise<void> => {
const response = await fetch(API_URL + `/api/v1/clips/${id}`, {
method: 'DELETE',
credentials: 'include',
});
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}`);
};
const getStreamStatus = async (): Promise<boolean> => {
const response = await fetch(API_URL + '/api/v1/stream/current', { credentials: 'include' });
if (!response.ok) return false;
const result: APIResponse = await response.json();
return result.status === 'success' && result.data?.isStreaming === true;
};
const isThumbnailAvailable = async (id: number): 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' });
if (!response.ok) { return response.ok;
return false; };
}
return true;
}
export { export {
login, login,
logout, logout,
uploadFile, compress,
editFile, getJob,
processFile,
convertFile,
getProgress,
getMetadata,
getUser, getUser,
getClips, getClips,
getClipById, getClipById,
getVideoBlob, getVideoBlob,
isThumbnailAvailable patchClip,
deleteClip,
getStreamStatus,
isThumbnailAvailable,
}; };

View File

@@ -33,15 +33,13 @@ type Clip = {
createdAt: string, createdAt: string,
} }
type ProgressResult = { type JobResponse = {
process: { uuid: string,
progress: number, progress: number,
complete: boolean isComplete: boolean,
}; state: 'READY' | 'PROCESSING' | 'SUCCEEDED' | 'FAILED',
conversion: { errorOutput: string | null,
progress: number, createdAt: string,
complete: boolean
};
}; };
export type { export type {
@@ -49,5 +47,5 @@ export type {
VideoMetadata, VideoMetadata,
User, User,
Clip, Clip,
ProgressResult JobResponse
} }

View File

@@ -2,76 +2,42 @@ function formatTime(seconds: number): string {
const h = Math.floor(seconds / 3600); const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60); const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60); const s = Math.floor(seconds % 60);
const pad = (n: number) => n.toString().padStart(2, '0');
const padded = (n: number) => n.toString().padStart(2, '0'); if (h > 0) return `${h}:${pad(m)}:${pad(s)}`;
return `${m}:${pad(s)}`;
if (h > 0) {
return `${h}:${padded(m)}:${padded(s)}`;
} else {
return `${m}:${padded(s)}`;
}
} }
// Parses a UTC date string, appending Z if no timezone offset is present so
// the browser always interprets the value as UTC rather than local time.
function stringToDate(dateString: string): Date { function stringToDate(dateString: string): Date {
const validIso = dateString.substring(0, 23); const normalized = dateString.replace(/(\.\d{3})\d+/, "$1");
const date = new Date(validIso); const withTz = /[Zz]$|[+-]\d{2}:\d{2}$|[+-]\d{4}$/.test(normalized) ? normalized : `${normalized}Z`;
if (isNaN(date.getTime())) { const date = new Date(withTz);
throw new Error("Invalid date string"); if (isNaN(date.getTime())) throw new Error("Invalid date string");
}
return date; return date;
} }
function formatLocalDate(dateString: string, options?: Intl.DateTimeFormatOptions): string {
return stringToDate(dateString).toLocaleDateString(undefined, options ?? { year: "numeric", month: "long", day: "numeric" });
}
function dateToTimeAgo(date: Date): string { function dateToTimeAgo(date: Date): string {
const now = new Date(); const secondsAgo = Math.floor((Date.now() - date.getTime()) / 1000);
const secondsAgo = Math.floor((now.getTime() - date.getTime()) / 1000); const units: [number, string][] = [
[31536000, 'year'],
if (secondsAgo < 60) { [2592000, 'month'],
return `${secondsAgo} seconds ago`; [86400, 'day'],
} else if (secondsAgo < 3600) { [3600, 'hour'],
const minutes = Math.floor(secondsAgo / 60); [60, 'minute'],
];
if (minutes === 1) { for (const [threshold, unit] of units) {
return "1 minute ago"; 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 { export { formatTime, stringToDate, dateToTimeAgo, formatLocalDate };
formatTime,
stringToDate,
dateToTimeAgo
}

View File

@@ -15,12 +15,6 @@ RUN apt-get update && \
apt-get clean && \ apt-get clean && \
rm -rf /var/lib/apt/lists/* 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 WORKDIR /app
COPY --from=builder /app/target/*.jar app.jar COPY --from=builder /app/target/*.jar app.jar
EXPOSE 8080 EXPOSE 8080

View File

@@ -19,7 +19,7 @@ public class CorsConfig {
public void addCorsMappings(CorsRegistry registry) { public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/v1/**") registry.addMapping("/api/v1/**")
.allowedOrigins(frontendUrl) .allowedOrigins(frontendUrl)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")
.allowedHeaders("*") .allowedHeaders("*")
.allowCredentials(true); .allowCredentials(true);
} }

View File

@@ -30,11 +30,10 @@ public class SecurityConfig {
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/api/v1/**").permitAll()
.requestMatchers("/api/v1/download/clip/**","/api/v1/clips/", "/api/v1/clips/**").authenticated() .requestMatchers("/api/v1/media/**").authenticated()
.requestMatchers("/api/v1/auth/login", "/api/v1/auth/user").permitAll() .requestMatchers("/api/v1/jobs/**").authenticated()
.requestMatchers("/api/v1/upload", "/api/v1/download/**").permitAll() .requestMatchers("/api/v1/users/me", "/api/v1/users/logout").authenticated()
.requestMatchers("/api/v1/edit/**", "/api/v1/process/**", "/api/v1/progress/**", "/api/v1/convert/**").permitAll() .requestMatchers("/api/v1/users/login").permitAll()
.requestMatchers("/api/v1/metadata/**").permitAll()
.requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll() .requestMatchers("/api/v1/stream/start", "/api/v1/stream/stop", "/api/v1/stream/heartbeat").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
) )

View File

@@ -1,9 +1,14 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.ClipDTO; import com.ddf.vodsystem.controllers.dto.ClipResponse;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.Clip; import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.services.ClipService; 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.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -15,15 +20,16 @@ import java.util.Optional;
public class ClipController { public class ClipController {
private final ClipService clipService; private final ClipService clipService;
private static final String SUCCESS = "success"; private static final String SUCCESS = "success";
private static final String FILENAME_HEADER = "inline; filename=\"%s\"";
public ClipController(ClipService clipService) { public ClipController(ClipService clipService) {
this.clipService = clipService; this.clipService = clipService;
} }
@GetMapping("") @GetMapping("")
public ResponseEntity<APIResponse<List<ClipDTO>>> getClips() { public ResponseEntity<APIResponse<List<ClipResponse>>> getClips() {
List<Clip> clips = clipService.getClipsByUser(); List<Clip> clips = clipService.getClipsByUser();
List<ClipDTO> clipDTOs = clips.stream() List<ClipResponse> clipDTOs = clips.stream()
.map(this::convertToDTO) .map(this::convertToDTO)
.toList(); .toList();
@@ -36,13 +42,13 @@ public class ClipController {
} }
@GetMapping("/{id}") @GetMapping("/{id}")
public ResponseEntity<APIResponse<ClipDTO>> getClipById(@PathVariable Long id) { public ResponseEntity<APIResponse<ClipResponse>> getClipById(@PathVariable Long id) {
Optional<Clip> clip = clipService.getClipById(id); Optional<Clip> clip = clipService.getClipById(id);
if (clip.isEmpty()) { if (clip.isEmpty()) {
return ResponseEntity.notFound().build(); return ResponseEntity.notFound().build();
} }
ClipDTO clipDTO = convertToDTO(clip.get()); ClipResponse clipDTO = convertToDTO(clip.get());
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>(SUCCESS, new APIResponse<>(SUCCESS,
@@ -52,6 +58,17 @@ public class ClipController {
); );
} }
@PatchMapping("/{id}")
public ResponseEntity<APIResponse<ClipResponse>> updateClip(@PathVariable Long id,
@RequestBody ClipUpdateRequest updateFields) {
Clip clip = clipService.updateClip(id, updateFields);
ClipResponse clipDTO = convertToDTO(clip);
return ResponseEntity.ok(
new APIResponse<>(SUCCESS, "Clip updated successfully", clipDTO)
);
}
@DeleteMapping("/{id}") @DeleteMapping("/{id}")
public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) { public ResponseEntity<APIResponse<String>> deleteClip(@PathVariable Long id) {
if (!clipService.deleteClip(id)) { if (!clipService.deleteClip(id)) {
@@ -67,14 +84,42 @@ public class ClipController {
); );
} }
private ClipDTO convertToDTO(Clip clip) { @GetMapping("/{id}/media")
ClipDTO dto = new ClipDTO(); public ResponseEntity<Resource> downloadClip(@PathVariable Long id) {
dto.setId(clip.getId()); Resource resource = clipService.downloadClip(id);
dto.setUserId(clip.getUser().getId());
dto.setTitle(clip.getTitle()); if (resource == null || !resource.exists()) {
dto.setDescription(clip.getDescription()); return ResponseEntity.notFound().build();
dto.setDuration(clip.getDuration()); }
dto.setCreatedAt(clip.getCreatedAt());
return dto; 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(),
clip.getUser().getId(),
clip.getTitle(),
clip.getDescription(),
clip.getDuration(),
clip.getCreatedAt()
);
} }
} }

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -1,12 +1,7 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.exceptions.AlreadyStreaming; import com.ddf.vodsystem.exceptions.*;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound;
import com.ddf.vodsystem.exceptions.KeyNotFound;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.HttpMediaTypeNotSupportedException; import org.springframework.web.HttpMediaTypeNotSupportedException;
@@ -92,4 +87,11 @@ public class GlobalExceptionHandler {
APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null); APIResponse<Void> response = new APIResponse<>(ERROR, ex.getMessage(), null);
return ResponseEntity.status(HttpStatus.CONFLICT).body(response); return ResponseEntity.status(HttpStatus.CONFLICT).body(response);
} }
@ExceptionHandler(StorageException.class)
public ResponseEntity<APIResponse<Void>> handleStorage(StorageException e) {
logger.error("Storage operation failed", e);
APIResponse<Void> response = new APIResponse<>(ERROR, "Could not process file", null);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
} }

View File

@@ -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);
}
}

View File

@@ -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())
));
}
}

View File

@@ -1,47 +0,0 @@
package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.JobService;
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 JobService jobService;
public MetadataController(JobService jobService) {
this.jobService = jobService;
}
@GetMapping("/original/{uuid}")
public ResponseEntity<APIResponse<ClipOptions>> getMetadata(@PathVariable String uuid) {
ClipOptions originalMetadata = jobService.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 = jobService.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));
}
}

View File

@@ -1,8 +1,7 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.controllers.input.ClipSectionRequest; import com.ddf.vodsystem.controllers.dto.StreamResponse;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.controllers.input.SaveSectionRequest;
import com.ddf.vodsystem.entities.Stream; import com.ddf.vodsystem.entities.Stream;
import com.ddf.vodsystem.services.StreamService; import com.ddf.vodsystem.services.StreamService;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -10,13 +9,14 @@ import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.io.IOException;
import java.util.List; import java.util.List;
import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/v1/stream") @RequestMapping("/api/v1/stream")
public class StreamController { public class StreamController {
private static final Logger logger = LoggerFactory.getLogger(StreamController.class); private static final Logger logger = LoggerFactory.getLogger(StreamController.class);
private static final String SUCCESS = "success";
private final StreamService streamService; private final StreamService streamService;
@@ -29,7 +29,7 @@ public class StreamController {
public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) { public ResponseEntity<APIResponse<Stream>> startStream(@RequestParam("name") String streamKey) {
logger.info("Stream start callback for key: {}", streamKey); logger.info("Stream start callback for key: {}", streamKey);
Stream stream = streamService.startStream(streamKey); Stream stream = streamService.startStream(streamKey);
return ResponseEntity.ok(new APIResponse<>("success", "Stream started", stream)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream started", stream));
} }
// Called by nginx on_publish_done // Called by nginx on_publish_done
@@ -37,33 +37,29 @@ public class StreamController {
public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) { public ResponseEntity<APIResponse<Void>> stopStream(@RequestParam("name") String streamKey) {
logger.info("Stream stop callback for key: {}", streamKey); logger.info("Stream stop callback for key: {}", streamKey);
streamService.endStream(streamKey); streamService.endStream(streamKey);
return ResponseEntity.ok(new APIResponse<>("success", "Stream ended", null)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream ended", null));
} }
// Called by nginx on_update // Called by nginx on_update
@PostMapping("/heartbeat") @PostMapping("/heartbeat")
public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) { public ResponseEntity<APIResponse<Void>> heartbeat(@RequestParam("name") String streamKey) {
streamService.heartbeatStream(streamKey); streamService.heartbeatStream(streamKey);
return ResponseEntity.ok(new APIResponse<>("success", "Heartbeat recorded", null)); 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}") @GetMapping("/history/{userId}")
public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) { public ResponseEntity<APIResponse<List<Stream>>> getStreamHistory(@PathVariable Long userId) {
List<Stream> history = streamService.getStreamHistory(userId); List<Stream> history = streamService.getStreamHistory(userId);
return ResponseEntity.ok(new APIResponse<>("success", "Stream history retrieved", history)); return ResponseEntity.ok(new APIResponse<>(SUCCESS, "Stream history retrieved", history));
}
@GetMapping("/current")
public ResponseEntity<APIResponse<StreamResponse>> getCurrentStream() {
Optional<Stream> stream = streamService.getActiveStream();
return stream.map(value -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved",
new StreamResponse(true, value.getId())))).orElseGet(() -> ResponseEntity.ok(new APIResponse<>(SUCCESS,
"Streaming status retrieved",
new StreamResponse(false, null))));
} }
} }

View File

@@ -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);
}
}

View File

@@ -1,7 +1,7 @@
package com.ddf.vodsystem.controllers; package com.ddf.vodsystem.controllers;
import com.ddf.vodsystem.dto.APIResponse; import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.dto.TokenDTO; import com.ddf.vodsystem.controllers.dto.Token;
import com.ddf.vodsystem.entities.User; import com.ddf.vodsystem.entities.User;
import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.UserService; import com.ddf.vodsystem.services.UserService;
@@ -14,18 +14,19 @@ import org.springframework.web.bind.annotation.*;
import java.util.Optional; import java.util.Optional;
@RestController @RestController
@RequestMapping("/api/v1/auth/") @RequestMapping("/api/v1/users")
public class UserController { public class UserController {
private final UserService userService; private final UserService userService;
@Value("${jwt.expiration}") @Value("${jwt.expiration}")
private long jwtExpiration; private long jwtExpiration;
private static final String SUCCESS = "success";
public UserController(UserService userService) { public UserController(UserService userService) {
this.userService = userService; this.userService = userService;
} }
@GetMapping("/user") @GetMapping("/me")
public ResponseEntity<APIResponse<User>> user() { public ResponseEntity<APIResponse<User>> user() {
Optional<User> user = userService.getLoggedInUser(); Optional<User> user = userService.getLoggedInUser();
@@ -34,14 +35,14 @@ public class UserController {
} }
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>("success", "User retrieved successfully", user.get()) new APIResponse<>(SUCCESS, "User retrieved successfully", user.get())
); );
} }
@PostMapping("/login") @PostMapping("/login")
public ResponseEntity<APIResponse<TokenDTO>> login(@RequestBody TokenDTO tokenDTO, public ResponseEntity<APIResponse<Token>> login(@RequestBody Token token,
HttpServletResponse response) { HttpServletResponse response) {
String jwt = userService.login(tokenDTO.getToken()); String jwt = userService.login(token.token());
ResponseCookie cookie = ResponseCookie.from("token", jwt) ResponseCookie cookie = ResponseCookie.from("token", jwt)
.httpOnly(true) .httpOnly(true)
@@ -54,7 +55,7 @@ public class UserController {
response.addHeader("Set-Cookie", cookie.toString()); response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>("success", "Logged in successfully", new TokenDTO(jwt)) new APIResponse<>(SUCCESS, "Logged in successfully", new Token(jwt))
); );
} }
@@ -71,7 +72,7 @@ public class UserController {
response.addHeader("Set-Cookie", cookie.toString()); response.addHeader("Set-Cookie", cookie.toString());
return ResponseEntity.ok( return ResponseEntity.ok(
new APIResponse<>("success", "Logged out successfully", null) new APIResponse<>(SUCCESS, "Logged out successfully", null)
); );
} }
} }

View File

@@ -0,0 +1,12 @@
package com.ddf.vodsystem.controllers.dto;
import java.time.Instant;
public record ClipResponse(
Long id,
Long userId,
String title,
String description,
Float duration,
Instant createdAt
) {}

View File

@@ -1,4 +1,4 @@
package com.ddf.vodsystem.controllers.input; package com.ddf.vodsystem.controllers.dto;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;

View File

@@ -0,0 +1,6 @@
package com.ddf.vodsystem.controllers.dto;
public record ClipUpdateRequest (
String title,
String description
) {}

View File

@@ -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
) {
}

View File

@@ -1,4 +1,4 @@
package com.ddf.vodsystem.controllers.input; package com.ddf.vodsystem.controllers.dto;
import java.time.Instant; import java.time.Instant;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;

View File

@@ -0,0 +1,7 @@
package com.ddf.vodsystem.controllers.dto;
public record StreamResponse(
boolean isStreaming,
Long id
) {
}

View File

@@ -0,0 +1,5 @@
package com.ddf.vodsystem.controllers.dto;
public record Token(
String token
) {}

View File

@@ -0,0 +1,3 @@
package com.ddf.vodsystem.controllers.dto;
public record UUIDResponse(String uuid) {}

View File

@@ -1,15 +0,0 @@
package com.ddf.vodsystem.dto;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class ClipDTO {
private Long id;
private Long userId;
private String title;
private String description;
private Float duration;
private LocalDateTime createdAt;
}

View File

@@ -1,15 +1,31 @@
package com.ddf.vodsystem.dto; package com.ddf.vodsystem.dto;
import jakarta.validation.constraints.*;
import lombok.Data; import lombok.Data;
@Data @Data
public class ClipOptions { public class ClipOptions {
@NotBlank
private String title; private String title;
private String description; private String description;
@NotNull
@Positive
private Float startPoint; private Float startPoint;
@NotNull @Positive
private Float duration; private Float duration;
@DecimalMin("1.0") @DecimalMax("120.0")
private Float fps; private Float fps;
@Positive
private Integer width; private Integer width;
@Positive
private Integer height; private Integer height;
@Positive
private Float fileSize; private Float fileSize;
} }

View File

@@ -1,28 +1,21 @@
package com.ddf.vodsystem.dto; package com.ddf.vodsystem.dto;
import java.io.File;
import lombok.Data; import lombok.Data;
import java.nio.file.Path;
import java.time.Instant;
@Data @Data
public class Job { public class Job {
private String uuid; private String uuid;
private File inputFile; private ProgressTracker progressTracker = new ProgressTracker();
private File outputFile; private JobState state = JobState.READY;
private String errorOutput;
// configs private Path download;
private ClipOptions inputClipOptions; private Instant createdAt = Instant.now();
private ClipOptions outputClipOptions = new ClipOptions();
// job status public Job(String uuid) {
private JobStatus status = new JobStatus();
public Job(String uuid,
File inputFile,
File outputFile,
ClipOptions inputClipOptions) {
this.uuid = uuid; this.uuid = uuid;
this.inputFile = inputFile;
this.outputFile = outputFile;
this.inputClipOptions = inputClipOptions;
} }
} }

View File

@@ -0,0 +1,8 @@
package com.ddf.vodsystem.dto;
public enum JobState {
READY,
PROCESSING,
SUCCEEDED,
FAILED
}

View File

@@ -1,12 +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;
}

View File

@@ -1,10 +0,0 @@
package com.ddf.vodsystem.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
@Data
@AllArgsConstructor
public class TokenDTO {
private String token;
}

View File

@@ -5,7 +5,7 @@ import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.Instant;
@Entity @Entity
@Table(name = "clips") @Table(name = "clips")
@@ -28,7 +28,7 @@ public class Clip {
private String description; private String description;
@Column(name = "created_at") @Column(name = "created_at")
private LocalDateTime createdAt; private Instant createdAt;
@Column(name = "width", nullable = false) @Column(name = "width", nullable = false)
private Integer width; private Integer width;

View File

@@ -4,7 +4,7 @@ import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import java.time.LocalDateTime; import java.time.Instant;
@Entity @Entity
@Table(name = "streams") @Table(name = "streams")
@@ -21,11 +21,11 @@ public class Stream {
private User user; private User user;
@Column(name = "start_time") @Column(name = "start_time")
private LocalDateTime startDate; private Instant startDate;
@Column(name = "end_time") @Column(name = "end_time")
private LocalDateTime endDate; private Instant endDate;
@Column(name = "last_seen") @Column(name = "last_seen")
private LocalDateTime lastSeen; private Instant lastSeen;
} }

View File

@@ -3,7 +3,7 @@ package com.ddf.vodsystem.entities;
import jakarta.persistence.*; import jakarta.persistence.*;
import lombok.Data; import lombok.Data;
import java.time.LocalDateTime; import java.time.Instant;
@Entity @Entity
@Table(name = "users") @Table(name = "users")
@@ -33,7 +33,7 @@ public class User {
private Integer role; // 0: user, 1: admin private Integer role; // 0: user, 1: admin
@Column(name = "created_at") @Column(name = "created_at")
private LocalDateTime createdAt; private Instant createdAt;
@Column(name = "stream_key") @Column(name = "stream_key")
private String streamKey; private String streamKey;

View File

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

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import com.ddf.vodsystem.entities.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime; import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@@ -13,5 +13,5 @@ import java.util.Optional;
public interface StreamRepository extends JpaRepository<Stream, Long> { public interface StreamRepository extends JpaRepository<Stream, Long> {
Optional<Stream> findByUserAndEndDateIsNull(User user); Optional<Stream> findByUserAndEndDateIsNull(User user);
List<Stream> findByUser(User user); List<Stream> findByUser(User user);
List<Stream> findByEndDateIsNullAndLastSeenBefore(LocalDateTime cutoff); List<Stream> findByEndDateIsNullAndLastSeenBefore(Instant cutoff);
} }

View File

@@ -12,6 +12,7 @@ import org.slf4j.LoggerFactory;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
@@ -60,9 +61,12 @@ public class JwtFilter extends OncePerRequestFilter {
} }
Optional<Authentication> authentication = getAuthentication(jwt); Optional<Authentication> authentication = getAuthentication(jwt);
authentication.ifPresent(value -> authentication.ifPresent(auth -> {
SecurityContextHolder.getContext().setAuthentication(value) SecurityContext context = SecurityContextHolder.createEmptyContext();
); context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
});
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
} }

View File

@@ -1,23 +1,26 @@
package com.ddf.vodsystem.services; package com.ddf.vodsystem.services;
import com.ddf.vodsystem.dto.ClipOptions; import com.ddf.vodsystem.dto.ClipOptions;
import com.ddf.vodsystem.controllers.dto.ClipUpdateRequest;
import com.ddf.vodsystem.entities.*; import com.ddf.vodsystem.entities.*;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; 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.List;
import java.util.Optional; import java.util.Optional;
import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutionException;
import com.ddf.vodsystem.exceptions.FFMPEGException; import com.ddf.vodsystem.exceptions.*;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.repositories.ClipRepository; import com.ddf.vodsystem.repositories.ClipRepository;
import com.ddf.vodsystem.services.media.MetadataService; import com.ddf.vodsystem.services.media.MetadataService;
import com.ddf.vodsystem.services.media.ThumbnailService; import com.ddf.vodsystem.services.media.ThumbnailService;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@Service @Service
@@ -69,18 +72,40 @@ public class ClipService {
Optional<Clip> clip = clipRepository.findById(id); Optional<Clip> clip = clipRepository.findById(id);
if (clip.isEmpty()) { if (clip.isEmpty()) {
logger.warn("Clip with ID {} not found", id); throw new ClipNotFound("Clip with id " + id + " not found.");
return clip;
} }
if (!isAuthenticatedForClip(clip.get())) { if (!isAuthenticatedForClip(clip.get())) {
logger.warn("User is not authorized to access clip with ID {}", id); throw new NotAuthenticated("You are not authorized to access clip: " + id);
throw new NotAuthenticated("You are not authorized to access this clip");
} }
return clip; return clip;
} }
public Clip updateClip(Long id, ClipUpdateRequest newFields) {
Optional<Clip> possibleClip = clipRepository.findById(id);
if (possibleClip.isEmpty()) {
throw new ClipNotFound("Clip with id " + id + " not found.");
}
Clip clip = possibleClip.get();
if (!isAuthenticatedForClip(clip)) {
throw new NotAuthenticated("You are not authorized to access clip: " + id);
}
if (newFields.title() != null) {
clip.setTitle(newFields.title());
}
if (newFields.description() != null) {
clip.setDescription(newFields.description());
}
return clipRepository.saveAndFlush(clip);
}
/** /**
* Deletes a clip by its ID, ensuring the user is authenticated to perform the deletion. * Deletes a clip by its ID, ensuring the user is authenticated to perform the deletion.
* *
@@ -121,6 +146,52 @@ public class ClipService {
return user.get().getId().equals(clip.getUser().getId()); 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 * 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 * @param options ClipOptions object of the clip metadata to save to the database. All fields required except for title, description
@@ -131,51 +202,62 @@ public class ClipService {
*/ */
public Clip saveClip(ClipOptions options, public Clip saveClip(ClipOptions options,
User user, User user,
String videoPath, Path videoPath,
String thumbnailPath) { Path thumbnailPath) {
Clip clip = new Clip(); Clip clip = new Clip();
clip.setUser(user); clip.setUser(user);
clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip"); clip.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled Clip");
clip.setDescription(options.getDescription() != null ? options.getDescription() : ""); clip.setDescription(options.getDescription() != null ? options.getDescription() : "");
clip.setCreatedAt(LocalDateTime.now()); clip.setCreatedAt(Instant.now());
clip.setWidth(options.getWidth()); clip.setWidth(options.getWidth());
clip.setHeight(options.getHeight()); clip.setHeight(options.getHeight());
clip.setFps(options.getFps()); clip.setFps(options.getFps());
clip.setDuration(options.getDuration() - options.getStartPoint()); clip.setDuration(options.getDuration() - options.getStartPoint());
clip.setFileSize(options.getFileSize()); clip.setFileSize(options.getFileSize());
clip.setVideoPath(videoPath); clip.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString());
clip.setThumbnailPath(thumbnailPath); clip.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString());
return clipRepository.save(clip); return clipRepository.save(clip);
} }
public void persistClip(ClipOptions clipOptions, public void persistClip(String title,
String description,
User user, User user,
File tempFile, Path clipFile,
String fileName) { String fileName) {
// Move clip from temp to output directory Path newClipFile;
File clipFile = directoryService.getUserClipsFile(user.getId(), fileName); Path thumbnailFile;
File thumbnailFile = directoryService.getUserThumbnailsFile(user.getId(), fileName + ".png");
directoryService.cutFile(tempFile, clipFile); // Move temp file from temp dir to output dir
try {
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);
}
ClipOptions clipMetadata; ClipOptions clipMetadata;
try { try {
clipMetadata = metadataService.getVideoMetadata(clipFile).get(); clipMetadata = metadataService.getVideoMetadata(newClipFile).get();
} catch (InterruptedException | ExecutionException e) { } catch (InterruptedException | ExecutionException e) {
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
throw new FFMPEGException("Error retrieving video metadata for clip: " + e.getMessage()); throw new FFMPEGException("Error retrieving video metadata for clip: " + e.getMessage());
} }
// Thumbnail generation can fail with error propagation
try { try {
thumbnailService.createThumbnail(clipFile, thumbnailFile, 0.0f); thumbnailService.createThumbnail(newClipFile, thumbnailFile, 0.0f);
} catch (IOException | InterruptedException e) { } catch (InterruptedException e) {
logger.error("Error generating thumbnail for user: {}, {}", user.getId(), e.getMessage()); logger.error("Thumbnail generation interrupted for user: {}", user.getId(), e);
Thread.currentThread().interrupt(); Thread.currentThread().interrupt();
} catch (IOException e) {
logger.error("Error generating thumbnail for user: {}", user.getId(), e);
} }
clipMetadata.setTitle(clipOptions.getTitle()); clipMetadata.setTitle(title);
clipMetadata.setDescription(clipOptions.getDescription()); clipMetadata.setDescription(description);
Clip clip = saveClip(clipMetadata, user, clipFile.getAbsolutePath(), thumbnailFile.getAbsolutePath()); Clip clip = saveClip(clipMetadata, user, newClipFile, thumbnailFile);
logger.info("Clip created successfully with ID: {}", clip.getId()); logger.info("Clip created successfully with ID: {}", clip.getId());
} }

View File

@@ -10,7 +10,7 @@ import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.nio.file.Paths; import java.util.stream.Stream;
import java.nio.file.StandardCopyOption; import java.nio.file.StandardCopyOption;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -20,145 +20,120 @@ public class DirectoryService {
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(DirectoryService.class); private static final Logger logger = org.slf4j.LoggerFactory.getLogger(DirectoryService.class);
@Value("${storage.outputs}") @Value("${media.users}")
private String outputDir; private String usersDir;
@Value("${storage.temp.inputs}") @Value("${media.streams}")
private String streamsDir;
@Value("${media.temp.inputs}")
private String tempInputsDir; private String tempInputsDir;
@Value("${storage.temp.outputs}") @Value("${media.temp.outputs}")
private String tempOutputsDir; private String tempOutputsDir;
private static final long TEMP_DIR_TIME_LIMIT = 3 * 60 * 60 * (long) 1000; // 3 hours 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 private static final long TEMP_DIR_CLEANUP_RATE = 30 * 60 * (long) 1000; // 30 minutes
public File getTempInputFile(String filename) { private static final String VODS_DIR_NAME = "vods";
String dir = tempInputsDir + File.separator + filename; private static final String CLIPS_DIR_NAME = "clips";
return new File(dir); private static final String THUMBNAIL_DIR_NAME = "thumbnails";
public Path getTempInputDir() {
return Path.of(tempInputsDir);
} }
public File getTempOutputFile(String filename) { public Path getTempOutputDir() {
String dir = tempOutputsDir + File.separator + filename; return Path.of(tempOutputsDir);
return new File(dir);
} }
public File getUserClipsFile(Long userId, String fileName) { public Path getUserDir(Long userId) throws IOException {
if (userId == null || fileName == null || fileName.isEmpty()) { Path userDir = Path.of(usersDir + File.separator + userId);
throw new IllegalArgumentException("User ID and file name cannot be null or empty"); Files.createDirectories(userDir);
return userDir;
} }
String dir = outputDir + File.separator + userId + File.separator + "clips" + File.separator + fileName; public Path getVodsDir(Long userId) throws IOException {
File file = new File(dir); Path vodDir = getUserDir(userId).resolve(VODS_DIR_NAME);
Files.createDirectories(vodDir);
try { return vodDir;
createDirectory(file.getParent());
} catch (IOException e) {
logger.error("Error creating clips directory: {}", e.getMessage());
} }
return file; public Path getClipsDir(Long userId) throws IOException {
Path clipsDir = getUserDir(userId).resolve(CLIPS_DIR_NAME);
Files.createDirectories(clipsDir);
return clipsDir;
} }
public File getVodFile(String filename) throws IOException { public Path getThumbnailsDir(Long userId) throws IOException {
String dir = outputDir + File.separator + "vods" + File.separator + filename; Path thumbnailsDir = getUserDir(userId).resolve(THUMBNAIL_DIR_NAME);
File file = new File(dir); Files.createDirectories(thumbnailsDir);
createDirectory(file.getParent()); return thumbnailsDir;
return file;
} }
public File getUserThumbnailsFile(Long userId, String fileName) { public Path getStreamDir(String streamKey) throws IOException {
if (userId == null || fileName == null || fileName.isEmpty()) { Path streamFolder = Path.of(streamsDir, streamKey);
throw new IllegalArgumentException("User ID and file name cannot be null or empty"); Files.createDirectories(streamFolder);
return streamFolder;
} }
String dir = outputDir + File.separator + userId + File.separator + "thumbnails" + File.separator + fileName; public Path relativisePath(Path path) {
File file = new File(dir); Path media = Path.of(usersDir).toAbsolutePath();
Path newPath = path.toAbsolutePath();
try { return media.relativize(newPath);
createDirectory(file.getParent());
} catch (IOException e) {
logger.error("Error creating thumbnails directory: {}", e.getMessage());
} }
return file; public Path resolvePath(String path) {
Path media = Path.of(usersDir).toAbsolutePath();
Path newPath = Path.of(path);
return media.resolve(newPath);
} }
public void saveAtDir(File file, MultipartFile multipartFile) { public void saveMultipartFile(Path path, MultipartFile multipartFile) throws IOException {
try { Files.createDirectories(path.getParent());
createDirectory(file.getAbsolutePath()); Files.copy(multipartFile.getInputStream(), path, StandardCopyOption.REPLACE_EXISTING);
Path filePath = Paths.get(file.getAbsolutePath());
Files.copy(multipartFile.getInputStream(), filePath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
logger.error(e.getMessage());
}
} }
public void copyFile(File source, File target) { public void copyFile(Path source, Path target) throws IOException {
Path sourcePath = Paths.get(source.getAbsolutePath()); Files.createDirectories(target.getParent());
Path destPath = Paths.get(target.getAbsolutePath()); Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
logger.info("Copied file from {} to {}", source, target);
try {
Files.createDirectories(destPath.getParent());
Files.copy(sourcePath, destPath, StandardCopyOption.REPLACE_EXISTING);
logger.info("Copied file from {} to {}", sourcePath, destPath);
} catch (IOException e) {
logger.error(e.getMessage());
}
}
public void cutFile(File source, File target) {
copyFile(source, target);
try {
Files.deleteIfExists(source.toPath());
logger.info("Deleted source file: {}", source.getAbsolutePath());
} catch (IOException e) {
logger.error("Error deleting source file: {}", e.getMessage());
}
}
public String getFileExtension(String filePath) {
Path path = Paths.get(filePath);
String fileName = path.getFileName().toString();
int dotIndex = fileName.lastIndexOf('.');
if (dotIndex == -1) {
return ""; // No extension
}
return fileName.substring(dotIndex + 1);
}
private void createDirectory(String dir) throws IOException {
// Create the directory if it doesn't exist
Path outputPath = Paths.get(dir);
if (Files.notExists(outputPath)) {
Files.createDirectories(outputPath);
logger.info("Created directory: {}", outputPath);
}
} }
private void cleanUpDirectory(String dir) throws IOException { private void cleanUpDirectory(String dir) throws IOException {
File file = new File(dir); Path dirPath = Path.of(dir);
File[] files = file.listFiles();
if (files == null) { if (Files.notExists(dirPath)) {
logger.warn("No files found in directory: {}", dir); logger.warn("No files found in directory: {}", dir);
return; return;
} }
for (File f : files){ try (Stream<Path> files = Files.list(dirPath)) {
if (f.isFile() && f.lastModified() < (System.currentTimeMillis() - TEMP_DIR_TIME_LIMIT)) { files.filter(Files::isRegularFile)
Files.delete(f.toPath()); .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 @PostConstruct
public void createDirectoriesIfNotExist() throws IOException { public void createDirectoriesIfNotExist() throws IOException {
createDirectory(tempInputsDir); Files.createDirectories(Path.of(tempInputsDir));
createDirectory(tempOutputsDir); Files.createDirectories(Path.of(tempOutputsDir));
createDirectory(outputDir); Files.createDirectories(Path.of(usersDir));
} }
@Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE) @Scheduled(fixedRate = TEMP_DIR_CLEANUP_RATE)

Some files were not shown because too many files have changed in this diff Show More