viddl-rb 0.99 → 1.0.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: 869f88d6332b09dea9e9c01d3205a1965b9c8c48
4
- data.tar.gz: f208de4df1e7fb57d66e2e005aee7f1dbda147f6
3
+ metadata.gz: aff24988a745ce18c4928aca3c4e50fb47232f1d
4
+ data.tar.gz: c0fff623ca863b24999582c94d582a2de1288c1f
5
5
  SHA512:
6
- metadata.gz: c656fa95771d40f473c698a46fa6395129b6ad5100794479a245d1ade7c51d7d0131077baae825ecf120925abc67c62a24a3924d0470b91b26e5b92d8b165784
7
- data.tar.gz: 01be0e2e4510fc211f8661999e5d34f2e74c4582a57ce05619babb2f80b9c48c34a448c669e5f873db5ef250e1f27789932e658e3400ac4fd03019c32dd4c404
6
+ metadata.gz: 04af8ad42b49b38d890fb2e1536a2e8de0573c6a419c984bb3135078c18cc98a1f3fd9c760d885c540372ebdbf303d164147f240e3ca760edf0161bac16042b7
7
+ data.tar.gz: 9a7b77633e663298a5ea5872afef78491de249906ff598d73401c15e9f2db4ba99635118e83e74fcebb61ca65da30d59b0955b03846595fd9720c4b70d357b63
data/Rakefile CHANGED
@@ -2,12 +2,13 @@ require 'rubygems'
2
2
  require 'bundler/setup'
3
3
  require 'rake/testtask'
4
4
 
5
- ALL_INTEGRATION = FileList["spec/integration/*.rb", "spec/integration/*/*.rb"]
6
- ALL_UNIT = FileList["spec/unit/*/*.rb"]
5
+ SKIPPED_INTEGRATION = FileList["spec/integration/youtube/cipher_guesser_spec.rb"]
6
+ ALL_INTEGRATION = FileList["spec/integration/*.rb", "spec/integration/*/*.rb"] - SKIPPED_INTEGRATION
7
+ ALL_UNIT = FileList["spec/unit/*.rb", "spec/unit/*/*.rb"]
7
8
 
8
- task :default => [:all]
9
+ task :default => [:test_all]
9
10
 
10
- Rake::TestTask.new(:all) do |t|
11
+ Rake::TestTask.new(:test_all) do |t|
11
12
  t.test_files = ALL_INTEGRATION + ALL_UNIT
12
13
  end
13
14
 
@@ -34,3 +35,11 @@ end
34
35
  Rake::TestTask.new(:test_cipher_loader) do |t|
35
36
  t.test_files = FileList["spec/integration/youtube/cipher_loader_spec.rb"]
36
37
  end
38
+
39
+ Rake::TestTask.new(:test_cipher_guesser) do |t|
40
+ t.test_files = FileList["spec/unit/youtube/cipher_guesser_spec.rb"]
41
+ end
42
+
43
+ Rake::TestTask.new(:test_decipherer) do |t|
44
+ t.test_files = FileList["spec/unit/youtube/decipherer_spec.rb"]
45
+ end
@@ -15,15 +15,17 @@ class Downloader
15
15
  name,
16
16
  :save_dir => params[:save_dir],
17
17
  :tool => params[:tool] && params[:tool].to_sym
18
- unless result
18
+ if result
19
+ puts "Download for #{name} successful."
20
+ url_name[:on_downloaded].call(true) if url_name[:on_downloaded]
21
+ ViddlRb::AudioHelper.extract(name, params[:save_dir]) if params[:extract_audio]
22
+ else
23
+ url_name[:on_downloaded].call(false) if url_name[:on_downloaded]
19
24
  if params[:abort_on_failure]
20
25
  raise DownloadFailedError, "Download for #{name} failed."
21
26
  else
22
27
  puts "Download for #{name} failed. Moving onto next file."
23
28
  end
24
- else
25
- puts "Download for #{name} successful."
26
- ViddlRb::AudioHelper.extract(name, params[:save_dir]) if params[:extract_audio]
27
29
  end
28
30
  end
29
31
  end
