apple-tv-converter 0.4.4 → 0.5.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -20,75 +20,67 @@ module AppleTvConverter
20
20
  end
21
21
 
22
22
  def logout
23
- begin
24
- response = @server.call("LogOut", get_token)
25
- parse_response! response
26
- @token = nil if response[:success]
27
- rescue EOFError
28
- logout
29
- rescue XMLRPC::FaultException => e
30
- puts "Error:"
31
- puts e.faultCode
32
- puts e.faultString
33
- raise e
34
- end
23
+ response = make_call("LogOut", get_token)
24
+ parse_response! response
25
+ @token = nil if response[:success]
35
26
  end
36
27
 
37
28
  def search_subtitles(media, &block)
38
- options = [
29
+ language_options = languages.map(&:to_s).join(',') if languages.any?
30
+ options = []
31
+
32
+ # Query by movie hash
33
+ options << {
39
34
  :moviehash => media.movie_hash.to_s,
40
35
  :moviebytesize => media.movie_file_size.to_s
41
- ]
42
- language_options = languages.map(&:to_s).join(',') if languages.any?
36
+ }
37
+ # Query by movie name
38
+ options << { :query => media.show }
39
+ # and IMDB id if present
40
+ options.last[:imdb_id] = media.imdb_id if media.imdb_id
41
+
42
+ # Add common options
43
+ options.each do |query_option|
44
+ query_option[:sublanguageid] = language_options if language_options
45
+ query_option[:season] = media.season if media.is_tv_show_episode?
46
+ query_option[:episode] = media.number if media.is_tv_show_episode?
47
+ end
43
48
 
44
- options.first[:sublanguageid] = language_options if language_options
45
49
  response = search_for_subtitles(media, options)
46
-
47
- if response[:success]
48
- if response['data']
49
- Opensubtitles.subtitles[media] = response['data']
50
- block.call response['data'] if block
51
- else
52
- # Could not find matches by hash, try by name (and season/episode)
53
- options = [ :query => media.show ]
54
-
55
- options.first[:season] = media.season if media.is_tv_show_episode?
56
- options.first[:episode] = media.number if media.is_tv_show_episode?
57
- options.first[:sublanguageid] = language_options if language_options
58
-
59
- response = search_for_subtitles(media, options)
60
-
61
- if response[:success] && response['data']
62
- Opensubtitles.subtitles[media] = response['data']
63
- block.call response['data'] if block
64
- end
65
- end
50
+ if response[:success] && response['data']
51
+ Opensubtitles.subtitles[media] = response['data']
52
+ block.call response['data'] if block
66
53
  end
67
54
  end
68
55
 
56
+ def has_found_subtitles?(media)
57
+ (Opensubtitles.subtitles[media] && Opensubtitles.subtitles[media].any?) == true
58
+ end
59
+
69
60
  def download_subtitles(media, &block)
61
+ return unless has_found_subtitles? media
62
+
70
63
  data = Opensubtitles.subtitles[media]
71
64
  media_subtitles = filter_subtitles(data, media)
65
+
66
+ # If we have subtitles matched by moviehash, get only the first and ignore the rest
67
+ # otherwise, get all
68
+ media_subtitles = Hash[*media_subtitles.map { |language, subs| [language, [ subs.detect { |s| s['MatchedBy'] } || subs ].flatten ] }.flatten(1) ]
69
+
72
70
  block.call :search, media_subtitles if block
73
71
 
74
- # We now have only one subtitle per language code, so start downloading
75
- media_subtitles.each do |language_code, subtitle|
76
- block.call :downloading, subtitle
77
- download_subtitle(media, subtitle)
78
- block.call :downloaded, subtitle
72
+ # We now have one or many subtitles per language code, so start downloading
73
+ media_subtitles.each do |language_code, subtitles|
74
+ subtitles.each do |subtitle|
75
+ block.call :downloading, subtitle
76
+ download_subtitle(media, subtitle)
77
+ block.call :downloaded, subtitle
78
+ end
79
79
  end
