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.
- checksums.yaml +7 -0
- data/.gitignore +19 -0
- data/.rspec +3 -0
- data/.rubocop.yml +75 -0
- data/CHANGELOG +3 -0
- data/Gemfile +9 -0
- data/Guardfile +37 -0
- data/LICENSE.txt +22 -0
- data/README.md +81 -0
- data/Rakefile +14 -0
- data/bin/movie_organizer +9 -0
- data/lib/movie_organizer.rb +58 -0
- data/lib/movie_organizer/file_copier.rb +69 -0
- data/lib/movie_organizer/logger.rb +47 -0
- data/lib/movie_organizer/media.rb +95 -0
- data/lib/movie_organizer/media_list.rb +31 -0
- data/lib/movie_organizer/movie.rb +41 -0
- data/lib/movie_organizer/organizer.rb +68 -0
- data/lib/movie_organizer/settings.rb +47 -0
- data/lib/movie_organizer/string.rb +13 -0
- data/lib/movie_organizer/tv_show.rb +96 -0
- data/lib/movie_organizer/version.rb +5 -0
- data/lib/movie_organizer/video.rb +47 -0
- data/movie_organizer.gemspec +43 -0
- data/spec/files/The.Walking.Dead.S04E08.HDTV.x264-2HD.mp4 +0 -0
- data/spec/files/movies/Dunkirk.2017.BluRay.1080p/Dunkirk.2017.BluRay.1080p.mp4 +0 -0
- 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
- data/spec/files/short_video.mp4 +0 -0
- data/spec/fixtures/.blank_settings.yml +0 -0
- data/spec/fixtures/.movie_organizer.yml +19 -0
- data/spec/fixtures/.no_source_directories.yml +15 -0
- data/spec/lib/movie_organizer/file_copier_spec.rb +38 -0
- data/spec/lib/movie_organizer/logger_spec.rb +34 -0
- data/spec/lib/movie_organizer/media_list_spec.rb +49 -0
- data/spec/lib/movie_organizer/media_spec.rb +64 -0
- data/spec/lib/movie_organizer/movie_spec.rb +54 -0
- data/spec/lib/movie_organizer/organizer_spec.rb +35 -0
- data/spec/lib/movie_organizer/tv_show_spec.rb +80 -0
- data/spec/lib/movie_organizer_spec.rb +66 -0
- data/spec/spec_helper.rb +94 -0
- data/spec/support/filename_mappings.yml +10 -0
- data/spec/support/shared_contexts/media_shared.rb +50 -0
- data/tmdb-logo-primary-green.png +0 -0
- 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,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
|