data/bin/viddl-rb CHANGED
@@ -28,14 +28,14 @@ begin
28
28
  puts "Will try to extract audio: #{params[:extract_audio] == true}."
29
29
  puts "Analyzing URL: #{params[:url]}"
30
30
 
31
- app = Driver.new(params)
32
- app.start # starts the download process
31
+ driver = Driver.new(params)
32
+ driver.start # starts the download process
33
33
 
34
34
  rescue OptionParser::ParseError, ViddlRb::RequirementError => e
35
35
  puts "Error: #{e.message}"
36
36
  exit(1)
37
37
 
38
- rescue StandardError => e
38
+ rescue => e
39
39
  puts "Error: #{e.message}"
40
40
  puts "\nBacktrace:"
41
41
  puts e.backtrace
@@ -52,27 +52,28 @@ module ViddlRb
52
52
  File.join(File.dirname(File.expand_path(__FILE__)), "..")
53
53
  end
54
54
 
55
- #checks to see whether the os has a certain utility like wget or curl
56
- #`` returns the standard output of the process
57
- #system returns the exit code of the process
55
+ # checks to see whether the os has a certain utility like wget or curl
56
+ # `` returns the standard output of the process
57
+ # system returns the exit code of the process
58
58
  def self.os_has?(utility)
59
-
60
- unless windows?
61
- `which #{utility}`.include?(utility.to_s)
62
- else
59
+ if windows?
63
60
  if !system("where /q where").nil? #if Windows has the where utility
64
61
  system("where /q #{utility}") #/q is the quiet mode flag
65
62
  else
66
- begin #as a fallback we just run the utility itself
63
+ begin #as a fallback we just run the utility itself
67
64
  system(utility)
68
65
  rescue Errno::ENOENT
69
66
  false
70
67
  end
71
68
  end
69
+ else
70
+ # This might work in windows too... I am not quite sure :-/
71
+ ENV['PATH'].split(':').any?{|dir| File.exist?( File.join(dir, utility.to_s) ) }
72
72
  end
73
73
  end
74
74
 
75
- #recursively get the final location (after following all redirects) for an url.
75
+ # recursively get the final location (after following all redirects)
76
+ # for an url.
76
77
  def self.get_final_location(url)
77
78
  Net::HTTP.get_response(URI.parse(url)) do |res|
78
79
  location = res["location"]
data/plugins/blip.rb CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  class Blip < PluginBase
3
2
  # this will be called by the main app to check whether this plugin is responsible for the url passed
4
3
  def self.matches_provider?(url)
data/plugins/vimeo.rb CHANGED
@@ -1,4 +1,3 @@
1
-
2
1
  class Vimeo < PluginBase
3
2
 
4
3
  #this will be called by the main app to check whether this plugin is responsible for the url passed
data/plugins/youtube.rb CHANGED
@@ -1,18 +1,16 @@
1
-
2
1
  class Youtube < PluginBase
3
2
 
3
+ #TODO: TEST THIS: https://www.youtube.com/watch?v=Qapou-3-fM8&list=PL_Z529zmzNGcOVBJA0MgjjQoKiBcmMQWh
4
+
4
5
  # this will be called by the main app to check whether this plugin is responsible for the url passed
5
6
  def self.matches_provider?(url)
6
7
  url.include?("youtube.com") || url.include?("youtu.be")
7
8
  end
8
9
 
9
10
  def self.get_urls_and_filenames(url, options = {})
11
+ initialize_components(options)
10
12
 
11
- @url_resolver = UrlResolver.new
12
- @video_resolver = VideoResolver.new(Decipherer.new(CipherLoader.new))
13
- @format_picker = FormatPicker.new(options)
14
-
15
- urls = @url_resolver.get_all_urls(url, options[:filter])
13
+ urls = @url_resolver.get_all_urls(url, options[:filter])
16
14
  videos = get_videos(urls)
17
15
 
18
16
  return_value = videos.map do |video|
@@ -23,6 +21,14 @@ class Youtube < PluginBase
23
21
  return_value.empty? ? download_error("No videos could be downloaded.") : return_value
24
22
  end
25
23
 
