diff --git a/.gitea/workflows/unit-tests.yml b/.gitea/workflows/unit-tests.yml new file mode 100644 index 0000000..b3a69f4 --- /dev/null +++ b/.gitea/workflows/unit-tests.yml @@ -0,0 +1,23 @@ +name: Unit Tests + +on: + push: + +jobs: + unit-tests: + name: Maven unit tests + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: https://github.com/actions/checkout@v4 + + - name: Set up Java 21 + uses: https://github.com/actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + cache: maven + + - name: Run unit tests + run: ./mvnw --batch-mode --no-transfer-progress test diff --git a/.gitignore b/.gitignore index 60a230e..4a0b46b 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,3 @@ build/ frontend/.flowbite-react generated -.mvn diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..ffcab66 --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip diff --git a/mvnw b/mvnw index 19529dd..bd8896b 100755 --- a/mvnw +++ b/mvnw @@ -19,7 +19,7 @@ # ---------------------------------------------------------------------------- # ---------------------------------------------------------------------------- -# Apache Maven Wrapper startup batch script, version 3.3.2 +# Apache Maven Wrapper startup batch script, version 3.3.4 # # Optional ENV vars # ----------------- @@ -105,14 +105,17 @@ trim() { printf "%s" "${1}" | tr -d '[:space:]' } +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + # parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties while IFS="=" read -r key value; do case "${key-}" in distributionUrl) distributionUrl=$(trim "${value-}") ;; distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; esac -done <"${0%/*}/.mvn/wrapper/maven-wrapper.properties" -[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in ${0%/*}/.mvn/wrapper/maven-wrapper.properties" +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" case "${distributionUrl##*/}" in maven-mvnd-*bin.*) @@ -130,7 +133,7 @@ maven-mvnd-*bin.*) distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" ;; maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; -*) MVN_CMD="mvn${0##*/mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; esac # apply MVNW_REPOURL and calculate MAVEN_HOME @@ -227,7 +230,7 @@ if [ -n "${distributionSha256Sum-}" ]; then echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 exit 1 elif command -v sha256sum >/dev/null; then - if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c >/dev/null 2>&1; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then distributionSha256Result=true fi elif command -v shasum >/dev/null; then @@ -252,8 +255,41 @@ if command -v unzip >/dev/null; then else tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" fi -printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/mvnw.url" -mv -- "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" clean || : exec_maven "$@" diff --git a/mvnw.cmd b/mvnw.cmd index 249bdf3..92450f9 100644 --- a/mvnw.cmd +++ b/mvnw.cmd @@ -19,7 +19,7 @@ @REM ---------------------------------------------------------------------------- @REM ---------------------------------------------------------------------------- -@REM Apache Maven Wrapper startup batch script, version 3.3.2 +@REM Apache Maven Wrapper startup batch script, version 3.3.4 @REM @REM Optional ENV vars @REM MVNW_REPOURL - repo url base for downloading maven distribution @@ -40,7 +40,7 @@ @SET __MVNW_ARG0_NAME__= @SET MVNW_USERNAME= @SET MVNW_PASSWORD= -@IF NOT "%__MVNW_CMD__%"=="" (%__MVNW_CMD__% %*) +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) @echo Cannot start maven from wrapper >&2 && exit /b 1 @GOTO :EOF : end batch / begin powershell #> @@ -73,16 +73,30 @@ switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { # apply MVNW_REPOURL and calculate MAVEN_HOME # maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ if ($env:MVNW_REPOURL) { - $MVNW_REPO_PATTERN = if ($USE_MVND) { "/org/apache/maven/" } else { "/maven/mvnd/" } - $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace '^.*'+$MVNW_REPO_PATTERN,'')" + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" } $distributionUrlName = $distributionUrl -replace '^.*/','' $distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' -$MAVEN_HOME_PARENT = "$HOME/.m2/wrapper/dists/$distributionUrlNameMain" + +$MAVEN_M2_PATH = "$HOME/.m2" if ($env:MAVEN_USER_HOME) { - $MAVEN_HOME_PARENT = "$env:MAVEN_USER_HOME/wrapper/dists/$distributionUrlNameMain" + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" } -$MAVEN_HOME_NAME = ([System.Security.Cryptography.MD5]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' $MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { @@ -134,7 +148,33 @@ if ($distributionSha256Sum) { # unzip and move Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null -Rename-Item -Path "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" -NewName $MAVEN_HOME_NAME | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null try { Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null } catch { diff --git a/pom.xml b/pom.xml index 5d188e4..8929aa3 100644 --- a/pom.xml +++ b/pom.xml @@ -85,6 +85,11 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java b/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java index 7041658..85a2e49 100644 --- a/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java +++ b/src/main/java/com/ddf/vodsystem/dto/ClipOptions.java @@ -27,5 +27,5 @@ public class ClipOptions { private Integer height; @Positive - private Float fileSize; + private Long fileSize; } diff --git a/src/main/java/com/ddf/vodsystem/entities/Clip.java b/src/main/java/com/ddf/vodsystem/entities/Clip.java index eafa28e..bb3ff2d 100644 --- a/src/main/java/com/ddf/vodsystem/entities/Clip.java +++ b/src/main/java/com/ddf/vodsystem/entities/Clip.java @@ -43,7 +43,7 @@ public class Clip { private Float duration; @Column(name = "file_size", nullable = false) - private Float fileSize; + private Long fileSize; @Column(name = "video_path", nullable = false, length = 255) private String videoPath; diff --git a/src/main/java/com/ddf/vodsystem/entities/Vod.java b/src/main/java/com/ddf/vodsystem/entities/Vod.java index a53675d..1e0d738 100644 --- a/src/main/java/com/ddf/vodsystem/entities/Vod.java +++ b/src/main/java/com/ddf/vodsystem/entities/Vod.java @@ -42,7 +42,7 @@ public class Vod { private Float duration; @Column(name = "file_size", nullable = false) - private Float fileSize; + private Long fileSize; @Column(name = "video_path", nullable = false, length = 255) private String videoPath; diff --git a/src/main/java/com/ddf/vodsystem/services/ClipService.java b/src/main/java/com/ddf/vodsystem/services/ClipService.java index e0dfb43..763e382 100644 --- a/src/main/java/com/ddf/vodsystem/services/ClipService.java +++ b/src/main/java/com/ddf/vodsystem/services/ClipService.java @@ -222,14 +222,11 @@ public class ClipService { } // Thumbnail generation can fail with error propagation - try { - thumbnailService.createThumbnail(newClipFile, 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); - } + thumbnailService.createThumbnail(newClipFile, thumbnailFile, 0.0f) + .exceptionally(ex -> { + logger.error("Thumbnail job for user {} failed: {}", user.getId(), ex.toString()); + return null; + }); clipMetadata.setTitle(title); clipMetadata.setDescription(description); diff --git a/src/main/java/com/ddf/vodsystem/services/MediaService.java b/src/main/java/com/ddf/vodsystem/services/MediaService.java index 50c88cc..2c6b43b 100644 --- a/src/main/java/com/ddf/vodsystem/services/MediaService.java +++ b/src/main/java/com/ddf/vodsystem/services/MediaService.java @@ -13,13 +13,10 @@ import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.time.Instant; import java.util.*; -import java.util.stream.Collectors; -import java.util.stream.Stream; @Service public class MediaService { @@ -31,7 +28,6 @@ public class MediaService { private final ClipService clipService; private static final float CLIP_MAX_LENGTH = 180; - private static final int HLS_SEGMENT_LENGTH = 3; private final StreamActionsService streamActionsService; private final VodService vodService; private final MarkerService markerService; @@ -63,9 +59,8 @@ public class MediaService { * @return the {@link Job} tracking the compression; state transitions to * {@link JobState#SUCCEEDED} or {@link JobState#FAILED} asynchronously * @throws IOException if saving the upload to the temporary directory fails - * @throws InterruptedException if the current thread is interrupted while setting up the job */ - public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException, InterruptedException { + public Job compress(MultipartFile file, ClipOptions clipOptions) throws IOException { Job job = jobRegistryService.generateJob(); Optional user = userService.getLoggedInUser(); @@ -234,11 +229,11 @@ public class MediaService { private SegmentContext resolveSegments(User user, Instant startTime, Instant endTime) throws IOException { Path streamDir = directoryService.getStreamDir(user.getStreamKey()); - List segments = getSegmentsInRange(streamDir, startTime, endTime); + List segments = streamActionsService.getSegmentsInRange(streamDir, startTime, endTime); if (segments.isEmpty()) { throw new IllegalArgumentException("No stream segments found in the given time range"); } - long firstMs = parseTimestampMs(segments.getFirst()); + long firstMs = streamActionsService.parseTimestampMs(segments.getFirst()); float trimOffset = Math.max(0f, (startTime.toEpochMilli() - firstMs) / 1000f); return new SegmentContext(segments, trimOffset); } @@ -260,26 +255,5 @@ public class MediaService { return job; } - private List getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException { - long startMs = startTime.toEpochMilli(); - long endMs = endTime.toEpochMilli(); - try (Stream files = Files.list(streamDirectory)) { - return files - .filter(p -> p.getFileName().toString().endsWith(".ts")) - .filter(p -> { - long segMs = parseTimestampMs(p); - return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs; - }) - .sorted(Comparator.comparingLong(this::parseTimestampMs)) - .collect(Collectors.toList()); - } - } - - private long parseTimestampMs(Path path) { - String name = path.getFileName().toString().replace(".ts", ""); - long value = Long.parseLong(name); - // nginx hls_fragment_naming system uses seconds; convert to ms - return value < 1_000_000_000_000L ? value * 1000L : value; - } } diff --git a/src/main/java/com/ddf/vodsystem/services/VodService.java b/src/main/java/com/ddf/vodsystem/services/VodService.java index ff51c87..9e19947 100644 --- a/src/main/java/com/ddf/vodsystem/services/VodService.java +++ b/src/main/java/com/ddf/vodsystem/services/VodService.java @@ -201,14 +201,11 @@ public class VodService { 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); - } + thumbnailService.createThumbnail(newVodFile, thumbnailFile, 0.0f) + .exceptionally(ex -> { + logger.error("Thumbnail job for user {} failed: {}", user.getId(), ex.toString()); + return null; + }); vodMetadata.setTitle(title); vodMetadata.setDescription(description); diff --git a/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java b/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java index e3478d5..c0f00b6 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java +++ b/src/main/java/com/ddf/vodsystem/services/media/CommandRunner.java @@ -2,6 +2,7 @@ package com.ddf.vodsystem.services.media; import com.ddf.vodsystem.dto.CommandOutput; import com.ddf.vodsystem.dto.ProgressTracker; +import org.springframework.stereotype.Component; import java.io.BufferedReader; import java.io.IOException; @@ -11,10 +12,11 @@ import java.util.function.Consumer; import java.util.regex.Matcher; import java.util.regex.Pattern; +@Component public class CommandRunner { private static final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); - public static CommandOutput run(List command, Consumer onOutput) throws IOException, InterruptedException { + public CommandOutput run(List command, Consumer onOutput) throws IOException, InterruptedException { ProcessBuilder processBuilder = new ProcessBuilder(command); processBuilder.redirectErrorStream(true); @@ -41,19 +43,15 @@ public class CommandRunner { return commandOutput; } - public static CommandOutput run(List command) throws IOException, InterruptedException { + public CommandOutput run(List command) throws IOException, InterruptedException { return run(command, null); } - public static void setProgress(String line, ProgressTracker progress, float length) { + public void setProgress(String line, ProgressTracker progress, float length) { Matcher matcher = timePattern.matcher(line); if (matcher.find()) { float timeInMs = Float.parseFloat(matcher.group(1)) / 1000000f; progress.setProgress(timeInMs / length); } } - - private CommandRunner() { - // Private constructor to prevent instantiation - } } \ No newline at end of file diff --git a/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java index 883bf3c..b1299c0 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/CompressionService.java @@ -13,42 +13,45 @@ import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @Service public class CompressionService { private static final Logger logger = LoggerFactory.getLogger(CompressionService.class); + private final CommandRunner commandRunner; 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 CompressionService(CommandRunner commandRunner) { + this.commandRunner = commandRunner; + } @Async("ffmpegTaskExecutor") public CompletableFuture compress(Path inputFile, Path outputFile, ClipOptions clipOptions, ProgressTracker progress - ) throws IOException, InterruptedException { + ) { logger.info("Compressing video from {} to {}", inputFile.toAbsolutePath(), outputFile.toAbsolutePath()); - List command = buildCommand( - inputFile, - outputFile, - clipOptions - ); - CommandOutput result = CommandRunner.run(command, line -> CommandRunner.setProgress(line, progress, clipOptions.getDuration())); - progress.markComplete(); + try { + List command = buildCommand( + inputFile, + outputFile, + clipOptions + ); + CommandOutput result = commandRunner.run(command, line -> commandRunner.setProgress(line, progress, clipOptions.getDuration())); + progress.markComplete(); - return CompletableFuture.completedFuture(result); - } - - 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); + return CompletableFuture.completedFuture(result); + } catch (IOException e) { + logger.error("IO error on compress call: {}", e.toString()); + return CompletableFuture.failedFuture(e); + } catch (InterruptedException e) { + logger.error("Thread error on compress call: {}", e.toString()); + Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture(e); } } @@ -75,7 +78,7 @@ public class CompressionService { return command; } - private List buildBitrate(Float length, Float fileSize) { + private List buildBitrate(Float length, Long fileSize) { List command = new ArrayList<>(); float bitrate = ((fileSize * 8) / length) * BITRATE_MULTIPLIER; @@ -112,7 +115,7 @@ public class CompressionService { return command; } - private List buildCommand(Path inputFile, Path outputFile, ClipOptions clipOptions) { + protected List buildCommand(Path inputFile, Path outputFile, ClipOptions clipOptions) { List command = new ArrayList<>(); command.add("ffmpeg"); command.add("-progress"); diff --git a/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java b/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java index 9bf26b0..08bf15b 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/MetadataService.java @@ -14,16 +14,20 @@ import java.io.IOException; import java.nio.file.Path; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.Future; @Service public class MetadataService { private static final Logger logger = LoggerFactory.getLogger(MetadataService.class); + public final CommandRunner commandRunner; private static final String DURATION = "duration"; + public MetadataService(CommandRunner commandRunner) { + this.commandRunner = commandRunner; + } + @Async("ffmpegTaskExecutor") - public Future getVideoMetadata(Path file) { + public CompletableFuture getVideoMetadata(Path file) { logger.info("Getting metadata for file {}", file); List command = List.of( @@ -39,7 +43,7 @@ public class MetadataService { StringBuilder outputBuilder = new StringBuilder(); try { - CommandOutput output = CommandRunner.run(command); + CommandOutput output = commandRunner.run(command); for (String line : output.getOutput()) { outputBuilder.append(line); @@ -47,9 +51,11 @@ public class MetadataService { JsonNode node = mapper.readTree(outputBuilder.toString()); return CompletableFuture.completedFuture(parseVideoMetadata(node)); - } catch (IOException | InterruptedException e) { + } catch (IOException e) { + return CompletableFuture.failedFuture(e); + } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new FFMPEGException("Error while getting video metadata: " + e); + return CompletableFuture.failedFuture(e); } } @@ -58,13 +64,12 @@ public class MetadataService { metadata.setStartPoint(0f); JsonNode streamNode = extractStreamNode(node); + JsonNode formatNode = extractFormatNode(node); - metadata.setDuration(extractDuration(streamNode)); + metadata.setDuration(extractDuration(streamNode, formatNode)); metadata.setWidth(getWidth(streamNode)); metadata.setHeight(getHeight(streamNode)); metadata.setFps(extractFps(streamNode)); - - JsonNode formatNode = extractFormatNode(node); metadata.setFileSize(extractFileSize(formatNode)); extractEndPointFromFormat(metadata, formatNode); @@ -79,11 +84,15 @@ public class MetadataService { return streamNode; } - private Float extractDuration(JsonNode streamNode) { + private Float extractDuration(JsonNode streamNode, JsonNode formatNode) { if (streamNode.has(DURATION)) { return Float.valueOf(streamNode.get(DURATION).asText()); } + if (formatNode.has(DURATION)) { + return Float.valueOf(formatNode.get(DURATION).asText()); + } + throw new FFMPEGException("ffprobe duration missing"); } @@ -108,9 +117,9 @@ public class MetadataService { return (formatNode != null && !formatNode.isMissingNode()) ? formatNode : null; } - private Float extractFileSize(JsonNode formatNode) { + private Long extractFileSize(JsonNode formatNode) { if (formatNode != null && formatNode.has("size")) { - return Float.parseFloat(formatNode.get("size").asText()); + return Long.parseLong(formatNode.get("size").asText()); } throw new FFMPEGException("ffprobe file size missing"); diff --git a/src/main/java/com/ddf/vodsystem/services/media/RemuxService.java b/src/main/java/com/ddf/vodsystem/services/media/RemuxService.java index 86c4c2d..38ac85c 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/RemuxService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/RemuxService.java @@ -2,6 +2,8 @@ package com.ddf.vodsystem.services.media; import com.ddf.vodsystem.dto.CommandOutput; import com.ddf.vodsystem.dto.ProgressTracker; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -9,38 +11,43 @@ import java.io.File; import java.io.IOException; import java.util.List; import java.util.concurrent.CompletableFuture; -import java.util.regex.Matcher; -import java.util.regex.Pattern; @Service public class RemuxService { - private final Pattern timePattern = Pattern.compile("out_time_ms=(\\d+)"); + private static final Logger logger = LoggerFactory.getLogger(RemuxService.class); + private final CommandRunner commandRunner; + + public RemuxService(CommandRunner commandRunner) { + this.commandRunner = commandRunner; + } @Async("ffmpegTaskExecutor") public CompletableFuture remux(File inputFile, File outputFile, ProgressTracker remuxProgress, float length - ) throws IOException, InterruptedException { - List command = List.of( - "ffmpeg", - "-progress", "pipe:1", - "-y", - "-i", inputFile.getAbsolutePath(), - "-c:v", "h264", - "-c:a", "aac", - "-f", "mp4", - outputFile.getAbsolutePath() - ); + ) { + try { + List command = List.of( + "ffmpeg", + "-progress", "pipe:1", + "-y", + "-i", inputFile.getAbsolutePath(), + "-c:v", "h264", + "-c:a", "aac", + "-f", "mp4", + outputFile.getAbsolutePath() + ); - return CompletableFuture.completedFuture(CommandRunner.run(command, line -> setProgress(line, remuxProgress, length))); - } - - 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); + return CompletableFuture.completedFuture(commandRunner.run(command, line -> + commandRunner.setProgress(line, remuxProgress, length))); + } catch (IOException e) { + logger.error("IO error on remux call: {}", e.toString()); + return CompletableFuture.failedFuture(e); + } catch (InterruptedException e) { + logger.error("Thread error on remux call: {}", e.toString()); + Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture(e); } } } 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 201cc22..532b55a 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/StreamActionsService.java @@ -1,15 +1,21 @@ package com.ddf.vodsystem.services.media; +import com.ddf.vodsystem.dto.CommandOutput; import com.ddf.vodsystem.dto.ProgressTracker; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +import java.time.Instant; +import java.util.Comparator; import java.util.List; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +import java.util.stream.Stream; /** * Service responsible for performing stream-related media actions, such as @@ -23,6 +29,13 @@ import java.util.stream.Collectors; @Service public class StreamActionsService { private static final Logger logger = LoggerFactory.getLogger(StreamActionsService.class); + private final CommandRunner commandRunner; + + private static final int HLS_SEGMENT_LENGTH = 3; + + public StreamActionsService(CommandRunner commandRunner) { + this.commandRunner = commandRunner; + } /** * Concatenates a list of video segments and extracts a trimmed section @@ -42,12 +55,11 @@ public class StreamActionsService { * to extract * @param outputFile the destination file the trimmed section will be * written to; overwritten if it already exists - * @return a {@link CompletableFuture} that completes with - * {@code outputFile} on success, or completes exceptionally on - * failure + * @return a {@link CompletableFuture} that completes with the ffmpeg + * command output on success, or completes exceptionally on failure */ @Async("ffmpegTaskExecutor") - public CompletableFuture saveSection( + public CompletableFuture saveSection( List segments, float trimOffset, float duration, @@ -59,6 +71,10 @@ public class StreamActionsService { .map(p -> p.toAbsolutePath().toString()) .collect(Collectors.joining("|")); + if (concatInput.isEmpty()) { + throw new IllegalStateException("Must have at least one segment"); + } + List command = List.of( "ffmpeg", "-y", @@ -71,13 +87,54 @@ public class StreamActionsService { logger.info("Saving section with ({} segments) to '{}'", segments.size(), outputFile); - CommandRunner.run(command, line -> CommandRunner.setProgress(line, progressTracker, duration)); - progressTracker.markComplete(); + CommandOutput output = commandRunner.run(command, line -> commandRunner.setProgress(line, progressTracker, duration)); - return CompletableFuture.completedFuture(outputFile); - } catch (Exception e) { - logger.error("Failed to save section to '{}'", outputFile, e); + progressTracker.markComplete(); + return CompletableFuture.completedFuture(output); + } catch (IOException e) { + logger.error("IO error on saveSection call: {}", e.toString()); + return CompletableFuture.failedFuture(e); + } catch (InterruptedException e) { + logger.error("Thread error on saveSection call: {}", e.toString()); + Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture(e); + } catch (RuntimeException e) { + logger.error("Failed to save section to '{}': {}", outputFile, e.toString()); return CompletableFuture.failedFuture(e); } } -} \ No newline at end of file + + public List getSegmentsInRange(Path streamDirectory, Instant startTime, Instant endTime) throws IOException { + long startMs = startTime.toEpochMilli(); + long endMs = endTime.toEpochMilli(); + + if (endTime.toEpochMilli() <= startTime.toEpochMilli()) { + throw new IllegalArgumentException("Start time must be before end time"); + } + + try (Stream files = Files.list(streamDirectory)) { + return files + .filter(p -> p.getFileName().toString().endsWith(".ts")) + .filter(Files::isRegularFile) + .filter(p -> { + long segMs = parseTimestampMs(p); + return segMs < endMs && (segMs + HLS_SEGMENT_LENGTH * 1000) > startMs; + }) + .sorted(Comparator.comparingLong(this::parseTimestampMs)) + .collect(Collectors.toList()); + } + } + + public long parseTimestampMs(Path path) { + String fullName = path.getFileName().toString(); + + if (!fullName.endsWith(".ts")) { + throw new IllegalArgumentException("File must end in ts"); + } + + String name = fullName.substring(0, fullName.length() - 3); + long value = Long.parseLong(name); + // nginx hls_fragment_naming system uses seconds; convert to ms + return value < 1_000_000_000_000L ? value * 1000L : value; + } +} diff --git a/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java b/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java index 7e11508..70fa517 100644 --- a/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java +++ b/src/main/java/com/ddf/vodsystem/services/media/ThumbnailService.java @@ -1,5 +1,6 @@ package com.ddf.vodsystem.services.media; +import com.ddf.vodsystem.dto.CommandOutput; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Async; @@ -8,23 +9,37 @@ import org.springframework.stereotype.Service; import java.io.IOException; import java.nio.file.Path; import java.util.List; +import java.util.concurrent.CompletableFuture; @Service public class ThumbnailService { private static final Logger logger = LoggerFactory.getLogger(ThumbnailService.class); + private final CommandRunner commandRunner; + + public ThumbnailService(CommandRunner commandRunner) { + this.commandRunner = commandRunner; + } @Async("ffmpegTaskExecutor") - public void createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) throws IOException, InterruptedException { + public CompletableFuture createThumbnail(Path inputFile, Path outputFile, Float timeInVideo) { logger.info("Creating thumbnail at {} seconds", timeInVideo); - List command = List.of( - "ffmpeg", - "-ss", timeInVideo.toString(), - "-i", inputFile.toAbsolutePath().toString(), - "-frames:v", "1", - outputFile.toAbsolutePath().toString() - ); + try { + List command = List.of( + "ffmpeg", + "-ss", timeInVideo.toString(), + "-i", inputFile.toAbsolutePath().toString(), + "-frames:v", "1", + outputFile.toAbsolutePath().toString() + ); - CommandRunner.run(command); + CommandOutput output = commandRunner.run(command); + return CompletableFuture.completedFuture(output); + } catch (IOException e) { + return CompletableFuture.failedFuture(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return CompletableFuture.failedFuture(e); + } } } diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index 4f33216..4796b02 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -34,7 +34,7 @@ CREATE TABLE IF NOT EXISTS clips ( height INTEGER NOT NULL, fps FLOAT NOT NULL, duration FLOAT NOT NULL, - file_size FLOAT NOT NULL, + file_size BIGINT NOT NULL, video_path VARCHAR(255) NOT NULL, thumbnail_path VARCHAR(255) NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE @@ -50,7 +50,7 @@ CREATE TABLE IF NOT EXISTS vods ( height INTEGER NOT NULL, fps FLOAT NOT NULL, duration FLOAT NOT NULL, - file_size FLOAT NOT NULL, + file_size BIGINT NOT NULL, video_path VARCHAR(255) NOT NULL, thumbnail_path VARCHAR(255) NOT NULL, FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE diff --git a/src/test/java/com/ddf/vodsystem/services/media/CompressionServiceTest.java b/src/test/java/com/ddf/vodsystem/services/media/CompressionServiceTest.java new file mode 100644 index 0000000..8a77aa9 --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/services/media/CompressionServiceTest.java @@ -0,0 +1,173 @@ +package com.ddf.vodsystem.services.media; + +import com.ddf.vodsystem.dto.ClipOptions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class CompressionServiceTest { + @Mock + private CommandRunner commandRunner; + + private CompressionService compressionService; + + @BeforeEach + void setUp() { + compressionService = new CompressionService(commandRunner); + } + + @Test + void buildCommand_minimalClipOptions_smallCommand() { + Path input = Path.of("/tmp/input.mp4"); + Path output = Path.of("/tmp/output.mp4"); + ClipOptions options = new ClipOptions(); + options.setTitle("hello"); + options.setStartPoint(10.0f); + options.setDuration(30.0f); + + List command = compressionService.buildCommand(input, output, options); + + assertThat(command).containsSubsequence("-ss", "10.0"); + assertThat(command).containsSubsequence("-t", "30.0"); + assertThat(command).containsSubsequence("-i", input.toAbsolutePath().toString()); + + assertThat(command).doesNotContain("-vf"); + assertThat(command).doesNotContain("-b:v"); + + assertThat(command).endsWith(output.toAbsolutePath().toString()); + } + + @Test + void buildCommand_hasFileSize_hasCorrectBitrate() { + Path input = Path.of("/tmp/input.mp4"); + Path output = Path.of("/tmp/output.mp4"); + ClipOptions options = new ClipOptions(); + + options.setTitle("hello"); + options.setStartPoint(10.0f); + options.setDuration(30.0f); + options.setFileSize(10L); + + float maximumBitrateKbps = (options.getFileSize() * 8 * 1000) / options.getDuration(); + + List command = compressionService.buildCommand(input, output, options); + + assertThat(command).contains("-b:v", "-b:a"); + + int bvIndex = command.indexOf("-b:v"); + int baIndex = command.indexOf("-b:a"); + + String videoBitrateStr = command.get(bvIndex + 1); + String audioBitrateStr = command.get(baIndex + 1); + + assertThat(videoBitrateStr).isNotBlank().endsWith("k"); + assertThat(audioBitrateStr).isNotBlank().endsWith("k"); + + // Strip the trailing "k" so we can compare the numbers, not the strings + float videoBitrateKbps = Float.parseFloat(videoBitrateStr.replace("k", "")); + float audioBitrateKbps = Float.parseFloat(audioBitrateStr.replace("k", "")); + + float totalBitrateKbps = videoBitrateKbps + audioBitrateKbps; + + assertThat(totalBitrateKbps).isLessThanOrEqualTo(maximumBitrateKbps); + } + + @Test + void buildCommand_hasWidthHeight_hasVideoFilters() { + Path input = Path.of("/tmp/input.mp4"); + Path output = Path.of("/tmp/output.mp4"); + ClipOptions options = new ClipOptions(); + + options.setTitle("hello"); + options.setStartPoint(10.0f); + options.setDuration(30.0f); + options.setWidth(1920); + options.setHeight(1080); + + List command = compressionService.buildCommand(input, output, options); + + assertThat(command).containsSequence("-vf", "scale=1920:1080"); + } + + @Test + void buildCommand_onlyWidth_aspectRatioKept() { + Path input = Path.of("/tmp/input.mp4"); + Path output = Path.of("/tmp/output.mp4"); + ClipOptions options = new ClipOptions(); + + options.setTitle("hello"); + options.setStartPoint(10.0f); + options.setDuration(30.0f); + options.setWidth(1920); + + List command = compressionService.buildCommand(input, output, options); + + assertThat(command).containsSequence("-vf", "scale=1920:-1"); + } + + @Test + void buildCommand_onlyHeight_aspectRatioKept() { + Path input = Path.of("/tmp/input.mp4"); + Path output = Path.of("/tmp/output.mp4"); + ClipOptions options = new ClipOptions(); + + options.setTitle("hello"); + options.setStartPoint(10.0f); + options.setDuration(30.0f); + options.setHeight(1080); + + List command = compressionService.buildCommand(input, output, options); + + assertThat(command).containsSequence("-vf", "scale=-1:1080"); + } + + @Test + void buildCommand_onlyHasFps_hasNoScaleFilter() { + Path input = Path.of("/tmp/input.mp4"); + Path output = Path.of("/tmp/output.mp4"); + ClipOptions options = new ClipOptions(); + + options.setTitle("hello"); + options.setStartPoint(10.0f); + options.setDuration(30.0f); + options.setFps(60f); + + List command = compressionService.buildCommand(input, output, options); + + assertThat(command).containsSequence("-vf", "fps=60.0"); + } + + @Test + void buildCommand_noInputFile_throwsNullPointer() { + Path output = Path.of("/tmp/output.mp4"); + ClipOptions options = new ClipOptions(); + + options.setTitle("hello"); + options.setStartPoint(10.0f); + options.setDuration(30.0f); + + assertThrows(NullPointerException.class, () -> + compressionService.buildCommand(null, output, options) + ); + } + + @Test + void buildCommand_noOutputFile_throwsNullPointer() { + Path input = Path.of("/tmp/output.mp4"); + ClipOptions options = new ClipOptions(); + + options.setTitle("hello"); + options.setStartPoint(10.0f); + options.setDuration(30.0f); + + assertThrows(NullPointerException.class, () -> + compressionService.buildCommand(input, null, options) + ); + } +} \ No newline at end of file diff --git a/src/test/java/com/ddf/vodsystem/services/media/MetadataServiceTest.java b/src/test/java/com/ddf/vodsystem/services/media/MetadataServiceTest.java new file mode 100644 index 0000000..0e4d241 --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/services/media/MetadataServiceTest.java @@ -0,0 +1,312 @@ +package com.ddf.vodsystem.services.media; + +import com.ddf.vodsystem.dto.ClipOptions; +import com.ddf.vodsystem.dto.CommandOutput; +import com.ddf.vodsystem.exceptions.FFMPEGException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class MetadataServiceTest { + + @Mock + private CommandRunner commandRunner; + + private MetadataService metadataService; + + private static final Path INPUT = Path.of("/tmp/input.mp4"); + + @BeforeEach + void setUp() { + metadataService = new MetadataService(commandRunner); + Thread.interrupted(); // clear any flag leaked from a previous test + } + + @AfterEach + void clearInterruptFlag() { + Thread.interrupted(); + } + + /** Wraps a raw ffprobe JSON payload into a successful CommandOutput. */ + private CommandOutput ffprobeOutput(String json) { + CommandOutput output = new CommandOutput(); + output.addLine(json); + return output; + } + + private ClipOptions runMetadata() throws Exception { + return metadataService.getVideoMetadata(INPUT).get(); + } + private void assertMetadataFailsWith(Class exceptionType, String messageFragment) { + assertThatThrownBy(() -> metadataService.getVideoMetadata(INPUT).get()) + .satisfies(thrown -> { + Throwable cause = thrown instanceof ExecutionException ? thrown.getCause() : thrown; + assertThat(cause).isInstanceOf(exceptionType); + assertThat(cause.getMessage()).contains(messageFragment); + }); + } + + private void assertMetadataFailsWith(String messageFragment) { + assertMetadataFailsWith(FFMPEGException.class, messageFragment); + } + + // --------------------------------------------------------------- + // Happy path / parsing behavior + // --------------------------------------------------------------- + + @Test + void getVideoMetadata_validJson_parsesAllFields() throws Exception { + String json = """ + { + "streams": [ + { + "width": 1920, + "height": 1080, + "r_frame_rate": "30/1", + "duration": "60.000000" + } + ], + "format": { + "size": "10485760", + "duration": "60.000000" + } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + ClipOptions options = runMetadata(); + + assertThat(options.getStartPoint()).isEqualTo(0f); + assertThat(options.getDuration()).isEqualTo(60.0f); + assertThat(options.getWidth()).isEqualTo(1920); + assertThat(options.getHeight()).isEqualTo(1080); + assertThat(options.getFps()).isEqualTo(30.0f); + assertThat(options.getFileSize()).isEqualTo(10485760L); + } + + @Test + void getVideoMetadata_ffprobeCommandIsCorrect() throws Exception { + String json = """ + { + "streams": [ { "width": 1920, "height": 1080, "r_frame_rate": "30/1", "duration": "60.0" } ], + "format": { "size": "1000", "duration": "60.0" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + runMetadata(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + Mockito.verify(commandRunner).run(captor.capture()); + List command = captor.getValue(); + + assertThat(command).startsWith("ffprobe"); + assertThat(command).containsSequence("-print_format", "json"); + assertThat(command).containsSequence("-select_streams", "v:0"); + assertThat(command).containsSequence("-i", INPUT.toAbsolutePath().toString()); + } + + @Test + void getVideoMetadata_fractionalFrameRate_computesFps() throws Exception { + String json = """ + { + "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "30000/1001", "duration": "10.0" } ], + "format": { "size": "500", "duration": "10.0" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + ClipOptions options = runMetadata(); + + assertThat(options.getFps()).isCloseTo(29.97f, org.assertj.core.data.Offset.offset(0.01f)); + } + + @Test + void getVideoMetadata_integerFrameRate_parsesFps() throws Exception { + String json = """ + { + "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25", "duration": "10.0" } ], + "format": { "size": "500", "duration": "10.0" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + ClipOptions options = runMetadata(); + + assertThat(options.getFps()).isEqualTo(25.0f); + } + + @Test + void getVideoMetadata_zeroDenominatorFrameRate_fpsIsNull() throws Exception { + String json = """ + { + "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "0/0", "duration": "10.0" } ], + "format": { "size": "500", "duration": "10.0" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + ClipOptions options = runMetadata(); + + assertThat(options.getFps()).isNull(); + } + + @Test + void getVideoMetadata_missingFrameRate_fpsIsNull() throws Exception { + String json = """ + { + "streams": [ { "width": 1280, "height": 720, "duration": "10.0" } ], + "format": { "size": "500", "duration": "10.0" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + ClipOptions options = runMetadata(); + + assertThat(options.getFps()).isNull(); + } + + @Test + void getVideoMetadata_missingStreams_throwsFFMPEGException() throws Exception { + String json = """ + { + "streams": [], + "format": { "size": "500", "duration": "10.0" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + assertMetadataFailsWith("streams missing"); + } + + @Test + void getVideoMetadata_missingWidth_throwsFFMPEGException() throws Exception { + String json = """ + { + "streams": [ { "height": 720, "r_frame_rate": "25", "duration": "10.0" } ], + "format": { "size": "500", "duration": "10.0" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + assertMetadataFailsWith("width missing"); + } + + @Test + void getVideoMetadata_missingHeight_throwsFFMPEGException() throws Exception { + String json = """ + { + "streams": [ { "width": 1280, "r_frame_rate": "25", "duration": "10.0" } ], + "format": { "size": "500", "duration": "10.0" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + assertMetadataFailsWith("height missing"); + } + + @Test + void getVideoMetadata_missingFileSize_throwsFFMPEGException() throws Exception { + String json = """ + { + "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25", "duration": "10.0" } ], + "format": { "duration": "10.0" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + assertMetadataFailsWith("file size missing"); + } + + @Test + void getVideoMetadata_durationMissingEverywhere_throwsFFMPEGException() throws Exception { + String json = """ + { + "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25" } ], + "format": { "size": "500" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + assertMetadataFailsWith("duration missing"); + } + + @Test + void getVideoMetadata_commandThrowsIOException_completesExceptionally() throws Exception { + when(commandRunner.run(anyList())).thenThrow(new IOException("ffprobe boom")); + + assertMetadataFailsWith(IOException.class, "ffprobe boom"); + } + + @Test + void getVideoMetadata_streamMissingDuration_fallsBackToFormatDuration() throws Exception { + // format=duration is requested from ffprobe specifically as a fallback source, + // used when the stream itself lacks "duration". + String json = """ + { + "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25" } ], + "format": { "size": "500", "duration": "45.5" } + } + """; + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + ClipOptions options = runMetadata(); + + assertThat(options.getDuration()).isEqualTo(45.5f); + } + + @Test + void getVideoMetadata_largeFileSize_preservesExactByteCount() throws Exception { + // fileSize is a Long specifically so byte counts for large files (any real + // video is well past the 2^24 float-precision boundary) survive exactly. + long exactBytes = 16_777_217L; // smallest integer a 32-bit float cannot represent exactly + String json = """ + { + "streams": [ { "width": 1280, "height": 720, "r_frame_rate": "25", "duration": "10.0" } ], + "format": { "size": "%d", "duration": "10.0" } + } + """.formatted(exactBytes); + when(commandRunner.run(anyList())).thenReturn(ffprobeOutput(json)); + + ClipOptions options = runMetadata(); + + assertThat(options.getFileSize()).isEqualTo(exactBytes); + } + + @Test + void getVideoMetadata_plainIOException_doesNotSetThreadInterruptFlag() throws Exception { + when(commandRunner.run(anyList())).thenThrow(new IOException("ffprobe boom")); + + assertMetadataFailsWith(IOException.class, "ffprobe boom"); + + assertThat(Thread.currentThread().isInterrupted()) + .as("a plain IOException must not leave the calling thread's interrupt flag set") + .isFalse(); + } + + @Test + void getVideoMetadata_interruptedException_setsThreadInterruptFlag() throws Exception { + when(commandRunner.run(anyList())).thenThrow(new InterruptedException("interrupted")); + + assertMetadataFailsWith(InterruptedException.class, "interrupted"); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } +} diff --git a/src/test/java/com/ddf/vodsystem/services/media/RemuxServiceTest.java b/src/test/java/com/ddf/vodsystem/services/media/RemuxServiceTest.java new file mode 100644 index 0000000..78fd62d --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/services/media/RemuxServiceTest.java @@ -0,0 +1,86 @@ +package com.ddf.vodsystem.services.media; + +import com.ddf.vodsystem.dto.CommandOutput; +import com.ddf.vodsystem.dto.ProgressTracker; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RemuxServiceTest { + + @Mock + private CommandRunner commandRunner; + + private RemuxService remuxService; + + @BeforeEach + void setUp() { + remuxService = new RemuxService(commandRunner); + Thread.interrupted(); + } + + @AfterEach + void clearInterruptFlag() { + Thread.interrupted(); + } + + @Test + void remux_success_completesWithCommandOutput() throws Exception { + File input = new File("videos/input.ts"); + File output = new File("videos/output.mp4"); + CommandOutput commandOutput = new CommandOutput(); + when(commandRunner.run(anyList(), any())).thenReturn(commandOutput); + + var future = remuxService.remux(input, output, new ProgressTracker(), 60f); + + assertThat(future.get()).isSameAs(commandOutput); + } + + @Test + void remux_ioFailure_completesExceptionally() throws Exception { + when(commandRunner.run(anyList(), any())).thenThrow(new IOException("ffmpeg failed")); + + var future = remuxService.remux( + new File("videos/input.ts"), + new File("videos/output.mp4"), + new ProgressTracker(), + 60f + ); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(IOException.class); + assertThat(Thread.currentThread().isInterrupted()).isFalse(); + } + + @Test + void remux_interruption_completesExceptionallyAndRestoresInterruptFlag() throws Exception { + when(commandRunner.run(anyList(), any())).thenThrow(new InterruptedException("interrupted")); + + var future = remuxService.remux( + new File("videos/input.ts"), + new File("videos/output.mp4"), + new ProgressTracker(), + 60f + ); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(InterruptedException.class); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } +} diff --git a/src/test/java/com/ddf/vodsystem/services/media/StreamActionsServiceTest.java b/src/test/java/com/ddf/vodsystem/services/media/StreamActionsServiceTest.java new file mode 100644 index 0000000..16baba1 --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/services/media/StreamActionsServiceTest.java @@ -0,0 +1,337 @@ +package com.ddf.vodsystem.services.media; + +import com.ddf.vodsystem.dto.CommandOutput; +import com.ddf.vodsystem.dto.ProgressTracker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class StreamActionsServiceTest { + + @Mock + private CommandRunner commandRunner; + + private StreamActionsService service; + + @TempDir + Path tempDir; + + @BeforeEach + void setUp() { + service = new StreamActionsService(commandRunner); + } + + private void createSegment(String name) throws IOException { + Files.createFile(tempDir.resolve(name)); + } + + // --------------------------------------------------------------- + // getSegmentsInRange: missing / invalid directories + // --------------------------------------------------------------- + + @Test + void getSegmentsInRange_directoryDoesNotExist_throwsIOException() { + Path missing = tempDir.resolve("does-not-exist"); + + assertThatThrownBy(() -> service.getSegmentsInRange(missing, Instant.EPOCH, Instant.now())) + .isInstanceOf(NoSuchFileException.class); + } + + @Test + void getSegmentsInRange_pathIsAFileNotADirectory_throwsIOException() throws IOException { + Path file = tempDir.resolve("not-a-directory.txt"); + Files.createFile(file); + + assertThatThrownBy(() -> service.getSegmentsInRange(file, Instant.EPOCH, Instant.now())) + .isInstanceOf(IOException.class); + } + + @Test + void getSegmentsInRange_emptyDirectory_returnsEmptyList() throws IOException { + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(100_000)); + + assertThat(result).isEmpty(); + } + + // --------------------------------------------------------------- + // getSegmentsInRange: filename filtering + // --------------------------------------------------------------- + + @Test + void getSegmentsInRange_ignoresFilesNotEndingInTs() throws IOException { + createSegment("1.ts"); + createSegment("playlist.m3u8"); + createSegment("1.ts.tmp"); + createSegment("readme.txt"); + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000)); + + assertThat(result).extracting(p -> p.getFileName().toString()).containsExactly("1.ts"); + } + + @Test + void getSegmentsInRange_directoryNamedLikeASegment_isNotIncluded() throws IOException { + Files.createDirectory(tempDir.resolve("5.ts")); + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000)); + + assertThat(result).isEmpty(); + } + + @Test + void getSegmentsInRange_malformedNonNumericFilename_throwsNumberFormatException() throws IOException { + // A real HLS fragment name is always numeric. A corrupted or partially-written + // file (e.g. "index.ts") crashes the whole listing instead of just being skipped. + createSegment("index.ts"); + + assertThatThrownBy(() -> + service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000))) + .isInstanceOf(NumberFormatException.class); + } + + @Test + void getSegmentsInRange_emptyFilenameBeforeExtension_throwsNumberFormatException() throws IOException { + createSegment(".ts"); + + assertThatThrownBy(() -> + service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(10_000))) + .isInstanceOf(NumberFormatException.class); + } + + // --------------------------------------------------------------- + // getSegmentsInRange: range-boundary arithmetic + // --------------------------------------------------------------- + + @Test + void getSegmentsInRange_segmentEndsExactlyAtRangeStart_isExcluded() throws IOException { + // "0.ts" spans [0, 3000)ms. A request starting exactly at 3000ms should not + // pull in a segment that ended the instant before. + createSegment("0.ts"); + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(3000), Instant.ofEpochMilli(10_000)); + + assertThat(result).isEmpty(); + } + + @Test + void getSegmentsInRange_segmentOverlapsRangeStartByOneMs_isIncluded() throws IOException { + createSegment("0.ts"); // spans [0, 3000) + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(2999), Instant.ofEpochMilli(10_000)); + + assertThat(result).hasSize(1); + } + + @Test + void getSegmentsInRange_segmentStartsExactlyAtRangeEnd_isExcluded() throws IOException { + createSegment("5.ts"); // starts at 5000ms + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5000)); + + assertThat(result).isEmpty(); + } + + @Test + void getSegmentsInRange_segmentStartsOneMsBeforeRangeEnd_isIncluded() throws IOException { + createSegment("5.ts"); // starts at 5000ms + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(5001)); + + assertThat(result).hasSize(1); + } + + @Test + void getSegmentsInRange_startAfterEnd_throwsIllegalArgument() throws IOException { + createSegment("10.ts"); + + assertThatThrownBy(() -> + service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(20_000), Instant.ofEpochMilli(10_000))) + .isInstanceOf(IllegalArgumentException.class); + + } + + @Test + void getSegmentsInRange_segmentsWhollyOutsideRange_areExcluded() throws IOException { + createSegment("0.ts"); // [0, 3000) + createSegment("100.ts"); // [100000, 103000) + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(50_000), Instant.ofEpochMilli(60_000)); + + assertThat(result).isEmpty(); + } + + @Test + void getSegmentsInRange_segmentsOutOfOrder_returnedInOrder() throws IOException { + createSegment("20.ts"); + createSegment("10.ts"); + createSegment("0.ts"); + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(25_000)); + + assertThat(result) + .extracting(p -> p.getFileName().toString()) + .containsExactly("0.ts", "10.ts", "20.ts"); + } + + @Test + void getSegmentsInRange_mixOfOverlappingAndNonOverlappingSegments_returnsOnlyOverlapping() throws IOException { + createSegment("0.ts"); // [0, 3000) + createSegment("3.ts"); // [3000, 6000) + createSegment("6.ts"); // [6000, 9000) + createSegment("9.ts"); // [9000, 12000) + + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(4000), Instant.ofEpochMilli(8000)); + + assertThat(result).extracting(p -> p.getFileName().toString()) + .containsExactly("3.ts", "6.ts"); + } + + @Test + void getSegmentsInRange_resultsAreSortedByTimestampRegardlessOfDirectoryOrder() throws IOException { + createSegment("30.ts"); + createSegment("10.ts"); + createSegment("20.ts"); + + List result = service.getSegmentsInRange(tempDir, Instant.ofEpochMilli(0), Instant.ofEpochMilli(100_000)); + + assertThat(result).extracting(p -> p.getFileName().toString()) + .containsExactly("10.ts", "20.ts", "30.ts"); + } + + // --------------------------------------------------------------- + // parseTimestampMs: unit-based conversion + // --------------------------------------------------------------- + + @Test + void parseTimestampMs_secondsBasedName_convertsToMillis() { + long result = service.parseTimestampMs(Path.of("42.ts")); + + assertThat(result).isEqualTo(42_000L); + } + + @Test + void parseTimestampMs_millisecondBasedName_usedAsIs() { + long result = service.parseTimestampMs(Path.of("1700000000000.ts")); + + assertThat(result).isEqualTo(1_700_000_000_000L); + } + + @Test + void parseTimestampMs_valueExactlyAtUnitThreshold_treatedAsMillis() { + long result = service.parseTimestampMs(Path.of("1000000000000.ts")); + + assertThat(result).isEqualTo(1_000_000_000_000L); + } + + @Test + void parseTimestampMs_valueOneBelowUnitThreshold_treatedAsSecondsAndMultiplied() { + long result = service.parseTimestampMs(Path.of("999999999999.ts")); + + assertThat(result).isEqualTo(999_999_999_999_000L); + } + + @Test + void parseTimestampMs_nonNumericName_throwsNumberFormatException() { + assertThatThrownBy(() -> service.parseTimestampMs(Path.of("abc.ts"))) + .isInstanceOf(NumberFormatException.class); + } + + @Test + void parseTimestampMs_filenameContainsExtraTsSubstring_throwsIllegalArgumentException() { + assertThatThrownBy(() -> service.parseTimestampMs(Path.of("1.ts5.ts"))) + .isInstanceOf(IllegalArgumentException.class); + } + + // --------------------------------------------------------------- + // saveSection + // --------------------------------------------------------------- + + @Test + void saveSection_success_buildsExpectedCommandAndCompletesWithCommandOutput() throws Exception { + Path segmentA = Path.of("/tmp/streams/a.ts"); + Path segmentB = Path.of("/tmp/streams/b.ts"); + Path output = Path.of("/tmp/out/clip.mp4"); + ProgressTracker progress = new ProgressTracker(); + CommandOutput commandOutput = new CommandOutput(); + + when(commandRunner.run(anyList(), any())).thenReturn(commandOutput); + + var future = service.saveSection(List.of(segmentA, segmentB), 5.0f, 12.0f, output, progress); + + assertThat(future.get()).isSameAs(commandOutput); + assertThat(progress.isComplete()).isTrue(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + Mockito.verify(commandRunner).run(captor.capture(), any()); + List command = captor.getValue(); + + assertThat(command).containsSequence("-i", + "concat:" + segmentA.toAbsolutePath() + "|" + segmentB.toAbsolutePath()); + assertThat(command).containsSequence("-ss", "5.0"); + assertThat(command).containsSequence("-t", "12.0"); + assertThat(command).containsSequence("-c", "copy"); + assertThat(command).endsWith(output.toAbsolutePath().toString()); + } + + @Test + void saveSection_commandRunnerThrowsIOException_completesExceptionallyWithoutMarkingProgressComplete() throws Exception { + Path output = Path.of("/tmp/out/clip.mp4"); + ProgressTracker progress = new ProgressTracker(); + + when(commandRunner.run(anyList(), any())).thenThrow(new IOException("ffmpeg boom")); + + var future = service.saveSection(List.of(Path.of("/tmp/a.ts")), 0f, 10f, output, progress); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(IOException.class); + assertThat(progress.isComplete()).isFalse(); + } + + @Test + void saveSection_emptySegmentList_completesExceptionallyWithIllegalStateException() { + Path output = Path.of("/tmp/out/clip.mp4"); + ProgressTracker progress = new ProgressTracker(); + + var future = service.saveSection(List.of(), 0f, 10f, output, progress); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(IllegalStateException.class); + assertThat(progress.isComplete()).isFalse(); + } + + @Test + void saveSection_nullOutputFile_completesExceptionally() { + ProgressTracker progress = new ProgressTracker(); + + var future = service.saveSection(List.of(Path.of("/tmp/a.ts")), 0f, 10f, null, progress); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(NullPointerException.class); + assertThat(progress.isComplete()).isFalse(); + } +} diff --git a/src/test/java/com/ddf/vodsystem/services/media/ThumbnailServiceTest.java b/src/test/java/com/ddf/vodsystem/services/media/ThumbnailServiceTest.java new file mode 100644 index 0000000..3a0f6fc --- /dev/null +++ b/src/test/java/com/ddf/vodsystem/services/media/ThumbnailServiceTest.java @@ -0,0 +1,82 @@ +package com.ddf.vodsystem.services.media; + +import com.ddf.vodsystem.dto.CommandOutput; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.nio.file.Path; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ThumbnailServiceTest { + + @Mock + private CommandRunner commandRunner; + + private ThumbnailService thumbnailService; + + @BeforeEach + void setUp() { + thumbnailService = new ThumbnailService(commandRunner); + Thread.interrupted(); + } + + @AfterEach + void clearInterruptFlag() { + Thread.interrupted(); + } + + @Test + void createThumbnail_success_completesWithCommandOutput() throws Exception { + Path input = Path.of("videos/input.mp4"); + Path output = Path.of("thumbnails/output.jpg"); + CommandOutput commandOutput = new CommandOutput(); + when(commandRunner.run(anyList())).thenReturn(commandOutput); + + var future = thumbnailService.createThumbnail(input, output, 12.5f); + + assertThat(future.get()).isSameAs(commandOutput); + } + + @Test + void createThumbnail_ioFailure_completesExceptionally() throws Exception { + when(commandRunner.run(anyList())).thenThrow(new IOException("ffmpeg failed")); + + var future = thumbnailService.createThumbnail( + Path.of("videos/input.mp4"), + Path.of("thumbnails/output.jpg"), + 12.5f + ); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(IOException.class); + assertThat(Thread.currentThread().isInterrupted()).isFalse(); + } + + @Test + void createThumbnail_interruption_completesExceptionallyAndRestoresInterruptFlag() throws Exception { + when(commandRunner.run(anyList())).thenThrow(new InterruptedException("interrupted")); + + var future = thumbnailService.createThumbnail( + Path.of("videos/input.mp4"), + Path.of("thumbnails/output.jpg"), + 12.5f + ); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(InterruptedException.class); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + } +}