From 913c5c8790e4567517428992fdb4370d00d5e23b Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Wed, 1 Jul 2026 21:36:50 +0100 Subject: [PATCH 1/9] fix(exceptions): catch specific error instead of broad --- .../ddf/vodsystem/services/MediaService.java | 3 ++- .../services/media/StreamActionsService.java | 7 +----- src/main/resources/db/data.sql | 25 ------------------- 3 files changed, 3 insertions(+), 32 deletions(-) delete mode 100644 src/main/resources/db/data.sql diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java index db7ffb0..8a791a9 100644 --- a/src/main/java/com/ddf/vodsystem/services/MediaService.java +++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java @@ -16,6 +16,7 @@ import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.*; +import java.util.stream.Collectors; import java.util.stream.Stream; @Service @@ -214,7 +215,7 @@ public class MediaService { return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs; }) .sorted(Comparator.comparingLong(this::parseTimestampMs)) - .toList(); + .collect(Collectors.toList()); } } diff --git a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java index a3ba110..201cc22 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -6,7 +6,6 @@ import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; -import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.concurrent.CompletableFuture; @@ -76,13 +75,9 @@ public class StreamActionsService { progressTracker.markComplete(); return CompletableFuture.completedFuture(outputFile); - } catch (IOException e) { + } catch (Exception e) { logger.error("Failed to save section to '{}'", outputFile, e); return CompletableFuture.failedFuture(e); - } catch (InterruptedException e) { - logger.error("Failed to save section"); - Thread.currentThread().interrupt(); - return CompletableFuture.failedFuture(e); } } } \ No newline at end of file diff --git a/src/main/resources/db/data.sql b/src/main/resources/db/data.sql deleted file mode 100644 index 8b831b3..0000000 --- a/src/main/resources/db/data.sql +++ /dev/null @@ -1,25 +0,0 @@ -INSERT INTO users ( google_id, username, email, name) -VALUES - ('google-uid-001', 'alice', 'alice@example.com', 'Alice Example'), - ( 'google-uid-002', 'bob', 'bob@example.com', 'Bob Example'), - ('google-uid-003', 'carol', 'carol@example.com', 'Carol Example'), - ( 'google-uid-004', 'wizard42', 'gandalf@middle.earth', 'Gandalf the Grey'), - ( 'google-uid-005', 'catnap', 'whiskers@meowmail.com', 'Sir Whiskers McFluff'), - ( 'google-uid-006', 'robotron', 'bender@futurama.tv', 'Bender Rodriguez'), - ('google-uid-007', 'unicorn', 'sparkle@rainbow.com', 'Princess Sparklehoof'), - ( 'google-uid-008', 'pirate', 'blackbeard@seas.com', 'Edward Teach'), - ( 'google-uid-009', 'detective', 'holmes@bakerstreet.uk', 'Sherlock Holmes'), - ( 'google-uid-010', 'timey', 'docbrown@delorean.net', 'Dr. Emmett Brown'); - -INSERT INTO clips (user_id, title, description, width, height, fps, duration, file_size, video_path) -VALUES - (4, 'Fireworks Over Hobbiton', 'A magical display of fireworks by Gandalf.', 1920, 1080, 30, 120, 104857600, '/videos/fireworks_hobbiton.mp4'), - (5, 'Catnap Chronicles', 'Sir Whiskers McFluff naps in 12 different positions.', 1280, 720, 24, 60, 52428800, '/videos/catnap_chronicles.mp4'), - (6, 'Bite My Shiny Metal...', 'Bender shows off his new upgrades.', 1920, 1080, 60, 45, 73400320, '/videos/bender_upgrades.mp4'), - (7, 'Rainbow Dash', 'Princess Sparklehoof gallops across a double rainbow.', 1920, 1080, 30, 90, 67108864, '/videos/rainbow_dash.mp4'), - ( 8, 'Pirate Karaoke Night', 'Blackbeard sings sea shanties with his crew.', 1280, 720, 25, 180, 157286400, '/videos/pirate_karaoke.mp4'), - ( 9, 'The Case of the Missing Sandwich', 'Sherlock Holmes investigates a lunchtime mystery.', 1920, 1080, 30, 75, 50331648, '/videos/missing_sandwich.mp4'), - ( 10, '88 Miles Per Hour', 'Doc Brown demonstrates time travel with style.', 1920, 1080, 60, 30, 41943040, '/videos/88mph.mp4'), - ( 1, 'Alice in Videoland', 'Alice explores a surreal digital wonderland.', 1280, 720, 30, 150, 94371840, '/videos/alice_videoland.mp4'), - ( 2, 'Bob''s Building Bonanza', 'Bob constructs a house out of cheese.', 1920, 1080, 24, 200, 209715200, '/videos/bob_cheesehouse.mp4'), - ( 3, 'Carol''s Coding Catastrophe', 'Carol debugs a spaghetti codebase.', 1280, 720, 30, 100, 73400320, '/videos/carol_coding.mp4'); \ No newline at end of file -- 2.49.1 From 83fcb44670fd04bd8f6a65e9cf6494058727e975 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Wed, 1 Jul 2026 21:54:17 +0100 Subject: [PATCH 2/9] feat(backend): implement VoD functionality --- .../controllers/dto/VodUpdateRequest.java | 6 + .../java/com/ddf/vodsystem/entities/Vod.java | 52 +++++ .../ddf/vodsystem/exceptions/VodNotFound.java | 7 + .../vodsystem/repositories/VodRepository.java | 16 ++ .../ddf/vodsystem/services/VodService.java | 198 ++++++++++++++++++ src/main/resources/db/schema.sql | 16 ++ 6 files changed, 295 insertions(+) create mode 100644 src/main/java/com/ddf/vodsystem/controllers/dto/VodUpdateRequest.java create mode 100644 src/main/java/com/ddf/vodsystem/entities/Vod.java create mode 100644 src/main/java/com/ddf/vodsystem/exceptions/VodNotFound.java create mode 100644 src/main/java/com/ddf/vodsystem/repositories/VodRepository.java create mode 100644 src/main/java/com/ddf/vodsystem/services/VodService.java diff --git a/src/main/java/com/ddf/vodsystem/controllers/dto/VodUpdateRequest.java b/src/main/java/com/ddf/vodsystem/controllers/dto/VodUpdateRequest.java new file mode 100644 index 0000000..1709d94 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/controllers/dto/VodUpdateRequest.java @@ -0,0 +1,6 @@ +package com.ddf.vodsystem.controllers.dto; + +public record VodUpdateRequest( + String title, + String description +) {} diff --git a/src/main/java/com/ddf/vodsystem/entities/Vod.java b/src/main/java/com/ddf/vodsystem/entities/Vod.java new file mode 100644 index 0000000..a53675d --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/entities/Vod.java @@ -0,0 +1,52 @@ +package com.ddf.vodsystem.entities; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.*; +import lombok.Data; + +import java.time.Instant; + +@Entity +@Table(name = "vods") +@Data +public class Vod { + @Id + @Column(name = "id", nullable = false, unique = true) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JsonIgnore + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "title", nullable = false, length = 255) + private String title; + + @Column(name = "description") + private String description; + + @Column(name = "created_at") + private Instant createdAt; + + @Column(name = "width", nullable = false) + private Integer width; + + @Column(name = "height", nullable = false) + private Integer height; + + @Column(name = "fps", nullable = false) + private Float fps; + + @Column(name = "duration", nullable = false) + private Float duration; + + @Column(name = "file_size", nullable = false) + private Float fileSize; + + @Column(name = "video_path", nullable = false, length = 255) + private String videoPath; + + @Column(name = "thumbnail_path", nullable = false, length = 255) + private String thumbnailPath; +} diff --git a/src/main/java/com/ddf/vodsystem/exceptions/VodNotFound.java b/src/main/java/com/ddf/vodsystem/exceptions/VodNotFound.java new file mode 100644 index 0000000..ff6fca1 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/exceptions/VodNotFound.java @@ -0,0 +1,7 @@ +package com.ddf.vodsystem.exceptions; + +public class VodNotFound extends RuntimeException { + public VodNotFound(String message) { + super(message); + } +} diff --git a/src/main/java/com/ddf/vodsystem/repositories/VodRepository.java b/src/main/java/com/ddf/vodsystem/repositories/VodRepository.java new file mode 100644 index 0000000..52863e3 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/repositories/VodRepository.java @@ -0,0 +1,16 @@ +package com.ddf.vodsystem.repositories; + +import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.entities.Vod; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface VodRepository extends JpaRepository +{ + @Query("SELECT v FROM Vod v WHERE v.user = ?1") + List findByUser(User user); +} diff --git a/src/main/java/com/ddf/vodsystem/services/VodService.java b/src/main/java/com/ddf/vodsystem/services/VodService.java new file mode 100644 index 0000000..a356be7 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/VodService.java @@ -0,0 +1,198 @@ +package com.ddf.vodsystem.services; + +import com.ddf.vodsystem.controllers.dto.VodUpdateRequest; +import com.ddf.vodsystem.dto.ClipOptions; +import com.ddf.vodsystem.entities.User; +import com.ddf.vodsystem.entities.Vod; +import com.ddf.vodsystem.exceptions.*; +import com.ddf.vodsystem.repositories.VodRepository; +import com.ddf.vodsystem.services.media.MetadataService; +import com.ddf.vodsystem.services.media.ThumbnailService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.io.FileSystemResource; +import org.springframework.core.io.Resource; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.ExecutionException; + +@Service +public class VodService { + private static final Logger logger = LoggerFactory.getLogger(VodService.class); + + private final VodRepository vodRepository; + private final DirectoryService directoryService; + private final MetadataService metadataService; + private final ThumbnailService thumbnailService; + private final UserService userService; + + public VodService(VodRepository vodRepository, + DirectoryService directoryService, + MetadataService metadataService, + ThumbnailService thumbnailService, + UserService userService) { + this.vodRepository = vodRepository; + this.directoryService = directoryService; + this.metadataService = metadataService; + this.thumbnailService = thumbnailService; + this.userService = userService; + } + + public Vod getVodById(Long id) { + Optional vod = vodRepository.findById(id); + + if (vod.isEmpty()) { + throw new VodNotFound("Vod ID " + id + " does not exist"); + } + + if (!isAuthenticatedForVod(vod.get())) { + throw new NotAuthenticated("User not authenticated"); + } + + return vod.get(); + } + + public List getUserVods() { + Optional user = userService.getLoggedInUser(); + + if (user.isEmpty()) { + throw new NotAuthenticated("User must be logged in to retrieve vods"); + } + + return vodRepository.findByUser(user.get()); + } + + public Vod updateVod(Long id, VodUpdateRequest newFields) { + Vod vod = getVodById(id); + + if (newFields.title() != null) { + vod.setTitle(newFields.title()); + } + + if (newFields.description() != null) { + vod.setDescription(newFields.description()); + } + + return vodRepository.saveAndFlush(vod); + } + + public boolean deleteVod(Long id) { + Vod vod = getVodById(id); + + deleteVodFiles(vod); + vodRepository.delete(vod); + + logger.info("Vod with ID {} deleted successfully", id); + return true; + } + + public Resource downloadVod(Long id) { + Vod vod = getVodById(id); + Path file = directoryService.resolvePath(vod.getVideoPath()); + + if (!Files.exists(file)) { + throw new VodNotFound("Vod file not found for ID: " + id); + } + + return new FileSystemResource(file); + } + + public Resource downloadThumbnail(Long id) { + Vod vod = getVodById(id); + Path file = directoryService.resolvePath(vod.getThumbnailPath()); + + if (!Files.exists(file)) { + throw new VodNotFound("Thumbnail file not found for vod ID: " + id); + } + + return new FileSystemResource(file); + } + + public void persist(String title, + String description, + User user, + Path vodFile, + String fileName) { + Path newVodFile; + Path thumbnailFile; + + try { + newVodFile = directoryService.getVodsDir(user.getId()).resolve(fileName); + thumbnailFile = directoryService.getThumbnailsDir(user.getId()).resolve(fileName + ".png"); + directoryService.copyFile(vodFile, newVodFile); + } catch (IOException e) { + throw new StorageException("Failed to move vod from temporary directory to output directory", e); + } + + ClipOptions vodMetadata; + try { + vodMetadata = metadataService.getVideoMetadata(newVodFile).get(); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + throw new FFMPEGException("Error retrieving video metadata for vod: " + e.getMessage()); + } + + try { + thumbnailService.createThumbnail(newVodFile, thumbnailFile, 0.0f); + } catch (InterruptedException e) { + logger.error("Thumbnail generation interrupted for user: {}", user.getId(), e); + Thread.currentThread().interrupt(); + } catch (IOException e) { + logger.error("Error generating thumbnail for user: {}", user.getId(), e); + } + + vodMetadata.setTitle(title); + vodMetadata.setDescription(description); + + Vod vod = saveToDb(vodMetadata, user, newVodFile, thumbnailFile); + logger.info("Vod created successfully with ID: {}", vod.getId()); + } + + private Vod saveToDb(ClipOptions options, + User user, + Path videoPath, + Path thumbnailPath) { + Vod vod = new Vod(); + vod.setUser(user); + vod.setTitle(options.getTitle() != null ? options.getTitle() : "Untitled VoD"); + vod.setDescription(options.getDescription() != null ? options.getDescription() : ""); + vod.setCreatedAt(Instant.now()); + vod.setWidth(options.getWidth()); + vod.setHeight(options.getHeight()); + vod.setFps(options.getFps()); + vod.setDuration(options.getDuration()); + vod.setFileSize(options.getFileSize()); + vod.setVideoPath(directoryService.relativisePath(videoPath.toAbsolutePath()).toString()); + vod.setThumbnailPath(directoryService.relativisePath(thumbnailPath.toAbsolutePath()).toString()); + return vodRepository.save(vod); + } + + private void deleteVodFiles(Vod vod) { + File vodFile = new File(vod.getVideoPath()); + File thumbnailFile = new File(vod.getThumbnailPath()); + + try { + Files.deleteIfExists(vodFile.toPath()); + Files.deleteIfExists(thumbnailFile.toPath()); + } catch (IOException e) { + logger.error("Could not delete vod files for vod ID: {}", vod.getId()); + } + } + + private boolean isAuthenticatedForVod(Vod vod) { + Optional user = userService.getLoggedInUser(); + + if (vod == null || user.isEmpty()) { + return false; + } + + return user.get().getId().equals(vod.getUser().getId()); + } +} diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 2cc5bab..efbb646 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -38,4 +38,20 @@ CREATE TABLE IF NOT EXISTS clips ( video_path VARCHAR(255) NOT NULL, thumbnail_path VARCHAR(255) NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS vods ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + title VARCHAR(255) NOT NULL, + description TEXT, + created_at TIMESTAMPTZ DEFAULT NOW(), + width INTEGER NOT NULL, + height INTEGER NOT NULL, + fps FLOAT NOT NULL, + duration FLOAT NOT NULL, + file_size FLOAT NOT NULL, + video_path VARCHAR(255) NOT NULL, + thumbnail_path VARCHAR(255) NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); \ No newline at end of file -- 2.49.1 From 48ff0eb8e5b226b53d7e66fee4f79efd540bfd21 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite Date: Wed, 1 Jul 2026 22:01:41 +0100 Subject: [PATCH 3/9] feat(frontend): implement vods in frontend --- frontend/src/App.tsx | 6 ++ frontend/src/components/Sidebar.tsx | 3 +- frontend/src/components/video/VideoCard.tsx | 16 ++-- frontend/src/utils/endpoints.ts | 74 ++++++++++++++++++- frontend/src/utils/types.ts | 10 +++ .../controllers/GlobalExceptionHandler.java | 14 ++++ 6 files changed, 113 insertions(+), 10 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3dd46ba..5ffc1d3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,8 +6,11 @@ import ClipEdit from './pages/ClipEdit'; import Home from './pages/Home'; import {useEffect} from "react"; import MyClips from './pages/MyClips'; +import MyVods from './pages/MyVods'; import VideoPlayer from "./pages/VideoPlayer.tsx"; +import VodPlayer from "./pages/VodPlayer.tsx"; import EditClip from "./pages/EditClip.tsx"; +import EditVod from "./pages/EditVod.tsx"; import Profile from "./pages/Profile.tsx"; import { GoogleOAuthProvider } from '@react-oauth/google'; @@ -26,8 +29,11 @@ function App() { } /> } /> } /> + } /> } /> + } /> } /> + } /> } /> diff --git a/frontend/src/components/Sidebar.tsx b/frontend/src/components/Sidebar.tsx index ca409d6..bd3ecd2 100644 --- a/frontend/src/components/Sidebar.tsx +++ b/frontend/src/components/Sidebar.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import SidebarButton from "./buttons/SidebarButton.tsx"; -import { Plus, Film, Home, User as UserIcon } from 'lucide-react'; +import { Plus, Film, Home, User as UserIcon, Video } from 'lucide-react'; import type {User} from "../utils/types.ts"; type props = { @@ -31,6 +31,7 @@ const Sidebar = ({user, className}: props) => {
} label="Clips" /> + } label="VoDs" />
)} diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index 084aafc..1561ceb 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -13,17 +13,21 @@ type VideoCardProps = { onEdit?: () => void, onDelete?: () => void, className?: string, + mediaApiPath?: string, + playerPath?: string, + itemLabel?: string, } const fallbackThumbnail = "../../../public/default_thumbnail.png"; const API_URL = import.meta.env.VITE_API_URL; -const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }: VideoCardProps) => { +const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className, mediaApiPath = '/api/v1/clips', playerPath = '/video', itemLabel = 'clip' }: VideoCardProps) => { const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt))); const [thumbnailAvailable, setThumbnailAvailable] = useState(true); const [menuOpen, setMenuOpen] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const menuRef = useRef(null); + const thumbnailUrl = `${API_URL}${mediaApiPath}/${id}/thumbnail`; useEffect(() => { const interval = setInterval(() => { @@ -33,10 +37,10 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }, [createdAt]); useEffect(() => { - isThumbnailAvailable(id) + isThumbnailAvailable(thumbnailUrl) .then(setThumbnailAvailable) .catch(() => setThumbnailAvailable(false)); - }, []); + }, [thumbnailUrl]); useEffect(() => { if (!menuOpen) return; @@ -57,11 +61,11 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className }; return ( - +
Video Thumbnail @@ -105,7 +109,7 @@ const VideoCard = ({ id, title, duration, createdAt, onEdit, onDelete, className )} {confirmDelete && (
-

Delete this clip?

+

Delete this {itemLabel}?

+ +

Edit VoD

+ + {loading ? ( +
Loading...
+ ) : ( + +
+ + setTitle(e.target.value)} + placeholder="Enter title" + className={inputClass} + /> +
+ +
+ +