80
80
  end
81
81
 
82
82
  def status
83
- begin
84
- @server.call('ServerInfo')
85
- rescue EOFError
86
- status
87
- rescue XMLRPC::FaultException => e
88
- puts "Error:"
89
- puts e.faultCode
90
- puts e.faultString
91
- end
83
+ make_call('ServerInfo')
92
84
  end
93
85
 
94
86
  private
@@ -100,19 +92,10 @@ module AppleTvConverter
100
92
  def logged_in? ; return !@token.nil? ; end
101
93
 
102
94
  def login
103
- begin
104
- response = @server.call("LogIn", '', '', '', USER_AGENT)
105
- parse_response! response
95
+ response = make_call("LogIn", '', '', '', USER_AGENT)
96
+ parse_response! response
106
97
 
107
- @token = response['token'] if response[:success]
108
- rescue EOFError
109
- login
110
- rescue XMLRPC::FaultException => e
111
- puts "Error:"
112
- puts e.faultCode
113
- puts e.faultString
114
- raise e
115
- end
98
+ @token = response['token'] if response[:success]
116
99
  end
117
100
 
118
101
  def get_token
@@ -120,80 +103,131 @@ module AppleTvConverter
120
103
  return @token
121
104
  end
122
105
 
123
- def filter_subtitles(data, media)
124
- media_subtitles = data.select { |s| s['SubFormat'].downcase == 'srt' } # Filter by format
125
- media_subtitles = media_subtitles.select { |s| languages.empty? || languages.include?(s['SubLanguageID']) } # Filter by language
106
+ def normalize(string) ; return string.gsub(/[^0-9a-z ]/i, '').gsub(/\s+/, ' ').downcase.strip ; end
126
107
 
127
- exact_match = media_subtitles.select do |s|
128
- !File.basename(media.original_filename).downcase.index(s['MovieReleaseName'].downcase).nil? ||
129
- !File.basename(media.original_filename).downcase.index(s['SubFileName'].gsub(/\..*?$/, '').downcase).nil?
108
+ def filter_subtitles(data, media)
109
+ # "MatchedBy" -> "moviehash"
110
+ # "MatchedBy" -> "imdbid"
111
+ # "MatchedBy" -> "fulltext"
112
+
113
+ # Define priorities by match type
114
+ data.each do |s|
115
+ s[:priority] = case s['MatchedBy']
116
+ when 'moviehash' then 100
117
+ when 'imdbid' then 200
118
+ when 'fulltext' then 300
119
+ else 400
120
+ end
130
121
  end
131
122
 
132
- # We found exact matches on the movie name, so ignore the rest
133
- media_subtitles = exact_match if exact_match.any?
123
+
124
+ # Order the subtitles first by lowest priority (my match)
125
+ # and then by download count (descending). This way, we'll get the
126
+ # best, top downloaded match on top
127
+ media_subtitles = data.sort { |c, d| [d[:priority], c['SubDownloadsCnt'].to_i] <=> [c[:priority], d['SubDownloadsCnt'].to_i] }.reverse
128
+
129
+
130
+ # Get only unique subtitle entries (we can have more than one)
131
+ # due to different 'MatchedBy'
132
+ media_subtitles = media_subtitles.uniq { |s| s['IDSubtitle'] }
133
+
134
+ # Filter by subtitles format (srt)
135
+ media_subtitles = media_subtitles.select { |s| s['SubFormat'].downcase == 'srt' }
136
+ # Filter by number of discs (1)
137
+ media_subtitles = media_subtitles.select { |s| s['SubSumCD'] == '1' }
138
+ # Filter by language
139
+ media_subtitles = media_subtitles.select { |s| languages.empty? || languages.include?(s['SubLanguageID']) }
140
+ # Filter by movie name (unless it's an episode, as the movie name can be the episode's title)
141
+ media_subtitles = media_subtitles.select { |s| s['MatchedBy'] == 'moviehash' || normalize(s['MovieName']) == normalize(media.show) } unless media.is_tv_show_episode?
142
+
143
+ # exact_match = media_subtitles.select do |s|
144
+ # !File.basename(media.original_filename).downcase.index(s['MovieReleaseName'].downcase).nil? ||
145
+ # !File.basename(media.original_filename).downcase.index(s['SubFileName'].gsub(/\..*?$/, '').downcase).nil?
146
+ # end
147
+
148
+ # # We found exact matches on the movie name, so ignore the rest
149
+ # media_subtitles = exact_match if exact_match.any?
150
+
134
151
  # Group the subtitles by language code
