movie_organizer 0.1.11 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/.gitignore +1 -0
  3. data/.rubocop.yml +3 -0
  4. data/CHANGELOG +10 -0
  5. data/Gemfile +2 -1
  6. data/README.md +27 -14
  7. data/lib/movie_organizer.rb +33 -14
  8. data/lib/movie_organizer/file_copier.rb +33 -19
  9. data/lib/movie_organizer/logger.rb +2 -0
  10. data/lib/movie_organizer/media_list.rb +1 -15
  11. data/lib/movie_organizer/medium.rb +176 -0
  12. data/lib/movie_organizer/movie.rb +50 -18
  13. data/lib/movie_organizer/options.rb +23 -0
  14. data/lib/movie_organizer/organizer.rb +29 -11
  15. data/lib/movie_organizer/settings.rb +3 -0
  16. data/lib/movie_organizer/string.rb +8 -0
  17. data/lib/movie_organizer/tmdb_instance.rb +58 -0
  18. data/lib/movie_organizer/tv_show.rb +51 -37
  19. data/lib/movie_organizer/tvdb_instance.rb +31 -0
  20. data/lib/movie_organizer/version.rb +1 -1
  21. data/lib/movie_organizer/video.rb +39 -11
  22. data/movie_organizer.gemspec +5 -2
  23. data/spec/files/Dunkirk.2017.BluRay.1080p.mp4 +0 -0
  24. data/spec/files/bad.flv +0 -0
  25. data/spec/files/bad.mp4 +1 -0
  26. data/spec/files/good.3gp +0 -0
  27. data/spec/files/good.mov +0 -0
  28. data/spec/files/good.mp4 +0 -0
  29. data/spec/files/good.ogv +0 -0
  30. data/spec/files/good.webm +0 -0
  31. data/spec/files/{The.Walking.Dead.S04E08.HDTV.x264-2HD.mp4 → tv_shows/The.Walking.Dead.S04E08.HDTV.x264-2HD.mp4} +0 -0
  32. data/spec/fixtures/.blank_settings.yml +3 -0
  33. data/spec/lib/movie_organizer/file_copier_spec.rb +5 -2
  34. data/spec/lib/movie_organizer/media_list_spec.rb +8 -37
  35. data/spec/lib/movie_organizer/medium_spec.rb +97 -0
  36. data/spec/lib/movie_organizer/movie_spec.rb +21 -14
  37. data/spec/lib/movie_organizer/tmdb_instance_spec.rb +39 -0
  38. data/spec/lib/movie_organizer/tvdb_instance_spec.rb +17 -0
  39. data/spec/lib/movie_organizer/video_spec.rb +6 -4
  40. data/spec/lib/movie_organizer_spec.rb +1 -9
  41. data/spec/spec_helper.rb +5 -0
  42. data/spec/support/shared_contexts/media_shared.rb +9 -0
  43. data/spec/support/vcr.rb +17 -0
  44. metadata +74 -21
  45. data/lib/movie_organizer/media.rb +0 -110
  46. data/spec/lib/movie_organizer/media_spec.rb +0 -65
@@ -1,33 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/scp'
4
- require 'net/ssh'
5
-
6
3
  module MovieOrganizer
7
- class Movie < Media
8
- def initialize(filename, options)
9
- super
4
+ class Movie < Medium
5
+ attr_reader :tmdb_instance
6
+
7
+ class << self
8
+ # Determine if the passed file is most likely a Movie.
9
+ # If it is, that usually means the file was named with movie title and year somwehere in
10
+ # the filename
11
+ #
12
+ # @return [Boolean] TmdbInstance if likely a Movie, false if not
13
+ def match?(filepath)
14
+ base = basename(filepath)
15
+ possible_year = possible_year_in_title(base)
16
+ clean_title = sanitize(base).gsub(/[\s\.\-\_]\(?\s*\d+\s*\)?/, '')
17
+ tmdb_instance = TmdbInstance.new(clean_title, possible_year)
18
+ return tmdb_instance if tmdb_instance.movie?
19
+ false
20
+ end
21
+
22
+ private
23
+
24
+ def possible_year_in_title(title)
25
+ title_with_year = sanitize(title)
26
+ md = title_with_year.match(/(\d+)/)
27
+ md ? md[1] : nil
28
+ end
29
+ end
30
+
31
+ def initialize(filename, tmdb_instance)
32
+ super(filename)
33
+ @tmdb_instance = tmdb_instance
10
34
  end