24
+ def self.initialize_components(options)
25
+ @cipher_io = CipherIO.new
26
+ coordinator = DecipherCoordinator.new(Decipherer.new(@cipher_io), CipherGuesser.new)
27
+ @video_resolver = VideoResolver.new(coordinator)
28
+ @url_resolver = UrlResolver.new
29
+ @format_picker = FormatPicker.new(options)
30
+ end
31
+
26
32
  def self.notify(message)
27
33
  puts "[YOUTUBE] #{message}"
28
34
  end
@@ -37,17 +43,26 @@ class Youtube < PluginBase
37
43
  @video_resolver.get_video(url)
38
44
  rescue VideoResolver::VideoRemovedError
39
45
  notify "The video #{url} has been removed."
46
+ nil
40
47
  rescue => e
41
48
  notify "Error getting the video: #{e.message}"
49
+ nil
42
50
  end
43
51
  end
44
-
45
52
  videos.reject(&:nil?)
46
53
  end
47
54
 
48
55
  def self.make_url_filname_hash(video, format)
49
56
  url = video.get_download_url(format.itag)
50
57
  name = PluginBase.make_filename_safe(video.title) + ".#{format.extension}"
51
- {url: url, name: name}
58
+ {url: url, name: name, on_downloaded: make_downloaded_callback(video)}
59
+ end
60
+
61
+ def self.make_downloaded_callback(video)
62
+ return nil unless video.signature_guess?
63
+
64
+ lambda do |success|
65
+ @cipher_io.add_cipher(video.cipher_version, video.cipher_operations) if success
66
+ end
52
67
  end
53
68
  end
