diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx
index 25c2767..745a77f 100644
--- a/frontend/src/components/video/VideoCard.tsx
+++ b/frontend/src/components/video/VideoCard.tsx
@@ -1,14 +1,13 @@
import clsx from "clsx";
import { formatTime, stringToDate, dateToTimeAgo } from "../../utils/utils.ts";
import { Link } from "react-router-dom";
-import { useState } from "react";
+import {useEffect, useState} from "react";
+import { isThumbnailAvailable } from "../../utils/endpoints.ts";
type VideoCardProps = {
id: number,
title: string,
duration: number,
- thumbnailPath: string | null,
- videoPath: string,
createdAt: string,
className?: string
}
@@ -19,31 +18,35 @@ const VideoCard = ({
id,
title,
duration,
- thumbnailPath,
createdAt,
className
}: VideoCardProps) => {
- const initialSrc = thumbnailPath && thumbnailPath.trim() !== "" ? thumbnailPath : fallbackThumbnail;
- const [imgSrc, setImgSrc] = useState(initialSrc);
const [timeAgo, setTimeAgo] = useState(dateToTimeAgo(stringToDate(createdAt)));
+ const [thumbnailAvailable, setThumbnailAvailable] = useState(true);
setTimeout(() => {
setTimeAgo(dateToTimeAgo(stringToDate(createdAt)))
}, 1000);
+ useEffect(() => {
+ isThumbnailAvailable(id)
+ .then((available) => {
+ setThumbnailAvailable(available);
+ })
+ .catch(() => {
+ setThumbnailAvailable(false);
+ });
+ });
+
+
return (

{
- if (imgSrc !== fallbackThumbnail) {
- setImgSrc(fallbackThumbnail);
- }
- }}
/>
diff --git a/frontend/src/utils/endpoints.ts b/frontend/src/utils/endpoints.ts
index c777edb..cb4924e 100644
--- a/frontend/src/utils/endpoints.ts
+++ b/frontend/src/utils/endpoints.ts
@@ -176,6 +176,15 @@ const getClipById = async (id: string): Promise
=> {
}
};
+const isThumbnailAvailable = async (id: number): Promise => {
+ const response = await fetch(`/api/v1/download/thumbnail/${id}`);
+ if (!response.ok) {
+ return false;
+ }
+
+ return true;
+}
+
export {
uploadFile,
editFile,
@@ -184,5 +193,6 @@ export {
getMetadata,
getUser,
getClips,
- getClipById
+ getClipById,
+ isThumbnailAvailable
};
\ No newline at end of file
diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts
index 4036a90..54f2875 100644
--- a/frontend/src/utils/types.ts
+++ b/frontend/src/utils/types.ts
@@ -23,14 +23,10 @@ type User = {
type Clip = {
id: number,
+ userId: number,
title: string,
description: string,
duration: number,
- thumbnailPath: string,
- videoPath: string,
- fps: number,
- width: number,
- height: number,
createdAt: string,
}
diff --git a/pom.xml b/pom.xml
index d722517..33fdb83 100644
--- a/pom.xml
+++ b/pom.xml
@@ -66,6 +66,11 @@
org.springframework.boot
spring-boot-starter-security
+
+ org.mapstruct
+ mapstruct
+ 1.5.5.Final
+
diff --git a/src/main/java/com/ddf/vodsystem/controllers/AuthController.java b/src/main/java/com/ddf/vodsystem/controllers/AuthController.java
index 1197a6e..3f65892 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/AuthController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/AuthController.java
@@ -1,6 +1,6 @@
package com.ddf.vodsystem.controllers;
-import com.ddf.vodsystem.entities.APIResponse;
+import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
diff --git a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java
index a20cceb..f484bef 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/ClipController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/ClipController.java
@@ -1,6 +1,7 @@
package com.ddf.vodsystem.controllers;
-import com.ddf.vodsystem.entities.APIResponse;
+import com.ddf.vodsystem.dto.ClipDTO;
+import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.entities.Clip;
import com.ddf.vodsystem.exceptions.NotAuthenticated;
import com.ddf.vodsystem.services.ClipService;
@@ -24,19 +25,23 @@ public class ClipController {
}
@GetMapping("/")
- public ResponseEntity>> getClips(@AuthenticationPrincipal OAuth2User principal) {
+ public ResponseEntity>> getClips(@AuthenticationPrincipal OAuth2User principal) {
if (principal == null) {
throw new NotAuthenticated("User is not authenticated");
}
List clips = clipService.getClipsByUser();
+ List clipDTOs = clips.stream()
+ .map(this::convertToDTO)
+ .toList();
+
return ResponseEntity.ok(
- new APIResponse<>("success", "Clips retrieved successfully", clips)
+ new APIResponse<>("success", "Clips retrieved successfully", clipDTOs)
);
}
@GetMapping("/{id}")
- public ResponseEntity> getClipById(@AuthenticationPrincipal OAuth2User principal, @PathVariable Long id) {
+ public ResponseEntity> getClipById(@AuthenticationPrincipal OAuth2User principal, @PathVariable Long id) {
if (principal == null) {
throw new NotAuthenticated("User is not authenticated");
}
@@ -46,8 +51,21 @@ public class ClipController {
return ResponseEntity.notFound().build();
}
+ ClipDTO clipDTO = convertToDTO(clip);
+
return ResponseEntity.ok(
- new APIResponse<>("success", "Clip retrieved successfully", clip)
+ new APIResponse<>("success", "Clip retrieved successfully", clipDTO)
);
}
+
+ private ClipDTO convertToDTO(Clip clip) {
+ ClipDTO dto = new ClipDTO();
+ dto.setId(clip.getId());
+ dto.setUserId(clip.getUser().getId());
+ dto.setTitle(clip.getTitle());
+ dto.setDescription(clip.getDescription());
+ dto.setDuration(clip.getDuration());
+ dto.setCreatedAt(clip.getCreatedAt());
+ return dto;
+ }
}
diff --git a/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java b/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java
index 26dce88..6d99c7e 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/DownloadController.java
@@ -65,4 +65,18 @@ public class DownloadController {
.contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
.body(resource);
}
+
+ @GetMapping("/thumbnail/{id}")
+ public ResponseEntity downloadThumbnail(@AuthenticationPrincipal OAuth2User principal, @PathVariable Long id) {
+ Resource resource = downloadService.downloadThumbnail(id);
+
+ if (resource == null || !resource.exists()) {
+ return ResponseEntity.notFound().build();
+ }
+
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "inline; filename=\"" + resource.getFilename() + "\"")
+ .contentType(MediaTypeFactory.getMediaType(resource).orElse(MediaType.APPLICATION_OCTET_STREAM))
+ .body(resource);
+ }
}
diff --git a/src/main/java/com/ddf/vodsystem/controllers/EditController.java b/src/main/java/com/ddf/vodsystem/controllers/EditController.java
index d341902..9376aa9 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/EditController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/EditController.java
@@ -1,12 +1,11 @@
package com.ddf.vodsystem.controllers;
-import com.ddf.vodsystem.entities.VideoMetadata;
+import com.ddf.vodsystem.dto.VideoMetadata;
import com.ddf.vodsystem.services.EditService;
import lombok.AllArgsConstructor;
import lombok.Data;
-import com.ddf.vodsystem.entities.APIResponse;
+import com.ddf.vodsystem.dto.APIResponse;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
diff --git a/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java b/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java
index 12234f7..106abf8 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/GlobalExceptionHandler.java
@@ -1,6 +1,6 @@
package com.ddf.vodsystem.controllers;
-import com.ddf.vodsystem.entities.APIResponse;
+import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.ddf.vodsystem.exceptions.JobNotFinished;
import com.ddf.vodsystem.exceptions.JobNotFound;
diff --git a/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java b/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java
index a39b257..cc80ea4 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/MetadataController.java
@@ -1,7 +1,7 @@
package com.ddf.vodsystem.controllers;
-import com.ddf.vodsystem.entities.VideoMetadata;
-import com.ddf.vodsystem.entities.APIResponse;
+import com.ddf.vodsystem.dto.VideoMetadata;
+import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.JobService;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
diff --git a/src/main/java/com/ddf/vodsystem/controllers/UploadController.java b/src/main/java/com/ddf/vodsystem/controllers/UploadController.java
index 501a0ea..01f2698 100644
--- a/src/main/java/com/ddf/vodsystem/controllers/UploadController.java
+++ b/src/main/java/com/ddf/vodsystem/controllers/UploadController.java
@@ -1,6 +1,6 @@
package com.ddf.vodsystem.controllers;
-import com.ddf.vodsystem.entities.APIResponse;
+import com.ddf.vodsystem.dto.APIResponse;
import com.ddf.vodsystem.services.UploadService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
diff --git a/src/main/java/com/ddf/vodsystem/entities/APIResponse.java b/src/main/java/com/ddf/vodsystem/dto/APIResponse.java
similarity index 89%
rename from src/main/java/com/ddf/vodsystem/entities/APIResponse.java
rename to src/main/java/com/ddf/vodsystem/dto/APIResponse.java
index 2183ad0..436fb4a 100644
--- a/src/main/java/com/ddf/vodsystem/entities/APIResponse.java
+++ b/src/main/java/com/ddf/vodsystem/dto/APIResponse.java
@@ -1,4 +1,4 @@
-package com.ddf.vodsystem.entities;
+package com.ddf.vodsystem.dto;
import lombok.Data;
diff --git a/src/main/java/com/ddf/vodsystem/dto/ClipDTO.java b/src/main/java/com/ddf/vodsystem/dto/ClipDTO.java
new file mode 100644
index 0000000..bd299f1
--- /dev/null
+++ b/src/main/java/com/ddf/vodsystem/dto/ClipDTO.java
@@ -0,0 +1,15 @@
+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;
+}
diff --git a/src/main/java/com/ddf/vodsystem/entities/VideoMetadata.java b/src/main/java/com/ddf/vodsystem/dto/VideoMetadata.java
similarity index 88%
rename from src/main/java/com/ddf/vodsystem/entities/VideoMetadata.java
rename to src/main/java/com/ddf/vodsystem/dto/VideoMetadata.java
index c3e5621..6eb6a9c 100644
--- a/src/main/java/com/ddf/vodsystem/entities/VideoMetadata.java
+++ b/src/main/java/com/ddf/vodsystem/dto/VideoMetadata.java
@@ -1,4 +1,4 @@
-package com.ddf.vodsystem.entities;
+package com.ddf.vodsystem.dto;
import lombok.Data;
diff --git a/src/main/java/com/ddf/vodsystem/entities/Clip.java b/src/main/java/com/ddf/vodsystem/entities/Clip.java
index 0ae1c77..924b0b5 100644
--- a/src/main/java/com/ddf/vodsystem/entities/Clip.java
+++ b/src/main/java/com/ddf/vodsystem/entities/Clip.java
@@ -47,4 +47,7 @@ public class Clip {
@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/entities/Job.java b/src/main/java/com/ddf/vodsystem/entities/Job.java
index 5e59c31..9c828b6 100644
--- a/src/main/java/com/ddf/vodsystem/entities/Job.java
+++ b/src/main/java/com/ddf/vodsystem/entities/Job.java
@@ -3,6 +3,7 @@ package com.ddf.vodsystem.entities;
import java.io.File;
import java.util.concurrent.atomic.AtomicReference;
+import com.ddf.vodsystem.dto.VideoMetadata;
import org.springframework.security.core.context.SecurityContext;
import lombok.Data;
diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java
index 8bae4dc..a32a6cb 100644
--- a/src/main/java/com/ddf/vodsystem/services/ClipService.java
+++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java
@@ -1,5 +1,6 @@
package com.ddf.vodsystem.services;
+import com.ddf.vodsystem.dto.VideoMetadata;
import com.ddf.vodsystem.entities.*;
import java.io.File;
@@ -83,8 +84,20 @@ public class ClipService {
private void persistClip(VideoMetadata videoMetadata, User user, Job job) {
// Move clip from temp to output directory
String fileExtension = directoryService.getFileExtension(job.getOutputFile().getAbsolutePath());
- File outputFile = directoryService.getOutputFile(job.getUuid(), fileExtension);
- directoryService.copyFile(job.getOutputFile(), outputFile);
+
+ File clipOutputDir = directoryService.getUserClipsDir(user.getId());
+ File clipOutputFile = new File(clipOutputDir, job.getUuid() + "." + fileExtension);
+ directoryService.copyFile(job.getOutputFile(), clipOutputFile);
+
+ File thumbnailOutputDir = directoryService.getUserThumbnailsDir(user.getId());
+ File thumbnailOutputFile = new File(thumbnailOutputDir, job.getUuid() + ".png");
+
+ try {
+ ffmpegService.generateThumbnail(clipOutputFile, thumbnailOutputFile, 0.0f);
+ } catch (IOException | InterruptedException e) {
+ logger.error("Error generating thumbnail for clip: {}", e.getMessage());
+ Thread.currentThread().interrupt();
+ }
// Save clip to database
Clip clip = new Clip();
@@ -97,7 +110,8 @@ public class ClipService {
clip.setFps(videoMetadata.getFps());
clip.setDuration(videoMetadata.getEndPoint() - videoMetadata.getStartPoint());
clip.setFileSize(videoMetadata.getFileSize());
- clip.setVideoPath(outputFile.getPath());
+ clip.setVideoPath(clipOutputFile.getPath());
+ clip.setThumbnailPath(thumbnailOutputFile.getPath());
clipRepository.save(clip);
}
diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java
index 3a9841f..eb74e90 100644
--- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java
+++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java
@@ -61,6 +61,32 @@ public class DirectoryService {
return new File(dir);
}
+ public File getUserClipsDir(Long userId) {
+ if (userId == null) {
+ throw new IllegalArgumentException("User ID cannot be null");
+ }
+
+ String dir = outputDir + File.separator + userId + File.separator + "clips";
+ return new File(dir);
+ }
+
+ public File getUserThumbnailsDir(Long userId) {
+ if (userId == null) {
+ throw new IllegalArgumentException("User ID cannot be null");
+ }
+
+ String dir = outputDir + File.separator + userId + File.separator + "thumbnails";
+ File thumbnailDir = new File(dir);
+
+ try {
+ createDirectory(thumbnailDir.getAbsolutePath());
+ } catch (IOException e) {
+ logger.error("Error creating thumbnails directory: {}", e.getMessage());
+ }
+
+ return thumbnailDir;
+ }
+
public void saveAtDir(File file, MultipartFile multipartFile) {
try {
createDirectory(file.getAbsolutePath());
@@ -118,7 +144,6 @@ public class DirectoryService {
Files.delete(f.toPath());
}
}
-
}
@PostConstruct
diff --git a/src/main/java/com/ddf/vodsystem/services/DownloadService.java b/src/main/java/com/ddf/vodsystem/services/DownloadService.java
index 504cc44..24345b7 100644
--- a/src/main/java/com/ddf/vodsystem/services/DownloadService.java
+++ b/src/main/java/com/ddf/vodsystem/services/DownloadService.java
@@ -65,4 +65,19 @@ public class DownloadService {
Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id));
return downloadClip(clip);
}
+
+ public Resource downloadThumbnail(Clip clip) {
+ String path = clip.getThumbnailPath();
+ File file = new File(path);
+ if (!file.exists()) {
+ throw new JobNotFound("Thumbnail file not found");
+ }
+
+ return new FileSystemResource(file);
+ }
+
+ public Resource downloadThumbnail(Long id) {
+ Clip clip = clipRepository.findById(id).orElseThrow(() -> new JobNotFound("Clip not found with id: " + id));
+ return downloadThumbnail(clip);
+ }
}
diff --git a/src/main/java/com/ddf/vodsystem/services/EditService.java b/src/main/java/com/ddf/vodsystem/services/EditService.java
index e69e2f9..b102cc8 100644
--- a/src/main/java/com/ddf/vodsystem/services/EditService.java
+++ b/src/main/java/com/ddf/vodsystem/services/EditService.java
@@ -1,5 +1,6 @@
package com.ddf.vodsystem.services;
+import com.ddf.vodsystem.dto.VideoMetadata;
import com.ddf.vodsystem.entities.*;
import org.springframework.stereotype.Service;
diff --git a/src/main/java/com/ddf/vodsystem/services/FfmpegService.java b/src/main/java/com/ddf/vodsystem/services/FfmpegService.java
index 2e36ed8..24379ce 100644
--- a/src/main/java/com/ddf/vodsystem/services/FfmpegService.java
+++ b/src/main/java/com/ddf/vodsystem/services/FfmpegService.java
@@ -1,6 +1,6 @@
package com.ddf.vodsystem.services;
-import com.ddf.vodsystem.entities.VideoMetadata;
+import com.ddf.vodsystem.dto.VideoMetadata;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -48,6 +48,35 @@ public class FfmpegService {
runWithProgress(inputFile, outputFile, videoMetadata, new AtomicReference<>(0f));
}
+ public void generateThumbnail(File inputFile, File outputFile, Float time) throws IOException, InterruptedException {
+ logger.info("Generating thumbnail at {} seconds", time);
+
+ List command = new ArrayList<>();
+ command.add("ffmpeg");
+ command.add("-ss");
+ command.add(time.toString());
+ command.add("-i");
+ command.add(inputFile.getAbsolutePath());
+ command.add("-frames:v");
+ command.add("1");
+ command.add(outputFile.getAbsolutePath());
+
+ String strCommand = String.join(" ", command);
+ logger.info("FFMPEG thumbnail command: {}", strCommand);
+
+ ProcessBuilder processBuilder = new ProcessBuilder(command);
+ processBuilder.redirectErrorStream(true);
+
+ Process process = processBuilder.start();
+
+ if (process.waitFor() != 0) {
+ logger.error("FFMPEG process failed to generate thumbnail");
+ throw new IOException("FFMPEG process failed to generate thumbnail");
+ }
+
+ logger.info("Thumbnail generated successfully at {}", outputFile.getAbsolutePath());
+ }
+
private void updateJobProgress(Process process, AtomicReference progress, Float length) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream()));
diff --git a/src/main/java/com/ddf/vodsystem/services/MetadataService.java b/src/main/java/com/ddf/vodsystem/services/MetadataService.java
index 080dd16..a307e90 100644
--- a/src/main/java/com/ddf/vodsystem/services/MetadataService.java
+++ b/src/main/java/com/ddf/vodsystem/services/MetadataService.java
@@ -1,6 +1,6 @@
package com.ddf.vodsystem.services;
-import com.ddf.vodsystem.entities.VideoMetadata;
+import com.ddf.vodsystem.dto.VideoMetadata;
import com.ddf.vodsystem.exceptions.FFMPEGException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
diff --git a/src/main/java/com/ddf/vodsystem/services/UploadService.java b/src/main/java/com/ddf/vodsystem/services/UploadService.java
index 9afbc75..78fdf46 100644
--- a/src/main/java/com/ddf/vodsystem/services/UploadService.java
+++ b/src/main/java/com/ddf/vodsystem/services/UploadService.java
@@ -1,7 +1,7 @@
package com.ddf.vodsystem.services;
import com.ddf.vodsystem.entities.Job;
-import com.ddf.vodsystem.entities.VideoMetadata;
+import com.ddf.vodsystem.dto.VideoMetadata;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 51bb1cf..e2605ea 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -6,7 +6,7 @@ spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=2GB
storage.temp.inputs=videos/inputs/
storage.temp.outputs=videos/outputs/
-storage.outputs=videos/clips/
+storage.outputs=outputs/
## Server Configuration
server.servlet.session.timeout=30m
diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql
index 60dc57a..d61b245 100644
--- a/src/main/resources/db/schema.sql
+++ b/src/main/resources/db/schema.sql
@@ -23,5 +23,6 @@ CREATE TABLE IF NOT EXISTS clips (
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