135
152
  media_subtitles = media_subtitles.group_by { |a| a['SubLanguageID'] }
136
153
 
137
- # Since we can have more than one subtitle per language that matches our movie
138
- # order the grouped subtitles by download count (descending), and keep only
139
- # the first (we basically going with the majority of the people)
140
- return Hash[*(media_subtitles.map {|a,b| [a, b.sort { |c, d| c['SubDownloadsCnt'].to_i <=> d['SubDownloadsCnt'].to_i }.reverse.first] }).flatten]
154
+ all_subtitles = Hash[*media_subtitles.flatten(1)]
155
+
156
+ return all_subtitles
141
157
  end
142
158
 
143
159
  def search_for_subtitles(media, options)
144
- begin
145
- response = @server.call("SearchSubtitles", get_token, options)
146
- parse_response! response
160
+ response = make_call("SearchSubtitles", get_token, options)
161
+ parse_response! response
147
162
 
148
- return response
149
- rescue EOFError
150
- logout
151
- rescue XMLRPC::FaultException => e
152
- puts "Error:"
153
- puts e.faultCode
154
- puts e.faultString
155
- raise e
156
- end
163
+ return response
157
164
  end
158
165
 
159
166
  def download_subtitle(media, subtitle)
160
- begin
161
- response = @server.call("DownloadSubtitles", get_token, [ subtitle['IDSubtitleFile'] ])
162
- parse_response! response
163
-
164
- if response[:success]
165
- data = response['data']
166
-
167
- if data
168
- data.each do |subtitle_data|
169
- # Decode Base64 encoded gzipped data
170
- zip_data = Base64.decode64(subtitle_data['data'])
171
- # UnGZip it
172
- unzipped_data = Zlib::GzipReader.new(StringIO.new(zip_data)).read
173
- # Write it to a new file
174
- File.open(media.get_new_subtitle_filename(subtitle['SubLanguageID'], subtitle_data['idsubtitlefile']), 'wb') { |file| file.write(unzipped_data) }
175
- end
167
+ response = make_call("DownloadSubtitles", get_token, [ subtitle['IDSubtitleFile'] ])
168
+ parse_response! response
169
+
170
+ if response[:success]
171
+ data = response['data']
172
+
173
+ if data
174
+ data.each do |subtitle_data|
175
+ # Decode Base64 encoded gzipped data
176
+ zip_data = Base64.decode64(subtitle_data['data'])
177
+ # UnGZip it
178
+ unzipped_data = Zlib::GzipReader.new(StringIO.new(zip_data)).read
179
+ # Write it to a new file
180
+ File.open(media.get_new_subtitle_filename(subtitle['SubLanguageID'], subtitle_data['idsubtitlefile']), 'wb') { |file| file.write(unzipped_data) }
176
181
  end
177
182
  end
178
- rescue EOFError
179
- download_subtitle subtitle
183
+ end
184
+ end
185
+
186
+ def download(url)
187
+ Net::HTTP.get(URI.parse(url))
188
+ end
189
+
190
+ def make_call(function, *parameters)
191
+ do_make_call function, 0, parameters
192
+ end
193
+
194
+ def do_make_call(function, retries, *parameters)
195
+ begin
196
+ # Flatten the parameters to the correct depth
197
+ @server.call(*[function, parameters.flatten(1)].flatten(1))
198
+ rescue EOFError => e
199
+ if retries < 3
200
+ # retry
201
+ puts "Error (EOFError): retrying"
202
+ do_make_call function, retries + 1, *parameters
203
+ else
204
+ puts "Error (EOFError): retried 3 times, giving up"
205
+ raise e
206
+ end
180
207
  rescue XMLRPC::FaultException => e
