|
#!/bin/env ruby |
|
|
|
# License: MIT |
|
|
|
require 'shellwords' |
|
require 'tempfile' |
|
|
|
class MuxerCLI |
|
attr_reader :dir |
|
|
|
# https://en.wikipedia.org/wiki/List_of_ISO_639_language_codes |
|
LANGS = { |
|
"English" => "eng", |
|
"Bulgarian" => "bul", |
|
"Chinese" => "zho", |
|
"German" => "deu", |
|
"Italian" => "ita", |
|
"Spanish" => "spa", |
|
"Portuguese" => "por", |
|
"Japanese" => "jpn", |
|
"French" => "fra", |
|
}.freeze |
|
|
|
MEDIA_EXT = %w[avi mp4 mpv webm mkv] |
|
SUBTITLE_EXT = %w[srt vtt] |
|
|
|
def initialize(dir) |
|
@dir = dir.chomp("/").freeze |
|
|
|
raise "specify valid dir" unless Dir.exists?(dir) |
|
end |
|
|
|
def file_ext(path) |
|
path.slice(/(?!:\.)[^.]+$/).freeze |
|
end |
|
|
|
# def filename(path) |
|
# path.slice(%r{/[^/]+$}).freeze |
|
# end |
|
|
|
def output_path |
|
@output_name ||= "#{dir}.#{output_ext}" |
|
end |
|
|
|
def output_ext |
|
if media_files.all? { file_ext(_1) == "mp4" } |
|
"mp4" |
|
elsif media_files.all? { file_ext(_1) == "webm" } |
|
"webm" |
|
else |
|
"mkv" |
|
end |
|
end |
|
|
|
def media_files |
|
return @media_files if @media_files |
|
|
|
discover_files |
|
|
|
@media_files |
|
end |
|
|
|
def subtitle_files |
|
return @subtitle_files if @subtitle_files |
|
|
|
discover_files |
|
|
|
@subtitle_files |
|
end |
|
|
|
def chapter_files |
|
return @chapter_files if @chapter_files |
|
|
|
discover_files |
|
|
|
@chapter_files |
|
end |
|
|
|
def discover_files |
|
return if @media_files && @subtitle_files |
|
|
|
media_files = [] |
|
subtitle_files = [] |
|
chapter_files = [] |
|
|
|
Dir.entries(dir).each do |file| |
|
next if %w[. ..].include? file |
|
|
|
raise "Don't know what to do with directories" if Dir.exists?(full_path(file)) |
|
|
|
if "chapters.txt" == file |
|
chapter_files << file |
|
next |
|
end |
|
|
|
ext = file_ext(file) |
|
if MEDIA_EXT.include? ext |
|
media_files << file |
|
elsif SUBTITLE_EXT.include? ext |
|
subtitle_files << file |
|
else |
|
raise "unknown file extension for #{file}" |
|
end |
|
end |
|
|
|
raise "one or two media files expected" unless (1..2).include? media_files.size |
|
raise "maximum one chapters file supported" if chapter_files.size > 1 |
|
|
|
@chapter_files = chapter_files |
|
@media_files = media_files |
|
@subtitle_files = subtitle_files |
|
nil |
|
end |
|
|
|
def full_path(file) |
|
File.join(dir, file) |
|
end |
|
|
|
def escaped_path(file) |
|
Shellwords.escape(full_path(file)) |
|
end |
|
|
|
def media_streams |
|
return @media_streams if @media_streams |
|
|
|
@media_streams = media_files.sum do |media| |
|
num = `ffprobe -show_entries format=nb_streams -v 0 -of compact=p=0:nk=1 #{escaped_path(media)}` |
|
Integer(num) |
|
end |
|
end |
|
|
|
def mux_command |
|
"ffmpeg #{file_options} #{subtitle_options} #{Shellwords.escape output_path}" |
|
end |
|
|
|
# just include all media as input |
|
# TODO: handle metadata copying more reliably, so far these are copied from first file |
|
def file_options |
|
inputs = (media_files + subtitle_files).map { "-i #{escaped_path(_1)}" }.join(" ") |
|
maps = (media_files + subtitle_files).size.times.map { "-map #{_1}" }.join(" ") |
|
"#{inputs} #{chapter_options} -c:v copy -c:a copy -c:s webvtt #{maps}" |
|
end |
|
|
|
def subtitle_options |
|
subtitle_stream = media_streams - 1 |
|
# inputnum = media_files.size |
|
options = subtitle_files.map do |subtitle| |
|
subtitle_stream += 1 |
|
"-metadata:s:#{subtitle_stream} language=#{lang_code(subtitle)} -metadata:s:#{subtitle_stream} handler_name=#{lang(subtitle)} -metadata:s:#{subtitle_stream} title=#{lang(subtitle)}" |
|
end |
|
|
|
options.join(" ") |
|
end |
|
|
|
# TODO: copy chapters from proper media file, not only look for external file |
|
def chapter_options |
|
"-i #{Shellwords.escape encoded_chapters} -map_chapters #{(media_files + subtitle_files).size}" if encoded_chapters |
|
end |
|
|
|
def encoded_chapters |
|
return @encoded_chapters if @encoded_chapters |
|
return if chapter_files.empty? |
|
|
|
chapters = File.readlines(full_path(chapter_files.first)).map do |line| |
|
line = line.strip |
|
next if line.empty? |
|
|
|
m = /^(\d):(\d{2}):(\d{2}) (.*)$/.match line |
|
raise "bad chapters format at #{line}" unless m |
|
|
|
hrs = m[1].to_i |
|
mins = m[2].to_i |
|
secs = m[3].to_i |
|
title = m[4] |
|
|
|
minutes = (hrs * 60) + mins |
|
seconds = secs + (minutes * 60) |
|
timestamp = (seconds * 1000) |
|
chap = { "title" => title, "START" => timestamp } |
|
end |
|
|
|
dst = Tempfile.create(%w[CHAPTERS .txt]) |
|
dst << ";FFMETADATA1\n\n" |
|
|
|
chapters.last["START"] += 1 |
|
chapters.each_with_index do |chapter, i| |
|
next_chapter = chapters[i + 1] |
|
break unless next_chapter |
|
|
|
dst << <<~EOCHAPTERS |
|
[CHAPTER] |
|
TIMEBASE=1/1000 |
|
START=#{chapter["START"]} |
|
END=#{next_chapter["START"] - 1} |
|
title=#{chapter["title"]} |
|
|
|
EOCHAPTERS |
|
end |
|
|
|
dst.close |
|
@encoded_chapters = dst.path |
|
end |
|
|
|
def lang_code(file) |
|
LANGS[lang(file)] |
|
end |
|
|
|
def lang(file) |
|
keys = LANGS.keys.select { file.include? _1 } |
|
raise "can't find language for #{file}" if keys.empty? |
|
raise "ambiguous file, found multiple languages: #{keys.join(" ")}" if keys.size > 1 |
|
keys.first |
|
end |
|
|
|
def run |
|
# raise "Output file already exists" if File.exists?(Shellwords.escape output_path) # ffmpeg will ask |
|
command = mux_command |
|
warn "Running: " + command |
|
|
|
system command |
|
end |
|
end |
|
|
|
MuxerCLI.new(ARGV[0]).run |