11
35
 
36
+ # Set the target filename
37
+ #
12
38
  def process!
13
- target_dir = File.join(MovieOrganizer.movie_directory, "#{title} (#{year})")
14
- target_file = File.join(target_dir, processed_filename)
15
- # logger.info(" target dir: [#{target_dir}]")
16
- logger.info(" target file: [#{target_file.green.bold}]")
17
- fc = FileCopier.new(filename, target_file, options)
18
- fc.copy
39
+ return nil unless tmdb_instance
40
+ tmdb_instance.likely_match
41
+ @target = File.join(target_dir, processed_filename)
42
+ Logger.instance.info(" target file: [#{@target.green.bold}]")
19
43
  end
20
44
 
21
45
  def processed_filename
22
- "#{title} (#{year})#{ext}"
46
+ "#{title} (#{year})#{extname}"
23
47
  end
24
48
 
25
49
  def title
26
- ext_regex = Regexp.new(ext.sub(/\./, '\\.'))
27
- newbase = sanitize(
28
- basename.sub(/#{ext_regex}$/, '').sub(/\(?#{year}\)?/, '')
29
- )
30
- @title ||= newbase
50
+ return sanitize(basename).gsub(/[\s\.\-\_]\(?\s*\d+\s*\)?/, '') unless tmdb_instance
51
+ tmdb_instance.title
52
+ end
53
+
54
+ def year
55
+ return nil unless tmdb_instance
56
+ tmdb_instance.year
57
+ end
58
+
59
+ private
60
+
61
+ def target_dir
62
+ @target_dir ||= File.join(MovieOrganizer.movie_directory, "#{title} (#{year})")
31
63
  end
32
64
  end
33
65
  end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'singleton'
4
+
5
+ module MovieOrganizer
6
+ class Options
7
+ include Singleton
8
+
9
+ def [](key)
10
+ @@_options[key]
11
+ end
12
+
13
+ def hash
14
+ @@_options
15
+ end
16
+
17
+ private
18
+
19
+ def initialize_hash(hash)
20
+ @@_options = hash
21
+ end
22
+ end
23
+ end
@@ -18,7 +18,9 @@ module MovieOrganizer
18
18
 
19
19
  def start
20
20
  start_time = Time.now
21
- @options = collect_args
21
+ options = MovieOrganizer::Options.instance
22
+ options.send(:initialize_hash, collect_args)
23
+
22
24
  logger.info('Starting MovieOrganizer...'.green)
23
25
  count = 0
24
26
 
@@ -28,10 +30,14 @@ module MovieOrganizer
28
30
  # Process each source file
29
31
  @media_list.file_collection.each do |file|
30
32
  # Get movie or TV show information so we can rename the file if necessary
31
- media = Media.subtype(file, options)
33
+ media = Medium.build_instance(file)
34
+ if media.nil?
35
+ logger.info("Skipping [#{file}]")
36
+ next
37
+ end
32
38
  # Move and/or rename the file
33
39
  logger.info("Processing [#{file}] - #{media.class.to_s.yellow}")
34
- media.process!
40
+ media.groom
35
41
  count += 1
36
42
  end
37
43
  elapsed = Time.now - start_time
@@ -40,28 +46,40 @@ module MovieOrganizer
40
46
 
41
47
  private
42
48
 
49
+ # rubocop:disable Metrics/MethodLength
43
50
  def collect_args
44
51
  Trollop.options do
45
52
  opt(
46
53
  :source_dir,
47
54
  'Source directories containing media files. Colon (:) separated.',
48
- type: :string, required: false, short: '-s')
55
+ type: :string, required: false, short: '-s'
56
+ )
57
+ opt(
58
+ :copy,
59
+ 'Copy instead of Move files',
60
+ type: :boolean, required: false, short: '-c',
61
+ default: false
62
+ )
49
63
  opt(
50
64
  :dry_run,
51
65
  'Do not actually move or copy files',
52
66
  type: :boolean, required: false, short: '-d',
53
- default: false)
54
- opt(
55
- :preserve_episode_name,
56
- 'Preserve episode names if they exist (experimental)',
57
- type: :boolean, required: false, short: '-p',
58
- default: false)
67
+ default: false
68
+ )
69
+ # opt(
70
+ # :preserve_episode_name,
71
+ # 'Preserve episode names if they exist (experimental)',
72
+ # type: :boolean, required: false, short: '-p',
73
+ # default: false
74
+ # )
59
75
  opt(
60
76
  :verbose,
61
77
  'Be verbose with output',
62
78
  type: :boolean, required: false, short: '-v',
63
- default: false)
79
+ default: false
80
+ )
64
81
  end
