From f0a4eed38182ae2b832501f0ff87c0b46a5af5a8 Mon Sep 17 00:00:00 2001 From: Dylan De Faoite <98231127+ThisBirchWood@users.noreply.github.com> Date: Sat, 26 Jul 2025 22:21:56 +0200 Subject: [PATCH] 17 clean up services structure (#18) * Refactor ClipService and MediaService * Refactor ClipService for less coupling to Jobs * PATCH unnecessary requests in frontend * REFACTOR MetadataService to use CommandRunner * REFACTOR DirectoryService and UploadService * REFACTOR ClipService * MERGE MetadataService with MediaService --- frontend/src/components/video/VideoCard.tsx | 2 +- .../com/ddf/vodsystem/dto/CommandOutput.java | 16 ++ .../ddf/vodsystem/services/ClipService.java | 107 ++++++--- .../ddf/vodsystem/services/CommandRunner.java | 46 ++++ .../vodsystem/services/DirectoryService.java | 75 ++++-- .../ddf/vodsystem/services/FfmpegService.java | 177 -------------- .../ddf/vodsystem/services/JobService.java | 10 +- .../ddf/vodsystem/services/MediaService.java | 225 ++++++++++++++++++ .../vodsystem/services/MetadataService.java | 158 ------------ .../ddf/vodsystem/services/UploadService.java | 12 +- 10 files changed, 429 insertions(+), 399 deletions(-) create mode 100644 src/main/java/com/ddf/vodsystem/dto/CommandOutput.java create mode 100644 src/main/java/com/ddf/vodsystem/services/CommandRunner.java delete mode 100644 src/main/java/com/ddf/vodsystem/services/FfmpegService.java create mode 100644 src/main/java/com/ddf/vodsystem/services/MediaService.java delete mode 100644 src/main/java/com/ddf/vodsystem/services/MetadataService.java diff --git a/frontend/src/components/video/VideoCard.tsx b/frontend/src/components/video/VideoCard.tsx index 745a77f..ffc2ab1 100644 --- a/frontend/src/components/video/VideoCard.tsx +++ b/frontend/src/components/video/VideoCard.tsx @@ -37,7 +37,7 @@ const VideoCard = ({ .catch(() => { setThumbnailAvailable(false); }); - }); + }, []); return ( diff --git a/src/main/java/com/ddf/vodsystem/dto/CommandOutput.java b/src/main/java/com/ddf/vodsystem/dto/CommandOutput.java new file mode 100644 index 0000000..643c3a6 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/dto/CommandOutput.java @@ -0,0 +1,16 @@ +package com.ddf.vodsystem.dto; + +import lombok.Data; + +import java.util.ArrayList; +import java.util.List; + +@Data +public class CommandOutput { + private List output = new ArrayList<>(); + private int exitCode; + + public void addLine(String line) { + output.add(line); + } +} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index 1c826f3..617ca49 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.ProgressTracker; import com.ddf.vodsystem.dto.VideoMetadata; import com.ddf.vodsystem.entities.*; @@ -7,6 +8,7 @@ import java.io.File; import java.io.IOException; import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import com.ddf.vodsystem.exceptions.NotAuthenticated; import com.ddf.vodsystem.repositories.ClipRepository; @@ -19,48 +21,53 @@ public class ClipService { private static final Logger logger = LoggerFactory.getLogger(ClipService.class); private final ClipRepository clipRepository; - private final MetadataService metadataService; private final DirectoryService directoryService; - private final FfmpegService ffmpegService; + private final MediaService mediaService; private final UserService userService; public ClipService(ClipRepository clipRepository, - MetadataService metadataService, DirectoryService directoryService, - FfmpegService ffmpegService, + MediaService mediaService, UserService userService) { this.clipRepository = clipRepository; - this.metadataService = metadataService; this.directoryService = directoryService; - this.ffmpegService = ffmpegService; + this.mediaService = mediaService; this.userService = userService; } /** - * Runs the FFMPEG command to create a video clip based on the provided job. - * Updates the job status and progress as the command executes. - * This method validates the input and output video metadata, - * Updates the job VideoMetadata with the output file size, - * - * @param job the job containing input and output video metadata - * @throws IOException if an I/O error occurs during command execution - * @throws InterruptedException if the thread is interrupted while waiting for the process to finish + * Run the clip creation process. + * This method normalizes the input metadata, compresses the video file, + * updates the output metadata with the file size, and saves the clip + * to the database if the user is authenticated. * + * @param inputMetadata The metadata of the input video file. + * @param outputMetadata The metadata for the output video file. + * @param inputFile The input video file to be processed. + * @param outputFile The output file where the processed video will be saved. + * @param progress A tracker to monitor the progress of the video processing. + * @throws IOException if an I/O error occurs during file processing. + * @throws InterruptedException if the thread is interrupted during processing. + * @return An Optional containing the created Clip if the user is authenticated, otherwise an empty Optional. */ - public void run(Job job) throws IOException, InterruptedException { - metadataService.normalizeVideoMetadata(job.getInputVideoMetadata(), job.getOutputVideoMetadata()); - ffmpegService.runWithProgress(job.getInputFile(), job.getOutputFile(), job.getOutputVideoMetadata(), job.getProgress()); + public Optional create(VideoMetadata inputMetadata, + VideoMetadata outputMetadata, + File inputFile, + File outputFile, + ProgressTracker progress) throws IOException, InterruptedException { + normalizeVideoMetadata(inputMetadata, outputMetadata); + mediaService.compress(inputFile, outputFile, outputMetadata, progress); - Float fileSize = metadataService.getFileSize(job.getOutputFile()); - job.getOutputVideoMetadata().setFileSize(fileSize); + Float fileSize = mediaService.getVideoMetadata(outputFile).getFileSize(); + outputMetadata.setFileSize(fileSize); User user = userService.getUser(); - if (user != null) { - persistClip(job.getOutputVideoMetadata(), user, job); + + if (user == null) { + return Optional.empty(); } - job.setStatus(JobStatus.FINISHED); - logger.info("FFMPEG finished successfully for job: {}", job.getUuid()); + return Optional.of(persistClip(outputMetadata, user, outputFile, inputFile.getName())); } public List getClipsByUser() { @@ -90,6 +97,26 @@ public class ClipService { return clip; } + public void deleteClip(Long id) { + Clip clip = getClipById(id); + if (clip == null) { + logger.warn("Clip with ID {} not found for deletion", id); + return; + } + + if (!isAuthenticatedForClip(clip)) { + logger.warn("User is not authorized to delete clip with ID {}", id); + throw new NotAuthenticated("You are not authorized to delete this clip"); + } + + File clipFile = new File(clip.getVideoPath()); + File thumbnailFile = new File(clip.getThumbnailPath()); + directoryService.deleteFile(clipFile); + directoryService.deleteFile(thumbnailFile); + + clipRepository.delete(clip); + } + public boolean isAuthenticatedForClip(Clip clip) { User user = userService.getUser(); if (user == null || clip == null) { @@ -98,20 +125,18 @@ public class ClipService { return user.getId().equals(clip.getUser().getId()); } - - private void persistClip(VideoMetadata videoMetadata, User user, Job job) { + private Clip persistClip(VideoMetadata videoMetadata, + User user, + File tempFile, + String fileName) { // Move clip from temp to output directory - String fileExtension = directoryService.getFileExtension(job.getOutputFile().getAbsolutePath()); + File clipFile = directoryService.getUserClipsFile(user.getId(), fileName); + File thumbnailFile = directoryService.getUserThumbnailsFile(user.getId(), fileName + ".png"); + directoryService.cutFile(tempFile, clipFile); - 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); + mediaService.createThumbnail(clipFile, thumbnailFile, 0.0f); } catch (IOException | InterruptedException e) { logger.error("Error generating thumbnail for clip: {}", e.getMessage()); Thread.currentThread().interrupt(); @@ -128,8 +153,18 @@ public class ClipService { clip.setFps(videoMetadata.getFps()); clip.setDuration(videoMetadata.getEndPoint() - videoMetadata.getStartPoint()); clip.setFileSize(videoMetadata.getFileSize()); - clip.setVideoPath(clipOutputFile.getPath()); - clip.setThumbnailPath(thumbnailOutputFile.getPath()); - clipRepository.save(clip); + clip.setVideoPath(clipFile.getPath()); + clip.setThumbnailPath(thumbnailFile.getPath()); + return clipRepository.save(clip); + } + + public void normalizeVideoMetadata(VideoMetadata inputFileMetadata, VideoMetadata outputFileMetadata) { + if (outputFileMetadata.getStartPoint() == null) { + outputFileMetadata.setStartPoint(0f); + } + + if (outputFileMetadata.getEndPoint() == null) { + outputFileMetadata.setEndPoint(inputFileMetadata.getEndPoint()); + } } } diff --git a/src/main/java/com/ddf/vodsystem/services/CommandRunner.java b/src/main/java/com/ddf/vodsystem/services/CommandRunner.java new file mode 100644 index 0000000..2b96247 --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/CommandRunner.java @@ -0,0 +1,46 @@ +package com.ddf.vodsystem.services; + +import com.ddf.vodsystem.dto.CommandOutput; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.List; +import java.util.function.Consumer; + +public class CommandRunner { + public static CommandOutput run(List command, Consumer onOutput) throws IOException, InterruptedException { + ProcessBuilder processBuilder = new ProcessBuilder(command); + processBuilder.redirectErrorStream(true); + + Process process = processBuilder.start(); + CommandOutput commandOutput = new CommandOutput(); + + // Read the output and error streams + BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); + String line; + while ((line = reader.readLine()) != null) { + commandOutput.addLine(line); + if (onOutput != null) { + onOutput.accept(line); + } + } + + int exitCode = process.waitFor(); + commandOutput.setExitCode(exitCode); + + if (exitCode != 0) { + throw new IOException("Command failed with exit code " + exitCode + ": " + String.join("\n", commandOutput.getOutput())); + } + + return commandOutput; + } + + public static CommandOutput run(List command) throws IOException, InterruptedException { + return run(command, null); + } + + private CommandRunner() { + // Private constructor to prevent instantiation + } +} \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java index 273cdbc..64252c5 100644 --- a/src/main/java/com/ddf/vodsystem/services/DirectoryService.java +++ b/src/main/java/com/ddf/vodsystem/services/DirectoryService.java @@ -32,40 +32,48 @@ public class DirectoryService { private static final long TEMP_DIR_TIMELIMIT = 3 * 60 * 60 * (long) 1000; // 3 hours private static final long TEMP_DIR_CLEANUP_RATE = 30 * 60 * (long) 1000; // 30 minutes - public File getTempInputFile(String id, String extension) { - String dir = tempInputsDir + File.separator + id + (extension.isEmpty() ? "" : "." + extension); + public File getTempInputFile(String filename) { + String dir = tempInputsDir + File.separator + filename; return new File(dir); } - public File getTempOutputFile(String id, String extension) { - String dir = tempOutputsDir + File.separator + id + (extension.isEmpty() ? "" : "." + extension); + public File getTempOutputFile(String filename) { + String dir = tempOutputsDir + File.separator + filename; return new File(dir); } - public File getUserClipsDir(Long userId) { - if (userId == null) { - throw new IllegalArgumentException("User ID cannot be null"); + public File getUserClipsFile(Long userId, String fileName) { + if (userId == null || fileName == null || fileName.isEmpty()) { + throw new IllegalArgumentException("User ID and file name cannot be null or empty"); } - 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); + String dir = outputDir + File.separator + userId + File.separator + "clips" + File.separator + fileName; + File file = new File(dir); try { - createDirectory(thumbnailDir.getAbsolutePath()); + createDirectory(file.getParent()); + } catch (IOException e) { + logger.error("Error creating clips directory: {}", e.getMessage()); + } + + return file; + } + + public File getUserThumbnailsFile(Long userId, String fileName) { + if (userId == null || fileName == null || fileName.isEmpty()) { + throw new IllegalArgumentException("User ID and file name cannot be null or empty"); + } + + String dir = outputDir + File.separator + userId + File.separator + "thumbnails" + File.separator + fileName; + File file = new File(dir); + + try { + createDirectory(file.getParent()); } catch (IOException e) { logger.error("Error creating thumbnails directory: {}", e.getMessage()); } - return thumbnailDir; + return file; } public void saveAtDir(File file, MultipartFile multipartFile) { @@ -91,6 +99,33 @@ public class DirectoryService { } } + 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 boolean deleteFile(File file) { + if (file == null || !file.exists()) { + logger.warn("File does not exist: {}", file); + return false; + } + + try { + Files.delete(file.toPath()); + logger.info("Deleted file: {}", file.getAbsolutePath()); + return true; + } catch (IOException e) { + logger.error("Error deleting file: {}", e.getMessage()); + return false; + } + } + public String getFileExtension(String filePath) { Path path = Paths.get(filePath); String fileName = path.getFileName().toString(); diff --git a/src/main/java/com/ddf/vodsystem/services/FfmpegService.java b/src/main/java/com/ddf/vodsystem/services/FfmpegService.java deleted file mode 100644 index d930c43..0000000 --- a/src/main/java/com/ddf/vodsystem/services/FfmpegService.java +++ /dev/null @@ -1,177 +0,0 @@ -package com.ddf.vodsystem.services; - -import com.ddf.vodsystem.dto.ProgressTracker; -import com.ddf.vodsystem.dto.VideoMetadata; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -@Service -public class FfmpegService { - private static final Logger logger = LoggerFactory.getLogger(FfmpegService.class); - - private static final float AUDIO_RATIO = 0.15f; - private static final float MAX_AUDIO_BITRATE = 128f; - private static final float BITRATE_MULTIPLIER = 0.9f; - private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); - - public void runWithProgress(File inputFile, File outputFile, VideoMetadata videoMetadata, ProgressTracker progress) throws IOException, InterruptedException { - logger.info("Starting FFMPEG process"); - - List command = buildCommand(inputFile, outputFile, videoMetadata); - - String strCommand = String.join(" ", command); - logger.info("FFMPEG command: {}", strCommand); - - ProcessBuilder processBuilder = new ProcessBuilder(command); - processBuilder.redirectErrorStream(true); - - Process process = processBuilder.start(); - logger.info("FFMPEG process started with PID: {}", process.pid()); - - updateJobProgress(process, progress, videoMetadata.getEndPoint() - videoMetadata.getStartPoint()); - process.waitFor(); - - logger.info("FFMPEG process completed successfully"); - } - - public void run(File inputFile, File outputFile, VideoMetadata videoMetadata) throws IOException, InterruptedException { - runWithProgress(inputFile, outputFile, videoMetadata, new ProgressTracker(0.0f)); - } - - public void generateThumbnail(File inputFile, File outputFile, Float timeInVideo) throws IOException, InterruptedException { - logger.info("Generating thumbnail at {} seconds", timeInVideo); - - List command = new ArrayList<>(); - command.add("ffmpeg"); - command.add("-ss"); - command.add(timeInVideo.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, ProgressTracker progress, Float length) throws IOException { - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - - String line; - while ((line = reader.readLine()) != null) { - logger.debug(line); - Matcher matcher = timePattern.matcher(line); - - if (matcher.find()) { - Float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f; - progress.setProgress(timeInMs/length); - } - } - } - - private List buildFilters(Float fps, Integer width, Integer height) { - List command = new ArrayList<>(); - command.add("-vf"); - - List filters = new ArrayList<>(); - - if (fps != null) { - logger.info("Frame rate set to {}", fps); - filters.add("fps=" + fps); - } - - if (!(width == null && height == null)) { - logger.info("Scaling video to width: {}, height: {}", width, height); - String w = (width != null) ? width.toString() : "-1"; - String h = (height != null) ? height.toString() : "-1"; - filters.add("scale=" + w + ":" + h); - } - - logger.info("Adding video filters"); - command.add(String.join(",", filters)); - return command; - } - - private List buildBitrate(Float length, Float fileSize) { - List command = new ArrayList<>(); - - float bitrate = ((fileSize * 8) / length) * BITRATE_MULTIPLIER; - float audioBitrate = bitrate * AUDIO_RATIO; - float videoBitrate; - - if (audioBitrate > MAX_AUDIO_BITRATE) { - audioBitrate = MAX_AUDIO_BITRATE; - videoBitrate = bitrate - MAX_AUDIO_BITRATE; - } else { - videoBitrate = bitrate * (1 - AUDIO_RATIO); - } - - command.add("-b:v"); - command.add(videoBitrate + "k"); - command.add("-b:a"); - command.add(audioBitrate + "k"); - - return command; - } - - private List buildInputs(File inputFile, Float startPoint, Float length) { - List command = new ArrayList<>(); - - command.add("-ss"); - command.add(startPoint.toString()); - - command.add("-i"); - command.add(inputFile.getAbsolutePath()); - - command.add("-t"); - command.add(Float.toString(length)); - - return command; - } - - private List buildCommand(File inputFile, File outputFile, VideoMetadata videoMetadata) { - List command = new ArrayList<>(); - command.add("ffmpeg"); - command.add("-progress"); - command.add("pipe:1"); - command.add("-y"); - - Float length = videoMetadata.getEndPoint() - videoMetadata.getStartPoint(); - command.addAll(buildInputs(inputFile, videoMetadata.getStartPoint(), length)); - - if (videoMetadata.getFps() != null || videoMetadata.getWidth() != null || videoMetadata.getHeight() != null) { - command.addAll(buildFilters(videoMetadata.getFps(), videoMetadata.getWidth(), videoMetadata.getHeight())); - } - - if (videoMetadata.getFileSize() != null) { - command.addAll(buildBitrate(length, videoMetadata.getFileSize())); - } - - // Output file - command.add(outputFile.getAbsolutePath()); - return command; - } -} diff --git a/src/main/java/com/ddf/vodsystem/services/JobService.java b/src/main/java/com/ddf/vodsystem/services/JobService.java index 242a736..8373091 100644 --- a/src/main/java/com/ddf/vodsystem/services/JobService.java +++ b/src/main/java/com/ddf/vodsystem/services/JobService.java @@ -87,7 +87,15 @@ public class JobService { SecurityContextHolder.setContext(job.getSecurityContext()); } - clipService.run(job); + clipService.create( + job.getInputVideoMetadata(), + job.getOutputVideoMetadata(), + job.getInputFile(), + job.getOutputFile(), + job.getProgress() + ); + + job.setStatus(JobStatus.FINISHED); } catch (IOException | InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java new file mode 100644 index 0000000..540c6aa --- /dev/null +++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java @@ -0,0 +1,225 @@ +package com.ddf.vodsystem.services; + +import com.ddf.vodsystem.dto.CommandOutput; +import com.ddf.vodsystem.dto.ProgressTracker; +import com.ddf.vodsystem.dto.VideoMetadata; +import com.ddf.vodsystem.exceptions.FFMPEGException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@Service +public class MediaService { + private static final Logger logger = LoggerFactory.getLogger(MediaService.class); + + private static final float AUDIO_RATIO = 0.15f; + private static final float MAX_AUDIO_BITRATE = 128f; + private static final float BITRATE_MULTIPLIER = 0.9f; + private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); + + public void compress(File inputFile, File outputFile, VideoMetadata videoMetadata, ProgressTracker progress) throws IOException, InterruptedException { + logger.info("Compressing video from {} to {}", inputFile.getAbsolutePath(), outputFile.getAbsolutePath()); + + float length = videoMetadata.getEndPoint() - videoMetadata.getStartPoint(); + List command = buildCommand(inputFile, outputFile, videoMetadata); + CommandRunner.run(command, line -> setProgress(line, progress, length)); + } + + public void createThumbnail(File inputFile, File outputFile, Float timeInVideo) throws IOException, InterruptedException { + logger.info("Creating thumbnail at {} seconds", timeInVideo); + + List command = List.of( + "ffmpeg", + "-ss", timeInVideo.toString(), + "-i", inputFile.getAbsolutePath(), + "-frames:v", "1", + outputFile.getAbsolutePath() + ); + + CommandRunner.run(command); + } + + public VideoMetadata getVideoMetadata(File file) { + logger.info("Getting metadata for file {}", file.getAbsolutePath()); + + List command = List.of( + "ffprobe", + "-v", "quiet", + "-print_format", "json", + "-show_format", "-select_streams", + "v:0", "-show_entries", "stream=duration,width,height,r_frame_rate:format=size,duration", + "-i", file.getAbsolutePath() + ); + + ObjectMapper mapper = new ObjectMapper(); + StringBuilder outputBuilder = new StringBuilder(); + + try { + CommandOutput output = CommandRunner.run(command); + + for (String line : output.getOutput()) { + outputBuilder.append(line); + } + + JsonNode node = mapper.readTree(outputBuilder.toString()); + return parseVideoMetadata(node); + } catch (IOException | InterruptedException e) { + Thread.currentThread().interrupt(); + throw new FFMPEGException("Error while getting video metadata: " + e); + } + } + + private VideoMetadata parseVideoMetadata(JsonNode node) { + VideoMetadata metadata = new VideoMetadata(); + metadata.setStartPoint(0f); + + JsonNode streamNode = node.path("streams").get(0); + + // if stream doesn't exist + if (streamNode == null || streamNode.isMissingNode()) { + throw new FFMPEGException("ffprobe streams missing"); + } + + if (streamNode.has("duration")) { + metadata.setEndPoint(Float.valueOf(streamNode.get("duration").asText())); + } + + if (streamNode.has("width")) { + metadata.setWidth(streamNode.get("width").asInt()); + } + + if (streamNode.has("height")) { + metadata.setHeight(streamNode.get("height").asInt()); + } + + if (streamNode.has("r_frame_rate")) { + String fpsFraction = streamNode.get("r_frame_rate").asText(); + + if (fpsFraction.contains("/")) { + String[] parts = fpsFraction.split("/"); + double numerator = Float.parseFloat(parts[0]); + double denominator = Float.parseFloat(parts[1]); + if (denominator != 0) { + metadata.setFps((float) (numerator / denominator)); + } + } else { + metadata.setFps(Float.valueOf(fpsFraction)); // Handle cases like "25" directly + } + } + + // Extract from the 'format' section + JsonNode formatNode = node.path("format"); + if (formatNode != null && !formatNode.isMissingNode()) { + if (formatNode.has("size")) { + metadata.setFileSize(Float.parseFloat(formatNode.get("size").asText())); + } + + // Use format duration as a fallback or primary source if stream duration is absent/zero + if (formatNode.has("duration") && metadata.getEndPoint() == null) { + metadata.setEndPoint(Float.parseFloat(formatNode.get("duration").asText())); + } + } + + return metadata; + } + + private void setProgress(String line, ProgressTracker progress, float length) { + Matcher matcher = timePattern.matcher(line); + if (matcher.find()) { + float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f; + progress.setProgress(timeInMs / length); + } + } + + private List buildFilters(Float fps, Integer width, Integer height) { + List command = new ArrayList<>(); + command.add("-vf"); + + List filters = new ArrayList<>(); + + if (fps != null) { + logger.info("Frame rate set to {}", fps); + filters.add("fps=" + fps); + } + + if (!(width == null && height == null)) { + logger.info("Scaling video to width: {}, height: {}", width, height); + String w = (width != null) ? width.toString() : "-1"; + String h = (height != null) ? height.toString() : "-1"; + filters.add("scale=" + w + ":" + h); + } + + logger.info("Adding video filters"); + command.add(String.join(",", filters)); + return command; + } + + private List buildBitrate(Float length, Float fileSize) { + List command = new ArrayList<>(); + + float bitrate = ((fileSize * 8) / length) * BITRATE_MULTIPLIER; + float audioBitrate = bitrate * AUDIO_RATIO; + float videoBitrate; + + if (audioBitrate > MAX_AUDIO_BITRATE) { + audioBitrate = MAX_AUDIO_BITRATE; + videoBitrate = bitrate - MAX_AUDIO_BITRATE; + } else { + videoBitrate = bitrate * (1 - AUDIO_RATIO); + } + + command.add("-b:v"); + command.add(videoBitrate + "k"); + command.add("-b:a"); + command.add(audioBitrate + "k"); + + return command; + } + + private List buildInputs(File inputFile, Float startPoint, Float length) { + List command = new ArrayList<>(); + + command.add("-ss"); + command.add(startPoint.toString()); + + command.add("-i"); + command.add(inputFile.getAbsolutePath()); + + command.add("-t"); + command.add(Float.toString(length)); + + return command; + } + + private List buildCommand(File inputFile, File outputFile, VideoMetadata videoMetadata) { + List command = new ArrayList<>(); + command.add("ffmpeg"); + command.add("-progress"); + command.add("pipe:1"); + command.add("-y"); + + Float length = videoMetadata.getEndPoint() - videoMetadata.getStartPoint(); + command.addAll(buildInputs(inputFile, videoMetadata.getStartPoint(), length)); + + if (videoMetadata.getFps() != null || videoMetadata.getWidth() != null || videoMetadata.getHeight() != null) { + command.addAll(buildFilters(videoMetadata.getFps(), videoMetadata.getWidth(), videoMetadata.getHeight())); + } + + if (videoMetadata.getFileSize() != null) { + command.addAll(buildBitrate(length, videoMetadata.getFileSize())); + } + + // Output file + command.add(outputFile.getAbsolutePath()); + return command; + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/MetadataService.java b/src/main/java/com/ddf/vodsystem/services/MetadataService.java deleted file mode 100644 index a307e90..0000000 --- a/src/main/java/com/ddf/vodsystem/services/MetadataService.java +++ /dev/null @@ -1,158 +0,0 @@ -package com.ddf.vodsystem.services; - -import com.ddf.vodsystem.dto.VideoMetadata; -import com.ddf.vodsystem.exceptions.FFMPEGException; -import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -import java.io.BufferedReader; -import java.io.File; -import java.io.IOException; -import java.io.InputStreamReader; - -@Service -public class MetadataService { - private static final Logger logger = LoggerFactory.getLogger(MetadataService.class); - - public VideoMetadata getVideoMetadata(File file) { - logger.info("Getting metadata for file {}", file.getAbsolutePath()); - - ProcessBuilder pb = new ProcessBuilder("ffprobe", - "-v", "quiet", - "-print_format", "json", - "-show_format", "-select_streams", - "v:0", "-show_entries", "stream=duration,width,height,r_frame_rate:format=size,duration", - "-i", file.getAbsolutePath()); - - Process process; - - try { - process = pb.start(); - handleFfprobeError(process); - VideoMetadata metadata = parseVideoMetadata(readStandardOutput(process)); - logger.info("Metadata for file {} finished with exit code {}", file.getAbsolutePath(), process.exitValue()); - return metadata; - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new FFMPEGException(e.getMessage()); - } catch (IOException e) { - throw new FFMPEGException(e.getMessage()); - } - } - - public Float getFileSize(File file) { - logger.info("Getting file size for {}", file.getAbsolutePath()); - VideoMetadata metadata = getVideoMetadata(file); - - if (metadata.getFileSize() == null) { - throw new FFMPEGException("File size not found"); - } - - return metadata.getFileSize(); - } - - public Float getVideoDuration(File file) { - logger.info("Getting video duration for {}", file.getAbsolutePath()); - VideoMetadata metadata = getVideoMetadata(file); - if (metadata.getEndPoint() == null) { - throw new FFMPEGException("Video duration not found"); - } - return metadata.getEndPoint(); - } - - public void normalizeVideoMetadata(VideoMetadata inputFileMetadata, VideoMetadata outputFileMetadata) { - if (outputFileMetadata.getStartPoint() == null) { - outputFileMetadata.setStartPoint(0f); - } - - if (outputFileMetadata.getEndPoint() == null) { - outputFileMetadata.setEndPoint(inputFileMetadata.getEndPoint()); - } - } - - private JsonNode readStandardOutput(Process process) throws IOException{ - // Read the standard output (JSON metadata) - BufferedReader reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - StringBuilder jsonOutput = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - jsonOutput.append(line); - } - - // Parse the JSON output - ObjectMapper mapper = new ObjectMapper(); - return mapper.readTree(jsonOutput.toString()); - } - - private void handleFfprobeError(Process process) throws IOException, InterruptedException { - int exitCode = process.waitFor(); - if (exitCode != 0) { - BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream())); - StringBuilder errorOutput = new StringBuilder(); - String line; - while ((line = errorReader.readLine()) != null) { - errorOutput.append(line).append("\n"); - } - - throw new FFMPEGException("ffprobe exited with code " + exitCode + ". Error: " + errorOutput); - } - } - - private VideoMetadata parseVideoMetadata(JsonNode node) { - VideoMetadata metadata = new VideoMetadata(); - metadata.setStartPoint(0f); - - JsonNode streamNode = node.path("streams").get(0); - - // if stream doesn't exist - if (streamNode == null || streamNode.isMissingNode()) { - throw new FFMPEGException("ffprobe streams missing"); - } - - if (streamNode.has("duration")) { - metadata.setEndPoint(Float.valueOf(streamNode.get("duration").asText())); - } - - if (streamNode.has("width")) { - metadata.setWidth(streamNode.get("width").asInt()); - } - - if (streamNode.has("height")) { - metadata.setHeight(streamNode.get("height").asInt()); - } - - if (streamNode.has("r_frame_rate")) { - String fpsFraction = streamNode.get("r_frame_rate").asText(); - - if (fpsFraction.contains("/")) { - String[] parts = fpsFraction.split("/"); - double numerator = Float.parseFloat(parts[0]); - double denominator = Float.parseFloat(parts[1]); - if (denominator != 0) { - metadata.setFps((float) (numerator / denominator)); - } - } else { - metadata.setFps(Float.valueOf(fpsFraction)); // Handle cases like "25" directly - } - } - - // Extract from the 'format' section - JsonNode formatNode = node.path("format"); - if (formatNode != null && !formatNode.isMissingNode()) { - if (formatNode.has("size")) { - metadata.setFileSize(Float.parseFloat(formatNode.get("size").asText())); - } - - // Use format duration as a fallback or primary source if stream duration is absent/zero - if (formatNode.has("duration") && metadata.getEndPoint() == null) { - metadata.setEndPoint(Float.parseFloat(formatNode.get("duration").asText())); - } - } - - return metadata; - } - -} diff --git a/src/main/java/com/ddf/vodsystem/services/UploadService.java b/src/main/java/com/ddf/vodsystem/services/UploadService.java index 78fdf46..3c81cdf 100644 --- a/src/main/java/com/ddf/vodsystem/services/UploadService.java +++ b/src/main/java/com/ddf/vodsystem/services/UploadService.java @@ -19,15 +19,15 @@ public class UploadService { private static final Logger logger = LoggerFactory.getLogger(UploadService.class); private final JobService jobService; - private final MetadataService metadataService; + private final MediaService mediaService; private final DirectoryService directoryService; @Autowired public UploadService(JobService jobService, - MetadataService metadataService, + MediaService mediaService, DirectoryService directoryService) { this.jobService = jobService; - this.metadataService = metadataService; + this.mediaService = mediaService; this.directoryService = directoryService; } @@ -36,13 +36,13 @@ public class UploadService { String uuid = generateShortUUID(); String extension = directoryService.getFileExtension(file.getOriginalFilename()); - File inputFile = directoryService.getTempInputFile(uuid, extension); - File outputFile = directoryService.getTempOutputFile(uuid, extension); + File inputFile = directoryService.getTempInputFile(uuid + "." + extension); + File outputFile = directoryService.getTempOutputFile(uuid + "." + extension); directoryService.saveAtDir(inputFile, file); // add job logger.info("Uploaded file and creating job with UUID: {}", uuid); - VideoMetadata videoMetadata = metadataService.getVideoMetadata(inputFile); + VideoMetadata videoMetadata = mediaService.getVideoMetadata(inputFile); Job job = new Job(uuid, inputFile, outputFile, videoMetadata); jobService.add(job);