torganiser 0.0.5 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/Rakefile +10 -5
- data/bin/torganiser +28 -18
- data/lib/torganiser.rb +11 -8
- data/lib/torganiser/arranger.rb +18 -12
- data/lib/torganiser/episode_file.rb +3 -42
- data/lib/torganiser/file_query.rb +5 -9
- data/lib/torganiser/match_one.rb +12 -0
- data/lib/torganiser/matcher.rb +63 -0
- data/lib/torganiser/runner.rb +5 -12
- data/lib/torganiser/scanner.rb +4 -6
- data/lib/torganiser/series.rb +1 -5
- data/lib/torganiser/version.rb +2 -1
- data/spec/spec_helper.rb +1 -1
- data/spec/torganiser/arranger_spec.rb +26 -9
- data/spec/torganiser/episode_file_spec.rb +46 -105
- data/spec/torganiser/file_query_spec.rb +28 -28
- data/spec/torganiser/match_one_spec.rb +10 -0
- data/spec/torganiser/matcher_spec.rb +142 -0
- data/spec/torganiser/runner_spec.rb +13 -49
- data/spec/torganiser/scanner_spec.rb +34 -35
- data/spec/torganiser/series_spec.rb +5 -6
- data/torganiser.gemspec +19 -18
- metadata +22 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 4a6e95d03cf82ff1426755d92efe2f603e7a504d
|
4
|
+
data.tar.gz: cfa305bdc2b3fb04ecba975f6a921e0e7c87fa1f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 545ffd98cbdf6fd662f542a6350c45ecd7797cfb2857c36aaa6d1d5f113502a1dd82c52efa863ac8ba3d782a76274cd0c0c09c431f03e7cc97bdec953a524938
|
7
|
+
data.tar.gz: 1bc8e5eb7a7ac3d0fd475c2302482514c4da7d0725ed688b777aec05789a165dce2585cc1dd101479126407f34dc4e3cd6ba6b668deec073d7293f87e6f948df
|
data/Rakefile
CHANGED
@@ -1,22 +1,27 @@
|
|
1
|
-
require
|
2
|
-
require
|
1
|
+
require 'bundler/gem_tasks'
|
2
|
+
require 'rspec/core/rake_task'
|
3
3
|
require 'reek/rake/task'
|
4
|
+
require 'rubocop/rake_task'
|
4
5
|
|
5
6
|
Reek::Rake::Task.new
|
6
7
|
|
7
8
|
begin
|
8
9
|
require 'cane/rake_task'
|
9
10
|
|
10
|
-
desc
|
11
|
+
desc 'Run cane to check quality metrics'
|
11
12
|
Cane::RakeTask.new(:quality) do |cane|
|
12
13
|
cane.abc_max = 10
|
13
14
|
cane.add_threshold 'coverage/.last_run.json', :>=, 100
|
14
15
|
end
|
15
16
|
|
16
17
|
rescue LoadError
|
17
|
-
warn
|
18
|
+
warn 'cane not available, quality task not provided.'
|
18
19
|
end
|
19
20
|
|
20
21
|
RSpec::Core::RakeTask.new(:spec)
|
21
22
|
|
22
|
-
|
23
|
+
RuboCop::RakeTask.new(:rubocop) do |task|
|
24
|
+
task.patterns = ['lib/**/*.rb', 'spec/**/*.rb']
|
25
|
+
end
|
26
|
+
|
27
|
+
task default: [:spec, :quality, :reek, :rubocop]
|
data/bin/torganiser
CHANGED
@@ -4,34 +4,44 @@ require 'torganiser'
|
|
4
4
|
|
5
5
|
Clamp do
|
6
6
|
|
7
|
-
option [
|
7
|
+
option ['--version', '-v'], :flag, 'Show version' do
|
8
8
|
puts Torganiser::VERSION
|
9
9
|
exit(0)
|
10
10
|
end
|
11
11
|
|
12
|
-
option [
|
13
|
-
|
14
|
-
|
15
|
-
|
12
|
+
option ['--collection', '-c'], 'DIRECTORY',
|
13
|
+
'Root of the collection into which to organise the files',
|
14
|
+
environment_variable: 'TORGANISER_COLLECTION',
|
15
|
+
required: true
|
16
16
|
|
17
|
-
option [
|
18
|
-
|
19
|
-
|
17
|
+
option ['--extension', '-e'], 'EXTENSION',
|
18
|
+
'Extension to include eg. mp4. May be specified multiple times',
|
19
|
+
multivalued: true, attribute_name: :extensions
|
20
20
|
|
21
|
-
option [
|
22
|
-
|
23
|
-
|
21
|
+
option ['--ignore', '-i'], 'PATTERN',
|
22
|
+
'Ignore files whose path matches pattern eg. May be specified multiple times.',
|
23
|
+
multivalued: true, attribute_name: :ignore
|
24
24
|
|
25
|
-
option
|
25
|
+
option '--dry-run', :flag, 'If specified, no files will be moved'
|
26
26
|
|
27
|
-
|
28
|
-
|
27
|
+
option '--copy', :flag, 'If specified, files will be copied instead of moved'
|
28
|
+
|
29
|
+
parameter 'FILES ...', 'Files or directories to organise',
|
30
|
+
attribute_name: :files
|
31
|
+
|
32
|
+
def scanner
|
33
|
+
Torganiser::Scanner.new(
|
34
|
+
files, extensions,
|
35
|
+
ignore.map { |string| Regexp.new(string) }
|
36
|
+
)
|
37
|
+
end
|
38
|
+
|
39
|
+
def arranger
|
40
|
+
Torganiser::Arranger.new(collection, dry_run: dry_run?, copy: copy?)
|
41
|
+
end
|
29
42
|
|
30
43
|
def execute
|
31
|
-
Torganiser::Runner.new(
|
32
|
-
files: files, extensions: extensions,
|
33
|
-
ignored: ignore, dry_run: dry_run?
|
34
|
-
).run
|
44
|
+
Torganiser::Runner.new(scanner: scanner, arranger: arranger).run
|
35
45
|
end
|
36
46
|
|
37
47
|
end
|
data/lib/torganiser.rb
CHANGED
@@ -1,12 +1,15 @@
|
|
1
|
-
require
|
2
|
-
require
|
3
|
-
require
|
4
|
-
require
|
5
|
-
require
|
6
|
-
require
|
7
|
-
require
|
1
|
+
require 'torganiser/version'
|
2
|
+
require 'torganiser/file_query'
|
3
|
+
require 'torganiser/series'
|
4
|
+
require 'torganiser/match_one'
|
5
|
+
require 'torganiser/matcher'
|
6
|
+
require 'torganiser/episode_file'
|
7
|
+
require 'torganiser/scanner'
|
8
|
+
require 'torganiser/arranger'
|
9
|
+
require 'torganiser/runner'
|
8
10
|
|
9
|
-
require
|
11
|
+
require 'clamp'
|
10
12
|
|
13
|
+
# Main module and entry point.
|
11
14
|
module Torganiser
|
12
15
|
end
|
data/lib/torganiser/arranger.rb
CHANGED
@@ -3,26 +3,33 @@ require 'fileutils'
|
|
3
3
|
module Torganiser
|
4
4
|
# Handles arranging episode files into a collection
|
5
5
|
class Arranger
|
6
|
+
attr_reader :collection, :dry_run, :copy
|
6
7
|
|
7
|
-
|
8
|
-
|
9
|
-
def initialize collection, dry_run: false
|
8
|
+
def initialize(collection, dry_run: false, copy: false)
|
10
9
|
@collection = collection
|
11
10
|
@dry_run = dry_run
|
11
|
+
@copy = copy
|
12
12
|
end
|
13
13
|
|
14
|
-
def arrange
|
14
|
+
def arrange(file)
|
15
15
|
episode = EpisodeFile.new(file)
|
16
|
-
|
16
|
+
arrange_episode(episode)
|
17
17
|
end
|
18
18
|
|
19
19
|
private
|
20
20
|
|
21
|
-
def
|
22
|
-
|
21
|
+
def arrange_episode(episode)
|
22
|
+
arrange_method.call episode.file, ensure_directory_for(episode)
|
23
|
+
end
|
24
|
+
|
25
|
+
def ensure_directory_for(episode)
|
26
|
+
Destination.new(collection, episode).directory.tap do |dir|
|
27
|
+
file_utils.mkdir_p dir unless File.exist? dir
|
28
|
+
end
|
29
|
+
end
|
23
30
|
|
24
|
-
|
25
|
-
file_utils.
|
31
|
+
def arrange_method
|
32
|
+
@arrange_method ||= file_utils.method(@copy ? :cp : :mv)
|
26
33
|
end
|
27
34
|
|
28
35
|
def file_utils
|
@@ -31,10 +38,9 @@ module Torganiser
|
|
31
38
|
|
32
39
|
# Models a destination for an episode file in a collection
|
33
40
|
class Destination
|
34
|
-
|
35
41
|
attr_reader :collection, :episode_file
|
36
42
|
|
37
|
-
def initialize
|
43
|
+
def initialize(collection, episode_file)
|
38
44
|
@collection = collection
|
39
45
|
@episode_file = episode_file
|
40
46
|
end
|
@@ -44,6 +50,7 @@ module Torganiser
|
|
44
50
|
end
|
45
51
|
|
46
52
|
private
|
53
|
+
|
47
54
|
def season_dir
|
48
55
|
"Season #{episode_file.season}"
|
49
56
|
end
|
@@ -55,7 +62,6 @@ module Torganiser
|
|
55
62
|
def series
|
56
63
|
episode_file.series
|
57
64
|
end
|
58
|
-
|
59
65
|
end
|
60
66
|
end
|
61
67
|
end
|
@@ -2,7 +2,6 @@ module Torganiser
|
|
2
2
|
# Models a file that contains a single episode of a TV series.
|
3
3
|
# Attempts to extract episode data, based on the filename.
|
4
4
|
class EpisodeFile
|
5
|
-
|
6
5
|
attr_reader :file
|
7
6
|
|
8
7
|
def initialize(file)
|
@@ -23,7 +22,8 @@ module Torganiser
|
|
23
22
|
|
24
23
|
def series
|
25
24
|
@series ||= begin
|
26
|
-
year =
|
25
|
+
year = episode_info[:year]
|
26
|
+
year = year.to_i if year
|
27
27
|
Series.new(series_name, year: year)
|
28
28
|
end
|
29
29
|
end
|
@@ -35,48 +35,9 @@ module Torganiser
|
|
35
35
|
end
|
36
36
|
|
37
37
|
def episode_info
|
38
|
-
@episode_info ||= Matcher.match(basename)
|
38
|
+
@episode_info ||= Matcher.match(basename) || fail(
|
39
39
|
"Unable to parse #{file}"
|
40
40
|
)
|
41
41
|
end
|
42
|
-
|
43
|
-
# A matcher that can extract semantic information from a
|
44
|
-
# properly named file.
|
45
|
-
module Matcher
|
46
|
-
separator = '(\.|\s|\s?\-\s?)'
|
47
|
-
|
48
|
-
long_format = [
|
49
|
-
's(?<season>\d+)', # season number
|
50
|
-
'e(?<episode>\d+)', # episode number
|
51
|
-
'(e\d+)?' # optional second episode number, ignored
|
52
|
-
].join('\s?') # optionally space separated
|
53
|
-
|
54
|
-
# season number and episode number together, optionally with an 'x'
|
55
|
-
short_format = '\[?(?<season>\d+)x?(?<episode>\d{2})\]?'
|
56
|
-
|
57
|
-
# specials don't fit nicely into the season/episode model.
|
58
|
-
special = "s(?<season>0)(?<episode>0)|s(?<season>\\d+)#{separator}special"
|
59
|
-
|
60
|
-
season_info = "(#{long_format}|#{short_format}|#{special})"
|
61
|
-
|
62
|
-
# Series name, and possibly year
|
63
|
-
series_with_year = '(?<name>.*)\.(?<year>\d{4})'
|
64
|
-
series_without_year = '(?<name>.*)'
|
65
|
-
|
66
|
-
# Series without year takes precedence
|
67
|
-
series = "(#{series_with_year}|#{series_without_year})"
|
68
|
-
|
69
|
-
PATTERN = %r{^
|
70
|
-
#{series} # Series name, and possibly year
|
71
|
-
#{separator}#{season_info} # season info
|
72
|
-
#{separator}.*$ # stuff we don't care about
|
73
|
-
}ix
|
74
|
-
|
75
|
-
def self.match basename
|
76
|
-
PATTERN.match basename
|
77
|
-
end
|
78
|
-
|
79
|
-
end
|
80
|
-
|
81
42
|
end
|
82
43
|
end
|
@@ -4,7 +4,6 @@ module Torganiser
|
|
4
4
|
# The 'pattern' method returns a Dir.glob style pattern
|
5
5
|
# that can be used to match a set of files.
|
6
6
|
class FileQuery
|
7
|
-
|
8
7
|
attr_reader :directories, :extensions
|
9
8
|
|
10
9
|
def initialize(directories: nil, extensions: nil)
|
@@ -18,16 +17,16 @@ module Torganiser
|
|
18
17
|
@directories.empty?
|
19
18
|
end
|
20
19
|
|
21
|
-
def add_extension
|
20
|
+
def add_extension(extensions)
|
22
21
|
@extensions.concat([*extensions])
|
23
22
|
end
|
24
23
|
|
25
|
-
def add_directory
|
24
|
+
def add_directory(directories)
|
26
25
|
@directories.concat([*directories])
|
27
26
|
end
|
28
27
|
|
29
28
|
def pattern
|
30
|
-
directory_pattern +
|
29
|
+
directory_pattern + '/**/' + extension_pattern
|
31
30
|
end
|
32
31
|
|
33
32
|
private
|
@@ -37,23 +36,20 @@ module Torganiser
|
|
37
36
|
end
|
38
37
|
|
39
38
|
def extension_pattern
|
40
|
-
|
39
|
+
'*' + (extensions.count > 0 ? ItemsPattern.new(extensions).to_s : '')
|
41
40
|
end
|
42
41
|
|
43
42
|
# Models a pattern that matchers a series of one or more string items.
|
44
43
|
class ItemsPattern
|
45
|
-
|
46
44
|
attr_reader :items
|
47
45
|
|
48
|
-
def initialize
|
46
|
+
def initialize(items)
|
49
47
|
@items = items
|
50
48
|
end
|
51
49
|
|
52
50
|
def to_s
|
53
51
|
items.count > 1 ? "{#{items.join(',')}}" : items.first
|
54
52
|
end
|
55
|
-
|
56
53
|
end
|
57
|
-
|
58
54
|
end
|
59
55
|
end
|
@@ -0,0 +1,63 @@
|
|
1
|
+
module Torganiser
|
2
|
+
# A matcher that can extract semantic information from a
|
3
|
+
# properly named file.
|
4
|
+
class Matcher
|
5
|
+
class << self
|
6
|
+
def match(basename)
|
7
|
+
pattern.match basename
|
8
|
+
end
|
9
|
+
|
10
|
+
private
|
11
|
+
|
12
|
+
def pattern
|
13
|
+
@pattern ||= /^
|
14
|
+
#{series} # Series name, and possibly year
|
15
|
+
#{separator}#{season_info} # season info
|
16
|
+
#{separator}.*$ # stuff we don't care about
|
17
|
+
/ix
|
18
|
+
end
|
19
|
+
|
20
|
+
def separator
|
21
|
+
@separator ||= one_of '\.', '\s', '\s?\-\s?'
|
22
|
+
end
|
23
|
+
|
24
|
+
def series
|
25
|
+
# Series with year takes precedence
|
26
|
+
one_of series_with_year, series_without_year
|
27
|
+
end
|
28
|
+
|
29
|
+
def series_without_year
|
30
|
+
'(?<name>.*)'
|
31
|
+
end
|
32
|
+
|
33
|
+
def series_with_year
|
34
|
+
'(?<name>.*)\.(?<year>\d{4})'
|
35
|
+
end
|
36
|
+
|
37
|
+
def season_info
|
38
|
+
one_of long_format, short_format, special
|
39
|
+
end
|
40
|
+
|
41
|
+
def long_format
|
42
|
+
[
|
43
|
+
's(?<season>\d+)', # season number
|
44
|
+
'e(?<episode>\d+)', # episode number
|
45
|
+
'(e\d+)?' # optional second episode number, ignored
|
46
|
+
].join('\s?') # optionally space separated
|
47
|
+
end
|
48
|
+
|
49
|
+
def short_format
|
50
|
+
'\[?(?<season>\d+)x?(?<episode>\d{2})\]?'
|
51
|
+
end
|
52
|
+
|
53
|
+
def special
|
54
|
+
# specials don't fit nicely into the season/episode model.
|
55
|
+
"s(?<season>0)0|s(?<season>\\d+)#{separator}special"
|
56
|
+
end
|
57
|
+
|
58
|
+
def one_of(*args)
|
59
|
+
MatchOne.new(args)
|
60
|
+
end
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
data/lib/torganiser/runner.rb
CHANGED
@@ -2,15 +2,9 @@ module Torganiser
|
|
2
2
|
# Runs the organisation process for a given array of
|
3
3
|
# files, extensions, and ignored files
|
4
4
|
class Runner
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
)
|
9
|
-
@scanner = Scanner.new(
|
10
|
-
files, extensions,
|
11
|
-
ignored.map { |string| Regexp.new(string) }
|
12
|
-
)
|
13
|
-
@arranger = Arranger.new(collection, dry_run: dry_run)
|
5
|
+
def initialize(scanner: scanner, arranger: arranger)
|
6
|
+
@scanner = scanner
|
7
|
+
@arranger = arranger
|
14
8
|
end
|
15
9
|
|
16
10
|
def run
|
@@ -20,10 +14,9 @@ module Torganiser
|
|
20
14
|
end
|
21
15
|
|
22
16
|
private
|
23
|
-
|
17
|
+
|
18
|
+
def arrange(episode_file)
|
24
19
|
@arranger.arrange episode_file
|
25
20
|
end
|
26
|
-
|
27
21
|
end
|
28
|
-
|
29
22
|
end
|
data/lib/torganiser/scanner.rb
CHANGED
@@ -2,7 +2,6 @@ module Torganiser
|
|
2
2
|
# Handles scanning a set of directories and files
|
3
3
|
# and returning any found episode files.
|
4
4
|
class Scanner
|
5
|
-
|
6
5
|
include Enumerable
|
7
6
|
|
8
7
|
def initialize(files, extensions, ignored)
|
@@ -26,11 +25,11 @@ module Torganiser
|
|
26
25
|
end
|
27
26
|
end
|
28
27
|
|
29
|
-
def ignored?
|
28
|
+
def ignored?(file)
|
30
29
|
@ignored_patterns.any? { |pattern| pattern.match file }
|
31
30
|
end
|
32
31
|
|
33
|
-
def ignore
|
32
|
+
def ignore(ignored)
|
34
33
|
ignored_patterns.concat([*ignored])
|
35
34
|
end
|
36
35
|
|
@@ -46,13 +45,13 @@ module Torganiser
|
|
46
45
|
end
|
47
46
|
end
|
48
47
|
|
49
|
-
def add_files
|
48
|
+
def add_files(files)
|
50
49
|
files.each do |file|
|
51
50
|
add_file file
|
52
51
|
end
|
53
52
|
end
|
54
53
|
|
55
|
-
def add_file
|
54
|
+
def add_file(file)
|
56
55
|
if File.file?(file)
|
57
56
|
ordinary_files << file
|
58
57
|
else
|
@@ -67,6 +66,5 @@ module Torganiser
|
|
67
66
|
def file_query
|
68
67
|
@file_query ||= FileQuery.new
|
69
68
|
end
|
70
|
-
|
71
69
|
end
|
72
70
|
end
|