movie_organizer 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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