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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 56db1180e21190c3099cc45f75066d9a27c9fb34d74364ed64c87c22f229da18
|
4
|
+
data.tar.gz: 34a39c981eb1cc29a7fdff7bb7bbd53ea464b9d0ebf973685bfe716e5da824af
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 9fbd33cf4a225627e99925e09f5d4a0cb3e2f13cc24cc2ef7da4485547079f07996ab6531491d8c1025d7394b0766d501e58a2ab312fbf09919efcccd8fd2616
|
7
|
+
data.tar.gz: 3db9a4aecbf719ceb5185f2ad00f78c853bc13df6c0ce400d4c8f954baa75ae9a98a1a0eb5b8c74dfb45170a0fe0b4c6a944000824c60c634a25ecfb2fb91b55
|
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -0,0 +1,75 @@
|
|
1
|
+
AllCops:
|
2
|
+
TargetRubyVersion: 2.3
|
3
|
+
|
4
|
+
# Include gemspec and Rakefile
|
5
|
+
Include:
|
6
|
+
- '**/*.gemspec'
|
7
|
+
- '**/*.podspec'
|
8
|
+
- '**/*.jbuilder'
|
9
|
+
- '**/*.rake'
|
10
|
+
- '**/Gemfile'
|
11
|
+
- '**/Rakefile'
|
12
|
+
- '**/Capfile'
|
13
|
+
- '**/Guardfile'
|
14
|
+
- '**/Podfile'
|
15
|
+
- '**/Thorfile'
|
16
|
+
- '**/Vagrantfile'
|
17
|
+
Exclude:
|
18
|
+
- 'vendor/**/*'
|
19
|
+
- 'stubs/**/*'
|
20
|
+
- 'spec/support/shared_contexts/*'
|
21
|
+
|
22
|
+
# Checks formatting of special comments
|
23
|
+
CommentAnnotation:
|
24
|
+
Keywords:
|
25
|
+
- TODO
|
26
|
+
- FIXME
|
27
|
+
- OPTIMIZE
|
28
|
+
- HACK
|
29
|
+
- REVIEW
|
30
|
+
|
31
|
+
########################################
|
32
|
+
# Style Cops
|
33
|
+
|
34
|
+
Style/Documentation:
|
35
|
+
Enabled: false
|
36
|
+
|
37
|
+
Style/FileName:
|
38
|
+
Enabled: false
|
39
|
+
|
40
|
+
Style/AlignParameters:
|
41
|
+
EnforcedStyle: with_fixed_indentation
|
42
|
+
|
43
|
+
Style/RegexpLiteral:
|
44
|
+
Enabled: false
|
45
|
+
|
46
|
+
Style/EmptyLinesAroundBlockBody:
|
47
|
+
Enabled: false
|
48
|
+
|
49
|
+
Style/RaiseArgs:
|
50
|
+
Enabled: false
|
51
|
+
|
52
|
+
Style/DoubleNegation:
|
53
|
+
Enabled: false
|
54
|
+
|
55
|
+
Style/PerlBackrefs:
|
56
|
+
Enabled: false
|
57
|
+
|
58
|
+
########################################
|
59
|
+
# Lint Cops
|
60
|
+
|
61
|
+
Lint/Eval:
|
62
|
+
Enabled: false
|
63
|
+
|
64
|
+
########################################
|
65
|
+
# Metrics Cops
|
66
|
+
|
67
|
+
Metrics/LineLength:
|
68
|
+
Max: 110
|
69
|
+
|
70
|
+
Metrics/MethodLength:
|
71
|
+
CountComments: false # count full line comments?
|
72
|
+
Max: 20
|
73
|
+
|
74
|
+
Metrics/ClassLength:
|
75
|
+
Max: 120
|
data/CHANGELOG
ADDED
data/Gemfile
ADDED
data/Guardfile
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# rubocop:disable all
|
2
|
+
# clearing :on
|
3
|
+
|
4
|
+
guard :bundler do
|
5
|
+
require 'guard/bundler'
|
6
|
+
require 'guard/bundler/verify'
|
7
|
+
helper = Guard::Bundler::Verify.new
|
8
|
+
|
9
|
+
files = ['Gemfile']
|
10
|
+
files += Dir['*.gemspec'] if files.any? { |f| helper.uses_gemspec?(f) }
|
11
|
+
|
12
|
+
# Assume files are symlinked from somewhere
|
13
|
+
files.each { |file| watch(helper.real_path(file)) }
|
14
|
+
end
|
15
|
+
|
16
|
+
CMD = 'bundle exec rspec -f doc --color'
|
17
|
+
|
18
|
+
guard :rspec, cmd: CMD do
|
19
|
+
require 'guard/rspec/dsl'
|
20
|
+
dsl = Guard::RSpec::Dsl.new(self)
|
21
|
+
|
22
|
+
# RSpec files
|
23
|
+
rspec = dsl.rspec
|
24
|
+
watch(rspec.spec_helper) { rspec.spec_dir }
|
25
|
+
watch(rspec.spec_support) { rspec.spec_dir }
|
26
|
+
watch(rspec.spec_files)
|
27
|
+
|
28
|
+
# Ruby files
|
29
|
+
ruby = dsl.ruby
|
30
|
+
dsl.watch_spec_files_for(ruby.lib_files)
|
31
|
+
end
|
32
|
+
|
33
|
+
# guard :rubocop, cli: ['-D'] do
|
34
|
+
# watch(%r{.+\.rb$})
|
35
|
+
# watch(%r{(?:.+/)?\.rubocop\.yml$}) { |m| File.dirname(m[0]) }
|
36
|
+
# end
|
37
|
+
# rubocop:enable all
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,22 @@
|
|
1
|
+
Copyright (c) 2015 Chris Blackburn
|
2
|
+
|
3
|
+
MIT License
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
6
|
+
a copy of this software and associated documentation files (the
|
7
|
+
"Software"), to deal in the Software without restriction, including
|
8
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
9
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
10
|
+
permit persons to whom the Software is furnished to do so, subject to
|
11
|
+
the following conditions:
|
12
|
+
|
13
|
+
The above copyright notice and this permission notice shall be
|
14
|
+
included in all copies or substantial portions of the Software.
|
15
|
+
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
17
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
18
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
19
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
20
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
21
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
22
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,81 @@
|
|
1
|
+
# MovieOrganizer
|
2
|
+
|
3
|
+
Automatically organize movies, tv shows and home videos.
|
4
|
+
|
5
|
+
I use [Plex](https://www.plex.tv/) as the centerpiece of my home entertainment center, which requires that I rip my BluRay movies and TV shows, and copy them into a certain location so my [Plex server](https://www.plex.tv/downloads/) can recognize them.
|
6
|
+
|
7
|
+
MovieOrganizer makes the job of organizing my ripped movies, tv shows and home videos as simple as:
|
8
|
+
|
9
|
+
```bash
|
10
|
+
movie_organizer
|
11
|
+
|
12
|
+
Starting MovieOrganizer...
|
13
|
+
Processing [/Users/midwire/Downloads/The Matrix (1990).mp4] - MovieOrganizer::Movie
|
14
|
+
target dir: [ssh://username@plex_server/media/movies/The Matrix (1990)]
|
15
|
+
target file: [ssh://username@plex_server/media/movies/The Matrix (1990)/The Matrix (1990).mp4]
|
16
|
+
|
17
|
+
...
|
18
|
+
```
|
19
|
+
|
20
|
+
## Features
|
21
|
+
|
22
|
+
* Copies media to remote hosts
|
23
|
+
* Can differentiate between most Movies, TV Shows and Home Video if they are named properly, and copy each to the appropriately configured directory
|
24
|
+
* Uses [The Movie Database](https://www.themoviedb.org) for movie identification
|
25
|
+
|
26
|
+
## Caveats
|
27
|
+
|
28
|
+
Note that this is beta software. I wrote it as a hack because I got tired of constantly copying my media over to Plex by hand. Having said that, it works but there are edge cases that are probably not covered. If you find a bug or problem, please file an issue and I'll address it ASAP.
|
29
|
+
|
30
|
+
## Installation
|
31
|
+
|
32
|
+
$ gem install movie_organizer
|
33
|
+
|
34
|
+
## Configuration
|
35
|
+
|
36
|
+
Sign up for an account at [The Movie Database](https://www.themoviedb.org/) and get an API key.
|
37
|
+
|
38
|
+
Create a file in your home directory named `.movie_organizer.yml` as follows:
|
39
|
+
|
40
|
+
```yaml
|
41
|
+
---
|
42
|
+
:space_warning: 20GB
|
43
|
+
:new_media_directories:
|
44
|
+
- "/Users/midwire/media_rips" # <- new media directory
|
45
|
+
:tv_shows:
|
46
|
+
:directory: "/Volumes/Genesis/TV Series" # <- a local directory
|
47
|
+
:movies:
|
48
|
+
:tmdb_key: df08efec9f01985d401a3cfedf5628a2 # <- use your own API key (this one is fake)
|
49
|
+
:directory: ssh://plex_admin@plex.local/media/media1/movies # <- remote directory
|
50
|
+
:videos:
|
51
|
+
:directory: "ssh://plex_admin@plex.local/media/media1/Family Videos" # <- remote directory
|
52
|
+
```
|
53
|
+
|
54
|
+
**NOTE:** If you want to use a remote host, you will need to configure passwordless logins using your ssh-key, which is outside the scope of this document. If you don't know how, please do a search for
|
55
|
+
|
56
|
+
Remote hosts are specified in this format:
|
57
|
+
|
58
|
+
```bash
|
59
|
+
ssh://username@hostname/path/to/remote/destination
|
60
|
+
```
|
61
|
+
|
62
|
+
## Usage
|
63
|
+
|
64
|
+
You should be able to simply run `movie_organizer`
|
65
|
+
|
66
|
+
Here are the command line options:
|
67
|
+
|
68
|
+
```bash
|
69
|
+
Options:
|
70
|
+
-s, --source-dir=<s> Source directories containing media files. Colon (:) separated. (Default: /Users/midwire/media_rips)
|
71
|
+
-d, --dry-run Do not actually move or copy files
|
72
|
+
-p, --preserve-episode-name Preserve episode names if they exist (experimental)
|
73
|
+
-v, --verbose Be verbose with output
|
74
|
+
-h, --help Show this message
|
75
|
+
```
|
76
|
+
|
77
|
+
## Attribution
|
78
|
+
|
79
|
+
### The Movie Database
|
80
|
+
|
81
|
+
<img src="tmdb-logo-primary-green.png" alt="TMDB Logo" style="float:left; padding: 0 10px 0 0;"/> We are profoundly pleased to have access to [The Movie Database](https://www.themoviedb.org) API. This product uses the TMDb API but is not endorsed or certified by TMDb.
|
data/Rakefile
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler/gem_tasks'
|
4
|
+
require 'rspec/core/rake_task'
|
5
|
+
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
7
|
+
task default: :spec
|
8
|
+
|
9
|
+
begin
|
10
|
+
require 'midwire_common/rake_tasks'
|
11
|
+
rescue LoadError
|
12
|
+
puts ">>> Could not load 'midwire_common' gem."
|
13
|
+
exit
|
14
|
+
end
|
data/bin/movie_organizer
ADDED
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Create a .test.env and a .development.env for your different local
|
4
|
+
# environments
|
5
|
+
require 'dotenv'
|
6
|
+
require 'colored'
|
7
|
+
require 'readline'
|
8
|
+
|
9
|
+
paths = %W(.env .env.#{ENV['APP_ENV']}).map { |name| "#{Dir.pwd}/#{name}" }
|
10
|
+
Dotenv.load(*paths).each { |k, v| ENV[k] = v }
|
11
|
+
|
12
|
+
require 'movie_organizer/version'
|
13
|
+
require 'midwire_common/string'
|
14
|
+
|
15
|
+
module MovieOrganizer
|
16
|
+
def self.root
|
17
|
+
Pathname.new(File.dirname(__FILE__)).parent
|
18
|
+
end
|
19
|
+
|
20
|
+
def self.current_environment
|
21
|
+
ENV.fetch('APP_ENV', 'development')
|
22
|
+
end
|
23
|
+
|
24
|
+
def self.config_file(filename = '.movie_organizer.yml')
|
25
|
+
return root.join('spec', 'fixtures', filename) if current_environment == 'test'
|
26
|
+
#:nocov:
|
27
|
+
home = ENV.fetch('HOME')
|
28
|
+
file = ENV.fetch('MO_CONFIG_FILE', File.join(home, '.movie_organizer.yml'))
|
29
|
+
FileUtils.touch(file)
|
30
|
+
file
|
31
|
+
#:nocov:
|
32
|
+
end
|
33
|
+
|
34
|
+
def self.source_directories(settings = Settings.new, test_response = nil)
|
35
|
+
settings[:new_media_directories] || begin
|
36
|
+
strings = prompt_for('Media source directories (separated by a colon)', test_response)
|
37
|
+
strings.split(':')
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
#:nocov:
|
42
|
+
def self.prompt_for(message = '', test_response = nil)
|
43
|
+
prompt = "#{message.dup}\n? "
|
44
|
+
return test_response if test_response
|
45
|
+
Readline.readline(prompt, true).squeeze(' ').strip
|
46
|
+
end
|
47
|
+
#:nocov:
|
48
|
+
|
49
|
+
autoload :FileCopier, 'movie_organizer/file_copier'
|
50
|
+
autoload :Logger, 'movie_organizer/logger'
|
51
|
+
autoload :Media, 'movie_organizer/media'
|
52
|
+
autoload :MediaList, 'movie_organizer/media_list'
|
53
|
+
autoload :Movie, 'movie_organizer/movie'
|
54
|
+
autoload :Organizer, 'movie_organizer/organizer'
|
55
|
+
autoload :Settings, 'movie_organizer/settings'
|
56
|
+
autoload :TvShow, 'movie_organizer/tv_show'
|
57
|
+
autoload :Video, 'movie_organizer/video'
|
58
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module MovieOrganizer
|
4
|
+
class FileCopier
|
5
|
+
attr_accessor :filename, :target_file, :options
|
6
|
+
attr_reader :username, :hostname, :remote_filename
|
7
|
+
|
8
|
+
def initialize(filename, target_file, options)
|
9
|
+
@filename = filename
|
10
|
+
@target_file = target_file
|
11
|
+
@options = options
|
12
|
+
end
|
13
|
+
|
14
|
+
def copy
|
15
|
+
ssh? ? remote_copy : local_copy
|
16
|
+
end
|
17
|
+
|
18
|
+
private
|
19
|
+
|
20
|
+
def local_copy
|
21
|
+
FileUtils.mkdir_p(File.dirname(target_file))
|
22
|
+
FileUtils.copy(
|
23
|
+
filename,
|
24
|
+
target_file,
|
25
|
+
noop: options[:dry_run]
|
26
|
+
)
|
27
|
+
end
|
28
|
+
|
29
|
+
def remote_copy
|
30
|
+
parse_target
|
31
|
+
return do_dry_run if options[:dry_run]
|
32
|
+
Net::SSH.start(hostname, username) do |ssh|
|
33
|
+
ssh.exec("mkdir -p '#{target_dir}'")
|
34
|
+
end
|
35
|
+
Net::SCP.start(hostname, username) do |scp|
|
36
|
+
scp.upload!(filename, remote_filename)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
def do_dry_run
|
41
|
+
puts("Would remotely execute: [#{"mkdir -p '#{target_dir}'"}] on #{hostname}")
|
42
|
+
puts("Would execute: [#{"scp '#{filename}' '#{remote_filename}'"}]")
|
43
|
+
end
|
44
|
+
|
45
|
+
def target_dir
|
46
|
+
@target_dir ||= begin
|
47
|
+
parts = @remote_filename.split('/')
|
48
|
+
parts[0..parts.length - 2].join('/').to_s
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def parse_target
|
53
|
+
return nil if @parse_target
|
54
|
+
@parse_target = true
|
55
|
+
temp ||= target_file.to_s.split('/')[2..99]
|
56
|
+
md = temp.join('/').match(/([\w\-\.]+)@([^\/]+)(\/.+)$/)
|
57
|
+
@username = md[1]
|
58
|
+
@hostname = md[2]
|
59
|
+
@remote_filename = md[3]
|
60
|
+
if @username.nil? || @hostname.nil? || @remote_filename.nil?
|
61
|
+
fail 'SSH path not formatted properly. Use [ssh://username@hostname/absolute/path]'
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
def ssh?
|
66
|
+
target_file.match?(/^ssh:/)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
require 'singleton'
|
2
|
+
require 'logger'
|
3
|
+
|
4
|
+
module MovieOrganizer
|
5
|
+
class Logger
|
6
|
+
include Singleton
|
7
|
+
|
8
|
+
attr_accessor :log_provider
|
9
|
+
|
10
|
+
def initialize(provider = default_logger)
|
11
|
+
@log_provider = provider
|
12
|
+
end
|
13
|
+
|
14
|
+
def log_exception(e, data = {})
|
15
|
+
msg = "EXCEPTION : #{e.class.name} : #{e.message}"
|
16
|
+
msg += "\n data : #{data.inspect}" if data && !data.empty?
|
17
|
+
msg += "\n #{e.backtrace[0, 6].join("\n ")}"
|
18
|
+
log_provider.error(msg)
|
19
|
+
end
|
20
|
+
|
21
|
+
def method_missing(meth, *args, &block)
|
22
|
+
if log_provider.respond_to?(meth)
|
23
|
+
log_provider.send(meth, *args, &block)
|
24
|
+
else
|
25
|
+
super
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
def respond_to?(meth, include_private = false)
|
30
|
+
if log_provider.respond_to?(meth)
|
31
|
+
true
|
32
|
+
else
|
33
|
+
super
|
34
|
+
end
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def default_logger
|
40
|
+
logger = ::Logger.new(STDOUT)
|
41
|
+
logger.formatter = proc do |_, _, _, msg|
|
42
|
+
"#{msg}\n"
|
43
|
+
end
|
44
|
+
logger
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|