diff --git a/.github/workflows/ant.yml b/.github/workflows/ant.yml deleted file mode 100644 index 87c3e99..0000000 --- a/.github/workflows/ant.yml +++ /dev/null @@ -1,16 +0,0 @@ -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/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..a7ca662 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,16 @@ +name: Java CI + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + java-version: 8 + distribution: temurin + - name: Run test suite with Gradle + run: ./gradlew test diff --git a/.gitignore b/.gitignore index d2940bf..e3d88c1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,6 @@ /.idea *.iml -/ivy /lib /build @@ -11,9 +10,8 @@ /dist /javadoc -/FAKE_BD /*.mkv -/bd.txt -/bd_title.txt /todo.txt +.gradle/ +build/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7f47efc --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What This Project Is + +A Java library that wraps [eac3to](https://www.videohelp.com/software/eac3to), a Windows CLI tool for scanning and demuxing MKV/M2TS files and Blu-ray directories. The library is Windows-only at runtime (requires `eac3to.exe`), but builds and tests run on any platform since tests mock the process runner. + +## Build System + +Gradle with wrapper. Source/target compatibility is Java 8. Set `JAVA_HOME` to Java 8 when building. + +```bash +# Build the jar (compiles and runs tests) +JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk-amd64 ./gradlew build + +# Run all tests +JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk-amd64 ./gradlew test + +# Run a subset of tests (partial class-name match) +JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk-amd64 ./gradlew test --tests "*Iso639*" + +# Clean all build artifacts +JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk-amd64 ./gradlew clean +``` + +## Publishing + +Publishing to Maven Central (Sonatype) requires GPG signing and Sonatype credentials. Tokens are generated at central.sonatype.com. + +```bash +# Publish snapshot (version must end in -SNAPSHOT) +JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk-amd64 ./gradlew publishToSonatype \ + -Ppublish.user= \ + -Ppublish.password= \ + -Psigning.gnupg.passphrase="" + +# Publish release (remove -SNAPSHOT from version in build.gradle first) +JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk-amd64 ./gradlew publishToSonatype closeAndReleaseSonatypeStagingRepository \ + -Ppublish.user= \ + -Ppublish.password= \ + -Psigning.gnupg.passphrase="" + +# Test publishing locally (no upload to Sonatype) +JAVA_HOME=/usr/lib/jvm/java-1.8.0-openjdk-amd64 ./gradlew publishToMavenLocal +``` + +Repository URLs configured in `build.gradle`: +- Snapshots: `https://central.sonatype.com/repository/maven-snapshots/` +- Staging: `https://ossrh-staging-api.central.sonatype.com/service/local/staging/deploy/maven2/` + +## Architecture + +### Entry Point + +`FileScanner` (interface) → `Eac3toScanner` (sole implementation). Consumers instantiate `Eac3toScanner` with the path to `eac3to.exe` and an output directory. + +### Parsing Flow + +`Eac3toScanner` invokes eac3to via `ProcessRunner` (seam interface), then parses its stdout line-by-line using `StringLineIterator`. Parsed output is assembled into a `Video` containing a list of `Track` objects. + +Eac3to output comes in two formats ("legacy" plain-English language names, "new" ISO 639-2 bracketed codes). `Iso639Language.fromToken()` normalizes both. + +### Model + +- `Video` → `List` +- `Track`: number, name (nullable quoted string), `Format`, `Iso639Language` +- `Format`: name string + `FormatType` enum (AUDIO/VIDEO/SUBTITLES/CHAPTERS) +- `Iso639Language`: full ISO 639-2 enum; `fromToken(String)` accepts plain names, aliases, bracketed codes (`[eng]`), bare codes — case-insensitive, whitespace-trimmed; `UNDETERMINED` sentinel for absent/unknown language + +### Format→Extension Mapping + +`src/main/resources/eac3to-format-extensions.json` maps format names to output file extensions and optional flags. Loaded by `FormatExtensionConfig`. Formats with multiple configs (DTS-HD Master Audio, TrueHD/AC3) produce multiple output files per track. VobSub tracks are silently skipped during demux (extension unknown). + +### Testing + +Tests mock `ProcessRunner` with fixture strings representing real eac3to output, so no eac3to binary is needed. `Eac3toScanner` exposes a package-private constructor accepting a `ProcessRunner` for this purpose. diff --git a/README.md b/README.md index 8f7cac5..11253c5 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ Video metadata = scanner.scanAndParseFile(new File("c:\\mkvs\\MyMovie.mkv")); ~~~~ `metadata` now contains a list of the tracks found in the mkv file. Each track object (in `metadata.getTracks()`) contains information about the track, such as: -- name, plaintext label, if it has one -- format name (AC-3, H-264, PGS, etc) -- format type (Audio, Video, Subtitles, Chapters) +- name: plaintext label, if it has one +- format name: (AC-3, H-264, PGS, etc) +- format type: (Audio, Video, Subtitles, Chapters) - language ### demuxing: @@ -28,19 +28,4 @@ demuxed tracks are output into the directory specified in the Eac3toScanner cons ### adding as a dependency -Page on [search.maven.org](http://search.maven.org/#artifactdetails%7Ccom.adashrod.mkvscanner%7Cmkvscanner%7C1.0.0%7Cpom) - -Page on [mvnrepository.com](http://mvnrepository.com/artifact/com.adashrod.mkvscanner/mkvscanner) - -##### Maven: -~~~~ - - com.adashrod.mkvscanner - mkvscanner - 1.1.0 - -~~~~ -##### Ivy: -~~~~ - -~~~~ +[Maven Central](http://search.maven.org/#artifactdetails%7Ccom.adashrod.mkvscanner%7Cmkvscanner%7C2.0.0%7Cpom) diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..2c54c4e --- /dev/null +++ b/build.gradle @@ -0,0 +1,91 @@ +plugins { + id 'java-library' + id 'maven-publish' + id 'signing' + id 'io.github.gradle-nexus.publish-plugin' version '1.3.0' +} + +group = 'com.adashrod.mkvscanner' +version = '2.0.0' + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + withSourcesJar() + withJavadocJar() +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.slf4j:slf4j-api:1.7.25' + implementation 'com.fasterxml.jackson.core:jackson-core:2.7.0' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.7.0' + + testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.4.2' + testImplementation 'org.junit.platform:junit-platform-engine:1.4.2' + testImplementation 'org.mockito:mockito-core:2.27.0' + testImplementation 'org.mockito:mockito-junit-jupiter:2.27.0' +} + +test { + useJUnitPlatform() + + testLogging { + events "passed", "skipped", "failed" + exceptionFormat "full" + showStackTraces true + showCauses true + showStandardStreams false + } +} + +publishing { + publications { + mavenJava(MavenPublication) { + from components.java + pom { + name = 'MkvScanner' + description = 'MkvScanner is a java API wrapper for eac3to' + url = 'https://www.github.com/adashrod/MkvScanner' + licenses { + license { + name = 'MIT License' + url = 'https://opensource.org/licenses/MIT' + distribution = 'repo' + } + } + scm { + url = 'https://www.github.com/adashrod/MkvScanner' + connection = 'scm:git:git@github.com:adashrod/MkvScanner.git' + developerConnection = 'scm:git:git@github.com:adashrod/MkvScanner.git' + } + developers { + developer { + name = 'Aaron Rodriguez' + email = 'adashrod@gmail.com' + url = 'https://www.github.com/adashrod' + } + } + } + } + } +} + +nexusPublishing { + repositories { + sonatype { + nexusUrl = uri('https://ossrh-staging-api.central.sonatype.com/service/local/') + snapshotRepositoryUrl = uri('https://central.sonatype.com/repository/maven-snapshots/') + username = findProperty('publish.user') ?: '' + password = findProperty('publish.password') ?: '' + } + } +} + +signing { + useGpgCmd() + sign publishing.publications.mavenJava +} diff --git a/build.xml b/build.xml deleted file mode 100644 index 8ff5481..0000000 --- a/build.xml +++ /dev/null @@ -1,242 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ca025c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..5eed7ee --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ivy.xml b/ivy.xml deleted file mode 100644 index a120411..0000000 --- a/ivy.xml +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/ivySettings.xml b/ivySettings.xml deleted file mode 100644 index 8e7d048..0000000 --- a/ivySettings.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/pomTemplate.xml b/pomTemplate.xml deleted file mode 100644 index b1d8eec..0000000 --- a/pomTemplate.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - 4.0.0 - ${ivy.pom.groupId} - ${ivy.pom.artifactId} - ${ivy.pom.packaging} - ${ivy.pom.version} - MkvScanner - MkvScanner is a java API wrapper for eac3to - https://www.github.com/adashrod/MkvScanner - - - MIT License - https://opensource.org/licenses/MIT - repo - - - - https://www.github.com/adashrod/MkvScanner - scm:git:git@github.com:adashrod/MkvScanner.git - scm:git:git@github.com:adashrod/MkvScanner.git - - - - Aaron Rodriguez - adashrod@gmail.com - https://www.github.com/adashrod - - - diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..bea57be --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'mkvscanner' diff --git a/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java b/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java index 9076bea..6f67638 100644 --- a/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java +++ b/src/main/java/com/adashrod/mkvscanner/Eac3toScanner.java @@ -1,6 +1,8 @@ package com.adashrod.mkvscanner; import com.adashrod.mkvscanner.model.Format; +import com.adashrod.mkvscanner.model.FormatType; +import com.adashrod.mkvscanner.model.Iso639Language; import com.adashrod.mkvscanner.model.Track; import com.adashrod.mkvscanner.model.Video; import com.adashrod.mkvscanner.util.FormatExtensionConfig; @@ -15,11 +17,9 @@ import org.slf4j.Marker; import org.slf4j.MarkerFactory; -import java.io.BufferedReader; import java.io.File; import java.io.IOException; import java.io.InputStream; -import java.io.InputStreamReader; import java.text.ParseException; import java.util.ArrayList; import java.util.Collection; @@ -51,7 +51,6 @@ 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) { @@ -67,7 +66,6 @@ public Eac3toScanner(final String executableLocation, final File outputDirectory 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(); } catch (final IOException ioe) { logger.error(fatal, "Config error: " + ioe.getMessage()); @@ -75,20 +73,6 @@ public Eac3toScanner(final String executableLocation, final File outputDirectory } } - private void loadEac3toLanguages() throws IOException { - final InputStream inputStream = getClass().getResourceAsStream("/eac3to-languages.txt"); - if (inputStream == null) { - throw new IOException("/eac3to-languages.txt is missing from mkvscanner jar!"); - } - final BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream)); - String line; - while ((line = reader.readLine()) != null) { - if (!line.isEmpty()) { - eac3toLanguages.add(line); - } - } - } - @SuppressWarnings("unchecked") private void loadEac3toFormatExtensions() throws IOException { final InputStream inputStream = getClass().getResourceAsStream("/eac3to-format-extensions.json"); @@ -192,7 +176,7 @@ public Collection demuxBluRayTitleByTracks(final File bluRayDirectory, f } @Override - public Collection demuxBluRayTitleByLanguages(final File bluRayDirectory, final int title, final Collection languagesToInclude) throws DemuxerException, IOException { + public Collection demuxBluRayTitleByLanguages(final File bluRayDirectory, final int title, final Collection languagesToInclude) throws DemuxerException, IOException { ensureDirectoryStatus(true, bluRayDirectory); return demuxHelper(bluRayDirectory, title, null, languagesToInclude); } @@ -204,7 +188,7 @@ public Collection demuxFileByTracks(final File file, final Collection demuxFileByLanguages(final File file, final Collection languagesToInclude) throws DemuxerException, IOException { + public Collection demuxFileByLanguages(final File file, final Collection languagesToInclude) throws DemuxerException, IOException { ensureDirectoryStatus(false, file); return demuxHelper(file, null, null, languagesToInclude); } @@ -323,16 +307,13 @@ private Track turnTokensIntoTrack(final List tokens) throws FormatTypePa // accounting for all of the different metadata string types (48kHz, 448kbps, etc) formatFound = true; if (token.equals("Chapters")) { - final Format matroskaChaptersFormat = new Format(); - matroskaChaptersFormat.setFormatType("Chapters"); - matroskaChaptersFormat.setName("Matroska"); - track.setFormat(matroskaChaptersFormat); + track.setFormat(Format.OGM_CHAPTERS); } else { - if (token.startsWith("Subtitle")) { + if (token.startsWith("Subtitle") || token.startsWith("*Subtitle")) { final String subtitleFormatName = parseSubtitleFormat(token); // PGS, VobSub, etc final Format subtitleFormat = new Format(); subtitleFormat.setName(subtitleFormatName); - subtitleFormat.setFormatType("Subtitles"); + subtitleFormat.setFormatType(FormatType.SUBTITLES); track.setFormat(subtitleFormat); } else { // save for later, don't set track.format until we also have the formatType @@ -343,7 +324,7 @@ private Track turnTokensIntoTrack(final List tokens) throws FormatTypePa continue; } if (!formatTypeFound) { - final String formatType = tryToParseFormatTypeIndicator(token); + final FormatType formatType = tryToParseFormatTypeIndicator(token); if (formatType != null) { final Format format = new Format(); format.setName(formatString); @@ -354,34 +335,38 @@ private Track turnTokensIntoTrack(final List tokens) throws FormatTypePa } } if (!languageFound) { - if (eac3toLanguages.contains(token)) { - track.setLanguage(token); + try { + final Iso639Language language = Iso639Language.fromToken(token); + track.setLanguage(language); languageFound = true; badFormatTypeIndex++; - } + } catch (final IllegalArgumentException ignored) {} } } if (!languageFound) { - track.setLanguage("Undetermined"); + track.setLanguage(Iso639Language.UNDETERMINED); } if (track.getFormat() == null) { - throw new FormatTypeParseException(null, tokens.get(badFormatTypeIndex)); + final String formatTypeToken = badFormatTypeIndex < tokens.size() ? + tokens.get(badFormatTypeIndex) : + String.format("Out of bounds in: %s", String.join(" ", tokens)); + throw new FormatTypeParseException(null, formatTypeToken); } return track; } private String parseSubtitleFormat(final CharSequence subtitleToken) { - final Pattern subtitleTokenFormat = Pattern.compile("Subtitle\\s*\\(([^)]+)\\).*"); + final Pattern subtitleTokenFormat = Pattern.compile("\\*?Subtitle\\s*\\(([^)]+)\\).*"); final Matcher matcher = subtitleTokenFormat.matcher(subtitleToken); return matcher.matches() ? matcher.group(1) : null; } - private String tryToParseFormatTypeIndicator(final CharSequence token) { - final Map cachedFormatTypesByPattern = new HashMap<>(); - cachedFormatTypesByPattern.put(videoIndicator, "Video"); - cachedFormatTypesByPattern.put(audioIndicator, "Audio"); - cachedFormatTypesByPattern.put(subtitlesIndicator, "Subtitles"); - cachedFormatTypesByPattern.put(chaptersIndicator, "Chapters"); + private FormatType tryToParseFormatTypeIndicator(final CharSequence token) { + final Map cachedFormatTypesByPattern = new HashMap<>(); + cachedFormatTypesByPattern.put(videoIndicator, FormatType.VIDEO); + cachedFormatTypesByPattern.put(audioIndicator, FormatType.AUDIO); + cachedFormatTypesByPattern.put(subtitlesIndicator, FormatType.SUBTITLES); + cachedFormatTypesByPattern.put(chaptersIndicator, FormatType.CHAPTERS); for (final Pattern p: new Pattern[]{videoIndicator, audioIndicator, subtitlesIndicator, chaptersIndicator}) { final Matcher m = p.matcher(token); if (m.matches()) { @@ -392,7 +377,7 @@ private String tryToParseFormatTypeIndicator(final CharSequence token) { } private Collection demuxHelper(final File fileToDemux, final Integer title, final Collection tracksToInclude, - final Collection languagesToInclude) throws DemuxerException, IOException { + final Collection languagesToInclude) throws DemuxerException, IOException { if (!(tracksToInclude != null ^ languagesToInclude != null)) { throw new IllegalArgumentException("demuxHelper must be called with exactly one of: tracksToInclude, languagesToInclude"); } @@ -417,9 +402,9 @@ private Collection demuxHelper(final File fileToDemux, final Integer tit (languagesToInclude != null && languagesToInclude.contains(track.getLanguage())); }).forEach((final Track track) -> { FormatExtensionConfig formatExtensionConfig = formatExtensionConfigs.get(track.getFormat().getName()); - if (formatExtensionConfig == null) { + if (formatExtensionConfig == null && track.getFormat().getFormatType() == FormatType.CHAPTERS) { // for the unusual case of chapters being labeled "Chapters" by eac3to - formatExtensionConfig = formatExtensionConfigs.get(track.getFormat().getFormatType()); + formatExtensionConfig = formatExtensionConfigs.get("Chapters"); } if (formatExtensionConfig == null) { // skip track if format is not supported (VobSub) @@ -440,17 +425,23 @@ private Collection demuxHelper(final File fileToDemux, final Integer tit if (title != null) { outputFilenameBuilder.append("_ti").append(title); } - outputFilenameBuilder.append("_tr").append(track.getNumber()).append("_").append(track.getLanguage()); + outputFilenameBuilder.append("_tr").append(track.getNumber()).append("_"); + + if (track.getFormat().getFormatType() != FormatType.CHAPTERS) { + final String friendlyFormatName = track.getFormat().getName().replaceAll("[/\\(\\)\\s]", "_"); + outputFilenameBuilder + .append(track.getLanguage().getCode()) + .append("_").append(friendlyFormatName); + } else { + outputFilenameBuilder.append("Chapters"); + } if (!ec.getFlags().isEmpty()) { // identifier for differentiating between multiple demuxed instances of a single track - outputFilenameBuilder.append("_").append(ec.getFlags().stream().reduce((final String s1, final String s2) -> { - return s1 + s2; - }).get().replaceAll("[\\-_\\s]*", "")); - } - if (!track.getFormat().getFormatType().equals("Chapters")) { - final String friendlyFormatName = track.getFormat().getName().replaceAll("[/\\(\\)\\s]", "_"); - outputFilenameBuilder.append("_").append(friendlyFormatName); + outputFilenameBuilder + .append("_") + .append(String.join("", ec.getFlags()).replaceAll("[\\-_\\s]*", "")); } + outputFilenameBuilder.append(".").append(ec.getExtension()); final File outputFile = new File(outputFilenameBuilder.toString()); demuxArguments.add(outputFile.getAbsolutePath()); diff --git a/src/main/java/com/adashrod/mkvscanner/FileScanner.java b/src/main/java/com/adashrod/mkvscanner/FileScanner.java index c0020c3..1c13bfd 100644 --- a/src/main/java/com/adashrod/mkvscanner/FileScanner.java +++ b/src/main/java/com/adashrod/mkvscanner/FileScanner.java @@ -1,5 +1,6 @@ package com.adashrod.mkvscanner; +import com.adashrod.mkvscanner.model.Iso639Language; import com.adashrod.mkvscanner.model.Video; import java.io.File; @@ -81,14 +82,14 @@ public interface FileScanner { * their language is included in languagesToInclude * @param bluRayDirectory the target BD dir * @param title which title on the BD structure to scan - * @param languagesToInclude tracks are included in demuxing if their language is included here + * @param languagesToInclude ISO 639-2 languages; a track is included in demuxing if its language code is here * @return output filenames created by the demuxing (just the names, not the fully qualified absolute names) * @throws NotBluRayDirectoryException scanned a directory that wasn't a BD dir * @throws CorruptBluRayStructureException BD dir can be scanned, but the titles can't * @throws DemuxerException error output from mkvscanner couldn't be parsed * @throws IOException thrown by executable process */ - Collection demuxBluRayTitleByLanguages(File bluRayDirectory, int title, Collection languagesToInclude) throws DemuxerException, IOException; + Collection demuxBluRayTitleByLanguages(File bluRayDirectory, int title, Collection languagesToInclude) throws DemuxerException, IOException; /** * Runs the mkvscanner on a file, demuxing specific tracks. Tracks are included in demuxing if their track number is @@ -106,11 +107,11 @@ public interface FileScanner { * Runs the mkvscanner on a file, demuxing specific tracks. Tracks are included in demuxing if their language is * included in languagesToInclude * @param file the target file to run the mkvscanner on - * @param languagesToInclude tracks are included in demuxing if their language is included here + * @param languagesToInclude ISO 639-2 languages; a track is included in demuxing if its language code is here * @return output filenames created by the demuxing (just the names, not the fully qualified absolute names) * @throws UnreadableFileException trying to scan a file that isn't a video container (non-mkv/m2ts) * @throws DemuxerException error output from mkvscanner couldn't be parsed * @throws IOException thrown by executable process */ - Collection demuxFileByLanguages(File file, Collection languagesToInclude) throws DemuxerException, IOException; + Collection demuxFileByLanguages(File file, Collection languagesToInclude) throws DemuxerException, IOException; } diff --git a/src/main/java/com/adashrod/mkvscanner/model/Format.java b/src/main/java/com/adashrod/mkvscanner/model/Format.java index 23b9c91..98dc7a6 100644 --- a/src/main/java/com/adashrod/mkvscanner/model/Format.java +++ b/src/main/java/com/adashrod/mkvscanner/model/Format.java @@ -4,11 +4,19 @@ * Created by aaron on 2016-01-09. */ public class Format { + public static final Format OGM_CHAPTERS; + + static { + OGM_CHAPTERS = new Format(); + OGM_CHAPTERS.setFormatType(FormatType.CHAPTERS); + OGM_CHAPTERS.setName("OGM Chapters"); + } + private String name; /** * todo: replace with enum */ - private String formatType; + private FormatType formatType; public String getName() { return name; @@ -16,10 +24,10 @@ public String getName() { public void setName(final String name) { this.name = name; } - public String getFormatType() { + public FormatType getFormatType() { return formatType; } - public void setFormatType(final String formatType) { + public void setFormatType(final FormatType formatType) { this.formatType = formatType; } diff --git a/src/main/java/com/adashrod/mkvscanner/model/FormatType.java b/src/main/java/com/adashrod/mkvscanner/model/FormatType.java new file mode 100644 index 0000000..4ec1b1e --- /dev/null +++ b/src/main/java/com/adashrod/mkvscanner/model/FormatType.java @@ -0,0 +1,11 @@ +package com.adashrod.mkvscanner.model; + +/** + * Created by aaron on 2026-06-13. + */ +public enum FormatType { + CHAPTERS, + VIDEO, + AUDIO, + SUBTITLES +} diff --git a/src/main/java/com/adashrod/mkvscanner/model/Iso639Language.java b/src/main/java/com/adashrod/mkvscanner/model/Iso639Language.java new file mode 100644 index 0000000..bdf1c47 --- /dev/null +++ b/src/main/java/com/adashrod/mkvscanner/model/Iso639Language.java @@ -0,0 +1,282 @@ +package com.adashrod.mkvscanner.model; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * ISO 639-2 languages. Each constant carries a canonical English {@link #getName() name}, a 3-letter + * {@link #getCode() code}, and any number of alternate names ({@link #getAliases() aliases}) by which the same + * language is known. {@link #fromToken(String)} resolves a language token (as emitted by eac3to) to a constant, + * accepting either a plain name/alias (e.g. {@code "English"}, {@code "Flemish"}) or a bracketed ISO code + * (e.g. {@code "[eng]"}), case-insensitively. + */ +public enum Iso639Language { + ABKHAZIAN("Abkhazian", "abk"), + AFAR("Afar", "aar"), + AFRIKAANS("Afrikaans", "afr"), + AKAN("Akan", "aka"), + ALBANIAN("Albanian", "sqi"), + AMHARIC("Amharic", "amh"), + ARABIC("Arabic", "ara"), + ARAGONESE("Aragonese", "arg"), + ARMENIAN("Armenian", "hye"), + ASSAMESE("Assamese", "asm"), + AVARIC("Avaric", "ava"), + AVESTAN("Avestan", "ave"), + AYMARA("Aymara", "aym"), + AZERBAIJANI("Azerbaijani", "aze"), + BAMBARA("Bambara", "bam"), + BASHKIR("Bashkir", "bak"), + BASQUE("Basque", "eus"), + BELARUSIAN("Belarusian", "bel"), + BENGALI("Bengali", "ben"), + BISLAMA("Bislama", "bis"), + BOSNIAN("Bosnian", "bos"), + BRETON("Breton", "bre"), + BULGARIAN("Bulgarian", "bul"), + BURMESE("Burmese", "mya"), + CATALAN("Catalan", "cat", "Valencian"), + CHAMORRO("Chamorro", "cha"), + CHECHEN("Chechen", "che"), + CHICHEWA("Chichewa", "nya", "Chewa", "Nyanja"), + CHINESE("Chinese", "zho"), + CHURCH_SLAVONIC("Church Slavonic", "chu", "Old Slavonic", "Old Church Slavonic"), + CHUVASH("Chuvash", "chv"), + CORNISH("Cornish", "cor"), + CORSICAN("Corsican", "cos"), + CREE("Cree", "cre"), + CROATIAN("Croatian", "hrv"), + CZECH("Czech", "ces"), + DANISH("Danish", "dan"), + DIVEHI("Divehi", "div", "Dhivehi", "Maldivian"), + DUTCH("Dutch", "nld", "Flemish"), + DZONGKHA("Dzongkha", "dzo"), + ENGLISH("English", "eng"), + ESPERANTO("Esperanto", "epo"), + ESTONIAN("Estonian", "est"), + EWE("Ewe", "ewe"), + FAROESE("Faroese", "fao"), + FIJIAN("Fijian", "fij"), + FINNISH("Finnish", "fin"), + FRENCH("French", "fra"), + WESTERN_FRISIAN("Western Frisian", "fry"), + FULAH("Fulah", "ful"), + GAELIC("Gaelic", "gla", "Scottish Gaelic"), + GALICIAN("Galician", "glg"), + GANDA("Ganda", "lug"), + GEORGIAN("Georgian", "kat"), + GERMAN("German", "deu"), + GREEK("Greek", "ell"), + KALAALLISUT("Kalaallisut", "kal", "Greenlandic"), + GUARANI("Guarani", "grn"), + GUJARATI("Gujarati", "guj"), + HAITIAN("Haitian", "hat", "Haitian Creole"), + HAUSA("Hausa", "hau"), + HEBREW("Hebrew", "heb"), + HERERO("Herero", "her"), + HINDI("Hindi", "hin"), + HIRI_MOTU("Hiri Motu", "hmo"), + HUNGARIAN("Hungarian", "hun"), + ICELANDIC("Icelandic", "isl"), + IDO("Ido", "ido"), + IGBO("Igbo", "ibo"), + INDONESIAN("Indonesian", "ind"), + INTERLINGUA("Interlingua", "ina"), + INTERLINGUE("Interlingue", "ile", "Occidental"), + INUKTITUT("Inuktitut", "iku"), + INUPIAQ("Inupiaq", "ipk"), + IRISH("Irish", "gle"), + ITALIAN("Italian", "ita"), + JAPANESE("Japanese", "jpn"), + JAVANESE("Javanese", "jav"), + KANNADA("Kannada", "kan"), + KANURI("Kanuri", "kau"), + KASHMIRI("Kashmiri", "kas"), + KAZAKH("Kazakh", "kaz"), + CENTRAL_KHMER("Central Khmer", "khm"), + KIKUYU("Kikuyu", "kik", "Gikuyu"), + KINYARWANDA("Kinyarwanda", "kin"), + KYRGYZ("Kyrgyz", "kir", "Kirghiz"), + KOMI("Komi", "kom"), + KONGO("Kongo", "kon"), + KOREAN("Korean", "kor"), + KUANYAMA("Kuanyama", "kua", "Kwanyama"), + KURDISH("Kurdish", "kur"), + LAO("Lao", "lao"), + LATIN("Latin", "lat"), + LATVIAN("Latvian", "lav"), + LIMBURGAN("Limburgan", "lim", "Limburger", "Limburgish"), + LINGALA("Lingala", "lin"), + LITHUANIAN("Lithuanian", "lit"), + LUBA_KATANGA("Luba-Katanga", "lub"), + LUXEMBOURGISH("Luxembourgish", "ltz", "Letzeburgesch"), + MACEDONIAN("Macedonian", "mkd"), + MALAGASY("Malagasy", "mlg"), + MALAY("Malay", "msa"), + MALAYALAM("Malayalam", "mal"), + MALTESE("Maltese", "mlt"), + MANX("Manx", "glv"), + MAORI("Maori", "mri"), + MARATHI("Marathi", "mar"), + MARSHALLESE("Marshallese", "mah"), + MONGOLIAN("Mongolian", "mon"), + NAURU("Nauru", "nau"), + NAVAJO("Navajo", "nav", "Navaho"), + NORTH_NDEBELE("North Ndebele", "nde"), + SOUTH_NDEBELE("South Ndebele", "nbl"), + NDONGA("Ndonga", "ndo"), + NEPALI("Nepali", "nep"), + NORWEGIAN("Norwegian", "nor"), + NORWEGIAN_BOKMAL("Norwegian Bokmål", "nob"), + NORWEGIAN_NYNORSK("Norwegian Nynorsk", "nno"), + OCCITAN("Occitan", "oci"), + OJIBWA("Ojibwa", "oji"), + ORIYA("Oriya", "ori"), + OROMO("Oromo", "orm"), + OSSETIAN("Ossetian", "oss", "Ossetic"), + PALI("Pali", "pli"), + PASHTO("Pashto", "pus", "Pushto"), + PERSIAN("Persian", "fas"), + POLISH("Polish", "pol"), + PORTUGUESE("Portuguese", "por"), + PUNJABI("Punjabi", "pan", "Panjabi"), + QUECHUA("Quechua", "que"), + ROMANIAN("Romanian", "ron", "Moldavian", "Moldovan"), + ROMANSH("Romansh", "roh"), + RUNDI("Rundi", "run"), + RUSSIAN("Russian", "rus"), + NORTHERN_SAMI("Northern Sami", "sme"), + SAMOAN("Samoan", "smo"), + SANGO("Sango", "sag"), + SANSKRIT("Sanskrit", "san"), + SARDINIAN("Sardinian", "srd"), + SERBIAN("Serbian", "srp"), + SHONA("Shona", "sna"), + SINDHI("Sindhi", "snd"), + SINHALA("Sinhala", "sin", "Sinhalese"), + SLOVAK("Slovak", "slk"), + SLOVENIAN("Slovenian", "slv"), + SOMALI("Somali", "som"), + SOUTHERN_SOTHO("Southern Sotho", "sot"), + SPANISH("Spanish", "spa", "Castilian"), + SUNDANESE("Sundanese", "sun"), + SWAHILI("Swahili", "swa"), + SWATI("Swati", "ssw"), + SWEDISH("Swedish", "swe"), + TAGALOG("Tagalog", "tgl"), + TAHITIAN("Tahitian", "tah"), + TAJIK("Tajik", "tgk"), + TAMIL("Tamil", "tam"), + TATAR("Tatar", "tat"), + TELUGU("Telugu", "tel"), + THAI("Thai", "tha"), + TIBETAN("Tibetan", "bod"), + TIGRINYA("Tigrinya", "tir"), + TONGA("Tonga", "ton"), + TSONGA("Tsonga", "tso"), + TSWANA("Tswana", "tsn"), + TURKISH("Turkish", "tur"), + TURKMEN("Turkmen", "tuk"), + TWI("Twi", "twi"), + UIGHUR("Uighur", "uig", "Uyghur"), + UKRAINIAN("Ukrainian", "ukr"), + URDU("Urdu", "urd"), + UZBEK("Uzbek", "uzb"), + VENDA("Venda", "ven"), + VIETNAMESE("Vietnamese", "vie"), + VOLAPUK("Volapük", "vol"), + WALLOON("Walloon", "wln"), + WELSH("Welsh", "cym"), + WOLOF("Wolof", "wol"), + XHOSA("Xhosa", "xho"), + SICHUAN_YI("Sichuan Yi", "iii", "Nuosu"), + YIDDISH("Yiddish", "yid"), + YORUBA("Yoruba", "yor"), + ZHUANG("Zhuang", "zha", "Chuang"), + ZULU("Zulu", "zul"), + /** Sentinel for a language that could not be resolved from eac3to output. */ + UNDETERMINED("Undetermined", "und"); + + private static final Map BY_NAME = new HashMap<>(); + private static final Map BY_CODE = new HashMap<>(); + static { + for (final Iso639Language language : values()) { + BY_NAME.put(language.name.toLowerCase(), language); + for (final String alias : language.aliases) { + BY_NAME.put(alias.toLowerCase(), language); + } + BY_CODE.put(language.code.toLowerCase(), language); + } + // ISO 639-2/B (bibliographic) codes as fallback aliases for the 20 languages where /T and /B differ. + // getCode() returns the /T code (modern standard); these widen what fromToken(...) accepts for backwards compatibility. + BY_CODE.put("alb", ALBANIAN); + BY_CODE.put("arm", ARMENIAN); + BY_CODE.put("baq", BASQUE); + BY_CODE.put("bur", BURMESE); + BY_CODE.put("chi", CHINESE); + BY_CODE.put("cze", CZECH); + BY_CODE.put("dut", DUTCH); + BY_CODE.put("fre", FRENCH); + BY_CODE.put("geo", GEORGIAN); + BY_CODE.put("ger", GERMAN); + BY_CODE.put("gre", GREEK); + BY_CODE.put("ice", ICELANDIC); + BY_CODE.put("mac", MACEDONIAN); + BY_CODE.put("mao", MAORI); + BY_CODE.put("may", MALAY); + BY_CODE.put("per", PERSIAN); + BY_CODE.put("rum", ROMANIAN); + BY_CODE.put("slo", SLOVAK); + BY_CODE.put("tib", TIBETAN); + BY_CODE.put("wel", WELSH); + } + + private final String name; + private final String code; + private final String[] aliases; + + Iso639Language(final String name, final String code, final String... aliases) { + this.name = name; + this.code = code; + this.aliases = aliases; + } + + public String getName() { + return name; + } + + public String getCode() { + return code; + } + + public java.util.List getAliases() { + return Collections.unmodifiableList(java.util.Arrays.asList(aliases)); + } + + /** + * Resolves a language token from scanner output to a language. The token may be a plain name or alias + * (e.g. {@code "English"}, {@code "Flemish"}) or an ISO 639-2 code with or without brackets (e.g. {@code "[eng]"} + * or {@code "eng"}); matching is case-insensitive. + * @param token the language token from scanner output + * @return the matching language, or {@code null} if none matches + */ + public static Iso639Language fromToken(final String token) { + if (token == null) { + throw new IllegalArgumentException("null token not allowed"); + } + final Iso639Language result; + final String trimmed = token.trim(); + if (trimmed.length() >= 2 && trimmed.charAt(0) == '[' && trimmed.charAt(trimmed.length() - 1) == ']') { + result = BY_CODE.get(trimmed.substring(1, trimmed.length() - 1).trim().toLowerCase()); + } else if (trimmed.length() == 3) { + result = BY_CODE.get(trimmed.toLowerCase()); + } else { + result = BY_NAME.get(trimmed.toLowerCase()); + } + if (result == null) { + throw new IllegalArgumentException(String.format("Unknown language name or code: %s", token)); + } + return result; + } +} diff --git a/src/main/java/com/adashrod/mkvscanner/model/Track.java b/src/main/java/com/adashrod/mkvscanner/model/Track.java index 6cfc141..c44b263 100644 --- a/src/main/java/com/adashrod/mkvscanner/model/Track.java +++ b/src/main/java/com/adashrod/mkvscanner/model/Track.java @@ -7,7 +7,7 @@ public class Track { private int number; private String name; private Format format; - private String language; + private Iso639Language language; public int getNumber() { return number; @@ -27,10 +27,10 @@ public Format getFormat() { public void setFormat(final Format format) { this.format = format; } - public String getLanguage() { + public Iso639Language getLanguage() { return language; } - public void setLanguage(final String language) { + public void setLanguage(final Iso639Language language) { this.language = language; } diff --git a/src/main/resources/eac3to-languages.txt b/src/main/resources/eac3to-languages.txt deleted file mode 100644 index c3e8ba7..0000000 --- a/src/main/resources/eac3to-languages.txt +++ /dev/null @@ -1,482 +0,0 @@ -Abkhazian -Achinese -Acoli -Adangme -Adyghe -Afar -Afrihili -Afrikaans -Afro-Asiatic -Ainu -Akan -Akkadian -Albanian -Aleut -Algonquian -Altaic -Amharic -Angika -Apache -Arabic -Aragonese -Arapaho -Arawak -Armenian -Aromanian -Artificial -Assamese -Asturian -Athapascan -Australian -Austronesian -Avaric -Avestan -Awadhi -Aymara -Azerbaijani -Balinese -Baltic -Baluchi -Bambara -Bamileke -Banda -Bantu -Basa -Bashkir -Basque -Batak -Beja -Belarusian -Bemba -Bengali -Berber -Bhojpuri -Bihari -Bikol -Bini -Bislama -Blin -Blissymbols -Bokmål -Bosnian -Braj -Breton -Buginese -Bulgarian -Buriat -Burmese -Caddo -Catalan -Caucasian -Cebuano -Celtic -C.A. Indian -Central Khmer -Chagatai -Chamic -Chamorro -Chechen -Cherokee -Cheyenne -Chibcha -Chichewa -Chinese -Chinook -Chipewyan -Choctaw -Church Slavic -Chuukese -Chuvash -Classical Newari -Classical Syriac -Coptic -Cornish -Corsican -Cree -Creek -Creoles -Creoles English -Creoles French -Creoles Portuguese -Crimean Tatar -Croatian -Cushitic -Czech -Dakota -Danish -Dargwa -Delaware -Dinka -Divehi -Dogri -Dogrib -Dravidian -Duala -Middle Dutch -Dutch -Dyula -Dzongkha -Eastern Frisian -Efik -Egyptian -Ekajuk -Elamite -English -Middle English -Old English -Erzya -Esperanto -Estonian -Ewe -Ewondo -Fang -Fanti -Faroese -Fijian -Filipino -Finnish -Finno-Ugrian -Fon -French -Middle French -Old French -Friulian -Fulah -Ga -Gaelic -Galibi Carib -Galician -Ganda -Gayo -Gbaya -Geez -Georgian -German -Middle German -Old German -Germanic -Gilbertese -Gondi -Gorontalo -Gothic -Grebo -Ancient Greek -Modern Greek -Guarani -Gujarati -Gwich'in -Haida -Haitian -Hausa -Hawaiian -Hebrew -Herero -Hiligaynon -Himachali -Hindi -Hiri Motu -Hittite -Hmong -Hungarian -Hupa -Iban -Icelandic -Ido -Igbo -Ijo -Iloko -Inari Sami -Indic -Indo-European -Indonesian -Ingush -Interlingua -Interlingue -Inuktitut -Inupiaq -Iranian -Irish -Middle Irish -OldIrish -Iroquoian -Italian -Japanese -Javanese -Judeo-Arabic -Judeo-Persian -Kabardian -Kabyle -Kachin -Kalaallisut -Kalmyk -Kamba -Kannada -Kanuri -Kara-Kalpak -Karachay-Balkar -Karelian -Karen -Kashmiri -Kashubian -Kawi -Kazakh -Khasi -Khoisan -Khotanese -Kikuyu -Kimbundu -Kinyarwanda -Kirghiz -Klingon -Komi -Kongo -Konkani -Korean -Kosraean -Kpelle -Kru -Kuanyama -Kumyk -Kurdish -Kurukh -Kutenai -Ladino -Lahnda -Lamba -Land Dayak -Lao -Latin -Latvian -Lezghian -Limburgan -Lingala -Lithuanian -Lojban -LowGerman -Lower Sorbian -Lozi -Luba-Katanga -Luba-Lulua -Luiseno -Lule Sami -Lunda -Luo -Lushai -Luxembourgish -Macedonian -Madurese -Magahi -Maithili -Makasar -Malagasy -Malay -Malayalam -Maltese -Manchu -Mandar -Mandingo -Manipuri -Manobo -Manx -Maori -Mapudungun -Marathi -Mari -Marshallese -Marwari -Masai -Mayan -Mende -Mi'kmaq -Minangkabau -Mirandese -Mohawk -Moksha -Mon-Khmer -Mongo -Mongolian -Mossi -Multiple -Munda -N'Ko -Nahuatl -Nauru -Navajo -North Ndebele -South Ndebele -Ndonga -Neapolitan -Nepal Bhasa -Nepali -Nias -Niger-Kordofanian -Nilo-Saharan -Niuean -zxx -Nogai -OldNorse -N.A. Indian -Northern Frisian -Northern Sami -Norwegian -Nubian -Nyamwezi -Nyankole -Nyoro -Nzima -Occitan -Aramaic -Ojibwa -Oriya -Oromo -Osage -Ossetian -Otomian -Pahlavi -Palauan -Pali -Pampanga -Pangasinan -Panjabi -Papiamento -Papuan -Pedi -Persian -OldPersian -Philippine -Phoenician -Pohnpeian -Polish -Portuguese -Prakrit -OldProvençal -Pushto -Quechua -Rajasthani -Rapanui -Rarotongan -Romance -Romanian -Romansh -Romany -Rundi -Russian -Salishan -Samaritan Aramaic -Sami languages -Samoan -Sandawe -Sango -Sanskrit -Santali -Sardinian -Sasak -Scots -Selkup -Semitic -Serbian -Serer -Shan -Shona -Sichuan Yi -Sicilian -Sidamo -Sign -Siksika -Sindhi -Sinhala -Sino-Tibetan -Siouan -Skolt Sami -Slave -Slavic -Slovak -Slovenian -Sogdian -Somali -Songhai -Soninke -Sorbian -Southern Sotho -S.A. Indian -Southern Altai -Southern Sami -Spanish -Sranan Tongo -Sukuma -Sumerian -Sundanese -Susu -Swahili -Swati -Swedish -Swiss German -Syriac -Tagalog -Tahitian -Tai -Tajik -Tamashek -Tamil -Tatar -Telugu -Tereno -Tetum -Thai -Tibetan -Tigre -Tigrinya -Timne -Tiv -Tlingit -TokPisin -Tokelau -Tonga -Tsimshian -Tsonga -Tswana -Tumbuka -Tupi -Turkish -Ottoman -Turkmen -Tuvalu -Tuvinian -Twi -Udmurt -Ugaritic -Uighur -Ukrainian -Umbundu -Uncoded -Undetermined -Upper Sorbian -Urdu -Uzbek -Vai -Venda -Vietnamese -Volapük -Votic -Wakashan -Walamo -Walloon -Waray -Washo -Welsh -Western Frisian -Wolof -Xhosa -Yakut -Yao -Yapese -Yiddish -Yoruba -Yupik -Zande -Zapotec -Zaza -Zenaga -Zhuang -Zulu -Zuni diff --git a/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java b/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java index ab389d5..2ec4780 100644 --- a/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java +++ b/src/test/java/com/adashrod/mkvscanner/Eac3toScannerTest.java @@ -1,5 +1,7 @@ package com.adashrod.mkvscanner; +import com.adashrod.mkvscanner.model.FormatType; +import com.adashrod.mkvscanner.model.Iso639Language; import com.adashrod.mkvscanner.model.Track; import com.adashrod.mkvscanner.model.Video; import com.adashrod.mkvscanner.util.ProcessResult; @@ -29,9 +31,6 @@ 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 @@ -189,6 +188,199 @@ public class Eac3toScannerTest { "1: AC3, English, 5.1 channels, 448kbps, 48kHz", "2: Subtitle (VobSub), English"); + // newer eac3to format: a blu-ray dir scan using bracketed ISO 639-2 codes and multi-segment m2ts + private static final String BD_NEW = String.join("\n", + "1) 00000.mpls, 00007.m2ts+00003.m2ts, 1:33:50", + " - Chapters, 2 chapters", + " - h264/AVC, 1080p24/1.001 (16:9)", + " - AC3, [eng], stereo, 48kHz", + " - Subtitle (PGS), [eng]", + " - Subtitle (PGS), [dan]", + " - Subtitle (PGS), [deu]", + " - Subtitle (PGS), [spa]", + " - Subtitle (PGS), [fra]", + " - Subtitle (PGS), [ita]", + " - Subtitle (PGS), [jpn]", + " - Subtitle (PGS), [nld]", + " - Subtitle (PGS), [nor]", + " - Subtitle (PGS), [fin]", + " - Subtitle (PGS), [swe]", + "", + "2) 00004.mpls, 00051.m2ts+00005.m2ts, 0:42:54", + " - Chapters, 8 chapters", + " - h264/AVC, 1080p24/1.001 (16:9)", + " - DTS Master Audio, [eng], multi-channel, 48kHz", + " - AC3, [deu], multi-channel, 48kHz", + " - AC3, [spa], stereo, 48kHz", + " - AC3, [fra], stereo, 48kHz", + " - AC3, [ita], multi-channel, 48kHz", + " - AC3, [jpn], stereo, 48kHz", + " - AC3, [eng], stereo, 48kHz", + " - Subtitle (PGS), [eng]", + " - Subtitle (PGS), [dan]", + " - Subtitle (PGS), [deu]", + " - Subtitle (PGS), [spa]", + " - Subtitle (PGS), [fra]", + " - Subtitle (PGS), [ita]", + " - Subtitle (PGS), [jpn]", + " - Subtitle (PGS), [nld]", + " - Subtitle (PGS), [nor]", + " - Subtitle (PGS), [fin]", + " - Subtitle (PGS), [swe]", + "", + "3) 00003.mpls, 00050.m2ts+00064.m2ts, 0:42:54", + " - Chapters, 8 chapters", + " - h264/AVC, 1080p24/1.001 (16:9)", + " - DTS Master Audio, [eng], multi-channel, 48kHz", + " - AC3, [deu], multi-channel, 48kHz", + " - AC3, [spa], stereo, 48kHz", + " - AC3, [fra], stereo, 48kHz", + " - AC3, [ita], multi-channel, 48kHz", + " - AC3, [jpn], stereo, 48kHz", + " - Subtitle (PGS), [eng]", + " - Subtitle (PGS), [dan]", + " - Subtitle (PGS), [deu]", + " - Subtitle (PGS), [spa]", + " - Subtitle (PGS), [fra]", + " - Subtitle (PGS), [ita]", + " - Subtitle (PGS), [jpn]", + " - Subtitle (PGS), [nld]", + " - Subtitle (PGS), [nor]", + " - Subtitle (PGS), [fin]", + " - Subtitle (PGS), [swe]", + "", + "4) 00001.mpls, 00057.m2ts+00064.m2ts, 0:42:53", + " - Chapters, 8 chapters", + " - h264/AVC, 1080p24/1.001 (16:9)", + " - DTS Master Audio, [eng], multi-channel, 48kHz", + " - AC3, [deu], multi-channel, 48kHz", + " - AC3, [spa], stereo, 48kHz", + " - AC3, [fra], stereo, 48kHz", + " - AC3, [ita], multi-channel, 48kHz", + " - AC3, [jpn], stereo, 48kHz", + " - Subtitle (PGS), [eng]", + " - Subtitle (PGS), [dan]", + " - Subtitle (PGS), [deu]", + " - Subtitle (PGS), [spa]", + " - Subtitle (PGS), [fra]", + " - Subtitle (PGS), [ita]", + " - Subtitle (PGS), [jpn]", + " - Subtitle (PGS), [nld]", + " - Subtitle (PGS), [nor]", + " - Subtitle (PGS), [fin]", + " - Subtitle (PGS), [swe]", + "", + "5) 00002.mpls, 00058.m2ts+00003.m2ts, 0:42:51", + " - Chapters, 8 chapters", + " - h264/AVC, 1080p24/1.001 (16:9)", + " - DTS Master Audio, [eng], multi-channel, 48kHz", + " - AC3, [deu], multi-channel, 48kHz", + " - AC3, [spa], stereo, 48kHz", + " - AC3, [fra], stereo, 48kHz", + " - AC3, [ita], multi-channel, 48kHz", + " - AC3, [jpn], stereo, 48kHz", + " - AC3, [eng], stereo, 48kHz", + " - Subtitle (PGS), [eng]", + " - Subtitle (PGS), [dan]", + " - Subtitle (PGS), [deu]", + " - Subtitle (PGS), [spa]", + " - Subtitle (PGS), [fra]", + " - Subtitle (PGS), [ita]", + " - Subtitle (PGS), [jpn]", + " - Subtitle (PGS), [nld]", + " - Subtitle (PGS), [nor]", + " - Subtitle (PGS), [fin]", + " - Subtitle (PGS), [swe]", + " - Subtitle (PGS), [jpn]", + "", + "6) 00014.mpls, 00054.m2ts+00065.m2ts, 0:19:12", + " - Chapters, 2 chapters", + " - MPEG2, 480i60/1.001 (4:3)", + " - AC3, [eng], stereo, 48kHz", + " - Subtitle (PGS), [eng]", + " - Subtitle (PGS), [dan]", + " - Subtitle (PGS), [deu]", + " - Subtitle (PGS), [spa]", + " - Subtitle (PGS), [fra]", + " - Subtitle (PGS), [ita]", + " - Subtitle (PGS), [jpn]", + " - Subtitle (PGS), [nld]", + " - Subtitle (PGS), [nor]", + " - Subtitle (PGS), [fin]", + " - Subtitle (PGS), [swe]"); + + // newer eac3to format: a single blu-ray title scan with preamble lines and bracketed ISO 639-2 codes + // (incl. /T codes deu/fra/nld); + private static final String BD_TITLE_NEW = String.join("\n", + "Fast mode enabled", + "Keeping dialnorm", + "analyze: 100%", + "M2TS, 1 video track, 1 audio track, 11 subtitle tracks, 1:33:49", + "1: Chapters, 2 chapters", + "2: h264/AVC, 1080p24/1.001 (16:9), SDR", + "3: AC3, [eng], 2.0 channels, 192kbps, 48kHz, dialnorm: -31dB", + "4: Subtitle (PGS), [eng]", + "5: Subtitle (PGS), [dan]", + "6: Subtitle (PGS), [deu]", + "7: Subtitle (PGS), [spa]", + "8: Subtitle (PGS), [fra]", + "9: Subtitle (PGS), [ita]", + "10: Subtitle (PGS), [jpn]", + "11: Subtitle (PGS), [nld]", + "12: Subtitle (PGS), [nor]", + "13: Subtitle (PGS), [fin]", + "14: Subtitle (PGS), [swe]"); + + private static final String BD_TITLE_STRANGE_SUBS = String.join("\n", + "Fast mode enabled", + "Keeping dialnorm", + "M2TS, 1 video track, 1 audio track, 12 subtitle tracks, 0:19:11", + "1: Chapters, 2 chapters", + "2: MPEG2, 480i60/1.001 (4:3)", + "3: AC3, [eng], 2.0 channels, 192kbps, 48kHz, dialnorm: -31dB", + "4: Subtitle (PGS), [eng]", + "5: Subtitle (PGS), [dan]", + "6: Subtitle (PGS), [deu]", + "7: Subtitle (PGS), [spa]", + "8: Subtitle (PGS), [fra]", + "9: Subtitle (PGS), [ita]", + "10: Subtitle (PGS), [jpn]", + "11: Subtitle (PGS), [nld]", + "12: Subtitle (PGS), [nor]", + "13: Subtitle (PGS), [fin]", + "14: Subtitle (PGS), [swe]", + "15: *Subtitle (PGS)"); + + // newer eac3to format: mkv file with a TrueHD track, bracketed codes, and quoted names + private static final String MKV_FILE_TRUEHD_NEW = String.join("\n", + "Fast mode enabled", + "Keeping dialnorm", + "analyze: 100%", + "MKV, 1 video track, 3 audio tracks, 2 subtitle tracks, 2:12:06, 24p/1.001", + "1: h264/AVC, [eng], 1080p24/1.001 (16:9), SDR", + "2: TrueHD, [eng], 7.1 channels, 48kHz, dialnorm: -31dB", + " \"TrueHD\"", + "3: AC3, [eng], 5.1 channels, 640kbps, 48kHz, dialnorm: -31dB", + "4: AC3, [spa], 5.1 channels, 640kbps, 48kHz, dialnorm: -31dB", + " \"Spanish\"", + "5: Subtitle (PGS), [eng]", + "6: Subtitle (PGS), [spa], \"Spanish\""); + + // newer eac3to format: mkv file with a DTS-HD MA track, a "(core: ...)" line, bracketed codes + private static final String MKV_FILE_DTSHD_NEW = String.join("\n", + "Fast mode enabled", + "Keeping dialnorm", + "analyze: 100%", + "MKV, 1 video track, 2 audio tracks, 2 subtitle tracks, 2:44:34, 24p/1.001", + "1: h264/AVC, [eng], 1080p24/1.001 (16:9), SDR", + "2: DTS-HD Master Audio, [eng], 5.1 channels, 24 bits, 48kHz, dialnorm: 0dB", + " (core: DTS, 5.1 channels, 1509kbps, 48kHz, dialnorm: 0dB)", + " \"DTS-HD\"", + "3: AC3, [spa], 5.1 channels, 448kbps, 48kHz, dialnorm: -31dB", + " \"Spanish\"", + "4: Subtitle (PGS), [eng]", + "5: Subtitle (PGS), [spa], \"Spanish\""); + @Test public void scanBluRayDir_returnsAllTitleNumbers(@TempDir final File bluRayDir) throws Exception { final FileScanner scanner = scannerReturning(0, BLU_RAY_DISC_LEGACY); @@ -206,34 +398,34 @@ public void scanAndParseBluRayTitle_parsesEveryTrack(@TempDir final File bluRayD // 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"); + assertTrack(tracks.get(0), 1, "OGM Chapters", FormatType.CHAPTERS, Iso639Language.UNDETERMINED); + assertTrack(tracks.get(1), 2, "VC-1", FormatType.VIDEO, Iso639Language.UNDETERMINED); + assertTrack(tracks.get(2), 3, "AC3", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(3), 4, "RAW/PCM", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(4), 5, "AC3", FormatType.AUDIO, Iso639Language.FRENCH); + assertTrack(tracks.get(5), 6, "AC3", FormatType.AUDIO, Iso639Language.SPANISH); + assertTrack(tracks.get(6), 7, "AC3", FormatType.AUDIO, Iso639Language.GERMAN); + assertTrack(tracks.get(7), 8, "AC3", FormatType.AUDIO, Iso639Language.ITALIAN); + assertTrack(tracks.get(8), 9, "AC3", FormatType.AUDIO, Iso639Language.SPANISH); + assertTrack(tracks.get(9), 10, "AC3 Surround", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(10), 11, "PGS", FormatType.SUBTITLES, Iso639Language.ENGLISH); + assertTrack(tracks.get(11), 12, "PGS", FormatType.SUBTITLES, Iso639Language.ENGLISH); + assertTrack(tracks.get(12), 13, "PGS", FormatType.SUBTITLES, Iso639Language.FRENCH); + assertTrack(tracks.get(13), 14, "PGS", FormatType.SUBTITLES, Iso639Language.GERMAN); + assertTrack(tracks.get(14), 15, "PGS", FormatType.SUBTITLES, Iso639Language.GERMAN); + assertTrack(tracks.get(15), 16, "PGS", FormatType.SUBTITLES, Iso639Language.ITALIAN); + assertTrack(tracks.get(16), 17, "PGS", FormatType.SUBTITLES, Iso639Language.ITALIAN); + assertTrack(tracks.get(17), 18, "PGS", FormatType.SUBTITLES, Iso639Language.SPANISH); + assertTrack(tracks.get(18), 19, "PGS", FormatType.SUBTITLES, Iso639Language.DUTCH); + assertTrack(tracks.get(19), 20, "PGS", FormatType.SUBTITLES, Iso639Language.CHINESE); + assertTrack(tracks.get(20), 21, "PGS", FormatType.SUBTITLES, Iso639Language.DANISH); + assertTrack(tracks.get(21), 22, "PGS", FormatType.SUBTITLES, Iso639Language.FINNISH); + assertTrack(tracks.get(22), 23, "PGS", FormatType.SUBTITLES, Iso639Language.KOREAN); + assertTrack(tracks.get(23), 24, "PGS", FormatType.SUBTITLES, Iso639Language.NORWEGIAN); + assertTrack(tracks.get(24), 25, "PGS", FormatType.SUBTITLES, Iso639Language.PORTUGUESE); + assertTrack(tracks.get(25), 26, "PGS", FormatType.SUBTITLES, Iso639Language.PORTUGUESE); + assertTrack(tracks.get(26), 27, "PGS", FormatType.SUBTITLES, Iso639Language.SPANISH); + assertTrack(tracks.get(27), 28, "PGS", FormatType.SUBTITLES, Iso639Language.SWEDISH); } @Test @@ -244,12 +436,12 @@ public void scanAndParseFile_parsesTracksAndQuotedNames() throws Exception { // 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 + assertTrack(tracks.get(0), 1, null, "h264/AVC", FormatType.VIDEO, Iso639Language.ENGLISH); + assertTrack(tracks.get(1), 2, "TrueHD", "TrueHD", FormatType.AUDIO, Iso639Language.ENGLISH); // name on its own line + assertTrack(tracks.get(2), 3, null, "AC3", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(3), 4, "Spanish", "AC3", FormatType.AUDIO, Iso639Language.SPANISH); // name on its own line + assertTrack(tracks.get(4), 5, null, "PGS", FormatType.SUBTITLES, Iso639Language.ENGLISH); + assertTrack(tracks.get(5), 6, "Spanish", "PGS", FormatType.SUBTITLES, Iso639Language.SPANISH); // name inline on the track line } @Test @@ -259,12 +451,98 @@ public void scanAndParseFile_ignoresParentheticalMetadataLines() throws Exceptio final List tracks = scanner.scanAndParseFile(new File("movie.mkv")).getTracks(); assertEquals(5, tracks.size()); - assertTrack(tracks.get(0), 1, null, "h264/AVC", "Video", "English"); + assertTrack(tracks.get(0), 1, null, "h264/AVC", FormatType.VIDEO, Iso639Language.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"); + assertTrack(tracks.get(1), 2, "DTS-HD", "DTS-HD Master Audio", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(2), 3, "Spanish", "AC3", FormatType.AUDIO, Iso639Language.SPANISH); + assertTrack(tracks.get(3), 4, null, "PGS", FormatType.SUBTITLES, Iso639Language.ENGLISH); + assertTrack(tracks.get(4), 5, "Spanish", "PGS", FormatType.SUBTITLES, Iso639Language.SPANISH); + } + + @Test + public void scanBluRayDir_new_returnsAllTitleNumbers(@TempDir final File bluRayDir) throws Exception { + final FileScanner scanner = scannerReturning(0, BD_NEW); + final Set titles = scanner.scanBluRayDir(bluRayDir); + + assertEquals(new HashSet<>(Arrays.asList(1, 2, 3, 4, 5, 6)), titles); + } + + @Test + public void scanAndParseBluRayTitle_new_parsesBracketedLanguageCodes(@TempDir final File bluRayDir) throws Exception { + final FileScanner scanner = scannerReturning(0, BD_TITLE_NEW); + + final List tracks = scanner.scanAndParseBluRayTitle(bluRayDir, 1).getTracks(); + + // preamble + summary lines are skipped; the 14 numbered lines are tracks. languages are bracketed ISO codes, + // including the /T codes [deu]/[fra]/[nld] + assertEquals(14, tracks.size()); + assertTrack(tracks.get(0), 1, "OGM Chapters", FormatType.CHAPTERS, Iso639Language.UNDETERMINED); + assertTrack(tracks.get(1), 2, "h264/AVC", FormatType.VIDEO, Iso639Language.UNDETERMINED); + assertTrack(tracks.get(2), 3, "AC3", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(3), 4, "PGS", FormatType.SUBTITLES, Iso639Language.ENGLISH); + assertTrack(tracks.get(4), 5, "PGS", FormatType.SUBTITLES, Iso639Language.DANISH); + assertTrack(tracks.get(5), 6, "PGS", FormatType.SUBTITLES, Iso639Language.GERMAN); // [deu] (ISO 639-2/T) + assertTrack(tracks.get(6), 7, "PGS", FormatType.SUBTITLES, Iso639Language.SPANISH); + assertTrack(tracks.get(7), 8, "PGS", FormatType.SUBTITLES, Iso639Language.FRENCH); // [fra] (ISO 639-2/T) + assertTrack(tracks.get(8), 9, "PGS", FormatType.SUBTITLES, Iso639Language.ITALIAN); + assertTrack(tracks.get(9), 10, "PGS", FormatType.SUBTITLES, Iso639Language.JAPANESE); + assertTrack(tracks.get(10), 11, "PGS", FormatType.SUBTITLES, Iso639Language.DUTCH); // [nld] (ISO 639-2/T) + assertTrack(tracks.get(11), 12, "PGS", FormatType.SUBTITLES, Iso639Language.NORWEGIAN); + assertTrack(tracks.get(12), 13, "PGS", FormatType.SUBTITLES, Iso639Language.FINNISH); + assertTrack(tracks.get(13), 14, "PGS", FormatType.SUBTITLES, Iso639Language.SWEDISH); + } + + @Test + public void scanAndParseBluRayTitle_strangeSubsWithoutLanguageAreHandledProperly(@TempDir final File bluRayDir) throws Exception { + final FileScanner scanner = scannerReturning(0, BD_TITLE_STRANGE_SUBS); + + final List tracks = scanner.scanAndParseBluRayTitle(bluRayDir, 1).getTracks(); + + assertTrack(tracks.get(0), 1, "OGM Chapters", FormatType.CHAPTERS, Iso639Language.UNDETERMINED); + assertTrack(tracks.get(1), 2, "MPEG2", FormatType.VIDEO, Iso639Language.UNDETERMINED); + assertTrack(tracks.get(2), 3, "AC3", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(3), 4, "PGS", FormatType.SUBTITLES, Iso639Language.ENGLISH); + assertTrack(tracks.get(4), 5, "PGS", FormatType.SUBTITLES, Iso639Language.DANISH); + assertTrack(tracks.get(5), 6, "PGS", FormatType.SUBTITLES, Iso639Language.GERMAN); + assertTrack(tracks.get(6), 7, "PGS", FormatType.SUBTITLES, Iso639Language.SPANISH); + assertTrack(tracks.get(7), 8, "PGS", FormatType.SUBTITLES, Iso639Language.FRENCH); + assertTrack(tracks.get(8), 9, "PGS", FormatType.SUBTITLES, Iso639Language.ITALIAN); + assertTrack(tracks.get(9), 10, "PGS", FormatType.SUBTITLES, Iso639Language.JAPANESE); + assertTrack(tracks.get(10), 11, "PGS", FormatType.SUBTITLES, Iso639Language.DUTCH); + assertTrack(tracks.get(11), 12, "PGS", FormatType.SUBTITLES, Iso639Language.NORWEGIAN); + assertTrack(tracks.get(12), 13, "PGS", FormatType.SUBTITLES, Iso639Language.FINNISH); + assertTrack(tracks.get(13), 14, "PGS", FormatType.SUBTITLES, Iso639Language.SWEDISH); + assertTrack(tracks.get(14), 15, "PGS", FormatType.SUBTITLES, Iso639Language.UNDETERMINED); + } + + @Test + public void scanAndParseFile_new_truehd_parsesBracketedCodesAndQuotedNames() throws Exception { + final FileScanner scanner = scannerReturning(0, MKV_FILE_TRUEHD_NEW); + + final List tracks = scanner.scanAndParseFile(new File("movie.mkv")).getTracks(); + + // same tracks as the *_LEGACY fixture, but languages arrive as bracketed [eng]/[spa] codes + assertEquals(6, tracks.size()); + assertTrack(tracks.get(0), 1, null, "h264/AVC", FormatType.VIDEO, Iso639Language.ENGLISH); + assertTrack(tracks.get(1), 2, "TrueHD", "TrueHD", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(2), 3, null, "AC3", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(3), 4, "Spanish", "AC3", FormatType.AUDIO, Iso639Language.SPANISH); + assertTrack(tracks.get(4), 5, null, "PGS", FormatType.SUBTITLES, Iso639Language.ENGLISH); + assertTrack(tracks.get(5), 6, "Spanish", "PGS", FormatType.SUBTITLES, Iso639Language.SPANISH); + } + + @Test + public void scanAndParseFile_new_dtshd_parsesBracketedCodesAndIgnoresCoreLine() throws Exception { + final FileScanner scanner = scannerReturning(0, MKV_FILE_DTSHD_NEW); + + final List tracks = scanner.scanAndParseFile(new File("movie.mkv")).getTracks(); + + assertEquals(5, tracks.size()); + assertTrack(tracks.get(0), 1, null, "h264/AVC", FormatType.VIDEO, Iso639Language.ENGLISH); + assertTrack(tracks.get(1), 2, "DTS-HD", "DTS-HD Master Audio", FormatType.AUDIO, Iso639Language.ENGLISH); + assertTrack(tracks.get(2), 3, "Spanish", "AC3", FormatType.AUDIO, Iso639Language.SPANISH); + assertTrack(tracks.get(3), 4, null, "PGS", FormatType.SUBTITLES, Iso639Language.ENGLISH); + assertTrack(tracks.get(4), 5, "Spanish", "PGS", FormatType.SUBTITLES, Iso639Language.SPANISH); } @Test @@ -289,11 +567,11 @@ public void demuxFileByTracks_buildsArgsAndIgnoresTrackNames() throws Exception 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)); + "2:", out(outputDir, "movie_tr2_eng_TrueHD.thd+ac3"), + "4:", out(outputDir, "movie_tr4_spa_AC3.ac3"), + "6:", out(outputDir, "movie_tr6_spa_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList("movie_tr2_eng_TrueHD.thd+ac3", + "movie_tr4_spa_AC3.ac3", "movie_tr6_spa_PGS.sup")), new HashSet<>(filenames)); } @Test @@ -313,15 +591,15 @@ public void demuxFileByTracks_emitsEveryConfigForMultiConfigFormat() throws Exce 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"))), + "2:", out(outputDir, "movie_tr2_eng_DTS-HD_Master_Audio.dtshd"), + "2:", out(outputDir, "movie_tr2_eng_DTS-HD_Master_Audio_core.dts"), "-core", + "3:", out(outputDir, "movie_tr3_spa_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)); + "movie_tr2_eng_DTS-HD_Master_Audio.dtshd", + "movie_tr2_eng_DTS-HD_Master_Audio_core.dts", + "movie_tr3_spa_AC3.ac3")), new HashSet<>(filenames)); } @Test @@ -336,23 +614,23 @@ public void demuxFileByTracks_skipsTrackWithNoFormatExtensionConfig() throws Exc 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)); + "1:", out(outputDir, "movie_tr1_eng_AC3.ac3")), commands.get(1)); + assertEquals(Collections.singleton("movie_tr1_eng_AC3.ac3"), new HashSet<>(filenames)); } private static void assertTrack(final Track track, final int number, final String formatName, - final String formatType, final String language) { + final FormatType formatType, final Iso639Language 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()); + final FormatType formatType, final Iso639Language language) { + assertEquals(number, track.getNumber(), "number: track " + number); + assertEquals(name, track.getName(), "name: track " + number); + assertNotNull(track.getFormat(), "format: track " + number); + assertEquals(formatName, track.getFormat().getName(), "formatName: track " + number); + assertEquals(formatType, track.getFormat().getFormatType(), "formatType: track " + number); + assertEquals(language, track.getLanguage(), "language: track " + number); } /** @@ -416,10 +694,10 @@ public void demuxFileByTracks_demuxesSelectedTrackNumbers() throws Exception { 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)); + "3:", out(outputDir, "movie_tr3_eng_AC3.ac3"), + "11:", out(outputDir, "movie_tr11_eng_PGS.sup")), commands.get(1)); assertEquals(new HashSet<>(Arrays.asList( - "movie_tr3_English_AC3.ac3", "movie_tr11_English_PGS.sup")), new HashSet<>(filenames)); + "movie_tr3_eng_AC3.ac3", "movie_tr11_eng_PGS.sup")), new HashSet<>(filenames)); } @Test @@ -428,17 +706,17 @@ public void demuxFileByLanguages_demuxesEveryTrackInLanguage() throws Exception 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")); + // German (ISO 639-2/T "deu") appears on tracks 7 (AC3), 14 (PGS) and 15 (PGS) + final Collection filenames = scanner.demuxFileByLanguages(file, Collections.singletonList(Iso639Language.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)); + "7:", out(outputDir, "movie_tr7_deu_AC3.ac3"), + "14:", out(outputDir, "movie_tr14_deu_PGS.sup"), + "15:", out(outputDir, "movie_tr15_deu_PGS.sup")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList("movie_tr7_deu_AC3.ac3", + "movie_tr14_deu_PGS.sup", "movie_tr15_deu_PGS.sup")), new HashSet<>(filenames)); } @Test @@ -455,11 +733,11 @@ public void demuxBluRayTitleByTracks_buildsArgsForTitleAndMixedFormats(@TempDir // 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")), + "1:", out(outputDir, "MOVIE_BD_ti1_tr1_Chapters.txt"), + "2:", out(outputDir, "MOVIE_BD_ti1_tr2_und_VC-1.mkv"), + "4:", out(outputDir, "MOVIE_BD_ti1_tr4_eng_RAW_PCM.pcm")), commands.get(1)); + assertEquals(new HashSet<>(Arrays.asList("MOVIE_BD_ti1_tr1_Chapters.txt", + "MOVIE_BD_ti1_tr2_und_VC-1.mkv", "MOVIE_BD_ti1_tr4_eng_RAW_PCM.pcm")), new HashSet<>(filenames)); } @@ -470,16 +748,16 @@ public void demuxBluRayTitleByLanguages_buildsArgsForTitleAndLanguage(@TempDir f 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")); + // French (ISO 639-2/T "fra") appears on tracks 5 (AC3) and 13 (PGS) + final Collection<String> filenames = scanner.demuxBluRayTitleByLanguages(bluRayDir, 1, Collections.singletonList(Iso639Language.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)); + "5:", out(outputDir, "MOVIE_BD_ti1_tr5_fra_AC3.ac3"), + "13:", out(outputDir, "MOVIE_BD_ti1_tr13_fra_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)); + "MOVIE_BD_ti1_tr5_fra_AC3.ac3", "MOVIE_BD_ti1_tr13_fra_PGS.sup")), new HashSet<>(filenames)); } private FileScanner scannerWithOutputDir(final File outputDir, final String scanOutput) throws Exception { diff --git a/src/test/java/com/adashrod/mkvscanner/model/Iso639LanguageTest.java b/src/test/java/com/adashrod/mkvscanner/model/Iso639LanguageTest.java new file mode 100644 index 0000000..2fcd2a1 --- /dev/null +++ b/src/test/java/com/adashrod/mkvscanner/model/Iso639LanguageTest.java @@ -0,0 +1,74 @@ +package com.adashrod.mkvscanner.model; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class Iso639LanguageTest { + @Test + public void fromToken_resolvesCanonicalName() { + assertEquals(Iso639Language.ENGLISH, Iso639Language.fromToken("English")); + assertEquals(Iso639Language.GERMAN, Iso639Language.fromToken("German")); + } + + @Test + public void fromToken_isCaseInsensitiveAndTrims() { + assertEquals(Iso639Language.ENGLISH, Iso639Language.fromToken(" english ")); + assertEquals(Iso639Language.SPANISH, Iso639Language.fromToken("SPANISH")); + } + + @Test + public void fromToken_resolvesAliases() { + assertEquals(Iso639Language.DUTCH, Iso639Language.fromToken("Flemish")); + assertEquals(Iso639Language.SPANISH, Iso639Language.fromToken("Castilian")); + } + + @Test + public void fromToken_resolvesBracketedIsoCode() { + assertEquals(Iso639Language.ENGLISH, Iso639Language.fromToken("[eng]")); + assertEquals(Iso639Language.SPANISH, Iso639Language.fromToken("[spa]")); + assertEquals(Iso639Language.UNDETERMINED, Iso639Language.fromToken("[und]")); + // case-insensitive inside the brackets too + assertEquals(Iso639Language.FRENCH, Iso639Language.fromToken("[FRE]")); + } + + @Test + public void fromToken_resolvesUnbracketedIsoCode() { + assertEquals(Iso639Language.ENGLISH, Iso639Language.fromToken("eng")); + assertEquals(Iso639Language.SPANISH, Iso639Language.fromToken("spa")); + assertEquals(Iso639Language.UNDETERMINED, Iso639Language.fromToken("und")); + assertEquals(Iso639Language.FRENCH, Iso639Language.fromToken("FRE")); + } + + @Test + public void fromToken_resolvesIso639_2TCodes() { + // eac3to emits ISO 639-2/T (terminological) codes; the enum now uses /T as the canonical code. + // Both bracketed and unbracketed forms resolve via fromToken + assertEquals(Iso639Language.GERMAN, Iso639Language.fromToken("[deu]")); + assertEquals(Iso639Language.FRENCH, Iso639Language.fromToken("[fra]")); + assertEquals(Iso639Language.DUTCH, Iso639Language.fromToken("[nld]")); + assertEquals(Iso639Language.CHINESE, Iso639Language.fromToken("zho")); + // getCode() returns the /T code (modern standard) + assertEquals("deu", Iso639Language.GERMAN.getCode()); + assertEquals("fra", Iso639Language.FRENCH.getCode()); + assertEquals("nld", Iso639Language.DUTCH.getCode()); + assertEquals("zho", Iso639Language.CHINESE.getCode()); + } + + @Test + public void fromToken_throwsForUnknownOrMalformed() { + assertThrows(IllegalArgumentException.class, () -> Iso639Language.fromToken("Klingon")); + assertThrows(IllegalArgumentException.class, () -> Iso639Language.fromToken("[zzz]")); // not a real code + assertThrows(IllegalArgumentException.class, () -> Iso639Language.fromToken(null)); + } + + @Test + public void accessors_exposeNameCodeAndAliases() { + assertEquals("English", Iso639Language.ENGLISH.getName()); + assertEquals("eng", Iso639Language.ENGLISH.getCode()); + assertEquals("nld", Iso639Language.DUTCH.getCode()); // ISO 639-2/T code + assertTrue(Iso639Language.DUTCH.getAliases().contains("Flemish")); + } +}