@@ -0,0 +1,114 @@
1
+ require 'open-uri'
2
+
3
+ class CipherGuesser
4
+ class CipherGuessError < StandardError; end
5
+
6
+ JS_URL = "http://s.ytimg.com/yts/jsbin/html5player-%s.js"
7
+
8
+ def guess(cipher_version)
9
+ js = download_player_javascript(cipher_version)
10
+ body = extract_decipher_function_body(js)
11
+
12
+ parse_function_body(body)
13
+ end
14
+
15
+ private
16
+
17
+ def download_player_javascript(cipher_version)
18
+ open(JS_URL % cipher_version).read
19
+ end
20
+
21
+ def extract_decipher_function_body(js)
22
+ function_name = js[decipher_function_name_regex, 1]
23
+ function_regex = get_function_regex(function_name)
24
+ match = function_regex.match(js)
25
+
26
+ raise(CipherGuessError, "Could not extract the decipher function") unless match
27
+ match[:brace]
28
+ end
29
+
30
+ def parse_function_body(body)
31
+ lines = body.split(";")
32
+
33
+ remove_non_decipher_lines!(lines)
34
+ do_pre_transformations!(lines)
35
+
36
+ lines.map do |line|
37
+ if /\(\w+,(?<index>\d+)\)/ =~ line # calling a two argument function (swap)
38
+ "w#{index}"
39
+ elsif /slice\((?<index>\d+)\)/ =~ line # calling slice
40
+ "s#{index}"
41
+ elsif /reverse\(\)/ =~ line # calling reverse
42
+ "r"
43
+ else
44
+ raise "Cannot parse line: #{line}"
45
+ end
46
+ end
47
+ end
48
+
49
+ def remove_non_decipher_lines!(lines)
50
+ # The first line splits the string into an array and the last joins and returns
51
+ lines.delete_at(0)
52
+ lines.delete_at(-1)
53
+ end
54
+
55
+ def do_pre_transformations!(lines)
56
+ change_inline_swap_to_function_call(lines) if inline_swap?(lines)
57
+ end
58
+
59
+ def inline_swap?(lines)
60
+ # Defining a variable = inline swap function
61
+ lines.any? { |line| line.include?("var ") }
62
+ end
63
+
64
+ def change_inline_swap_to_function_call(lines)
65
+ start_index = lines.find_index { |line| line.include?("var ") }
66
+ swap_lines = lines.slice!(start_index, 3) # inline swap is 3 lines long
67
+ i1, i2 = get_swap_indices(swap_lines)
68
+
69
+ lines.insert(start_index, "swap(#{i1},#{i2})")
70
+ lines
71
+ end
72
+
73
+ def get_swap_indices(lines)
74
+ i1 = lines.first[/(\d+)/, 1]
75
+ i2 = lines.last[/(\d+)/, 1]
76
+ [i1, i2]
77
+ end
78
+
79
+ def decipher_function_name_regex
80
+ # Find "C" in this: var A = B.sig || C (B.s)
81
+ /
82
+ \.sig
83
+ \s*
84
+ \|\|
85
+ (\w+)
86
+ \(
87
+ /x
88
+ end
89
+
90
+ def get_function_regex(function_name)
91
+ # Match the function function_name (that has one argument)
92
+ /
93
+ #{function_name}
94
+ \(
95
+ \w+
96
+ \)
97
+ #{function_body_regex}
98
+ /x
99
+ end
100
+
101
+ def function_body_regex
102
+ # Match nested braces
103
+ /
104
+ (?<brace>
105
+ {
106
+ (
107
+ [^{}]
108
+ | \g<brace>
109
+ )*
110
+ }
111
+ )
112
+ /x
113
+ end
114
+ end
@@ -4,7 +4,7 @@ require 'net/http'
4
4
  require 'openssl'
5
5
  require 'yaml'
6
6
 
7
- class CipherLoader
7
+ class CipherIO
8
8
 
9
9
  CIPHER_YAML_URL = "https://raw.github.com/rb2k/viddl-rb/master/plugins/youtube/ciphers.yml"
10
10
  CIPHER_YAML_PATH = File.join(ViddlRb::UtilityHelper.base_path, "plugins/youtube/ciphers.yml")
@@ -23,24 +23,29 @@ class CipherLoader
23
23
  @ciphers.dup
24
24
  end
25
25
 
26
+ def add_cipher(version, operations)
27
+ File.open(CIPHER_YAML_PATH, "a") do |file|
28
+ file.puts("#{version}: #{operations}")
29
+ end
30
+ end
31
+
26
32
  private
27
33
 
28
34
  def update_ciphers
29
- return if get_server_file_size == get_local_file_size
35
+ server_etag = get_server_etag
36
+ return if server_etag == @ciphers["ETag"]
30
37
 
31
38
  @ciphers.merge!(download_server_ciphers)
39
+ @ciphers["ETag"] = server_etag
32
40
  save_local_ciphers(@ciphers)
33
41
  end
34
42
 
35
- def get_local_file_size
36
- File.size(CIPHER_YAML_PATH)
37
- end
38
-
39
- def get_server_file_size
43
+ def get_server_etag
40
44
  uri = URI.parse(CIPHER_YAML_URL)
41
45
  http = make_http(uri)
42
46
  head = Net::HTTP::Head.new(uri.request_uri)
43
- http.request(head)["Content-Length"].to_i
47
+ etag = http.request(head)["ETag"]
48
+ etag.gsub('"', '') # remove leading and trailing quotes
44
49
  end
45
50
 
46
51
  def make_http(uri)
@@ -84,3 +84,16 @@ ima-vflxBu-5R: w40 w62 r s2 w21 s3 r w7 s3
84
84
  ima-vflrGwWV9: w36 w45 r s2 r
85
85
  ima-vflCME3y0: w8 s2 r w52
86
86
  ima-vfl1LZyZ5: w8 s2 r w52
87
+ ima-vfl4_saJa: r s1 w19 w9 w57 w38 s3 r s2
88
+ ima-en_US-vflP9269H: r w63 w37 s3 r w14 r
89
+ ima-en_US-vflkClbFb: s1 w12 w24 s1 w52 w70 s2
90
+ ima-en_US-vflYhChiG: w27 r s3
91
+ ima-en_US-vflWnCYSF: r s1 r s3 w19 r w35 w61 s2
92
+ en_US-vflbT9-GA: w51 w15 s1 w22 s1 w41 r w43 r
93
+ en_US-vflAYBrl7: s2 r w39 w43
94
+ en_US-vflS1POwl: w48 s2 r s1 w4 w35
95
+ en_US-vflLMtkhg: w30 r w30 w39
96
+ en_US-vflbJnZqE: w26 s1 w15 w3 w62 w54 w22
97
+ en_US-vflgd5txb: w26 s1 w15 w3 w62 w54 w22
98
+ en_US-vflTm330y: w26 s1 w15 w3 w62 w54 w22
99
+ en_US-vflnwMARr: s3 r w24 s2
@@ -0,0 +1,28 @@
1
+
2
+ class DecipherCoordinator
3
+
4
+ def initialize(decipherer, cipher_guesser)
5
+ @decipherer = decipherer
6
+ @cipher_guesser = cipher_guesser
7
+ end
8
+
9
+ def get_decipher_data(cipher_version)
10
+ ops = @decipherer.get_operations(cipher_version)
11
+ Youtube.notify "Cipher guess: no"
12
+ {version: cipher_version, operations: ops.join(" "), guess?: false}
13
+
14
+ rescue Decipherer::UnknownCipherVersionError => e
15
+ ops = @cipher_guesser.guess(cipher_version)
16
+ Youtube.notify "Cipher guess: yes"
17
+ {version: cipher_version, operations: ops.join(" "), guess?: true}
18
+
19
+ rescue Decipherer::UnknownCipherOperationError => e
20
+ Youtube.notify "Failed to parse the cipher from the Youtube player version #{cipher_version}\n" +
21
+ "Please submit a bug report at https://github.com/rb2k/viddl-rb"
22
+ raise e
23
+ end
24
+
25
+ def decipher_with_operations(cipher, operations)
26
+ @decipherer.decipher_with_operations(cipher, operations)
27
+ end
28
+ end
@@ -1,22 +1,36 @@
1
1
 
2
2
  class Decipherer
3
3
 
4
- class UnknownCipherVersionError < StandardError; end
5
4
  class UnknownCipherOperationError < StandardError; end
6
5
 
6
+ class UnknownCipherVersionError < StandardError
7
+
8
+ attr_reader :cipher_version
9
+
10
+ def initialize(cipher_version)
11
+ super("Unknown cipher version: #{cipher_version}")
12
+ @cipher_version = cipher_version
13
+ end
14
+ end
15
+
7
16
  def initialize(loader)
8
17
  @ciphers = loader.load_ciphers
9
18
  end
10
19
 
11
20
  def decipher_with_version(cipher, cipher_version)
12
- operations = @ciphers[cipher_version]
13
- raise UnknownCipherVersionError.new("Unknown cipher version: #{cipher_version}") unless operations
21
+ ops = get_operations(cipher_version)
22
+ decipher_with_operations(cipher, ops)
23
+ end
14
24
 
15
- decipher_with_operations(cipher, operations.split)
25
+ def get_operations(cipher_version)
26
+ operations = @ciphers[cipher_version]
27
+ raise UnknownCipherVersionError.new(cipher_version) unless operations
28
+ operations.split
16
29
  end
17
30
 
18
31
  def decipher_with_operations(cipher, operations)
19
32
  cipher = cipher.dup
33
+ operations = operations.split if operations.is_a?(String)
20
34
 
21
35
  operations.each do |op|
22
36
  cipher = apply_operation(cipher, op)
@@ -110,7 +110,7 @@ class FormatPicker
110
110
  when 1
111
111
  formats.first
112
112
  else
113
- get_default_format(matches_resolution)
113
+ get_default_format(formats)
114
114
  end
115
115
  end
116
116
  end
@@ -11,8 +11,13 @@ class VideoResolver
11
11
  end
12
12
 
13
13
  def get_video(url)
14
- @json = load_json(url)
15
- Video.new(get_title, parse_stream_map(get_stream_map))
14
+ @json = load_json(url)
15
+ decipher_data = @decipherer.get_decipher_data(get_html5player_version)
16
+ url_data = parse_stream_map(get_stream_map)
17
+
18
+ decipher_signatures!(url_data, decipher_data)
19
+
20
+ Video.new(get_title, url_data, decipher_data)
16
21
  end
17
22
 
18
23
  private
@@ -47,10 +52,7 @@ class VideoResolver
47
52
  #
48
53
  def parse_stream_map(stream_map)
49
54
  entries = stream_map.split(",")
50
-
51
- parsed = entries.map { |entry| parse_stream_map_entry(entry) }
52
- parsed.each { |entry| apply_signature!(entry) if entry[:sig] }
53
- parsed
55
+ entries.map { |entry| parse_stream_map_entry(entry) }
54
56
  end
55
57
 
56
58
  def parse_stream_map_entry(entry)
@@ -65,7 +67,7 @@ class VideoResolver
65
67
  end
66
68
 
67
69
  # The signature key can be either "sig" or "s".
68
- # Very rarely there is no "s" or "sig" paramater. In this case the signature is already
70
+ # Very rarely there is no "s" or "sig" parameter. In this case the signature is already
69
71
  # applied and the the video can be downloaded directly.
70
72
  def fetch_signature(params)
71
73
  sig = params.fetch("sig", nil) || params.fetch("s", nil)
@@ -79,32 +81,45 @@ class VideoResolver
79
81
  text
80
82
  end
81
83
 
82
- def apply_signature!(entry)
83
- sig = get_deciphered_sig(entry[:sig])
84
- entry[:url] << "&#{SIGNATURE_URL_PARAMETER}=#{sig}"
85
- entry.delete(:sig)
86
- end
84
+ def decipher_signatures!(url_data, decipher_data)
85
+ url_data.each do |entry|
86
+ next unless entry[:sig]
87
87
 
88
- def get_deciphered_sig(sig)
89
- return sig if sig.length == CORRECT_SIGNATURE_LENGTH
90
- @decipherer.decipher_with_version(sig, get_html5player_version)
88
+ sig = @decipherer.decipher_with_operations(entry[:sig], decipher_data[:operations])
89
+ entry[:url] << "&#{SIGNATURE_URL_PARAMETER}=#{sig}"
90
+ entry.delete(:sig)
91
+ end
91
92
  end
92
93
 
94
+
93
95
  class Video
94
96
  attr_reader :title
95
97
 
96
- def initialize(title, itags_urls)
98
+ def initialize(title, url_data, decipher_data)
97
99
  @title = title
98
- @itags_urls = itags_urls
100
+ @url_data = url_data
101
+ @decipher_data = decipher_data
99
102
  end
100
103
 
101
104
  def available_itags
102
- @itags_urls.map { |iu| iu[:itag] }
105
+ @url_data.map { |entry| entry[:itag] }
103
106
  end
104
107
 
105
108
  def get_download_url(itag)
106
- itag_url = @itags_urls.find { |iu| iu[:itag] == itag }
107
- itag_url[:url] if itag_url
109
+ entry = @url_data.find { |entry| entry[:itag] == itag }
110
+ entry[:url] if entry
111
+ end
112
+
113
+ def signature_guess?
114
+ @decipher_data[:guess?]
115
+ end
116
+
117
+ def cipher_operations
118
+ @decipher_data[:operations]
119
+ end
120
+
121
+ def cipher_version
122
+ @decipher_data[:version]
108
123
  end
109
124
  end
110
125
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: viddl-rb
3
3
  version: !ruby/object:Gem::Version
4
- version: "0.99"
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Marc Seeger
@@ -9,7 +9,7 @@ autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
11
 
12
- date: 2014-03-02 00:00:00 Z
12
+ date: 2014-04-07 00:00:00 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: mime-types
@@ -95,8 +95,10 @@ files:
95
95
  - plugins/soundcloud.rb
96
96
  - plugins/veoh.rb
97
97
  - plugins/vimeo.rb
98
- - plugins/youtube/cipher_loader.rb
98
+ - plugins/youtube/cipher_guesser.rb
99
+ - plugins/youtube/cipher_io.rb
99
100
  - plugins/youtube/ciphers.yml
101
+ - plugins/youtube/decipher_coordinator.rb
100
102
  - plugins/youtube/decipherer.rb
101
103
  - plugins/youtube/format_picker.rb
102
104
  - plugins/youtube/url_resolver.rb