181
- puts "Error:"
208
+ puts "Error (XMLRPC::FaultException):"
182
209
  puts e.faultCode
183
210
  puts e.faultString
184
211
  raise e
212
+ rescue RuntimeError => e
213
+ if retries < 3
214
+ # retry
215
+ puts "Error (RuntimeError, most likely 503): retrying after 2 seconds"
216
+ sleep 2
217
+ do_make_call function, retries + 1, *parameters
218
+ else
219
+ puts "Error (RuntimeError, most likely 503): retried 3 times, giving up"
220
+
221
+ raise e
222
+ end
185
223
  rescue Exception => e
186
- puts "Error:"
187
- # ap e
188
- # ap e.message
224
+ puts "Error (#{e.class})"
225
+ puts e.message
189
226
 
190
227
  raise e
191
228
  end
192
229
  end
193
230
 
194
- def download(url)
195
- Net::HTTP.get(URI.parse(url))
196
- end
197
231
 
198
232
  def parse_response!(response)
199
233
  # Clear the token in case of some errors
@@ -0,0 +1,261 @@
1
+ module AppleTvConverter
2
+ class TvDbFetcher
3
+ require 'httparty'
4
+ require 'yaml'
5
+ require 'net/http'
6
+ require 'zip/zip'
7
+ require 'xml'
8
+
9
+ include HTTParty
10
+
11
+ base_uri 'thetvdb.com/api'
12
+
13
+ def self.search(media, interactive = true, language = 'en')
14
+ get_updates_from_server
15
+
16
+ if media.tvdb_id
17
+ show_id = media.tvdb_id
18
+ else
19
+ data = load_config_file('show_ids') || {}
20
+
21
+ # http://thetvdb.com/api/GetSeries.php?seriesname=
22
+ unless data.has_key?(media.show)
23
+ show_ids = get_and_parse_data_from_server('show_ids', '/GetSeries.php', { :query => { :seriesname => media.show } }, ['Data', 'Series']) do |loaded_data|
24
+ loaded_data = [loaded_data].flatten
25
+
26
+ data[media.show] = if loaded_data.length > 1 && interactive
27
+ choice = 0
28
+ puts "\n *"
29
+
30
+ while true
31
+ puts %Q[ | Several shows found, choose the intended one:]
32
+
33
+ loaded_data.each_with_index do |item, index|
34
+ puts " | #{(index + 1).to_s.rjust(loaded_data.length.to_s.length)} - #{item['SeriesName']} (id: #{item['seriesid']})"
35
+ puts " | #{' '.rjust(loaded_data.length.to_s.length)} AKA: #{item['AliasNames']}" if item['AliasNames']
36
+ end
37
+
38
+ printf " |\n *- What's your choice (1..#{loaded_data.length})? "
39
+ choice = STDIN.gets.chomp.to_i
40
+
41
+ break if choice.between?(1, loaded_data.length)
42
+
43
+ puts " | Invalid choice!"
44
+ puts " |"
45
+ end
46
+
47
+ loaded_data[choice - 1]['seriesid']
48
+ else
49
+ loaded_data.first['seriesid']
50
+ end
51
+
52
+ # Return the new list sorted by show name
53
+ Hash[data.sort]
54
+ end
55
+ end
56
+
57
+ show_id = data[media.show]
58
+ end
59
+
60
+ if show_id.to_i > 0
61
+ # <mirrorpath_zip>/api/<apikey>/series/<seriesid>/all/<language>.zip
62
+ show_data = get_data(show_id, "/#{api_key}/series/#{show_id}/all/#{language}.zip", { :zip => true }) do |data|
63
+ show_data = xml_document_to_hash(XML::Document.string(data[language.to_s].gsub(/>\s*</im, '><')))
64
+ banners = xml_document_to_hash(XML::Document.string(data['banners'].gsub(/>\s*</im, '><'))) rescue { 'Banner' => [] }
65
+ actors = xml_document_to_hash(XML::Document.string(data['actors'].gsub(/>\s*</im, '><'))) rescue { 'Actor' => [] }
66
+
67
+ {
68
+ :series => show_data['Series'],
69
+ :episodes => [show_data['Episode']].flatten,
70
+ :banners => [banners['Banner']].flatten,
71
+ :actors => [actors['Actor']].flatten
72
+ }
73
+
74
+ end
75
+
76
+ return {
77
+ :episode => show_data[:episodes].detect { |ep| ep['SeasonNumber'].to_i == media.season.to_i && ep['EpisodeNumber'].to_i == media.number.to_i },
78
+ :show => show_data
79
+ }
80
+ end
81
+
82
+ return false
83
+ end
84
+
85
+ def self.get_poster(media)
86
+ local_file = File.join(AppleTvConverter.data_path, 'cache', 'tvdb', "#{media.tvdb_id}.jpg")
87
+
88
+ unless File.exists?(local_file)
89
+ artwork_filename = media.tvdb_movie[:show][:series]['poster'] || ''
90
+ artwork_filename = media.tvdb_movie_data('filename') || '' if artwork_filename.blank?
91
+ artwork_filename = "http://thetvdb.com/banners/#{artwork_filename}" if !artwork_filename.blank?
92
+
93
+ AppleTvConverter.copy artwork_filename, local_file unless artwork_filename.blank?
94
+ end
95
+
96
+ local_file
97
+ end
98
+
99
+ private
100
+
101
+ def self.api_key ; return '67FBF9F0670DBDF2' ; end
102
+ def self.local_cache_base_path
103
+ return File.expand_path(File.join(AppleTvConverter.data_path, 'cache', 'tvdb'))
104
+ end
105
+ def self.server_update_timestamp
106
+ @server_update_timestamp ||= load_config_file('update')
107
+
108
+ unless @server_update_timestamp
109
+ # http://thetvdb.com/api//Updates.php?type=none
110
+ @server_update_timestamp = get_data_from_server('/Updates.php', { :query => { :type => 'none' }})["Items"]["Time"] rescue nil
111
+ @server_update_timestamp = @server_update_timestamp.to_i unless @server_update_timestamp.nil?
112
+ save_config_file 'update', @server_update_timestamp
113
+ end
114
+
115
+ @server_update_timestamp
116
+ end
117
+
118
+ def self.load_config_file(filename)
119
+ full_filename = File.join(local_cache_base_path, filename =~ /\.yml$/ ? filename : "#{filename}.yml")
120
+ File.exists?(full_filename) ? YAML.load_file(full_filename) : nil
121
+ end
122
+
123
+ def self.save_config_file(filename, data)
124
+ full_filename = File.join(local_cache_base_path, filename =~ /\.yml$/ ? filename : "#{filename}.yml")
125
+ File.open(full_filename, 'w') { |f| f.write data.to_yaml }
126
+ end
127
+
128
+ def self.delete_config_file(filename)
129
+ full_filename = File.join(local_cache_base_path, filename =~ /\.yml$/ ? filename : "#{filename}.yml")
130
+ File.delete(full_filename) if File.exists?(full_filename)
131
+ end
132
+
133
+
134
+ def self.get_data_from_server(url, options = {})
135
+ AppleTvConverter.logger.debug " -> Getting from server: #{url}"
136
+ cache = options.delete(:cache) || true
137
+ zip = options.delete(:zip) || false
138
+ response = self.get(url, options).parsed_response
139
+
140
+ if zip
141
+ filename = File.join(local_cache_base_path, 'zip_file.zip')
142
+
143
+ begin
144
+ File.open(filename, 'wb') { |f| f.write response }
145
+ response = {}
146
+
147
+ Zip::ZipFile.open(filename) do |zipfile|
148
+ zipfile.each do |entry|
149
+ unless entry.name.downcase["__macosx"]
150
+ zip_data = zipfile.read(entry)
151
+ response[entry.name.to_s.gsub(/\.xml$/i, '')] = zip_data
152
+ end
153
+ end
154
+ end
155
+ rescue => e
156
+ ap [e, e.backtrace]
157
+
158
+ ensure
159
+ FileUtils.rm_f filename if File.exists?(filename)
160
+ end
161
+ end
162
+
163
+ return response
164
+ end
165
+
166
+ def self.get_data(filename, url, url_options, response_indexes = [])
167
+ AppleTvConverter.logger.debug "-> Getting data: #{filename}"
168
+ data = load_config_file(filename)
169
+
170
+ unless data
171
+ data = get_data_from_server(url, url_options)
172
+
173
+ if data
174
+ begin
175
+ response_indexes.each { |idx| data = data[idx] }
176
+
177
+ data = yield(data) if block_given?
178
+
179
+ save_config_file filename, data
180
+ rescue
181
+ data = nil
182
+ end
183
+ end
184
+ else
185
+ # ap ['found on cache', filename, data]
186
+ end
187
+
188
+ return data
189
+ end
190
+
191
+ def self.get_and_parse_data_from_server(filename, url, url_options, response_indexes = [])
192
+ cache = url_options.delete(:cache) || true
193
+ data = get_data_from_server(url, url_options)
194
+
195
+ if data
196
+ begin
197
+ response_indexes.each { |idx| data = data[idx] }
198
+
199
+ data = yield(data) if block_given?
200
+
201
+ save_config_file filename, data if cache
202
+ rescue
203
+ data = nil
204
+ end
205
+ end
206
+ end
207
+
208
+ def self.get_updates_from_server(options = {})
209
+ get_and_parse_data_from_server('updates', '/Updates.php', { :query => { :type => 'all', :time => load_config_file('update')}, :cache => false }, ['Items']) do |data|
210
+ if data
211
+ # Delete each show's cached data
212
+ data['Series'].each do |show_id|
213
+ delete_config_file show_id
214
+ delete_config_file "#{show_id}.jpg"
215
+ end
216
+
217
+ # Save the new timestamp
218
+ save_config_file 'update', data['Time']
219
+ @server_update_timestamp = data['Time']
220
+ end
221
+ end
222
+ end
223
+
224
+ def self.xml_document_to_hash(document)
225
+ def self.xml_node_to_hash(xml)
226
+ return nil if xml.children.empty?
227
+ return xml.children.first.to_s if xml.children.count == 1 && xml.children.first.text?
228
+
229
+ # Append a sequential number to the name to prevent replacing items that should be in an array
230
+ child_number = 0
231
+ Hash[*(xml.children.map { |child| child_number += 1 ; ["#{child.name}::#{child_number}", xml_node_to_hash(child)] }.compact.flatten(1))]
232
+ end
233
+
234
+ intermediate_hash = xml_node_to_hash(document.root)
235
+
236
+ return Hash[*(intermediate_hash.group_by do |obj|
237
+ obj.first.gsub(/::\d+$/, '')
238
+ end.map do |key, value|
239
+ # Remove the 'key' entries
240
+ value = value.flatten(1).delete_if { |v| v.to_s =~ /#{key}::\d+/ }
241
+
242
+ # Remove the sequential number from the keys
243
+ value.map! do |element|
244
+ Hash[*(element.map do |ikey, ivalue|
245
+ [ikey.gsub(/::\d+$/, ''), ivalue]
246
+ end.flatten(1))]
247
+ end
248
+
249
+ # If there's only one entry, remove the array
250
+ value = value.first if value.count == 1
251
+
252
+ [key, value]
253
+ end.flatten(1))]
254
+ end
255
+
256
+ FileUtils.mkdir_p local_cache_base_path
257
+
258
+ # Load the server timestamp on startup
259
+ server_update_timestamp
260
+ end
261
+ end