movie_organizer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +19 -0
  3. data/.rspec +3 -0
  4. data/.rubocop.yml +75 -0
  5. data/CHANGELOG +3 -0
  6. data/Gemfile +9 -0
  7. data/Guardfile +37 -0
  8. data/LICENSE.txt +22 -0
  9. data/README.md +81 -0
  10. data/Rakefile +14 -0
  11. data/bin/movie_organizer +9 -0
  12. data/lib/movie_organizer.rb +58 -0
  13. data/lib/movie_organizer/file_copier.rb +69 -0
  14. data/lib/movie_organizer/logger.rb +47 -0
  15. data/lib/movie_organizer/media.rb +95 -0
  16. data/lib/movie_organizer/media_list.rb +31 -0
  17. data/lib/movie_organizer/movie.rb +41 -0
  18. data/lib/movie_organizer/organizer.rb +68 -0
  19. data/lib/movie_organizer/settings.rb +47 -0
  20. data/lib/movie_organizer/string.rb +13 -0
  21. data/lib/movie_organizer/tv_show.rb +96 -0
  22. data/lib/movie_organizer/version.rb +5 -0
  23. data/lib/movie_organizer/video.rb +47 -0
  24. data/movie_organizer.gemspec +43 -0
  25. data/spec/files/The.Walking.Dead.S04E08.HDTV.x264-2HD.mp4 +0 -0
  26. data/spec/files/movies/Dunkirk.2017.BluRay.1080p/Dunkirk.2017.BluRay.1080p.mp4 +0 -0
  27. data/spec/files/movies/The Matrix (1999) [BluRay] [1080p]/The Matrix (1999) [BluRay] [1080p].mp4 b/data/spec/files/movies/The Matrix (1999) [BluRay] [1080p]/The Matrix (1999) [BluRay] → [1080p].mp4 +0 -0
  28. data/spec/files/short_video.mp4 +0 -0
  29. data/spec/fixtures/.blank_settings.yml +0 -0
  30. data/spec/fixtures/.movie_organizer.yml +19 -0
  31. data/spec/fixtures/.no_source_directories.yml +15 -0
  32. data/spec/lib/movie_organizer/file_copier_spec.rb +38 -0
  33. data/spec/lib/movie_organizer/logger_spec.rb +34 -0
  34. data/spec/lib/movie_organizer/media_list_spec.rb +49 -0
  35. data/spec/lib/movie_organizer/media_spec.rb +64 -0
  36. data/spec/lib/movie_organizer/movie_spec.rb +54 -0
  37. data/spec/lib/movie_organizer/organizer_spec.rb +35 -0
  38. data/spec/lib/movie_organizer/tv_show_spec.rb +80 -0
  39. data/spec/lib/movie_organizer_spec.rb +66 -0
  40. data/spec/spec_helper.rb +94 -0
  41. data/spec/support/filename_mappings.yml +10 -0
  42. data/spec/support/shared_contexts/media_shared.rb +50 -0
  43. data/tmdb-logo-primary-green.png +0 -0
  44. metadata +331 -0
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'titleize'
4
+ require 'streamio-ffmpeg'
5
+ require 'themoviedb'
6
+
7
+ module MovieOrganizer
8
+ # This meta-class factory accepts the media filename, and from that
9
+ # determines if it is likely to be:
10
+ #
11
+ # 1. a TV show
12
+ # 2. a Movie
13
+ # 3. a home video or other type
14
+ class Media
15
+ attr_accessor :filename, :options, :logger, :settings
16
+
17
+ def self.subtype(filename, options)
18
+ instance = new(filename, options)
19
+ return TvShow.new(filename, options) if instance.tv_show?
20
+ return Movie.new(filename, options) if instance.movie?
21
+ Video.new(filename, options)
22
+ end
23
+
24
+ def initialize(filename, options)
25
+ @filename = filename
26
+ @options = options
27
+ @tv_show = nil
28
+ @logger = Logger.instance
29
+ @settings = Settings.new
30
+ end
31
+
32
+ def tv_show?
33
+ return @tv_show unless @tv_show.nil?
34
+ @tv_show = false
35
+ @tv_show = true unless filename.match(/S\d+E\d+/i).nil?
36
+ @tv_show = true unless filename.match(/\d+x\d+/i).nil?
37
+ @tv_show
38
+ end
39
+
40
+ def movie?
41
+ return @movie unless @movie.nil?
42
+ @movie = false
43
+ Tmdb::Api.key(settings[:movies][:tmdb_key])
44
+ title = sanitize(File.basename(filename, ext)).gsub(/\d\d\d\d/, '').strip
45
+ matches = Tmdb::Movie.find(title)
46
+ @movie = matches.any?
47
+ @movie
48
+ end
49
+
50
+ def year
51
+ md = basename.match(/\((\d\d\d\d)\)|(19\d\d)|(20\d\d)/)
52
+ md ? md.captures.compact.first : nil
53
+ end
54
+
55
+ protected
56
+
57
+ def basename
58
+ File.basename(filename)
59
+ end
60
+
61
+ def ext
62
+ File.extname(filename)
63
+ end
64
+
65
+ def verbose?
66
+ options[:verbose]
67
+ end
68
+
69
+ def dry_run?
70
+ options[:dry_run]
71
+ end
72
+
73
+ # rubocop:disable Metrics/AbcSize
74
+ def sanitize(str)
75
+ cleanstr = str.gsub(/-\s*-/, '')
76
+ cleanstr = cleanstr.gsub(/\[?1080p\]?/, '').strip
77
+ cleanstr = cleanstr.gsub(/\[?720p\]?/, '').strip
78
+ cleanstr = cleanstr.gsub(/\[[^\]]+\]/, '').strip
79
+ cleanstr = cleanstr.gsub(/EXTENDED/, '').strip
80
+ cleanstr = cleanstr.gsub(/YIFY/, '').strip
81
+ cleanstr = cleanstr.gsub(/VPPV/, '').strip
82
+ cleanstr = cleanstr.gsub(/BluRay/i, '').strip
83
+ cleanstr = cleanstr.gsub(/BrRip/i, '').strip
84
+ cleanstr = cleanstr.gsub(/ECI/i, '').strip
85
+ cleanstr = cleanstr.gsub(/HDTV/i, '').strip
86
+ cleanstr = cleanstr.gsub(/x264/, '').strip
87
+ cleanstr = cleanstr.gsub(/-lol/i, '').strip
88
+ # cleanstr = cleanstr.gsub(/[\.\s-]us[\.\s-]/i, ' ').strip
89
+ cleanstr = cleanstr.gsub(/-\s*/, '').strip
90
+ cleanstr = cleanstr.gsub(/\s\s+/, ' ').strip
91
+ cleanstr.gsub(/[\.\+]/, ' ').strip
92
+ end
93
+ # rubocop:enable Metrics/AbcSize
94
+ end
95
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'mime/types'
4
+
5
+ module MovieOrganizer
6
+ class MediaList
7
+ attr_accessor :file_collection
8
+
9
+ # Walk the source_directories finding all pertinent media files
10
+ def initialize(directories = MovieOrganizer.source_directories)
11
+ @file_collection = []
12
+ directories.each do |directory|
13
+ Dir["#{directory}/**/*"].each do |entry|
14
+ file_collection << entry if media?(entry)
15
+ end
16
+ end
17
+ end
18
+
19
+ def media?(filename)
20
+ video?(filename) || subtitle?(filename)
21
+ end
22
+
23
+ def video?(filename)
24
+ MIME::Types.of(filename).map(&:media_type).include?('video')
25
+ end
26
+
27
+ def subtitle?(filename)
28
+ !MIME::Types.of(filename).map(&:content_type).grep(/(subtitle$|subrip$)/).empty?
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/scp'
4
+ require 'net/ssh'
5
+
6
+ module MovieOrganizer
7
+ class Movie < Media
8
+ def initialize(filename, options)
9
+ super
10
+ end
11
+
12
+ def process!
13
+ settings = Settings.new
14
+ target_dir = File.join(
15
+ settings[:movies][:directory],
16
+ "#{title} (#{year})"
17
+ )
18
+ FileUtils.mkdir_p(target_dir, noop: dry_run?)
19
+ target_file = File.join(
20
+ target_dir,
21
+ processed_filename
22
+ )
23
+ logger.info(" target dir: [#{target_dir}]")
24
+ logger.info(" target file: [#{target_file.green.bold}]")
25
+ fc = FileCopier.new(filename, target_file, options)
26
+ fc.copy
27
+ end
28
+
29
+ def processed_filename
30
+ "#{title} (#{year})#{ext}"
31
+ end
32
+
33
+ def title
34
+ ext_regex = Regexp.new(ext.sub(/\./, '\\.'))
35
+ newbase = sanitize(
36
+ basename.sub(/#{ext_regex}$/, '').sub(/\(?#{year}\)?/, '')
37
+ )
38
+ @title ||= newbase
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pry'
4
+ require 'trollop'
5
+ require 'colored'
6
+
7
+ module MovieOrganizer
8
+ class Organizer
9
+ attr_accessor :logger, :options
10
+
11
+ # Make a singleton but allow the class to be instantiated for easier testing
12
+ def self.instance
13
+ @instance || new
14
+ end
15
+
16
+ def initialize
17
+ @logger = Logger.instance
18
+ end
19
+
20
+ def start
21
+ start_time = Time.now
22
+ @options = collect_args
23
+ logger.info('Starting MovieOrganizer...'.green)
24
+ count = 0
25
+
26
+ # Enumerate all of the new source media
27
+ @media_list = MediaList.new(MovieOrganizer.source_directories)
28
+
29
+ # Process each source file
30
+ @media_list.file_collection.each do |file|
31
+ # Get movie or TV show information so we can rename the file if necessary
32
+ media = Media.subtype(file, options)
33
+ # Move and/or rename the file
34
+ logger.info("Processing [#{file}] - #{media.class.to_s.yellow}")
35
+ media.process!
36
+ count += 1
37
+ end
38
+ elapsed = Time.now - start_time
39
+ logger.info("Processed #{count} vidoes in [#{elapsed}] seconds.".yellow)
40
+ end
41
+
42
+ private
43
+
44
+ def collect_args
45
+ Trollop.options do
46
+ opt(
47
+ :source_dir,
48
+ 'Source directories containing media files. Colon (:) separated.',
49
+ type: :string, required: false, short: '-s')
50
+ opt(
51
+ :dry_run,
52
+ 'Do not actually move or copy files',
53
+ type: :boolean, required: false, short: '-d',
54
+ default: false)
55
+ opt(
56
+ :preserve_episode_name,
57
+ 'Preserve episode names if they exist (experimental)',
58
+ type: :boolean, required: false, short: '-p',
59
+ default: false)
60
+ opt(
61
+ :verbose,
62
+ 'Be verbose with output',
63
+ type: :boolean, required: false, short: '-v',
64
+ default: false)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ #:nocov:
6
+ module MovieOrganizer
7
+ # Simple class for YAML settings
8
+ class Settings
9
+ attr_reader :file
10
+ attr_reader :config
11
+
12
+ def initialize(file = MovieOrganizer.config_file)
13
+ @file = file
14
+ end
15
+
16
+ def load
17
+ @config ||= YAML.load_file(file) || {}
18
+ self
19
+ end
20
+
21
+ def save
22
+ File.open(file, 'w') { |thefile| thefile.write(YAML.dump(config)) }
23
+ self
24
+ end
25
+
26
+ def [](key)
27
+ load
28
+ config[key]
29
+ end
30
+
31
+ def []=(key, value)
32
+ load
33
+ config[key] = value
34
+ end
35
+
36
+ def data
37
+ load
38
+ config
39
+ end
40
+
41
+ def all
42
+ load
43
+ config
44
+ end
45
+ end
46
+ end
47
+ #:nocov:
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class String
4
+ # html = <<-stop.here_with_pipe(delimeter="\n")
5
+ # |<!-- Begin: comment -->
6
+ # |<script type="text/javascript">
7
+ # stop
8
+ def here_with_pipe(delimeter = ' ')
9
+ lines = split("\n")
10
+ lines.map! { |c| c.sub!(/\s*\|/, '') }
11
+ lines.join(delimeter)
12
+ end
13
+ end
@@ -0,0 +1,96 @@
1
+ module MovieOrganizer
2
+ class TvShow < Media
3
+ S_E_EXPRESSIONS = [
4
+ /(s(\d+)e(\d+))/i,
5
+ /((\d+)x(\d+))/i,
6
+ /[\.\s]((\d)(\d+))[\.\s]/i
7
+ ]
8
+
9
+ def initialize(filename, options)
10
+ super
11
+ @season = nil
12
+ @episode = nil
13
+ @episode_title = nil
14
+ @season_and_episode = nil
15
+ end
16
+
17
+ def process!
18
+ return nil if should_skip?
19
+ # rename the file
20
+ fail "Show not configured #{basename}" if title.nil?
21
+ target_dir = File.join(
22
+ settings[:tv_shows][:directory],
23
+ title,
24
+ "Season #{season.sub(/^0+/, '')}"
25
+ )
26
+ FileUtils.mkdir_p(target_dir, noop: dry_run?)
27
+ target_file = File.join(target_dir, processed_filename)
28
+ logger.info(" target dir: [#{target_dir}]")
29
+ logger.info(" target file: [#{target_file.green.bold}]")
30
+ FileUtils.move(
31
+ filename,
32
+ target_file,
33
+ force: true, noop: dry_run?
34
+ )
35
+ rescue ArgumentError => err
36
+ raise err unless err.message.match(/^same file:/)
37
+ end
38
+
39
+ # Standardize the filename
40
+ # @return [String] cleaned filename
41
+ def processed_filename
42
+ return nil if should_skip?
43
+ if options[:preserve_episode_name] && episode_title
44
+ "#{title} - #{season_and_episode} - #{episode_title}#{ext}"
45
+ else
46
+ "#{title} - #{season_and_episode}#{ext}"
47
+ end
48
+ end
49
+
50
+ def title
51
+ return @title unless @title.nil?
52
+ settings[:tv_shows][:my_shows].each do |show|
53
+ md = sanitize(basename).match(Regexp.new(sanitize(show), Regexp::IGNORECASE))
54
+ if md
55
+ @title = md[0].titleize
56
+ return @title
57
+ end
58
+ end
59
+ @title
60
+ end
61
+
62
+ def season
63
+ return @season unless @season.nil?
64
+ season_and_episode
65
+ @season
66
+ end
67
+
68
+ private
69
+
70
+ def should_skip?
71
+ filename.match(/[\.\s-]?sample[\.\s-]?/)
72
+ end
73
+
74
+ def episode_title
75
+ return @episode_title unless @episode_title.nil?
76
+ md = basename.match(/([^-]+)-([^-]+)-([^-]+)/)
77
+ @episode_title = md[3].sub(/#{ext}$/, '').strip if md
78
+ @episode_title
79
+ end
80
+
81
+ def season_and_episode
82
+ return @season_and_episode unless @season_and_episode.nil?
83
+ clean_basename = sanitize(basename)
84
+ s_and_e_info = clean_basename.sub(Regexp.new(title, Regexp::IGNORECASE), '')
85
+ S_E_EXPRESSIONS.each do |regex|
86
+ md = s_and_e_info.match(regex)
87
+ next unless md
88
+ @season = md[2].rjust(2, '0')
89
+ @episode = md[3].rjust(2, '0')
90
+ @season_and_episode = "S#{@season}E#{@episode}"
91
+ return @season_and_episode
92
+ end
93
+ @season_and_episode
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MovieOrganizer
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MovieOrganizer
4
+ class Video < Media
5
+ attr_reader :settings
6
+
7
+ def initialize(filename, options)
8
+ @settings = Settings.new
9
+ super
10
+ end
11
+
12
+ def process!
13
+ binding.pry
14
+ FileUtils.mkdir_p(target_dir, noop: dry_run?)
15
+ logger.info(" target dir: [#{target_dir}]")
16
+ logger.info(" target file: [#{target_file.green.bold}]")
17
+ FileUtils.move(filename, target_file, force: true, noop: dry_run?)
18
+ end
19
+
20
+ def processed_filename
21
+ "#{title} (#{year})#{ext}"
22
+ end
23
+
24
+ def title
25
+ @title ||= begin
26
+ temp = MovieOrganizer.prompt_for(
27
+ <<-STRING.here_with_pipe(' ')
28
+ |Please enter a friendly title for this video: [#{filename}]
29
+ |or just hit enter to keep the current title
30
+ STRING
31
+ )
32
+ binding.pry
33
+ end
34
+ end
35
+
36
+ def date_time
37
+ filestat = File.stat(filename)
38
+ filestat.birthtime.strftime('%Y-%m-%d @ %l:%M %p')
39
+ end
40
+
41
+ # private
42
+
43
+ def target_dir
44
+ File.join(settings[:videos][:directory], "#{title} (#{date_time})")
45
+ end
46
+ end
47
+ end