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