#!/usr/bin/env ruby require 'thor' require 'json' require_relative '../lib/subs' module Subs class CLI < Thor class_option :version, type: :boolean, desc: 'Display version string', aliases: '-v' class_option :quiet, type: :boolean, desc: 'Do not show any output', aliases: '-q' class_option :debug, type: :boolean, desc: 'Show more verbose output', aliases: '-d' if Dir.exist?(File.expand_path('~/.config')) CONFIG_PATH = File.expand_path('~/.config/subs.json') else CONFIG_PATH = File.expand_path('~/.subs') end PROVIDERS = [SubDB, OpenSubtitles] def initialize(*args) super(*args) if options[:quiet] Subs.create_log(STDOUT, :fatal) elsif options[:debug] Subs.create_log(STDOUT, :debug) end if options[:version] != options[:quiet] say "Subs v.#{Subs::VERSION}" end begin @config = JSON.parse(File.read(CONFIG_PATH), symbolize_names: true) Subs.log.debug { "Configuration loaded successfully" } rescue Subs.log.debug { "Failed to load user configuration, using default" } @config = default_config File.open(CONFIG_PATH, 'wb') { |io| io.write(JSON.pretty_generate(@config)) } rescue nil end end desc 'find [PATH1 [,PATH2]]... [OPTIONS]', 'Input file/directory path(s) that video files will be searched for' method_option :recursive, type: :boolean, desc: 'Search directories recursively', aliases: '-r', default: false method_option :clobber, type: :boolean, desc: 'Overwrite existing subtitles', aliases: '-c', default: false method_option :auto, type: :boolean, desc: 'Automatically select best match and fetch without prompt', aliases: '-a', default: true method_option :method, type: :string, desc: 'Sets the search method', aliases: '-m', enum: %w(hash name any), default: 'hash' method_option :provider, type: :string, desc: 'Set which subtitles provider(s) are used', aliases: '-p', enum: %w(all subdb osdb), default: 'all' method_option :language, type: :string, desc: 'ISO-639 language code for desired subtitle language', aliases: '-l' method_option :test, type: :boolean, desc: 'Perform a dry-run without writing anything to disk', aliases: '-t' def find(*paths) # Compile list of videos to process paths.push(Dir.pwd) if paths.empty? videos = build_video_list(*paths) # Parse our arguments language = get_language(options[:language] || @config[:language]) providers = get_providers(options[:provider]) Subs.log.info { "Found #{videos.size.to_s.light_blue} videos to query"} # Perform search results = {} videos.each do |video| # Skip searching for videos that already have subtitles unless --clobber flag was passed if !options[:clobber] && Subs.subtitle_exist?(video, language) Subs.log.info { "Skipping #{File.basename(video).yellow}" } next end # Create instance of each provider, and apply specified search methods for each results[video] = [] providers.each do |klass| klass.new do |provider| results[video] += search(provider, video, language, options[:method].to_sym) end end Subs.log.info { "Found #{results[video].size.to_s.light_blue} results for #{File.basename(video).yellow}"} end process_results(results) end desc 'sync [+|-]', 'Offsets SubRip subtitle timings by number of milliseconds' method_option :backup, type: :boolean, desc: 'Backup original file', aliases: '-b' def sync(path, ms) unless File.exist?(path) Subs.log.error { "Cannot resolve path '#{path}'"} return end return if options[:backup] && !backup_file(path) match = /^([+-])?(\d+)$/.match(ms) unless match Subs.log.error { "Invalid offset parameter #{ms}".red } exit end offset = Integer(ms) regex = /^(\d{2}):(\d{2}):(\d{2}),(\d+) --> (\d{2}):(\d{2}):(\d{2}),(\d+)$/ buffer = StringIO.new begin File.open(path, 'rb').each do |line| match = regex.match(line) unless match buffer.write(line) next end t1 = Subs::SubRipTime.new(*match[1..4].map(&:to_i)) + offset t2 = Subs::SubRipTime.new(*match[5..8].map(&:to_i)) + offset buffer.write("#{t1} --> #{t2}\n") end buffer.seek(0, IO::SEEK_SET) File.open(path, 'wb') { |io| io.write(buffer.read) } rescue Subs.log.error { "Error occurred during subtilte re-sync".red } end end desc 'config ', 'Sets and stores a configuration option to persist across each run' def config(name, value) begin key = name.to_sym if value.nil? && @config.has_key?(key) @config.delete(key) return end @config[key] = value File.open(CONFIG_PATH, 'wb') { |io| io.write(JSON.pretty_generate(@config)) } rescue Subs.log.error { 'Error occurred setting configuration option' } end end no_commands do def default_config # Make half-attempt at getting the actual system language, otherwise just go with English begin env = ENV['LANG'] match = /([a-z]{2})_[A-Z][A-Z]\..+/.match(env) language = Language.from_alpha2(match[1]).alpha3 rescue language = 'eng' end { language: language, credentials: {} } end def build_video_list(*paths) videos = [] paths.each do |path| if File.directory?(path) videos += Subs.video_search(path, options[:recursive]) elsif File.exist?(path) ext = File.extname(path) Subs.log.warn { "Unrecognized video extension: ''#{ext}''" } unless Subs::VIDEO_EXTENSIONS.include?(ext) videos << File.expand_path(path) else Subs.log.warn { "Unable to resolve path '#{path}', skipping" } end end videos end def prune_list(videos) # Reject videos that already have subtitles unless "clobber" option is present count = videos.count videos.reject! { |v| Subs.subtitle_exist?(v, *options[:lang]) } if videos.count != count diff = count - videos.count Subs.log.info { "Existing subtitles found for #{diff.to_s.light_blue}, skipping." } end end def backup_file(path) unless File.exist?(path) Subs.log.error { "Cannot resolve path '#{path}'"} return false end bak = File.expand_path(path) + '.bak' if File.exist?(bak) Subs.log.debug { "Backup file '#{bak}' already exists" } return true end begin File.open(bak, 'wb') do |dest| File.open(path, 'rb') { |src| dest.write(src.read) } end Subs.log.debug { "Back up of #{path.yellow} was successful" } return true rescue Subs.log.error { "Error occurred during backup of '#{path}'"} return false end end def get_providers(name) return case name when 'all' then PROVIDERS when 'subdb' then [SubDB] when 'osdb' then [OpenSubtitles] else Subs.log.error { "Invalid provider: #{name}" } exit end end def get_language(code) code = code || @config[:language] iso639 = case code.size when 2 then Language.from_alpha2(code) when 3 then Language.from_alpha3(code) else nil end unless iso639 Subs.log.error { "Unsupported language code specified: #{code}"} exit end iso639 end def search(provider, video, language, method) results = [] # Search by hash if :hash or :any flag if provider.is_a?(HashSearcher) && (method == :hash || method == :any) results += provider.hash_search(video, language) end # Search by filename if :name flag, or :any flag and hash search failed if provider.is_a?(FilenameSearcher) && (method == :name || (method == :any && results.empty?)) results += provider.filename_search(video, language) end results end def process_results(results) choices = [] results.each_pair do |video, list| # Continue if no results for video next if list.empty? # Pick the first result (most probable) if --auto switch is set if options[:auto] choices << list.first next end say say "Select subtitle for #{File.basename(video).yellow}" list.each_with_index do |result, i| puts " #{(i + 1).to_s.rjust(2).light_blue}. #{result.name} (#{result.provider_name.blue})" end say input = ask 'Input number of choice, or leave blank to skip: ' i = input.chomp.strip.to_i rescue 0 choices << list[i - 1] if i > 0 end # Process the pruned results process_choices(choices.compact) end def process_choices(results) Subs.log.info { "Processing #{results.size.to_s.light_blue} results..." } # Group results by which provider they use results.group_by(&:provider).each_pair do |klass, list| klass.new do |provider| # Instantiate the provider and process the results list.each do |result| name = File.basename(result.video).yellow begin path = Subs.build_subtitle_path(result.video, result.language) File.open(path, 'wb') { |io| provider.process_result(io, result) } Subs.log.info { " Successfully processed #{name}".green } rescue Subs.log.error { " Failed to process #{name}".red } end end end end Subs.log.info { "Processing complete" } end end end end Subs::CLI.start(ARGV)