kodi-dedup 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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
@@ -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.
@@ -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
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "kodi_dedup"
4
+
5
+ KodiDedup::Cli.start
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
@@ -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,7 @@
1
+ module KodiDedup
2
+ class Episode < Medium
3
+ def mark_as_played!
4
+ KodiDedup.client.video_library.SetEpisodeDetails(episodeid: episodeid, playcount: 1, lastplayed: Time.now)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module KodiDedup
2
+ class Episodes < Media
3
+ def initialize(episodes)
4
+ super(media: episodes, singular_class: Episode, group_by_proc: -> (e) { [e.season, e.episode] })
5
+ end
6
+ end
7
+ 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,7 @@
1
+ module KodiDedup
2
+ class Movie < Medium
3
+ def mark_as_played!
4
+ KodiDedup.client.video_library.SetMovieDetails(movieid: movieid, playcount: 1, lastplayed: Time.now)
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,7 @@
1
+ module KodiDedup
2
+ class Movies < Media
3
+ def initialize(movies)
4
+ super(media: movies, singular_class: Movie, group_by_proc: -> (m) { m.label })
5
+ end
6
+ end
7
+ 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,7 @@
1
+ module KodiDedup
2
+ class Shows < Array
3
+ def initialize(series)
4
+ super(series.map { |s| Show.new(s)})
5
+ end
6
+ end
7
+ 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