65
82
  end
83
+ # rubocop:enable Metrics/MethodLength
66
84
  end
67
85
  end
@@ -1,11 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'singleton'
3
4
  require 'yaml'
4
5
 
5
6
  #:nocov:
6
7
  module MovieOrganizer
7
8
  # Simple class for YAML settings
8
9
  class Settings
10
+ include Singleton
11
+
9
12
  attr_reader :file
10
13
  attr_reader :config
11
14
 
@@ -10,4 +10,12 @@ class String
10
10
  lines.map! { |c| c.sub!(/\s*\|/, '') }
11
11
  lines.join(delimeter)
12
12
  end
13
+
14
+ def escape_single_quotes!
15
+ gsub!(/[']/, '\\\\\'')
16
+ end
17
+
18
+ def escape_double_quotes!
19
+ gsub!(/["]/, '\\\\\"')
20
+ end
13
21
  end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'themoviedb'
4
+
5
+ # A cached movie lookup instance
6
+ module MovieOrganizer
7
+ class TmdbInstance
8
+ attr_reader :title, :year, :matches
9
+
10
+ def initialize(title, year = nil)
11
+ Tmdb::Api.key(api_key) # configure TMDB API key
12
+ @title = title
13
+ @year = year
14
+ end
15
+
16
+ def movie?
17
+ @matches = Tmdb::Movie.find(title)
18
+ sleep(0.25)
19
+ return self if matches.any?
20
+ false
21
+ end
22
+
23
+ # rubocop:disable Metrics/AbcSize
24
+ # rubocop:disable Style/RescueModifier
25
+ def likely_match
26
+ @likely_match ||= begin
27
+ if year.nil?
28
+ lm = matches.first
29
+ release_date = Date.parse(lm.release_date) rescue nil
30
+ @title = lm.title
31
+ @year = release_date&.year
32
+ else
33
+ lm = nil
34
+ matches.each do |m|
35
+ release_date = Date.parse(m.release_date) rescue nil
36
+ next unless release_date&.year.to_i == year.to_i
37
+ lm = m
38
+ @title = lm.title
39
+ @year = release_date&.year
40
+ break
41
+ end
42
+ lm ||= matches.first
43
+ end
44
+ lm
45
+ end
46
+ end
47
+ # rubocop:enable Style/RescueModifier
48
+ # rubocop:enable Metrics/AbcSize
49
+
50
+ private
51
+
52
+ def api_key
53
+ ENV.fetch('TMDB_KEY') do
54
+ Settings.instance[:movies][:tmdb_key]
55
+ end
56
+ end
57
+ end
58
+ end
@@ -1,59 +1,65 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MovieOrganizer
4
- class TvShow < Media
4
+ class TvShow < Medium
5
+ attr_reader :tvdb_instance, :preserve_episode_name
6
+
5
7
  S_E_EXPRESSIONS = [
6
8
  /(s(\d+)e(\d+))/i,
7
- /((\d+)x(\d+))/i,
8
- /[\.\s]((\d)(\d+))[\.\s]/i
9
- ]
9
+ /((\d+)x(\d+))/i
10
+ ].freeze
11
+
12
+ class << self
13
+ def match?(filepath)
14
+ index = nil
15
+ clean_title = nil
16
+ base = basename(filepath)
17
+ sanitized = sanitize(base)
18
+ S_E_EXPRESSIONS.each do |regex|
19
+ next unless (md = sanitized.match(regex))
20
+ index = sanitized.index(md[1], 0)
21
+ clean_title = sanitized[0..index - 1].strip
22
+ break
23
+ end
24
+ return false unless clean_title
25
+ tvdb_instance = TvdbInstance.new(clean_title)
26
+ return tvdb_instance if tvdb_instance.tv_show?
27
+ false
28
+ end
29
+ end
10
30
 
11
- def initialize(filename, options)
12
- super
13
- @season = nil
14
- @episode = nil
15
- @episode_title = nil
16
- @season_and_episode = nil
31
+ def initialize(filename, tvdb_instance)
32
+ super(filename)
33
+ @tvdb_instance = tvdb_instance
34
+ @season = nil
35
+ @episode = nil
36
+ @episode_title = nil
37
+ @season_and_episode = nil
38
+ @preserve_episode_name = false
17
39
  end
18
40
 
41
+ # Set the target filename
42
+ #
19
43
  def process!
20
44
  return nil if should_skip?
21
- # rename the file
22
- raise "Show not configured #{basename}" if title.nil?
23
- target_dir = File.join(
24
- MovieOrganizer.tv_shows_directory,
25
- title,
26
- "Season #{season.sub(/^0+/, '')}"
27
- )
28
- target_file = File.join(target_dir, processed_filename)
29
- logger.info(" target file: [#{target_file.green.bold}]")
30
- fc = FileCopier.new(filename, target_file, options)
31
- fc.copy
32
- rescue ArgumentError => err
33
- raise err unless err.message =~ /^same file:/
45
+
46
+ @target = File.join(target_dir, processed_filename)
47
+ Logger.instance.info(" target file: [#{@target.green.bold}]")
34
48
  end
35
49
 
36
50
  # Standardize the filename
37
51
  # @return [String] cleaned filename
38
52
  def processed_filename
39
53
  return nil if should_skip?
40
- if options[:preserve_episode_name] && episode_title
41
- "#{title} - #{season_and_episode} - #{episode_title}#{ext}"
54
+ if @preserve_episode_name && episode_title
55
+ "#{title} - #{season_and_episode} - #{episode_title}#{extname}"
42
56
  else
43
- "#{title} - #{season_and_episode}#{ext}"
57
+ "#{title} - #{season_and_episode}#{extname}"
44
58
  end
45
59
  end
46
60
 
47
61
  def title
48
- return @title unless @title.nil?
49
- settings[:tv_shows][:my_shows].each do |show|
50
- md = sanitize(basename).match(Regexp.new(sanitize(show), Regexp::IGNORECASE))
51
- if md
52
- @title = md[0].titleize
53
- return @title
54
- end
55
- end
56
- @title
62
+ tvdb_instance.match.title
57
63
  end
58
64
 
59
65
  def season
@@ -68,6 +74,14 @@ module MovieOrganizer
68
74
  filename.match(/[\.\s-]?sample[\.\s-]?/)
69
75
  end
70
76
 
77
+ def target_dir
78
+ @target_dir ||= File.join(
79
+ MovieOrganizer.tv_shows_directory,
80
+ title,
81
+ "Season #{season.sub(/^0+/, '')}"
82
+ )
83
+ end
84
+
71
85
  def episode_title
72
86
  return @episode_title unless @episode_title.nil?
73
87
  md = basename.match(/([^-]+)-([^-]+)-([^-]+)/)
@@ -77,8 +91,8 @@ module MovieOrganizer
77
91
 
78
92
  def season_and_episode
79
93
  return @season_and_episode unless @season_and_episode.nil?
80
- clean_basename = sanitize(basename)
81
- s_and_e_info = clean_basename.sub(Regexp.new(title, Regexp::IGNORECASE), '')
94
+ base = basename.gsub(/#{title}[\.\s]*/i, '')
95
+ s_and_e_info = sanitize(base)
82
96
  S_E_EXPRESSIONS.each do |regex|
83
97
  md = s_and_e_info.match(regex)
84
98
  next unless md
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tvdbr'
4
+
5
+ # A cached TV show lookup instance
6
+ module MovieOrganizer
7
+ class TvdbInstance
8
+ attr_reader :title, :year, :match, :tvdb
9
+
10
+ def initialize(title, year = nil)
11
+ @tvdb = Tvdbr::Client.new(api_key)
12
+ @title = title
13
+ @year = year
14
+ end
15
+
16
+ def tv_show?
17
+ @match = tvdb.find_series_by_title(title)
18
+ sleep(0.25)
19
+ return self if @match
20
+ false
21
+ end
22
+
23
+ private
24
+
25
+ def api_key
26
+ ENV.fetch('TVDB_KEY') do
27
+ Settings.instance[:movies][:tmdb_key]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module MovieOrganizer
4
- VERSION = '0.1.11'.freeze
4
+ VERSION = '1.0.1'.freeze
5
5
  end
@@ -3,24 +3,38 @@
3
3
  require_relative 'string'
4
4
 
5
5
  module MovieOrganizer
6
- class Video < Media
6
+ class Video < Medium
7
7
  attr_reader :settings
8
8
 
9
- def initialize(filename, options)
10
- @settings = Settings.new
9
+ VIDEO_EXTENSIONS = %w[
10
+ .m4v .mp4 .ogg .webm .mpeg .mpg .mov .mkv .avi
11
+ ].freeze
12
+
13
+ class << self
14
+ # This should be the last Media type to match on.
15
+ # Pretty much if the .ext matches video file types then consider it a video.
16
+ #
17
+ # @return [Boolean] true if matches file extensions, false if not
18
+ def match?(filepath)
19
+ return true if VIDEO_EXTENSIONS.include?(extname(filepath))
20
+ false
21
+ end
22
+ end
23
+
24
+ def initialize(filename)
25
+ @settings = Settings.instance
11
26
  super
12
27
  end
13
28
 
14
29
  def process!(file_copier = nil)
15
30
  target_file = File.join(target_dir, processed_filename)
16
- logger.info(" target dir: [#{target_dir}]")
17
- logger.info(" target file: [#{target_file.green.bold}]")
31
+ Logger.instance.info(" target file: [#{target_file.green.bold}]")
18
32
  fc = file_copier || FileCopier.new(filename, target_file, options)
19
- fc.copy
33
+ fc.copy!
20
34
  end
21
35
 
22
36
  def processed_filename
23
- "#{title} (#{year})#{ext}"
37
+ "#{title} (#{year})#{extname}"
24
38
  end
25
39
 
26
40
  def title
@@ -30,20 +44,34 @@ module MovieOrganizer
30
44
  |or hit enter to keep the current title
31
45
  STRING
32
46
  new_title = MovieOrganizer.prompt_for(prompt, '')
33
- return File.basename(filename, ext) if new_title.nil? || new_title.empty?
47
+ return File.basename(filename, extname) if new_title.nil? || new_title.empty?
34
48
  new_title
35
49
  end
36
50
  end
37
51
 
38
52
  def date_time
39
- filestat = File.stat(filename)
40
- filestat.birthtime.strftime('%Y-%m-%d @ %l:%M %p')
53
+ release_date&.strftime('%Y-%m-%d @ %l:%M %p')
41
54
  end
42
55
 
43
- # private
56
+ # Return assumed year of video.
57
+ # First try to get it from the filename itself
58
+ # Next get it from the operating system's creation date of the file
59
+ #
60
+ # @return [Fixnum] Assumed year of the video file
61
+ def year
62
+ derived = derived_year
63
+ return derived if derived
64
+ release_date&.year
65
+ end
44
66
 
45
67
  def target_dir
46
68
  File.join(MovieOrganizer.video_directory, "#{title} (#{date_time})")
47
69
  end
70
+
71
+ def release_date
72
+ st = File.stat(filename)
73
+ return st.birthtime unless MovieOrganizer.os == :retarded
74
+ st.ctime
75
+ end
48
76
  end
49
77
  end