Initial commit

This commit is contained in:
Guillaume Tâche
2023-12-29 20:01:04 +01:00
commit 8993710c38
35 changed files with 1512 additions and 0 deletions

View File

@@ -0,0 +1,85 @@
package com.github.gtache.ffmpeg;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
public class AbstractFFmpegRunner implements FFmpegRunner {
private static final Logger logger = LogManager.getLogger(AbstractFFmpegRunner.class);
private final String exe;
protected AbstractFFmpegRunner(final String exe) {
this.exe = Objects.requireNonNull(exe);
}
@Override
public void runCommand(final List<String> args) throws IOException {
final var copy = new ArrayList<>(args);
copy.add(0, exe);
final var builder = new ProcessBuilder(copy).redirectErrorStream(true);
logger.info("Running {}", copy);
final var process = builder.start();
new Thread(() -> {
try (final var reader = new BufferedReader(new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) {
while (reader.ready()) {
logger.info(reader.readLine());
}
} catch (final IOException e) {
logger.error("Error reading process output", e);
}
}).start();
try {
process.waitFor();
logger.info("Finished {}", copy);
} catch (final InterruptedException e) {
process.destroy();
logger.error("Couldn't wait for {}", copy, e);
}
}
protected static void addConversionArguments(final Format format, final Quality quality, final Compression compression, final List<String> args) {
args.add("-c:v");
switch (format) {
case H264 -> args.add("libx264");
case H265 -> {
args.add("libx265");
args.add("-vtag");
args.add("hvc1");
}
}
switch (compression) {
case LOW -> {
args.add("-preset");
args.add("fast");
}
case NORMAL -> {
}
case HIGH -> {
args.add("-preset");
args.add("veryslow");
}
}
switch (quality) {
case LOW -> {
args.add("-crf");
args.add(format == Format.H264 ? "26" : "30");
}
case NORMAL -> {
}
case HIGH -> {
args.add("-crf");
args.add(format == Format.H264 ? "18" : "22");
}
}
}
}

View File

@@ -0,0 +1,5 @@
package com.github.gtache.ffmpeg;
public class BindingConstants {
public static final String FFMPEG_EXE = "ffmpeg.exe";
}

View File

@@ -0,0 +1,5 @@
package com.github.gtache.ffmpeg;
public enum Compression {
LOW, NORMAL, HIGH
}

View File

@@ -0,0 +1,12 @@
package com.github.gtache.ffmpeg;
import java.io.IOException;
public interface Converter {
default void convertTo(final String file, final String output, final Format format) throws IOException {
convertTo(file, output, format, Quality.NORMAL, Compression.NORMAL);
}
void convertTo(final String file, final String output, final Format format, final Quality quality, final Compression compression) throws IOException;
}

View File

@@ -0,0 +1,29 @@
package com.github.gtache.ffmpeg;
import com.google.inject.name.Named;
import javax.inject.Inject;
import javax.inject.Singleton;
import java.io.IOException;
import java.util.ArrayList;
import static com.github.gtache.ffmpeg.BindingConstants.FFMPEG_EXE;
@Singleton
public class FFmpegConverter extends AbstractFFmpegRunner implements Converter {
@Inject
FFmpegConverter(@Named(FFMPEG_EXE) final String exe) {
super(exe);
}
@Override
public void convertTo(final String file, final String output, final Format format, final Quality quality, final Compression compression) throws IOException {
final var args = new ArrayList<String>();
args.add("-i");
args.add(file);
addConversionArguments(format, quality, compression, args);
args.add(output);
args.add("-y");
runCommand(args);
}
}

View File

@@ -0,0 +1,49 @@
package com.github.gtache.ffmpeg;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import static com.github.gtache.ffmpeg.BindingConstants.FFMPEG_EXE;
@Singleton
public class FFmpegHandler extends AbstractFFmpegRunner implements Handler {
private final FFmpegConverter converter;
private final FFmpegMerger merger;
@Inject
FFmpegHandler(@Named(FFMPEG_EXE) final String exe, final FFmpegConverter converter, final FFmpegMerger merger) {
super(exe);
this.converter = Objects.requireNonNull(converter);
this.merger = Objects.requireNonNull(merger);
}
@Override
public void convertTo(final String file, final String output, final Format format, final Quality quality, final Compression compression) throws IOException {
converter.convertTo(file, output, format, quality, compression);
}
@Override
public void merge(final List<String> files, final String output) throws IOException {
merger.merge(files, output);
}
@Override
public void mergeAndConvert(final List<String> files, final String output, final Format format, final Quality quality, final Compression compression) throws IOException {
final var args = new ArrayList<String>();
files.forEach(f -> {
args.add("-i");
args.add(f);
});
args.add("-filter_complex");
args.add("concat=n=" + files.size() + ":v=1:a=1");
addConversionArguments(format, quality, compression, args);
args.add(output);
args.add("-y");
runCommand(args);
}
}

View File

@@ -0,0 +1,32 @@
package com.github.gtache.ffmpeg;
import javax.inject.Inject;
import javax.inject.Named;
import javax.inject.Singleton;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.List;
import static com.github.gtache.ffmpeg.BindingConstants.FFMPEG_EXE;
@Singleton
public class FFmpegMerger extends AbstractFFmpegRunner implements Merger {
@Inject
FFmpegMerger(@Named(FFMPEG_EXE) final String exe) {
super(exe);
}
@Override
public void merge(final List<String> files, final String output) throws IOException {
final var absoluteFiles = files.stream().map(f -> "file " + Paths.get(f).toFile().getAbsolutePath()).toList();
final var tmpFile = Files.createTempFile("merge", ".txt");
tmpFile.toFile().deleteOnExit();
Files.write(tmpFile, absoluteFiles, StandardCharsets.UTF_8, StandardOpenOption.TRUNCATE_EXISTING);
final var args = List.of("-safe", "0", "-f", "concat", "-i", tmpFile.toFile().getAbsolutePath(), "-c", "copy", output);
runCommand(args);
}
}

View File

@@ -0,0 +1,16 @@
package com.github.gtache.ffmpeg;
import com.google.inject.AbstractModule;
import com.google.inject.name.Names;
import static com.github.gtache.ffmpeg.BindingConstants.FFMPEG_EXE;
public class FFmpegModule extends AbstractModule {
@Override
protected void configure() {
bindConstant().annotatedWith(Names.named(FFMPEG_EXE)).to("C:\\Program Files\\FFmpeg\\bin\\ffmpeg.exe");
bind(Merger.class).to(FFmpegMerger.class);
bind(Converter.class).to(FFmpegConverter.class);
bind(Handler.class).to(FFmpegHandler.class);
}
}

View File

@@ -0,0 +1,13 @@
package com.github.gtache.ffmpeg;
import java.io.IOException;
import java.util.Arrays;
import java.util.List;
public interface FFmpegRunner {
default void runCommand(final String... args) throws IOException {
runCommand(Arrays.asList(args));
}
void runCommand(final List<String> args) throws IOException;
}

View File

@@ -0,0 +1,5 @@
package com.github.gtache.ffmpeg;
public enum Format {
H264, H265
}

View File

@@ -0,0 +1,20 @@
package com.github.gtache.ffmpeg;
import java.io.IOException;
import java.nio.file.Files;
import java.util.List;
public interface Handler extends Merger, Converter {
default void mergeAndConvert(final List<String> files, final String output, final Format format, final Quality quality, final Compression compression) throws IOException {
if (!files.isEmpty()) {
final var split = files.get(0).split("\\.");
final var extension = split[split.length - 1];
final var tmp = Files.createTempFile("merge", extension);
final var tmpFile = tmp.toFile();
tmpFile.deleteOnExit();
merge(files, tmpFile.getAbsolutePath());
convertTo(tmpFile.getAbsolutePath(), output, format, quality, compression);
}
}
}

View File

@@ -0,0 +1,7 @@
package com.github.gtache.ffmpeg;
public class Main {
public static void main(String[] args) {
}
}

View File

@@ -0,0 +1,9 @@
package com.github.gtache.ffmpeg;
import java.io.IOException;
import java.util.List;
public interface Merger {
void merge(final List<String> files, final String output) throws IOException;
}

View File

@@ -0,0 +1,5 @@
package com.github.gtache.ffmpeg;
public enum Quality {
LOW, NORMAL, HIGH
}

View File

@@ -0,0 +1,5 @@
package com.github.gtache.plex;
public class BindingConstants {
}

View File

@@ -0,0 +1,9 @@
package com.github.gtache.plex;
import java.io.IOException;
public interface Compresser {
void compress(final String file) throws IOException;
void compressDirectory(final String path) throws IOException;
}

View File

@@ -0,0 +1,28 @@
package com.github.gtache.plex;
import com.github.gtache.ffmpeg.Compression;
import com.github.gtache.ffmpeg.Quality;
import com.google.inject.Guice;
import java.io.IOException;
public class Main {
public static void main(final String[] args) throws IOException {
final var injector = Guice.createInjector(new PlexModule());
switch (args[0]) {
case "merge" -> {
final var merger = injector.getInstance(Merger.class);
merger.mergeDirectory(args[1], Quality.NORMAL, Compression.NORMAL);
}
case "compress" -> {
final var compresser = injector.getInstance(Compresser.class);
compresser.compress(args[1]);
}
case "rename" -> {
final var renamer = injector.getInstance(Renamer.class);
renamer.renameSeries(args[1]);
}
}
}
}

View File

@@ -0,0 +1,11 @@
package com.github.gtache.plex;
import com.github.gtache.ffmpeg.Compression;
import com.github.gtache.ffmpeg.Quality;
import java.io.IOException;
public interface Merger {
void mergeDirectory(final String path, final Quality quality, final Compression compression) throws IOException;
}

View File

@@ -0,0 +1,5 @@
package com.github.gtache.plex;
public record Pair<K, V>(K key, V value) {
}

View File

@@ -0,0 +1,45 @@
package com.github.gtache.plex;
import com.github.gtache.ffmpeg.Compression;
import com.github.gtache.ffmpeg.Converter;
import com.github.gtache.ffmpeg.Format;
import com.github.gtache.ffmpeg.Quality;
import javax.inject.Inject;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Objects;
import java.util.regex.Pattern;
public class PlexCompresser implements Compresser {
private static final Pattern EXTENSION_PATTERN = Pattern.compile("\\.([A-Za-z0-9]{2,3})$");
private final Converter converter;
@Inject
PlexCompresser(final Converter converter) {
this.converter = Objects.requireNonNull(converter);
}
@Override
public void compress(final String file) throws IOException {
final var matcher = EXTENSION_PATTERN.matcher(file);
if (matcher.find()) {
final var extension = matcher.group(1);
final var newName = matcher.replaceAll("-archived." + extension);
converter.convertTo(file, newName, Format.H265, Quality.HIGH, Compression.HIGH);
} else {
throw new IllegalArgumentException("File doesn't have an extension : " + file);
}
}
@Override
public void compressDirectory(final String path) throws IOException {
try (final var files = Files.list(Paths.get(path))) {
for (final var file : files.toList()) {
compress(file.toFile().getAbsolutePath());
}
}
}
}

View File

@@ -0,0 +1,79 @@
package com.github.gtache.plex;
import com.github.gtache.ffmpeg.Compression;
import com.github.gtache.ffmpeg.Format;
import com.github.gtache.ffmpeg.Handler;
import com.github.gtache.ffmpeg.Quality;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import javax.inject.Inject;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
public class PlexMerger implements Merger {
private static final Logger logger = LogManager.getLogger(PlexMerger.class);
private static final Pattern PART_PATTERN = Pattern.compile(" - (cd|dis[ck]|dvd|p(?:ar)?t)([0-9])\\.([a-z0-9]{2,3})$");
private final Handler handler;
@Inject
PlexMerger(final Handler handler) {
this.handler = Objects.requireNonNull(handler);
}
@Override
public void mergeDirectory(final String path, final Quality quality, final Compression compression) throws IOException {
final var mapping = new HashMap<String, Pair<String, Set<Pair<Integer, String>>>>();
try (final var files = Files.list(Paths.get(path))) {
files.forEach(p -> {
final var name = p.getFileName().toString();
final var matcher = PART_PATTERN.matcher(name);
if (matcher.find()) {
final var type = matcher.group(1);
final var number = Integer.parseInt(matcher.group(2));
final var extension = matcher.group(3);
final var basename = PART_PATTERN.matcher(name).replaceAll("");
if (mapping.containsKey(basename)) {
final var pair = mapping.get(basename);
if (pair.key().equals(type)) {
final var set = pair.value();
if (set.stream().anyMatch(existing -> existing.key().equals(number))) {
throw new IllegalArgumentException("Different extensions for same file " + basename + " - " + number);
}
set.add(new Pair<>(number, extension));
} else {
throw new IllegalArgumentException("Different types for " + basename + " : " + type + " and " + pair.key());
}
} else {
final var set = new HashSet<Pair<Integer, String>>();
set.add(new Pair<>(number, extension));
mapping.put(basename, new Pair<>(type, set));
}
}
});
}
for (final var entry : mapping.entrySet()) {
final var name = entry.getKey();
final var pair = entry.getValue();
final var type = pair.key();
final var set = pair.value();
final var numberSet = set.stream().map(Pair::key).collect(Collectors.toSet());
if (IntStream.range(1, set.size() + 1).allMatch(numberSet::contains)) {
final var files = set.stream().sorted(Comparator.comparing(Pair::key)).map(i -> path + File.separator + name + " - " + type + i.key() + "." + i.value()).toList();
handler.mergeAndConvert(files, path + File.separator + name + ".mp4", Format.H265, quality, compression);
} else {
logger.warn("Missing parts for {}", name);
}
}
}
}

View File

@@ -0,0 +1,14 @@
package com.github.gtache.plex;
import com.github.gtache.ffmpeg.FFmpegModule;
import com.google.inject.AbstractModule;
public class PlexModule extends AbstractModule {
@Override
protected void configure() {
install(new FFmpegModule());
bind(Merger.class).to(PlexMerger.class);
bind(Compresser.class).to(PlexCompresser.class);
bind(Renamer.class).to(PlexRenamer.class);
}
}

View File

@@ -0,0 +1,88 @@
package com.github.gtache.plex;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.regex.Pattern;
public class PlexRenamer implements Renamer {
private static final Pattern NAME_PATTERN = Pattern.compile("([^(]+)\\(([0-9]+)\\)");
private static final Pattern SEASON_PATTERN = Pattern.compile("[sS](?:eason )?([0-9]+)");
private static final Pattern EPISODE_PATTERN = Pattern.compile("[eE](?:pisode )?([0-9]+)(?:( ?- ?[^.]+)?|[^.]*)\\.([a-zA-Z0-9]+)$");
@Override
public void renameSeries(final String path, final String name, final int year) {
final var folderO = Paths.get(path);
try (final var seasonStreams = Files.list(folderO)) {
seasonStreams.forEach(p -> {
try {
renameSeason(p, name, year);
} catch (final IOException e) {
throw new RuntimeException(e);
}
});
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
@Override
public void renameSeries(final String path) {
final var folderO = Paths.get(path);
final var folderName = folderO.getFileName().toString();
final var matcher = NAME_PATTERN.matcher(folderName);
if (matcher.matches()) {
final var name = matcher.group(1).trim();
final var year = Integer.parseInt(matcher.group(2));
renameSeries(path, name, year);
} else {
System.out.println("Not a correct series name : " + folderName);
}
}
private static void renameSeason(final Path path, final String name, final int year) throws IOException {
final var folderName = path.getFileName().toString();
final var matcher = SEASON_PATTERN.matcher(folderName);
if (matcher.find()) {
final var seasonInt = Integer.parseInt(matcher.group(1));
final String seasonString;
if (seasonInt < 10) {
seasonString = "0" + seasonInt;
} else {
seasonString = String.valueOf(seasonInt);
}
try (final var episodeStream = Files.list(path)) {
episodeStream.forEach(p -> {
try {
renameEpisode(p, name, year, seasonString);
} catch (final IOException e) {
throw new RuntimeException(e);
}
});
}
Files.move(path, path.resolveSibling("Season " + seasonString));
}
}
private static void renameEpisode(final Path path, final String name, final int year, final String seasonString) throws IOException {
final var fileName = path.getFileName().toString();
final var matcher = EPISODE_PATTERN.matcher(fileName);
if (matcher.find()) {
final var episodeInt = Integer.parseInt(matcher.group(1));
final String episodeString;
if (episodeInt < 10) {
episodeString = "0" + episodeInt;
} else {
episodeString = String.valueOf(episodeInt);
}
final var additionalInfo = matcher.group(2) == null ? "" : matcher.group(2);
final var extension = matcher.group(3);
final var newName = additionalInfo.isBlank() ?
name + " (" + year + ") - S" + seasonString + "E" + episodeString + "." + extension :
name + " (" + year + ") - S" + seasonString + "E" + episodeString + " - " + additionalInfo + "." + extension;
Files.move(path, path.resolveSibling(newName));
}
}
}

View File

@@ -0,0 +1,7 @@
package com.github.gtache.plex;
public interface Renamer {
void renameSeries(final String path, final String name, final int year);
void renameSeries(final String path);
}

View File

@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="warn">
<Appenders>
<!-- Console appender configuration -->
<Console name="console" target="SYSTEM_OUT">
<PatternLayout
pattern="%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n" />
</Console>
</Appenders>
<Loggers>
<!-- Root logger referring to console appender -->
<Root level="info" additivity="false">
<AppenderRef ref="console" />
</Root>
</Loggers>
</Configuration>