diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml new file mode 100644 index 0000000..87c3e99 --- /dev/null +++ b/.github/workflows/ant.yml @@ -0,0 +1,16 @@ +name: Java CI + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up JDK 8 + uses: actions/setup-java@v1 + with: + java-version: 8 + - name: run test suite with ant + run: ant -noinput -buildfile build.xml test -DbuildEnv=ci diff --git a/build.xml b/build.xml index 1770408..8ff5481 100644 --- a/build.xml +++ b/build.xml @@ -1,15 +1,18 @@ + + @@ -20,7 +23,6 @@ - @@ -30,7 +32,7 @@ - + @@ -45,6 +47,10 @@ + + + + @@ -52,6 +58,17 @@ + + + + + + + + + + + @@ -75,6 +92,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -97,41 +153,73 @@ - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ivy.xml b/ivy.xml index 5afffb7..a120411 100644 --- a/ivy.xml +++ b/ivy.xml @@ -4,6 +4,7 @@ + @@ -23,5 +24,10 @@ + + + + + diff --git a/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java b/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java index 8b7138a..9076bea 100644 --- a/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java +++ b/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java @@ -4,7 +4,9 @@ import com.adashrod.mkvscanner.model.Track; import com.adashrod.mkvscanner.model.Video; import com.adashrod.mkvscanner.util.FormatExtensionConfig; -import com.adashrod.mkvscanner.util.StreamConsumer; +import com.adashrod.mkvscanner.util.ProcessBuilderRunner; +import com.adashrod.mkvscanner.util.ProcessResult; +import com.adashrod.mkvscanner.util.ProcessRunner; import com.adashrod.mkvscanner.util.StringLineIterator; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -48,12 +50,22 @@ public class Eac3toScanner implements FileScanner { private final String executableLocation; private final File outputDirectory; + private final ProcessRunner processRunner; private final Collection eac3toLanguages = new HashSet<>(); private final Map formatExtensionConfigs = new HashMap<>(); public Eac3toScanner(final String executableLocation, final File outputDirectory) { + this(executableLocation, outputDirectory, new ProcessBuilderRunner()); + } + + /** + * Package-private constructor that allows injecting a {@link ProcessRunner}, used by tests to feed fake process + * output (stdout/exit code) without launching the real executable. + */ + Eac3toScanner(final String executableLocation, final File outputDirectory, final ProcessRunner processRunner) { this.executableLocation = Objects.requireNonNull(executableLocation, "Eac3toScanner.executableLocation can't be null"); this.outputDirectory = Objects.requireNonNull(outputDirectory, "Eac3toScanner.outputDirectory can't be null"); + this.processRunner = Objects.requireNonNull(processRunner, "Eac3toScanner.processRunner can't be null"); try { loadEac3toLanguages(); loadEac3toFormatExtensions(); @@ -105,29 +117,19 @@ public String exec(final File file, final String... arguments) throws DemuxerExc command.add(executableLocation); command.add(file.getPath()); Collections.addAll(command, arguments); - final ProcessBuilder builder = new ProcessBuilder(command); - Process process = null; - final int exitValue; - final StreamConsumer stdOut; - final StreamConsumer stdErr; + final ProcessResult result; try { - process = builder.start(); - stdOut = new StreamConsumer(process.getInputStream()); - stdErr = new StreamConsumer(process.getErrorStream()); - stdOut.start(); - stdErr.start(); - exitValue = process.waitFor(); + result = processRunner.run(command); } catch (final InterruptedException ie) { - process.destroy(); logger.error("process interrupted", ie);// todo: propagate a wrapper exception or re-throw; if not re-throwing, re-interrupt return null; } // when piping eac3to output to a file or stream, each line is prepended by a bunch of backspace characters: \b // also: the first line has the eac3to progress bar "----" and some whitespace - final String output = stdOut.getStreamContent().replaceAll("\b", "").replaceAll("^-+\\s+", "").trim(); + final String output = result.getStdout().replaceAll("\b", "").replaceAll("^-+\\s+", "").trim(); - if (exitValue == 0) { + if (result.getExitValue() == 0) { return output; } else { final StringBuilder argumentsBuilder = new StringBuilder(file.getPath()).append(" "); diff --git a/src/main/java/com/adashrod/mkvscanner/util/ProcessBuilderRunner.java b/src/main/java/com/adashrod/mkvscanner/util/ProcessBuilderRunner.java new file mode 100644 index 0000000..3c4b880 --- /dev/null +++ b/src/main/java/com/adashrod/mkvscanner/util/ProcessBuilderRunner.java @@ -0,0 +1,28 @@ +package com.adashrod.mkvscanner.util; + +import java.io.IOException; +import java.util.List; + +/** + * Production {@link ProcessRunner} that launches the process with a {@link ProcessBuilder} and drains its stdout and + * stderr with {@link StreamConsumer} threads to avoid the buffer-deadlock described in {@link StreamConsumer}. + */ +public class ProcessBuilderRunner implements ProcessRunner { + @Override + public ProcessResult run(final List command) throws IOException, InterruptedException { + final ProcessBuilder builder = new ProcessBuilder(command); + Process process = null; + try { + process = builder.start(); + final StreamConsumer stdOut = new StreamConsumer(process.getInputStream()); + final StreamConsumer stdErr = new StreamConsumer(process.getErrorStream()); + stdOut.start(); + stdErr.start(); + final int exitValue = process.waitFor(); + return new ProcessResult(exitValue, stdOut.getStreamContent(), stdErr.getStreamContent()); + } catch (final InterruptedException ie) { + process.destroy(); + throw ie; + } + } +} diff --git a/src/main/java/com/adashrod/mkvscanner/util/ProcessResult.java b/src/main/java/com/adashrod/mkvscanner/util/ProcessResult.java new file mode 100644 index 0000000..05916ac --- /dev/null +++ b/src/main/java/com/adashrod/mkvscanner/util/ProcessResult.java @@ -0,0 +1,29 @@ +package com.adashrod.mkvscanner.util; + +/** + * Immutable result of running an external process: its exit value and the full contents of its stdout and stderr + * streams. + */ +public class ProcessResult { + private final int exitValue; + private final String stdout; + private final String stderr; + + public ProcessResult(final int exitValue, final String stdout, final String stderr) { + this.exitValue = exitValue; + this.stdout = stdout; + this.stderr = stderr; + } + + public int getExitValue() { + return exitValue; + } + + public String getStdout() { + return stdout; + } + + public String getStderr() { + return stderr; + } +} diff --git a/src/main/java/com/adashrod/mkvscanner/util/ProcessRunner.java b/src/main/java/com/adashrod/mkvscanner/util/ProcessRunner.java new file mode 100644 index 0000000..192ae5e --- /dev/null +++ b/src/main/java/com/adashrod/mkvscanner/util/ProcessRunner.java @@ -0,0 +1,20 @@ +package com.adashrod.mkvscanner.util; + +import java.io.IOException; +import java.util.List; + +/** + * An abstraction over launching an external process and capturing its output. This is the seam that allows the process + * execution to be mocked in tests so that fake stdout/exit codes can be fed to the code that parses and interprets + * scanner output. + */ +public interface ProcessRunner { + /** + * Runs the given command, blocks until it exits, and returns its exit value and captured stdout/stderr. + * @param command the command and its arguments (e.g. as passed to {@link ProcessBuilder}) + * @return the result of running the process + * @throws IOException if the process can't be started or an I/O error occurs + * @throws InterruptedException if the current thread is interrupted while waiting for the process to finish + */ + ProcessResult run(List command) throws IOException, InterruptedException; +} diff --git a/src/main/resources/eac3to-format-extensions.json b/src/main/resources/eac3to-format-extensions.json index c2740ed..1d12a38 100644 --- a/src/main/resources/eac3to-format-extensions.json +++ b/src/main/resources/eac3to-format-extensions.json @@ -13,6 +13,12 @@ "extension": "dts", "flags": ["-core"] }], + "DTS-HD Master Audio": [{ + "extension": "dtshd" + }, { + "extension": "dts", + "flags": ["-core"] + }], "DTS-ES": "dts", "EAC3": "eac3", "MP3": "mp3", diff --git a/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java b/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java new file mode 100644 index 0000000..ab389d5 --- /dev/null +++ b/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java @@ -0,0 +1,509 @@ +package com.adashrod.mkvscanner; + +import com.adashrod.mkvscanner.model.Track; +import com.adashrod.mkvscanner.model.Video; +import com.adashrod.mkvscanner.util.ProcessResult; +import com.adashrod.mkvscanner.util.ProcessRunner; +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.junit.jupiter.MockitoExtension; + +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Created by aaron on 2026-06-13. + */ +@ExtendWith(MockitoExtension.class) +public class Eac3toScannerTest { + @Mock + private ProcessRunner processRunner; + + // hard-coded stdout of running eac3to on a blu-ray dir + private static final String BLU_RAY_DISC_LEGACY = String.join("\n", + "1) 00000.mpls, 00000.m2ts, 2:28:52", + " - Chapters, 34 chapters", + " - VC-1, 1080p24 /1.001 (16:9)", + " - AC3, English, multi-channel, 48kHz", + " - RAW/PCM, English, multi-channel, 48kHz", + " - AC3, French, multi-channel, 48kHz", + " - AC3, Spanish, multi-channel, 48kHz", + " - AC3, German, multi-channel, 48kHz", + " - AC3, Italian, multi-channel, 48kHz", + " - AC3, Spanish, multi-channel, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "2) 00033.mpls, 00049.m2ts, 1:16:31", + " - Chapters, 15 chapters", + " - VC-1, 1080i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "3) 00003.mpls, 00004.m2ts, 0:43:08", + " - MPEG2, 480i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "4) 00006.mpls, 00007.m2ts, 0:23:11", + " - MPEG2, 480i60 /1.001 (4:3)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "5) 00005.mpls, 00006.m2ts, 0:21:31", + " - MPEG2, 480i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "6) 00004.mpls, 00005.m2ts, 0:21:25", + " - MPEG2, 480i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + "", + "7) 00007.mpls, 00008.m2ts, 0:20:42", + " - MPEG2, 480i60 /1.001 (16:9)", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + " - AC3, English, stereo, 48kHz", + ""); + + // hard-coded stdout of running eac3to on a single blu-ray title + private static final String BLU_RAY_TITLE_LEGACY = String.join("\n", + "M2TS, 1 video track, 8 audio tracks, 18 subtitle tracks, 2:28:52, 24p /1.001", + "1: Chapters, 34 chapters", + "2: VC-1, 1080p24 /1.001 (16:9)", + "3: AC3, English, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "4: RAW/PCM, English, 5.1 channels, 16 bits, 48kHz", + "5: AC3, French, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "6: AC3, Spanish, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "7: AC3, German, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "8: AC3, Italian, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "9: AC3, Spanish, 5.1 channels, 448kbps, 48kHz, dialnorm: -27dB", + "10: AC3 Surround, English, 2.0 channels, 192kbps, 48kHz, dialnorm: -27dB", + "11: Subtitle (PGS), English", + "12: Subtitle (PGS), English", + "13: Subtitle (PGS), French", + "14: Subtitle (PGS), German", + "15: Subtitle (PGS), German", + "16: Subtitle (PGS), Italian", + "17: Subtitle (PGS), Italian", + "18: Subtitle (PGS), Spanish", + "19: Subtitle (PGS), Dutch", + "20: Subtitle (PGS), Chinese", + "21: Subtitle (PGS), Danish", + "22: Subtitle (PGS), Finnish", + "23: Subtitle (PGS), Korean", + "24: Subtitle (PGS), Norwegian", + "25: Subtitle (PGS), Portuguese", + "26: Subtitle (PGS), Portuguese", + "27: Subtitle (PGS), Spanish", + "28: Subtitle (PGS), Swedish"); + + // hard-coded stdout of running eac3to on an mkv file with a TrueHD track; has quoted track names, both on their + // own line and inline + private static final String MKV_FILE_TRUEHD_LEGACY = String.join("\n", + "MKV, 1 video track, 3 audio tracks, 2 subtitle tracks, 2:12:06, 24p/1.001", + "1: h264/AVC, English, 1080p24/1.001 (16:9)", + "2: TrueHD, English, 7.1 channels, 48kHz, dialnorm: -31dB", + " \"TrueHD\"", + "3: AC3, English, 5.1 channels, 640kbps, 48kHz, dialnorm: -31dB", + "4: AC3, Spanish, 5.1 channels, 640kbps, 48kHz, dialnorm: -31dB", + " \"Spanish\"", + "5: Subtitle (PGS), English", + "6: Subtitle (PGS), Spanish, \"Spanish\""); + + // hard-coded stdout of running eac3to on an mkv file with a DTS-HD MA track; includes a parenthetical "(core: ...)" + // metadata line that the parser must ignore + private static final String MKV_FILE_DTSHD_LEGACY = String.join("\n", + "MKV, 1 video track, 2 audio tracks, 2 subtitle tracks, 2:44:34, 24p/1.001", + "1: h264/AVC, English, 1080p24/1.001 (16:9)", + "2: DTS-HD Master Audio, English, 5.1 channels, 24 bits, 48kHz, dialnorm: 0dB", + " (core: DTS, 5.1 channels, 1509kbps, 48kHz, dialnorm: 0dB)", + " \"DTS-HD\"", + "3: AC3, Spanish, 5.1 channels, 448kbps, 48kHz, dialnorm: -31dB", + " \"Spanish\"", + "4: Subtitle (PGS), English", + "5: Subtitle (PGS), Spanish, \"Spanish\""); + + // a track line whose format type (audio/video/subtitle/chapters) can't be determined: triggers the + // FormatTypeParseException -> ParseException -> RuntimeException path in parseSingleTitleOrFile + private static final String MALFORMED_TRACK_LEGACY = String.join("\n", + "MKV, 1 video track, 0 audio tracks, 0 subtitle tracks, 1:00:00, 24p/1.001", + "1: BogusFormat, gibberish, more gibberish"); + + // VobSub has no entry in eac3to-format-extensions.json, so demuxing track 2 is skipped while track 1 (AC3) is not + private static final String MKV_FILE_VOBSUB_LEGACY = String.join("\n", + "MKV, 1 video track, 1 audio track, 1 subtitle track, 1:00:00, 24p/1.001", + "1: AC3, English, 5.1 channels, 448kbps, 48kHz", + "2: Subtitle (VobSub), English"); + + @Test + public void scanBluRayDir_returnsAllTitleNumbers(@TempDir final File bluRayDir) throws Exception { + final FileScanner scanner = scannerReturning(0, BLU_RAY_DISC_LEGACY); + final Set titles = scanner.scanBluRayDir(bluRayDir); + + assertEquals(new HashSet<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7)), titles); + } + + @Test + public void scanAndParseBluRayTitle_parsesEveryTrack(@TempDir final File bluRayDir) throws Exception { + final FileScanner scanner = scannerReturning(0, BLU_RAY_TITLE_LEGACY); + + final Video video = scanner.scanAndParseBluRayTitle(bluRayDir, 1); + final List tracks = video.getTracks(); + + // the leading "M2TS, 1 video track, ..." summary line is not a track; the 28 numbered lines are + assertEquals(28, tracks.size()); + assertTrack(tracks.get(0), 1, "Matroska", "Chapters", "Undetermined"); + assertTrack(tracks.get(1), 2, "VC-1", "Video", "Undetermined"); + assertTrack(tracks.get(2), 3, "AC3", "Audio", "English"); + assertTrack(tracks.get(3), 4, "RAW/PCM", "Audio", "English"); + assertTrack(tracks.get(4), 5, "AC3", "Audio", "French"); + assertTrack(tracks.get(5), 6, "AC3", "Audio", "Spanish"); + assertTrack(tracks.get(6), 7, "AC3", "Audio", "German"); + assertTrack(tracks.get(7), 8, "AC3", "Audio", "Italian"); + assertTrack(tracks.get(8), 9, "AC3", "Audio", "Spanish"); + assertTrack(tracks.get(9), 10, "AC3 Surround", "Audio", "English"); + assertTrack(tracks.get(10), 11, "PGS", "Subtitles", "English"); + assertTrack(tracks.get(11), 12, "PGS", "Subtitles", "English"); + assertTrack(tracks.get(12), 13, "PGS", "Subtitles", "French"); + assertTrack(tracks.get(13), 14, "PGS", "Subtitles", "German"); + assertTrack(tracks.get(14), 15, "PGS", "Subtitles", "German"); + assertTrack(tracks.get(15), 16, "PGS", "Subtitles", "Italian"); + assertTrack(tracks.get(16), 17, "PGS", "Subtitles", "Italian"); + assertTrack(tracks.get(17), 18, "PGS", "Subtitles", "Spanish"); + assertTrack(tracks.get(18), 19, "PGS", "Subtitles", "Dutch"); + assertTrack(tracks.get(19), 20, "PGS", "Subtitles", "Chinese"); + assertTrack(tracks.get(20), 21, "PGS", "Subtitles", "Danish"); + assertTrack(tracks.get(21), 22, "PGS", "Subtitles", "Finnish"); + assertTrack(tracks.get(22), 23, "PGS", "Subtitles", "Korean"); + assertTrack(tracks.get(23), 24, "PGS", "Subtitles", "Norwegian"); + assertTrack(tracks.get(24), 25, "PGS", "Subtitles", "Portuguese"); + assertTrack(tracks.get(25), 26, "PGS", "Subtitles", "Portuguese"); + assertTrack(tracks.get(26), 27, "PGS", "Subtitles", "Spanish"); + assertTrack(tracks.get(27), 28, "PGS", "Subtitles", "Swedish"); + } + + @Test + public void scanAndParseFile_parsesTracksAndQuotedNames() throws Exception { + final FileScanner scanner = scannerReturning(0, MKV_FILE_TRUEHD_LEGACY); + + final List tracks = scanner.scanAndParseFile(new File("movie.mkv")).getTracks(); + + // the leading "MKV, 1 video track, ..." summary line is not a track; the 6 numbered lines are + assertEquals(6, tracks.size()); + assertTrack(tracks.get(0), 1, null, "h264/AVC", "Video", "English"); + assertTrack(tracks.get(1), 2, "TrueHD", "TrueHD", "Audio", "English"); // name on its own line + assertTrack(tracks.get(2), 3, null, "AC3", "Audio", "English"); + assertTrack(tracks.get(3), 4, "Spanish", "AC3", "Audio", "Spanish"); // name on its own line + assertTrack(tracks.get(4), 5, null, "PGS", "Subtitles", "English"); + assertTrack(tracks.get(5), 6, "Spanish", "PGS", "Subtitles", "Spanish"); // name inline on the track line + } + + @Test + public void scanAndParseFile_ignoresParentheticalMetadataLines() throws Exception { + final FileScanner scanner = scannerReturning(0, MKV_FILE_DTSHD_LEGACY); + + final List tracks = scanner.scanAndParseFile(new File("movie.mkv")).getTracks(); + + assertEquals(5, tracks.size()); + assertTrack(tracks.get(0), 1, null, "h264/AVC", "Video", "English"); + // the "(core: ...)" line is ignored; the following "DTS-HD" line sets this track's name + assertTrack(tracks.get(1), 2, "DTS-HD", "DTS-HD Master Audio", "Audio", "English"); + assertTrack(tracks.get(2), 3, "Spanish", "AC3", "Audio", "Spanish"); + assertTrack(tracks.get(3), 4, null, "PGS", "Subtitles", "English"); + assertTrack(tracks.get(4), 5, "Spanish", "PGS", "Subtitles", "Spanish"); + } + + @Test + public void scanAndParseFile_whenTrackFormatTypeUnparseable_throwsRuntimeException() throws Exception { + final FileScanner scanner = scannerReturning(0, MALFORMED_TRACK_LEGACY); + + final RuntimeException ex = assertThrows(RuntimeException.class, + () -> scanner.scanAndParseFile(new File("movie.mkv"))); + assertTrue(ex.getMessage().contains("Possible bug in MkvScanner"), + "unexpected message: " + ex.getMessage()); + } + + @Test + public void demuxFileByTracks_buildsArgsAndIgnoresTrackNames() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, MKV_FILE_TRUEHD_LEGACY); + final File file = new File("movie.mkv"); + + // tracks 4 and 6 carry quoted names ("Spanish") that must NOT leak into the output filenames + final Collection filenames = scanner.demuxFileByTracks(file, Arrays.asList(2, 4, 6)); + + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.mkv"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", "movie.mkv", + "2:", out(outputDir, "movie_tr2_English_TrueHD.thd+ac3"), + "4:", out(outputDir, "movie_tr4_Spanish_AC3.ac3"), + "6:", out(outputDir, "movie_tr6_Spanish_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList("movie_tr2_English_TrueHD.thd+ac3", + "movie_tr4_Spanish_AC3.ac3", "movie_tr6_Spanish_PGS.sup")), new HashSet<>(filenames)); + } + + @Test + public void demuxFileByTracks_emitsEveryConfigForMultiConfigFormat() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, MKV_FILE_DTSHD_LEGACY); + final File file = new File("movie.mkv"); + + // DTS-HD Master Audio maps to two ExtensionConfigs: {dtshd} and {dts, -core}, so track 2 demuxes to two files, + // the second of which appends the "-core" flag as an argument (and "core" into its filename) + final Collection filenames = scanner.demuxFileByTracks(file, Arrays.asList(2, 3)); + + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.mkv"), commands.get(0)); + + final List demux = commands.get(1); + assertEquals(Arrays.asList("eac3to", "movie.mkv"), demux.subList(0, 2)); + // the two configs come from a HashSet, so their relative order isn't guaranteed: compare order-insensitively + assertEquals(sorted(Arrays.asList( + "2:", out(outputDir, "movie_tr2_English_DTS-HD_Master_Audio.dtshd"), + "2:", out(outputDir, "movie_tr2_English_core_DTS-HD_Master_Audio.dts"), "-core", + "3:", out(outputDir, "movie_tr3_Spanish_AC3.ac3"))), + sorted(demux.subList(2, demux.size()))); + + assertEquals(new HashSet<>(Arrays.asList( + "movie_tr2_English_DTS-HD_Master_Audio.dtshd", + "movie_tr2_English_core_DTS-HD_Master_Audio.dts", + "movie_tr3_Spanish_AC3.ac3")), new HashSet<>(filenames)); + } + + @Test + public void demuxFileByTracks_skipsTrackWithNoFormatExtensionConfig() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, MKV_FILE_VOBSUB_LEGACY); + final File file = new File("movie.mkv"); + + // track 2 (VobSub) has no entry in eac3to-format-extensions.json, so it is skipped; only track 1 (AC3) demuxes + final Collection filenames = scanner.demuxFileByTracks(file, Arrays.asList(1, 2)); + + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.mkv"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", "movie.mkv", + "1:", out(outputDir, "movie_tr1_English_AC3.ac3")), commands.get(1)); + assertEquals(Collections.singleton("movie_tr1_English_AC3.ac3"), new HashSet<>(filenames)); + } + + private static void assertTrack(final Track track, final int number, final String formatName, + final String formatType, final String language) { + assertTrack(track, number, null, formatName, formatType, language); + } + + private static void assertTrack(final Track track, final int number, final String name, final String formatName, + final String formatType, final String language) { + assertEquals(number, track.getNumber()); + assertEquals(name, track.getName()); + assertNotNull(track.getFormat()); + assertEquals(formatName, track.getFormat().getName()); + assertEquals(formatType, track.getFormat().getFormatType()); + assertEquals(language, track.getLanguage()); + } + + /** + * Builds a scanner whose (mocked) executable returns the given exit code and stdout. exec() doesn't touch the + * filesystem, so the output directory is just a dummy and the scanned file need not exist. + */ + private FileScanner scannerReturning(final int exitValue, final String stdout) throws Exception { + when(processRunner.run(anyList())).thenReturn(new ProcessResult(exitValue, stdout, "")); + return new Eac3toScanner("eac3to", new File("out"), processRunner); + } + + @Test + public void exec_whenFormatNotDetected_throwsCorruptBluRayStructureException() throws Exception { + final FileScanner scanner = scannerReturning(1, "The format of the source file could not be detected."); + + final CorruptBluRayStructureException ex = assertThrows(CorruptBluRayStructureException.class, + () -> scanner.exec(new File("movie.m2ts"))); + assertEquals("movie.m2ts", ex.getFilename()); + assertEquals("The format of the source file could not be detected.", ex.getDemuxerOutput()); + } + + @Test + public void exec_whenFileUnreadable_throwsUnreadableFileException() throws Exception { + final FileScanner scanner = scannerReturning(1, "Error reading file \"movie.txt\"."); + + assertThrows(UnreadableFileException.class, () -> scanner.exec(new File("movie.txt"))); + } + + @Test + public void exec_whenConversionUnsupported_throwsFormatConversionException() throws Exception { + final FileScanner scanner = scannerReturning(1, "This audio conversion is not supported."); + + assertThrows(FormatConversionException.class, () -> scanner.exec(new File("movie.m2ts"))); + } + + @Test + public void exec_whenNotBluRayDirectory_throwsNotBluRayDirectoryException() throws Exception { + final FileScanner scanner = scannerReturning(1, "HD DVD / Blu-Ray disc structure not found."); + + assertThrows(NotBluRayDirectoryException.class, () -> scanner.exec(new File("not-a-bd-dir"))); + } + + @Test + public void exec_whenUnrecognizedError_throwsGenericDemuxerException() throws Exception { + final FileScanner scanner = scannerReturning(1, "some unrecognized eac3to failure"); + + final DemuxerException ex = assertThrows(DemuxerException.class, () -> scanner.exec(new File("movie.m2ts"))); + // a non-zero exit with no recognized message yields the base type, not one of the subclasses + assertEquals(DemuxerException.class, ex.getClass()); + } + + @Test + public void demuxFileByTracks_demuxesSelectedTrackNumbers() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, BLU_RAY_TITLE_LEGACY); + final File file = new File("movie.m2ts"); + + final Collection filenames = scanner.demuxFileByTracks(file, Arrays.asList(3, 11)); + + // demuxHelper scans first, then runs the demux; the scan of a plain file passes no extra args + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.m2ts"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", "movie.m2ts", + "3:", out(outputDir, "movie_tr3_English_AC3.ac3"), + "11:", out(outputDir, "movie_tr11_English_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList( + "movie_tr3_English_AC3.ac3", "movie_tr11_English_PGS.sup")), new HashSet<>(filenames)); + } + + @Test + public void demuxFileByLanguages_demuxesEveryTrackInLanguage() throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, BLU_RAY_TITLE_LEGACY); + final File file = new File("movie.m2ts"); + + // German appears on tracks 7 (AC3), 14 (PGS) and 15 (PGS) + final Collection filenames = scanner.demuxFileByLanguages(file, Collections.singletonList("German")); + + final List> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", "movie.m2ts"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", "movie.m2ts", + "7:", out(outputDir, "movie_tr7_German_AC3.ac3"), + "14:", out(outputDir, "movie_tr14_German_PGS.sup"), + "15:", out(outputDir, "movie_tr15_German_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList("movie_tr7_German_AC3.ac3", + "movie_tr14_German_PGS.sup", "movie_tr15_German_PGS.sup")), new HashSet<>(filenames)); + } + + @Test + public void demuxBluRayTitleByTracks_buildsArgsForTitleAndMixedFormats(@TempDir final File tempDir) throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, BLU_RAY_TITLE_LEGACY); + final File bluRayDir = new File(tempDir, "MOVIE_BD"); + assertTrue(bluRayDir.mkdir()); + + // track 1 = chapters (looked up by format type, no friendly-name suffix), 2 = VC-1 video, 4 = RAW/PCM audio + final Collection filenames = scanner.demuxBluRayTitleByTracks(bluRayDir, 1, Arrays.asList(1, 2, 4)); + + final List> commands = capturedCommands(2); + // scanning a title passes the ")" selector to the executable + assertEquals(Arrays.asList("eac3to", bluRayDir.getPath(), "1)"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", bluRayDir.getPath(), "1)", + "1:", out(outputDir, "MOVIE_BD_ti1_tr1_Undetermined.txt"), + "2:", out(outputDir, "MOVIE_BD_ti1_tr2_Undetermined_VC-1.mkv"), + "4:", out(outputDir, "MOVIE_BD_ti1_tr4_English_RAW_PCM.pcm")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList("MOVIE_BD_ti1_tr1_Undetermined.txt", + "MOVIE_BD_ti1_tr2_Undetermined_VC-1.mkv", "MOVIE_BD_ti1_tr4_English_RAW_PCM.pcm")), + new HashSet<>(filenames)); + } + + @Test + public void demuxBluRayTitleByLanguages_buildsArgsForTitleAndLanguage(@TempDir final File tempDir) throws Exception { + final File outputDir = new File("out"); + final FileScanner scanner = scannerWithOutputDir(outputDir, BLU_RAY_TITLE_LEGACY); + final File bluRayDir = new File(tempDir, "MOVIE_BD"); + assertTrue(bluRayDir.mkdir()); + + // French appears on tracks 5 (AC3) and 13 (PGS) + final Collection<String> filenames = scanner.demuxBluRayTitleByLanguages(bluRayDir, 1, Collections.singletonList("French")); + + final List<List<String>> commands = capturedCommands(2); + assertEquals(Arrays.asList("eac3to", bluRayDir.getPath(), "1)"), commands.get(0)); + assertEquals(Arrays.asList("eac3to", bluRayDir.getPath(), "1)", + "5:", out(outputDir, "MOVIE_BD_ti1_tr5_French_AC3.ac3"), + "13:", out(outputDir, "MOVIE_BD_ti1_tr13_French_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList( + "MOVIE_BD_ti1_tr5_French_AC3.ac3", "MOVIE_BD_ti1_tr13_French_PGS.sup")), new HashSet<>(filenames)); + } + + private FileScanner scannerWithOutputDir(final File outputDir, final String scanOutput) throws Exception { + when(processRunner.run(anyList())).thenReturn(new ProcessResult(0, scanOutput, "")); + return new Eac3toScanner("eac3to", outputDir, processRunner); + } + + /** Captures the command list of every {@code processRunner.run(...)} call, asserting there were exactly {@code n}. */ + @SuppressWarnings("unchecked") + private List<List<String>> capturedCommands(final int n) throws Exception { + final ArgumentCaptor<List<String>> captor = ArgumentCaptor.forClass(List.class); + verify(processRunner, times(n)).run(captor.capture()); + return captor.getAllValues(); + } + + /** The absolute demux output path the scanner would build for a file named {@code name} in {@code outputDir}. */ + private static String out(final File outputDir, final String name) { + return outputDir.getAbsolutePath() + File.separator + name; + } + + /** Returns a sorted copy of the given list, for order-insensitive comparison of command arguments. */ + private static List<String> sorted(final List<String> values) { + final List<String> copy = new ArrayList<>(values); + Collections.sort(copy); + return copy; + } +}