Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
.gradle/
.idea/
build/
src/test/
src/main/resources/images/
.gitattributes
.gitignore
Expand Down
12 changes: 7 additions & 5 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
FROM eclipse-temurin:25-alpine AS builder

WORKDIR /app

# bump: ffmpeg /static-ffmpeg:([\d.]+)/ docker:mwader/static-ffmpeg|~7.0
COPY --from=mwader/static-ffmpeg:7.0.2 /ff* /usr/bin/

COPY . .
RUN --mount=type=cache,target=/root/.gradle ./gradlew installDist
RUN --mount=type=cache,target=/root/.gradle ./gradlew check installDist --no-daemon

# bump: alpine /FROM alpine:([\d.]+)/ docker:alpine|^3
# bump: alpine link "Release notes" https://alpinelinux.org/posts/Alpine-$LATEST-released.html
FROM alpine:3.22.1 AS bot
FROM alpine:3.22.1

# bump: ffmpeg /static-ffmpeg:([\d.]+)/ docker:mwader/static-ffmpeg|~7.0
COPY --from=mwader/static-ffmpeg:7.0.2 /ffmpeg /usr/bin/
COPY --from=builder /usr/bin/ff* /usr/bin/
COPY --from=builder /app/build/install/Stickerify/ .

ENV CONCURRENT_PROCESSES=5
ENV FFMPEG_PATH=/usr/bin/ffmpeg
CMD ["./bin/Stickerify"]
1 change: 0 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ repositories {
dependencies {
compileOnly(libs.jspecify)
implementation(libs.gson)
implementation(libs.jave)
implementation(libs.logback.classic)
implementation(libs.telegram.bot.api)
implementation(libs.tika)
Expand Down
1 change: 0 additions & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
[libraries]
gson = "com.google.code.gson:gson:2.13.2"
hamcrest = "org.hamcrest:hamcrest:3.0"
jave = "ws.schild:jave-core:3.5.0"
jspecify = "org.jspecify:jspecify:1.0.0"
junit-jupiter = "org.junit.jupiter:junit-jupiter:5.13.4"
junit-platform = "org.junit.platform:junit-platform-launcher:1.13.4"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import static java.util.concurrent.Executors.newThreadPerTaskExecutor;

import com.github.stickerifier.stickerify.exception.BaseException;
import com.github.stickerifier.stickerify.exception.CorruptedVideoException;
import com.github.stickerifier.stickerify.exception.CorruptedFileException;
import com.github.stickerifier.stickerify.exception.FileOperationException;
import com.github.stickerifier.stickerify.exception.MediaException;
import com.github.stickerifier.stickerify.exception.TelegramApiException;
Expand Down Expand Up @@ -175,7 +175,7 @@ private void processFailure(TelegramRequest request, BaseException e, String fil
processTelegramFailure(request.getDescription(), telegramException, false);
}

if (e instanceof CorruptedVideoException) {
if (e instanceof CorruptedFileException) {
LOGGER.atInfo().log("Unable to reply to the {}: the file is corrupted", request.getDescription());
answerText(CORRUPTED, request);
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.github.stickerifier.stickerify.exception;

public class CorruptedFileException extends MediaException {
public CorruptedFileException(String message, Throwable cause) {
super(message, cause);
}
}

This file was deleted.

157 changes: 112 additions & 45 deletions src/main/java/com/github/stickerifier/stickerify/media/MediaHelper.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,11 @@
import static com.github.stickerifier.stickerify.media.MediaConstraints.VP9_CODEC;
import static java.nio.charset.StandardCharsets.UTF_8;

import com.github.stickerifier.stickerify.exception.CorruptedVideoException;
import com.github.stickerifier.stickerify.exception.CorruptedFileException;
import com.github.stickerifier.stickerify.exception.FileOperationException;
import com.github.stickerifier.stickerify.exception.MediaException;
import com.github.stickerifier.stickerify.exception.ProcessException;
import com.github.stickerifier.stickerify.process.OsConstants;
import com.github.stickerifier.stickerify.process.PathLocator;
import com.github.stickerifier.stickerify.process.ProcessHelper;
import com.google.gson.Gson;
import com.google.gson.JsonSyntaxException;
Expand All @@ -26,14 +25,12 @@
import org.jspecify.annotations.Nullable;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import ws.schild.jave.EncoderException;
import ws.schild.jave.MultimediaObject;
import ws.schild.jave.info.MultimediaInfo;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
import java.util.Set;
import java.util.zip.GZIPInputStream;

Expand Down Expand Up @@ -128,34 +125,105 @@ private static boolean isSupportedVideo(String mimeType) {
* @param file the file to check
* @return {@code true} if the file is compliant
* @throws FileOperationException if an error occurred retrieving the size of the file
* @throws InterruptedException if the current thread is interrupted while retrieving file info
*/
private static boolean isVideoCompliant(File file) throws FileOperationException, CorruptedVideoException {
private static boolean isVideoCompliant(File file) throws FileOperationException, CorruptedFileException, InterruptedException {
var mediaInfo = retrieveMultimediaInfo(file);
var videoInfo = mediaInfo.getVideo();
var videoSize = videoInfo.getSize();

return isSizeCompliant(videoSize.getWidth(), videoSize.getHeight())
&& videoInfo.getFrameRate() <= MAX_VIDEO_FRAMES
&& videoInfo.getDecoder().startsWith(VP9_CODEC)
&& mediaInfo.getDuration() > 0L
&& mediaInfo.getDuration() <= MAX_VIDEO_DURATION_MILLIS
&& mediaInfo.getAudio() == null
&& MATROSKA_FORMAT.equals(mediaInfo.getFormat())
&& isFileSizeLowerThan(file, MAX_VIDEO_FILE_SIZE);

var formatInfo = mediaInfo.format();
if (formatInfo == null) {
return false; // not a video, maybe throw
}

var duration = formatInfo.durationAsMillis();
if (duration == null) {
return false; // not a video, maybe throw
}

var videoInfo = mediaInfo.video();
if (videoInfo == null) {
return false; // not a video, maybe throw
}

var size = formatInfo.sizeAsLong();
var frameRate = videoInfo.frameRateAsFloat();

return isSizeCompliant(videoInfo.width(), videoInfo.height())
&& frameRate <= MAX_VIDEO_FRAMES
&& VP9_CODEC.equals(videoInfo.codec())
&& duration <= MAX_VIDEO_DURATION_MILLIS
&& mediaInfo.audio() == null
&& formatInfo.format().startsWith(MATROSKA_FORMAT)
&& size <= MAX_VIDEO_FILE_SIZE;
}

/**
* Convenience method to retrieve multimedia information of a file.
*
* @param file the video to check
* @return passed-in video's multimedia information
* @throws CorruptedVideoException if an error occurred retrieving video information
* @throws CorruptedFileException if an error occurred retrieving file information
* @throws InterruptedException if the current thread is interrupted while retrieving file info
*/
private static MultimediaInfo retrieveMultimediaInfo(File file) throws CorruptedVideoException {
static MultimediaInfo retrieveMultimediaInfo(File file) throws CorruptedFileException, InterruptedException {
var command = new String[] {
"ffprobe",
"-hide_banner",
"-v", "quiet",
"-print_format", "json",
"-show_format",
"-show_streams",
file.getAbsolutePath()
};

try {
return new MultimediaObject(file, PathLocator.INSTANCE).getInfo();
} catch (EncoderException e) {
throw new CorruptedVideoException("The video could not be processed successfully", e);
var lines = ProcessHelper.executeCommand(command);
var body = String.join("\n", lines);

return GSON.fromJson(body, MultimediaInfo.class);
} catch (ProcessException | JsonSyntaxException e) {
throw new CorruptedFileException("The file could not be processed successfully", e);
}
}

record MultimediaInfo(List<StreamInfo> streams, @Nullable FormatInfo format) {
@Nullable StreamInfo audio() {
return streams.stream()
.filter(s -> StreamInfo.TYPE_AUDIO.equals(s.type))
.findFirst()
.orElse(null);
}

@Nullable StreamInfo video() {
return streams.stream()
.filter(s -> StreamInfo.TYPE_VIDEO.equals(s.type))
.findFirst()
.orElse(null);
}
}
record StreamInfo(@SerializedName("codec_name") String codec, @SerializedName("codec_type") String type, int width, int height, @SerializedName("avg_frame_rate") String frameRateFraction) {
private static final String TYPE_AUDIO = "audio";
private static final String TYPE_VIDEO = "video";

float frameRateAsFloat() {
if (frameRateFraction.contains("/")) {
var ratio = frameRateFraction.split("/");
return Float.parseFloat(ratio[0]) / Float.parseFloat(ratio[1]);
} else {
return Float.parseFloat(frameRateFraction);
}
}
}
record FormatInfo(@SerializedName("format_name") String format, @SerializedName("duration") @Nullable String duration, String size) {
@Nullable Long durationAsMillis() {
if (duration == null) {
return null;
}
return (long) (Float.parseFloat(duration) * 1000);
}

long sizeAsLong() {
return Long.parseLong(size);
}
}

Expand Down Expand Up @@ -183,7 +251,11 @@ private static boolean isAnimatedStickerCompliant(File file, String mimeType) th

boolean isAnimationCompliant = isAnimationCompliant(sticker);
if (isAnimationCompliant) {
return isFileSizeLowerThan(file, MAX_ANIMATION_FILE_SIZE);
try {
return Files.size(file.toPath()) <= MAX_ANIMATION_FILE_SIZE;
} catch (IOException e) {
throw new FileOperationException(e);
}
}

LOGGER.atWarn().log("The {} doesn't meet Telegram's requirements", sticker);
Expand Down Expand Up @@ -226,38 +298,33 @@ private static boolean isAnimationCompliant(@Nullable AnimationDetails animation
&& animation.height() == MAX_SIDE_LENGTH;
}

/**
* Checks that passed-in file's size does not exceed the specified threshold.
*
* @param file the file to check
* @param threshold max allowed file size
* @return {@code true} if file's size is compliant
* @throws FileOperationException if an error occurred retrieving the size of the file
*/
private static boolean isFileSizeLowerThan(File file, long threshold) throws FileOperationException {
try {
return Files.size(file.toPath()) <= threshold;
} catch (IOException e) {
throw new FileOperationException(e);
}
}

/**
* Checks if passed-in image is already compliant with Telegram's requisites.
*
* @param image the image to check
* @param mimeType the MIME type of the file
* @return {@code true} if the file is compliant
* @throws CorruptedFileException if an error occurred retrieving image information
* @throws InterruptedException if the current thread is interrupted while retrieving file info
*/
private static boolean isImageCompliant(File image, String mimeType) throws CorruptedVideoException, FileOperationException {
var videoSize = retrieveMultimediaInfo(image).getVideo().getSize();
if (videoSize == null) {
throw new FileOperationException("Couldn't read image dimensions");
private static boolean isImageCompliant(File image, String mimeType) throws CorruptedFileException, InterruptedException {
var mediaInfo = retrieveMultimediaInfo(image);

var formatInfo = mediaInfo.format();
if (formatInfo == null) {
return false; // not an image, maybe throw
}

var videoInfo = mediaInfo.video();
if (videoInfo == null) {
return false; // not an image, maybe throw
}

var size = formatInfo.sizeAsLong();

return ("image/png".equals(mimeType) || "image/webp".equals(mimeType))
&& isSizeCompliant(videoSize.getWidth(), videoSize.getHeight())
&& isFileSizeLowerThan(image, MAX_IMAGE_FILE_SIZE);
&& isSizeCompliant(videoInfo.width(), videoInfo.height())
&& size <= MAX_IMAGE_FILE_SIZE;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
public final class OsConstants {
private static final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase().contains("windows");

public static final String FIND_EXECUTABLE = IS_WINDOWS ? "where" : "which";
public static final String NULL_FILE = IS_WINDOWS ? "NUL" : "/dev/null";

private OsConstants() {
Expand Down

This file was deleted.

1 change: 0 additions & 1 deletion src/main/resources/logback.xml
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,4 @@
</root>

<logger name="org.apache.tika" level="info" />
<logger name="ws.schild.jave" level="error" />
</configuration>
Loading
Loading