Skip to content

Commit 8f1825f

Browse files
Use FFprobe to check file informations (#406)
1 parent 2f03ec8 commit 8f1825f

File tree

16 files changed

+167
-166
lines changed

16 files changed

+167
-166
lines changed

.dockerignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
.gradle/
44
.idea/
55
build/
6-
src/test/
76
src/main/resources/images/
87
.gitattributes
98
.gitignore

Dockerfile

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
FROM eclipse-temurin:25-alpine AS builder
22

33
WORKDIR /app
4+
5+
# bump: ffmpeg /static-ffmpeg:([\d.]+)/ docker:mwader/static-ffmpeg|~7.0
6+
COPY --from=mwader/static-ffmpeg:7.0.2 /ff* /usr/bin/
7+
48
COPY . .
5-
RUN --mount=type=cache,target=/root/.gradle ./gradlew installDist
9+
RUN --mount=type=cache,target=/root/.gradle ./gradlew check installDist --no-daemon
610

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

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

1518
ENV CONCURRENT_PROCESSES=5
16-
ENV FFMPEG_PATH=/usr/bin/ffmpeg
1719
CMD ["./bin/Stickerify"]

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ therefore counting towards the four-contribution goal.
118118
* The bot is deployed on [Railway](https://railway.app?referralCode=rob)
119119
* The official documentation of the Telegram Bot API can be found [here](https://core.telegram.org/bots)
120120
* The library used by the bot to work with Telegram is [Java Telegram Bot API](https://github.com/pengrad/java-telegram-bot-api)
121-
* Video and image conversions use [FFmpeg](https://ffmpeg.org/) and [JAVE2](https://github.com/a-schild/jave2)
121+
* Video and image conversions use [FFmpeg](https://ffmpeg.org/)
122122
* Animated sticker validation uses [Gson](https://github.com/google/gson)
123123
* MIME type analysis is performed using [Apache Tika](https://tika.apache.org/)
124124

build.gradle

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ repositories {
1515
dependencies {
1616
compileOnly(libs.jspecify)
1717
implementation(libs.gson)
18-
implementation(libs.jave)
1918
implementation(libs.logback.classic)
2019
implementation(libs.telegram.bot.api)
2120
implementation(libs.tika)

gradle/libs.versions.toml

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
[libraries]
22
gson = "com.google.code.gson:gson:2.13.2"
33
hamcrest = "org.hamcrest:hamcrest:3.0"
4-
jave = "ws.schild:jave-core:3.5.0"
54
jspecify = "org.jspecify:jspecify:1.0.0"
65
junit-jupiter = "org.junit.jupiter:junit-jupiter:5.13.4"
76
junit-platform = "org.junit.platform:junit-platform-launcher:1.13.4"

src/main/java/com/github/stickerifier/stickerify/bot/Stickerify.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
import static java.util.concurrent.Executors.newThreadPerTaskExecutor;
1111

1212
import com.github.stickerifier.stickerify.exception.BaseException;
13-
import com.github.stickerifier.stickerify.exception.CorruptedVideoException;
13+
import com.github.stickerifier.stickerify.exception.CorruptedFileException;
1414
import com.github.stickerifier.stickerify.exception.FileOperationException;
1515
import com.github.stickerifier.stickerify.exception.MediaException;
1616
import com.github.stickerifier.stickerify.exception.TelegramApiException;
@@ -175,7 +175,7 @@ private void processFailure(TelegramRequest request, BaseException e, String fil
175175
processTelegramFailure(request.getDescription(), telegramException, false);
176176
}
177177

178-
if (e instanceof CorruptedVideoException) {
178+
if (e instanceof CorruptedFileException) {
179179
LOGGER.atInfo().log("Unable to reply to the {}: the file is corrupted", request.getDescription());
180180
answerText(CORRUPTED, request);
181181
} else {
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.github.stickerifier.stickerify.exception;
2+
3+
public class CorruptedFileException extends MediaException {
4+
public CorruptedFileException(String message, Throwable cause) {
5+
super(message, cause);
6+
}
7+
}

src/main/java/com/github/stickerifier/stickerify/exception/CorruptedVideoException.java

Lines changed: 0 additions & 7 deletions
This file was deleted.

src/main/java/com/github/stickerifier/stickerify/media/MediaConstraints.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ public final class MediaConstraints {
1111

1212
static final int MAX_SIDE_LENGTH = 512;
1313
static final int MAX_VIDEO_FRAMES = 30;
14-
static final int MAX_VIDEO_DURATION_MILLIS = 3000;
14+
static final float MAX_VIDEO_DURATION_SECONDS = 3.0F - (1.0F / MAX_VIDEO_FRAMES);
1515
static final String VP9_CODEC = "vp9";
1616
static final String MATROSKA_FORMAT = "matroska";
1717
static final long MAX_IMAGE_FILE_SIZE = 512_000L;
1818
static final long MAX_VIDEO_FILE_SIZE = 256_000L;
1919
static final long MAX_ANIMATION_FILE_SIZE = 64_000L;
2020
static final int MAX_ANIMATION_FRAME_RATE = 60;
21-
static final int MAX_ANIMATION_DURATION_SECONDS = 3;
21+
static final float MAX_ANIMATION_DURATION_SECONDS = 3.0F;
2222

2323
private MediaConstraints() {
2424
throw new UnsupportedOperationException();

src/main/java/com/github/stickerifier/stickerify/media/MediaHelper.java

Lines changed: 102 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,17 @@
66
import static com.github.stickerifier.stickerify.media.MediaConstraints.MAX_ANIMATION_FRAME_RATE;
77
import static com.github.stickerifier.stickerify.media.MediaConstraints.MAX_IMAGE_FILE_SIZE;
88
import static com.github.stickerifier.stickerify.media.MediaConstraints.MAX_SIDE_LENGTH;
9-
import static com.github.stickerifier.stickerify.media.MediaConstraints.MAX_VIDEO_DURATION_MILLIS;
9+
import static com.github.stickerifier.stickerify.media.MediaConstraints.MAX_VIDEO_DURATION_SECONDS;
1010
import static com.github.stickerifier.stickerify.media.MediaConstraints.MAX_VIDEO_FILE_SIZE;
1111
import static com.github.stickerifier.stickerify.media.MediaConstraints.MAX_VIDEO_FRAMES;
1212
import static com.github.stickerifier.stickerify.media.MediaConstraints.VP9_CODEC;
1313
import static java.nio.charset.StandardCharsets.UTF_8;
1414

15-
import com.github.stickerifier.stickerify.exception.CorruptedVideoException;
15+
import com.github.stickerifier.stickerify.exception.CorruptedFileException;
1616
import com.github.stickerifier.stickerify.exception.FileOperationException;
1717
import com.github.stickerifier.stickerify.exception.MediaException;
1818
import com.github.stickerifier.stickerify.exception.ProcessException;
1919
import com.github.stickerifier.stickerify.process.OsConstants;
20-
import com.github.stickerifier.stickerify.process.PathLocator;
2120
import com.github.stickerifier.stickerify.process.ProcessHelper;
2221
import com.google.gson.Gson;
2322
import com.google.gson.JsonSyntaxException;
@@ -26,14 +25,12 @@
2625
import org.jspecify.annotations.Nullable;
2726
import org.slf4j.Logger;
2827
import org.slf4j.LoggerFactory;
29-
import ws.schild.jave.EncoderException;
30-
import ws.schild.jave.MultimediaObject;
31-
import ws.schild.jave.info.MultimediaInfo;
3228

3329
import java.io.File;
3430
import java.io.FileInputStream;
3531
import java.io.IOException;
3632
import java.nio.file.Files;
33+
import java.util.List;
3734
import java.util.Set;
3835
import java.util.zip.GZIPInputStream;
3936

@@ -128,37 +125,98 @@ private static boolean isSupportedVideo(String mimeType) {
128125
* @param file the file to check
129126
* @return {@code true} if the file is compliant
130127
* @throws FileOperationException if an error occurred retrieving the size of the file
128+
* @throws InterruptedException if the current thread is interrupted while retrieving file info
131129
*/
132-
private static boolean isVideoCompliant(File file) throws FileOperationException, CorruptedVideoException {
130+
private static boolean isVideoCompliant(File file) throws FileOperationException, CorruptedFileException, InterruptedException {
133131
var mediaInfo = retrieveMultimediaInfo(file);
134-
var videoInfo = mediaInfo.getVideo();
135-
var videoSize = videoInfo.getSize();
136-
137-
return isSizeCompliant(videoSize.getWidth(), videoSize.getHeight())
138-
&& videoInfo.getFrameRate() <= MAX_VIDEO_FRAMES
139-
&& videoInfo.getDecoder().startsWith(VP9_CODEC)
140-
&& mediaInfo.getDuration() > 0L
141-
&& mediaInfo.getDuration() <= MAX_VIDEO_DURATION_MILLIS
142-
&& mediaInfo.getAudio() == null
143-
&& MATROSKA_FORMAT.equals(mediaInfo.getFormat())
144-
&& isFileSizeLowerThan(file, MAX_VIDEO_FILE_SIZE);
132+
133+
var formatInfo = mediaInfo.format();
134+
if (formatInfo == null) {
135+
return false;
136+
}
137+
138+
if (formatInfo.duration() == null) {
139+
return false;
140+
}
141+
142+
var videoInfo = mediaInfo.video();
143+
if (videoInfo == null) {
144+
return false;
145+
}
146+
147+
return isSizeCompliant(videoInfo.width(), videoInfo.height())
148+
&& videoInfo.frameRate() <= MAX_VIDEO_FRAMES
149+
&& VP9_CODEC.equals(videoInfo.codec())
150+
&& formatInfo.duration() <= MAX_VIDEO_DURATION_SECONDS
151+
&& mediaInfo.audio() == null
152+
&& formatInfo.format().startsWith(MATROSKA_FORMAT)
153+
&& formatInfo.size() <= MAX_VIDEO_FILE_SIZE;
145154
}
146155

147156
/**
148157
* Convenience method to retrieve multimedia information of a file.
149158
*
150159
* @param file the video to check
151160
* @return passed-in video's multimedia information
152-
* @throws CorruptedVideoException if an error occurred retrieving video information
161+
* @throws CorruptedFileException if an error occurred retrieving file information
162+
* @throws InterruptedException if the current thread is interrupted while retrieving file info
153163
*/
154-
private static MultimediaInfo retrieveMultimediaInfo(File file) throws CorruptedVideoException {
164+
static MultimediaInfo retrieveMultimediaInfo(File file) throws CorruptedFileException, InterruptedException {
165+
var command = new String[] {
166+
"ffprobe",
167+
"-hide_banner",
168+
"-v", "quiet",
169+
"-print_format", "json",
170+
"-show_format",
171+
"-show_streams",
172+
file.getAbsolutePath()
173+
};
174+
155175
try {
156-
return new MultimediaObject(file, PathLocator.INSTANCE).getInfo();
157-
} catch (EncoderException e) {
158-
throw new CorruptedVideoException("The video could not be processed successfully", e);
176+
var output = ProcessHelper.executeCommand(command);
177+
178+
return GSON.fromJson(output, MultimediaInfo.class);
179+
} catch (ProcessException | JsonSyntaxException e) {
180+
throw new CorruptedFileException("The file could not be processed successfully", e);
159181
}
160182
}
161183

184+
record MultimediaInfo(List<StreamInfo> streams, @Nullable FormatInfo format) {
185+
@Nullable
186+
StreamInfo audio() {
187+
return streams.stream()
188+
.filter(s -> s.type == CodecType.AUDIO)
189+
.findFirst()
190+
.orElse(null);
191+
}
192+
193+
@Nullable
194+
StreamInfo video() {
195+
return streams.stream()
196+
.filter(s -> s.type == CodecType.VIDEO)
197+
.findFirst()
198+
.orElse(null);
199+
}
200+
}
201+
202+
record StreamInfo(@SerializedName("codec_name") String codec, @SerializedName("codec_type") CodecType type, int width, int height, @SerializedName("avg_frame_rate") String frameRateRatio) {
203+
float frameRate() {
204+
if (frameRateRatio.contains("/")) {
205+
var ratio = frameRateRatio.split("/");
206+
return Float.parseFloat(ratio[0]) / Float.parseFloat(ratio[1]);
207+
} else {
208+
return Float.parseFloat(frameRateRatio);
209+
}
210+
}
211+
}
212+
213+
private enum CodecType {
214+
@SerializedName("video") VIDEO,
215+
@SerializedName("audio") AUDIO
216+
}
217+
218+
record FormatInfo(@SerializedName("format_name") String format, @Nullable Float duration, long size) {}
219+
162220
/**
163221
* Checks if the file is a {@code gzip} archive, then it reads its content and verifies if it's a valid JSON.
164222
* Once JSON information is retrieved, they are validated against Telegram's requirements.
@@ -183,7 +241,11 @@ private static boolean isAnimatedStickerCompliant(File file, String mimeType) th
183241

184242
boolean isAnimationCompliant = isAnimationCompliant(sticker);
185243
if (isAnimationCompliant) {
186-
return isFileSizeLowerThan(file, MAX_ANIMATION_FILE_SIZE);
244+
try {
245+
return Files.size(file.toPath()) <= MAX_ANIMATION_FILE_SIZE;
246+
} catch (IOException e) {
247+
throw new FileOperationException(e);
248+
}
187249
}
188250

189251
LOGGER.atWarn().log("The {} doesn't meet Telegram's requirements", sticker);
@@ -226,38 +288,31 @@ private static boolean isAnimationCompliant(@Nullable AnimationDetails animation
226288
&& animation.height() == MAX_SIDE_LENGTH;
227289
}
228290

229-
/**
230-
* Checks that passed-in file's size does not exceed the specified threshold.
231-
*
232-
* @param file the file to check
233-
* @param threshold max allowed file size
234-
* @return {@code true} if file's size is compliant
235-
* @throws FileOperationException if an error occurred retrieving the size of the file
236-
*/
237-
private static boolean isFileSizeLowerThan(File file, long threshold) throws FileOperationException {
238-
try {
239-
return Files.size(file.toPath()) <= threshold;
240-
} catch (IOException e) {
241-
throw new FileOperationException(e);
242-
}
243-
}
244-
245291
/**
246292
* Checks if passed-in image is already compliant with Telegram's requisites.
247293
*
248294
* @param image the image to check
249295
* @param mimeType the MIME type of the file
250296
* @return {@code true} if the file is compliant
297+
* @throws CorruptedFileException if an error occurred retrieving image information
298+
* @throws InterruptedException if the current thread is interrupted while retrieving file info
251299
*/
252-
private static boolean isImageCompliant(File image, String mimeType) throws CorruptedVideoException, FileOperationException {
253-
var videoSize = retrieveMultimediaInfo(image).getVideo().getSize();
254-
if (videoSize == null) {
255-
throw new FileOperationException("Couldn't read image dimensions");
300+
private static boolean isImageCompliant(File image, String mimeType) throws CorruptedFileException, InterruptedException {
301+
var mediaInfo = retrieveMultimediaInfo(image);
302+
303+
var formatInfo = mediaInfo.format();
304+
if (formatInfo == null) {
305+
return false;
306+
}
307+
308+
var imageInfo = mediaInfo.video();
309+
if (imageInfo == null) {
310+
return false;
256311
}
257312

258313
return ("image/png".equals(mimeType) || "image/webp".equals(mimeType))
259-
&& isSizeCompliant(videoSize.getWidth(), videoSize.getHeight())
260-
&& isFileSizeLowerThan(image, MAX_IMAGE_FILE_SIZE);
314+
&& isSizeCompliant(imageInfo.width(), imageInfo.height())
315+
&& formatInfo.size() <= MAX_IMAGE_FILE_SIZE;
261316
}
262317

263318
/**
@@ -362,7 +417,7 @@ private static File convertToWebm(File file) throws MediaException, InterruptedE
362417
"-c:v", "libvpx-" + VP9_CODEC,
363418
"-b:v", "650K",
364419
"-pix_fmt", "yuv420p",
365-
"-t", String.valueOf(MAX_VIDEO_DURATION_MILLIS / 1000),
420+
"-t", String.valueOf(MAX_VIDEO_DURATION_SECONDS),
366421
"-an",
367422
"-passlogfile", logPrefix
368423
};

0 commit comments

Comments
 (0)