kodi-dedup 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/CHANGELOG.md +18 -0
- data/LICENSE.txt +21 -0
- data/bin/console +14 -0
- data/bin/kodi-dedup +5 -0
- data/bin/setup +8 -0
- data/lib/kodi_dedup.rb +54 -0
- data/lib/kodi_dedup/classes/episode.rb +7 -0
- data/lib/kodi_dedup/classes/episodes.rb +7 -0
- data/lib/kodi_dedup/classes/media.rb +23 -0
- data/lib/kodi_dedup/classes/medium.rb +29 -0
- data/lib/kodi_dedup/classes/movie.rb +7 -0
- data/lib/kodi_dedup/classes/movies.rb +7 -0
- data/lib/kodi_dedup/classes/show.rb +16 -0
- data/lib/kodi_dedup/classes/shows.rb +7 -0
- data/lib/kodi_dedup/cli.rb +21 -0
- data/lib/kodi_dedup/cli/base.rb +30 -0
- data/lib/kodi_dedup/cli/episodes.rb +34 -0
- data/lib/kodi_dedup/cli/movies.rb +23 -0
- data/lib/kodi_dedup/config.rb +15 -0
- data/lib/kodi_dedup/deduplicator.rb +39 -0
- data/lib/kodi_dedup/media_file.rb +67 -0
- data/lib/kodi_dedup/mediainfo.rb +26 -0
- data/lib/kodi_dedup/version.rb +3 -0
- data/spec/data/episodes.json +40 -0
- data/spec/data/movies.json +34 -0
- data/spec/data/rpc-introspect.json +1 -0
- data/spec/data/shows.json +17 -0
- data/spec/kodi_dedup.rb +7 -0
- data/spec/lib/cli/episodes_spec.rb +48 -0
- data/spec/lib/cli/movies_spec.rb +45 -0
- data/spec/lib/media_file_spec.rb +49 -0
- data/spec/spec_helper.rb +43 -0
- metadata +198 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 1a4a6f8f9a4d15f8ac34d7a6d38956a1ab646e5a
|
4
|
+
data.tar.gz: 17f09a1039969cbed373952f021c6ca10fbb7148
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: ebad2e669659766867f77ea7568792fcaa98c061ee00acb58f0b938c3b8cb4b3758d17bd982b6e7ea9e073986d13f52cc0cb02bda576e294c495d64062d12434
|
7
|
+
data.tar.gz: ddbd0c06c50f97598d78003824996669b405861d60b27e59b5693413aadfc1be924db1c9908602d526f43eac507718029ddac87a770b2eba52a422a1f265a0e3
|
data/CHANGELOG.md
ADDED
@@ -0,0 +1,18 @@
|
|
1
|
+
# Change Log
|
2
|
+
|
3
|
+
All notable changes to this project will be documented in this file.
|
4
|
+
|
5
|
+
## [Unreleased]
|
6
|
+
|
7
|
+
### Added
|
8
|
+
|
9
|
+
### Changed
|
10
|
+
|
11
|
+
### Fixed
|
12
|
+
|
13
|
+
## [0.1.0] - 2017-05-06
|
14
|
+
|
15
|
+
- First release
|
16
|
+
|
17
|
+
[Unreleased]: https://github.com/dsander/kodi-dedup/compare/v0.1.0...HEAD
|
18
|
+
[0.1.0]: https://github.com/dsander/kodi-dedup/compare/8ab34227842f0ff3915b84c9f3cc6bdaf19e7cf4...v0.1.0
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2017 Dominik Sander
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
13
|
+
all copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
21
|
+
THE SOFTWARE.
|
data/bin/console
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
require "bundler/setup"
|
4
|
+
require "kodi_dedup"
|
5
|
+
|
6
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
7
|
+
# with your gem easier. You can also use a different console, if you like.
|
8
|
+
|
9
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
10
|
+
# require "pry"
|
11
|
+
# Pry.start
|
12
|
+
|
13
|
+
require "irb"
|
14
|
+
IRB.start
|
data/bin/kodi-dedup
ADDED
data/bin/setup
ADDED
data/lib/kodi_dedup.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
require 'fileutils'
|
2
|
+
require 'kodi'
|
3
|
+
require 'mediainfo'
|
4
|
+
require 'thor'
|
5
|
+
|
6
|
+
require 'kodi_dedup/classes/media'
|
7
|
+
require 'kodi_dedup/classes/medium'
|
8
|
+
require 'kodi_dedup/classes/episode'
|
9
|
+
require 'kodi_dedup/classes/episodes'
|
10
|
+
require 'kodi_dedup/classes/movie'
|
11
|
+
require 'kodi_dedup/classes/movies'
|
12
|
+
require 'kodi_dedup/classes/show'
|
13
|
+
require 'kodi_dedup/classes/shows'
|
14
|
+
require 'kodi_dedup/cli'
|
15
|
+
require 'kodi_dedup/cli/base'
|
16
|
+
require 'kodi_dedup/cli/episodes'
|
17
|
+
require 'kodi_dedup/cli/movies'
|
18
|
+
require 'kodi_dedup/config'
|
19
|
+
require 'kodi_dedup/deduplicator'
|
20
|
+
require "kodi_dedup/media_file"
|
21
|
+
require "kodi_dedup/mediainfo"
|
22
|
+
require "kodi_dedup/version"
|
23
|
+
|
24
|
+
module KodiDedup
|
25
|
+
def self.client
|
26
|
+
@client ||= Kodi::Client.new(config.url)
|
27
|
+
end
|
28
|
+
|
29
|
+
def self.shows
|
30
|
+
Shows.new(client.video_library.GetTVShows['tvshows'])
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.episodes(show_id)
|
34
|
+
episodes = KodiDedup.client.video_library.GetEpisodes(tvshowid: show_id, properties: [:season, :episode, :file, :lastplayed, :playcount])['episodes']
|
35
|
+
return [] unless episodes
|
36
|
+
Episodes.new(episodes)
|
37
|
+
end
|
38
|
+
|
39
|
+
def self.movies
|
40
|
+
Movies.new(KodiDedup.client.video_library.GetMovies(properties: [:file, :title, :playcount])['movies'])
|
41
|
+
end
|
42
|
+
|
43
|
+
def self.config!(options)
|
44
|
+
@config = Config.new(options)
|
45
|
+
end
|
46
|
+
|
47
|
+
def self.config
|
48
|
+
@config
|
49
|
+
end
|
50
|
+
|
51
|
+
def self.shell
|
52
|
+
@shell ||= Thor::Shell::Color.new
|
53
|
+
end
|
54
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class Media < Array
|
3
|
+
attr_reader :singular_class, :group_by_proc
|
4
|
+
|
5
|
+
def initialize(media:, singular_class:, group_by_proc: )
|
6
|
+
@singular_class = singular_class
|
7
|
+
@group_by_proc = group_by_proc
|
8
|
+
super(media.map { |m| singular_class.wrap(m) }.select { |m| m.exists? })
|
9
|
+
end
|
10
|
+
|
11
|
+
def grouped
|
12
|
+
group_by(&@group_by_proc).values.select { |media| media.length > 1 }.map { |media| self.class.new(media) }
|
13
|
+
end
|
14
|
+
|
15
|
+
def unplayed
|
16
|
+
select { |e| e.playcount == 0 }
|
17
|
+
end
|
18
|
+
|
19
|
+
def total_playcount
|
20
|
+
sum { |e| e.playcount }
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class Medium
|
3
|
+
def initialize(data)
|
4
|
+
@data = data
|
5
|
+
@data['file'] = @data['file'].gsub(KodiDedup.config.replace, KodiDedup.config.with) if KodiDedup.config.replace
|
6
|
+
end
|
7
|
+
|
8
|
+
def method_missing(method, *args)
|
9
|
+
return @data[method.to_s] if @data[method.to_s]
|
10
|
+
super(method, args)
|
11
|
+
end
|
12
|
+
|
13
|
+
def mark_as_played!
|
14
|
+
raise NotImplementedError
|
15
|
+
end
|
16
|
+
|
17
|
+
def exists?
|
18
|
+
File.exist?(file)
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.wrap(e)
|
22
|
+
if e.is_a?(self)
|
23
|
+
e
|
24
|
+
else
|
25
|
+
self.new(e)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class Show
|
3
|
+
def initialize(data)
|
4
|
+
@data = data
|
5
|
+
end
|
6
|
+
|
7
|
+
def episodes
|
8
|
+
@episodes ||= KodiDedup.episodes(tvshowid)
|
9
|
+
end
|
10
|
+
|
11
|
+
def method_missing(method, *args)
|
12
|
+
return @data[method.to_s] if @data[method.to_s]
|
13
|
+
super(method, args)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class Cli < Thor
|
3
|
+
desc "episodes", "Clean up duplicate show episodes in your Kodi library"
|
4
|
+
method_option :url, type: :string, required: true, desc: "URI string to the Kodi JSON API endpoint (http://kodi:kodi@localhost:8080/jsonrpc)"
|
5
|
+
method_option :perform, type: :boolean, default: false, desc: "Actually perform the actions"
|
6
|
+
method_option :replace, type: :hash, desc: "Replace 'key' with 'value' in the file paths returned by Kodi"
|
7
|
+
def episodes
|
8
|
+
KodiDedup.config!(options)
|
9
|
+
KodiDedup::Cli::Episodes.new.perform
|
10
|
+
end
|
11
|
+
|
12
|
+
desc "movies", "Clean up duplicate movies in your Kodi library"
|
13
|
+
method_option :url, type: :string, required: true, desc: "URI string to the Kodi JSON API endpoint (http://kodi:kodi@localhost:8080/jsonrpc)"
|
14
|
+
method_option :perform, type: :boolean, default: false, desc: "Actually perform the actions"
|
15
|
+
method_option :replace, type: :hash, desc: "Replace 'key' with 'value' in the file paths returned by Kodi"
|
16
|
+
def movies
|
17
|
+
KodiDedup.config!(options)
|
18
|
+
KodiDedup::Cli::Movies.new.perform
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class Cli
|
3
|
+
module Base
|
4
|
+
def deduplicate!(dedup)
|
5
|
+
dedup.playcounts do |entry|
|
6
|
+
entry.mark_as_played! if KodiDedup.config.perform
|
7
|
+
shell.say " ✓ marked all movies as played", :green
|
8
|
+
end
|
9
|
+
|
10
|
+
dedup.entries do |entries|
|
11
|
+
shell.say " found #{entries.length} duplicate file(s):"
|
12
|
+
entries.each_with_index do |m, i|
|
13
|
+
shell.say " #{i} #{m}"
|
14
|
+
end
|
15
|
+
next unless KodiDedup.config.perform
|
16
|
+
|
17
|
+
keep = shell.ask('Which file do you want to keep?', default: '0', limited_to: entries.length.times.map(&:to_s)).to_i
|
18
|
+
entries.each_with_index do |m, i|
|
19
|
+
next if i == keep
|
20
|
+
FileUtils.rm(m.filename)
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def shell
|
26
|
+
KodiDedup.shell
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class Cli
|
3
|
+
class Episodes
|
4
|
+
include Base
|
5
|
+
|
6
|
+
def perform
|
7
|
+
shell.say 'Dry running, call with --perform to change perform the deduplication', :green unless KodiDedup.config.perform
|
8
|
+
|
9
|
+
shell.say 'Locating duplicate episodes ...'
|
10
|
+
|
11
|
+
grouped_episodes_by_show do |show, episodes|
|
12
|
+
dedup = Deduplicator.new(episodes)
|
13
|
+
|
14
|
+
dedup.preable do
|
15
|
+
shell.say "#{show.label} #{episodes.first.label}", :yellow
|
16
|
+
end
|
17
|
+
|
18
|
+
deduplicate!(dedup)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
|
22
|
+
def grouped_episodes_by_show
|
23
|
+
KodiDedup.shows.each do |show|
|
24
|
+
next if show.episodes.empty?
|
25
|
+
|
26
|
+
show.episodes.grouped.each do |episodes|
|
27
|
+
next if episodes.length == 1
|
28
|
+
yield show, episodes
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,23 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class Cli
|
3
|
+
class Movies
|
4
|
+
include Base
|
5
|
+
|
6
|
+
def perform
|
7
|
+
shell.say 'Dry running, call with --perform to change perform the deduplication', :green unless KodiDedup.config.perform
|
8
|
+
|
9
|
+
shell.say 'Locating duplicate movies ...'
|
10
|
+
KodiDedup.movies.grouped.each do |movies|
|
11
|
+
movie = movies.first
|
12
|
+
dedup = Deduplicator.new(movies)
|
13
|
+
|
14
|
+
dedup.preable do
|
15
|
+
shell.say "#{movie.label}", :yellow
|
16
|
+
end
|
17
|
+
|
18
|
+
deduplicate!(dedup)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class Config
|
3
|
+
attr_reader :perform, :replace, :with, :url, :mediainfo
|
4
|
+
|
5
|
+
def initialize(options)
|
6
|
+
@perform = options['perform']
|
7
|
+
if options['replace']
|
8
|
+
@replace = options['replace'].keys.first
|
9
|
+
@with = options['replace'].values.first
|
10
|
+
end
|
11
|
+
@url = options['url']
|
12
|
+
@mediainfo = options[:mediainfo] || KodiDedup::Mediainfo
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,39 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class Deduplicator
|
3
|
+
attr_reader :subject
|
4
|
+
|
5
|
+
def initialize(subject)
|
6
|
+
@subject = subject
|
7
|
+
end
|
8
|
+
|
9
|
+
def preable
|
10
|
+
yield if deduplication_required?
|
11
|
+
end
|
12
|
+
|
13
|
+
def deduplication_required?
|
14
|
+
deduplicate_playcounts? || deduplicate_entries?
|
15
|
+
end
|
16
|
+
|
17
|
+
def deduplicate_playcounts?
|
18
|
+
subject.total_playcount > 0
|
19
|
+
end
|
20
|
+
|
21
|
+
def deduplicate_entries?
|
22
|
+
subject.length > 0
|
23
|
+
end
|
24
|
+
|
25
|
+
def playcounts
|
26
|
+
return unless deduplicate_playcounts?
|
27
|
+
subject.unplayed.each do |object|
|
28
|
+
yield object
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
def entries
|
33
|
+
return unless deduplicate_entries?
|
34
|
+
yield(subject.map do |e|
|
35
|
+
KodiDedup::MediaFile.new e.file
|
36
|
+
end.sort)
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module KodiDedup
|
2
|
+
class MediaFile
|
3
|
+
include Comparable
|
4
|
+
attr_reader :filename, :mediainfo
|
5
|
+
|
6
|
+
FORMATS = ['unknown', 'MPEG-4 Visual', 'AVC', 'HEVC']
|
7
|
+
|
8
|
+
def initialize(filename)
|
9
|
+
@filename = filename
|
10
|
+
@mediainfo = KodiDedup.config.mediainfo.new(filename: filename)
|
11
|
+
end
|
12
|
+
|
13
|
+
def <=>(other)
|
14
|
+
if other.resolution != resolution
|
15
|
+
other.resolution <=> resolution
|
16
|
+
elsif other.format_index != format_index
|
17
|
+
other.format_index <=> format_index
|
18
|
+
elsif other.size != size
|
19
|
+
size <=> other.size
|
20
|
+
else
|
21
|
+
0
|
22
|
+
end
|
23
|
+
end
|
24
|
+
|
25
|
+
def ==(other)
|
26
|
+
width == other.width && height == other.height && format == other.format && size == other.size
|
27
|
+
end
|
28
|
+
|
29
|
+
def size
|
30
|
+
(mediainfo.size / (1024*1024.0)).to_i
|
31
|
+
end
|
32
|
+
|
33
|
+
def resolution
|
34
|
+
width * height
|
35
|
+
end
|
36
|
+
|
37
|
+
def width
|
38
|
+
mediainfo.width
|
39
|
+
rescue ::Mediainfo::StreamProxy::NoStreamsForProxyError
|
40
|
+
0
|
41
|
+
end
|
42
|
+
|
43
|
+
def height
|
44
|
+
mediainfo.height
|
45
|
+
rescue ::Mediainfo::StreamProxy::NoStreamsForProxyError
|
46
|
+
0
|
47
|
+
end
|
48
|
+
|
49
|
+
def format
|
50
|
+
mediainfo.format
|
51
|
+
rescue ::Mediainfo::StreamProxy::NoStreamsForProxyError
|
52
|
+
'unknown'
|
53
|
+
end
|
54
|
+
|
55
|
+
def basename
|
56
|
+
File.basename(filename)
|
57
|
+
end
|
58
|
+
|
59
|
+
def format_index
|
60
|
+
FORMATS.index(format)
|
61
|
+
end
|
62
|
+
|
63
|
+
def to_s
|
64
|
+
"#{basename} (#{format}@#{width}x#{height} #{size}MB)"
|
65
|
+
end
|
66
|
+
end
|
67
|
+
end
|